Writing a DOS Handler or File System¶
A handler (or file system) is an ordinary Amiga process that receives DosPackets on a message port, performs the requested action and replies. dos.library itself implements very little: it finds the handler responsible for a path or object, turns each library call into a packet and returns the handler's results. This article follows §13.1 of the 2024 RKM and describes the handler side of that contract — how a handler is found and started, the startup packet, the dual C/BCPL startup stub, the main loop, the structures a handler must fill, volume management and shutdown. The packets themselves are listed in the DOS Packet Reference; sending and replying is covered in DOS Packets.
How dos.library finds the handler¶
For an existing object the port is stored in the object:
| Object | Port |
|---|---|
| File handle | fh_Type of the FileHandle (the handler may change it when opening) |
| Lock | fl_Task of the FileLock (set by the file system) |
| Notification request | nr_Handler of the NotifyRequest |
For a path, GetDeviceProc() decides. Lock(), DeviceProc() and the other path-based functions call it (internally, not through the library vector); Open() handles a few names itself first.
| Path | Port used |
|---|---|
| Relative (no colon) | fl_Task of pr_CurrentDir; if that is ZERO, pr_FileSystemTask |
PROGDIR:... |
Handler of the lock in pr_HomeDir |
CONSOLE:... |
pr_ConsoleTask of the calling process |
NIL:... |
No handler; GetDeviceProc() returns NULL |
*, CONSOLE:, NIL: alone |
Filtered by Open(): it builds a FileHandle with fh_Type = pr_ConsoleTask or NULL, without calling GetDeviceProc() |
NAME:... |
dol_Name is looked up in the device list (below) |
For a device-list entry:
- Volume —
dol_Taskis the file system port while the volume is inserted. If it is NULL the user is asked to insert the volume (ifpr_WindowPtrallows requesters); cancelling fails the lookup. - Assign —
dol_Task/dol_Lockgive the first directory; further multi-assign directories come from theAssignListlocks. Late and non-binding assigns resolvedol_AssignNamefirst; a late assign then turns into a regular one. - Device (
DLT_DEVICE) —dol_Taskis the running handler's port, or NULL if a handler must be started.
If no lock for
PROGDIR:or no console forCONSOLE:exists,GetDeviceProc()looks for a device, volume or assign of that name instead. The manual cannot say whether this is intended.
Starting the handler¶
When dol_Task of a device entry is NULL, GetDeviceProc() starts a new handler process:
- If
dol_SegListis ZERO, the file named bydol_Handleris loaded withLoadSeg()and stored indol_SegList. The loaded binary is scanned for a$STACK:cookie, which can raise the stack size (see Hunk Version and Stack Cookies). - A process is created with
dol_StackSizeanddol_Priority.dol_GlobVecselects how it starts:
dol_GlobVec |
Type | Entry point | Startup packet arrives | Device list held |
|---|---|---|---|---|
| −1 | C/assembler | First byte of the segment list | In pr_MsgPort |
Exclusive |
| −2 | C/assembler | First byte of the segment list | In pr_MsgPort |
Shared |
| 0 | BCPL, system global vector | Global vector entry 1 (START) |
As BPTR in d1 |
Exclusive |
| −3 | BCPL, system global vector | Global vector entry 1 (START) |
As BPTR in d1 |
Shared |
| > 0 | BCPL, custom global vector | Global vector entry 1 | As BPTR in d1 |
Exclusive |
dol_StackSize is in bytes for −1/−2 and in longwords for the BCPL values.
GetDeviceProc()sends the startup packet and keeps the device list locked until the packet is replied — exclusively ifdol_GlobVec ≥ −1, shared otherwise.
Deadlock warning. Because the list is locked, a handler must not call
LockDosList()during startup, and should not access files or locks at all from the handler process — manydos.libraryfunctions lock the device list implicitly. UseAttemptLockDosList()whenever the handler needs the list.
The startup packet¶
| Field | Value |
|---|---|
dp_Type |
ACTION_STARTUP (0, the same value as ACTION_NIL) |
dp_Arg1 |
BSTR: the full path that caused the start, not just the device name (e.g. the whole CON: window specification) |
dp_Arg2 |
Copy of dol_Startup: an integer, a BPTR to a BSTR or a BPTR to a FileSysStartupMsg, depending on the mountlist — nothing checks that it is what the handler expects |
dp_Arg3 |
BPTR to the DosList entry; the handler may modify it |
dp_Arg4 |
APTR to a MsgPort, set by the handler (initially NULL) |
dp_Res1 / dp_Res2 |
Success indicator / 0 or error code |
dp_Arg4 is an output: if the handler leaves it NULL, GetDeviceProc() returns the handler's pr_MsgPort in dvp_Port; otherwise it returns this port. It takes precedence even if the handler also fills dol_Task.
One process or many. GetDeviceProc() never fills dol_Task. A handler that serves everything from one process (every file system, the Queue-Handler) stores its port in dol_Task of the DosList from dp_Arg3 — the same port as in dp_Arg4 if it uses an alternative one. A handler that leaves dol_Task NULL (the CON-Handler) gets a new process for every Open("CON:..."); opening * or a CONSOLE: path still reaches an existing process through pr_ConsoleTask — for any handler whose port is installed there, not only the console.
On failure, reply with DOSFALSE and an error code, release everything and exit. On success, reply DOSTRUE and enter the main loop.
The dual-mode startup stub¶
A handler written in C can be started both as a C handler (GlobVec −1/−2) and as a BCPL handler (0/−3) with the manual's "BCPL kludge". The FFS uses the same technique so that it can run floppies mounted BCPL-style and hard-disk partitions mounted C-style.
SECTION text,code
XREF _main ; handler main()
G_GLOBMAX EQU 150 ; size of the global vector
G_START EQU 1 ; BCPL START entry
CodeHeader: DC.L (CodeEnd-CodeHeader)/4 ; longword offset to the BCPL table
* C start (GlobVec -1/-2): execution begins at CodeHeader, whose
* longword decodes as a harmless ORI instruction, and falls through.
CStart: sub.l a0,a0 ; no packet: main() must GetMsg()
bsr.w _main
rts
CNOP 0,4
* BCPL start (GlobVec 0/-3), reached through global vector entry 1.
BCPLStart: movem.l a0-a6,-(a7) ; BCPL needs a0/a2/a4/a5/a6 back
lsl.l #2,d1 ; BPTR to startup packet ...
move.l d1,a0 ; ... as C pointer in a0
bsr.w _main
movem.l (a7)+,a0-a6
jmp (a6) ; BCPL-style return
CNOP 0,4
BCPLTable: DC.L 0 ; end of the global list
DC.L G_START,BCPLStart-CodeHeader
DC.L G_GLOBMAX ; highest global used
CodeEnd:
END
How it works:
- For BCPL handlers, the runtime binder reads the first longword of the segment as a longword offset (not a byte offset) to the end of the initialisation table, then reads the table backwards: first the highest global used (150 is safe), then pairs of (byte offset of the function from the segment start, global vector index), ending at an offset of 0. Here it fills entry 1 (
START) withBCPLStart. - For C handlers, the process starts at the first byte of the segment — the offset longword itself. As long as the offset is below 64K it decodes as a harmless
ORIinstruction, and execution continues atCStart. main()receives the startup packet ina0for BCPL starts and NULL for C starts, in which case it must take the packet frompr_MsgPort.- A BCPL program must preserve
a0,a2,a4,a5anda6and return withjmp (a6). A global index beyond the vector size makes the binder fail withERROR_GLOBALS(111).
The Port-Handler entries built by the ROM and the resident CLI segment still use BCPL startup; new handlers should not depend on BCPL binding beyond this stub.
The main loop¶
The example below is the manual's minimal non-file-system handler (a do-nothing device that accepts any number of opens). It is written for SAS/C register arguments and expects the stub above. Two corrections were made to the printed listing: ACTION_END now replies success (otherwise Close() would fail and never free the handle), and ACTION_READ returns 0 for end-of-file (−1 means error).
#include <exec/execbase.h>
#include <dos/dos.h>
#include <dos/dosextens.h>
#include <proto/exec.h>
#include <proto/dos.h>
#ifndef ACTION_FORCE
#define ACTION_FORCE 2001
#endif
#ifndef ACTION_STACK
#define ACTION_STACK 2002
#endif
#ifndef ACTION_QUEUE
#define ACTION_QUEUE 2003
#endif
struct ExecBase *SysBase;
struct DosLibrary *DOSBase;
LONG __asm __saveds main(register __a0 struct DosPacket *pkt)
{
struct Process *proc;
struct Message *msg;
struct DosList *dlist;
LONG open = 0;
SysBase = *((struct ExecBase **)4L);
proc = (struct Process *)FindTask(NULL);
if (pkt == NULL) { /* C startup: fetch the packet */
WaitPort(&proc->pr_MsgPort);
msg = GetMsg(&proc->pr_MsgPort);
pkt = (struct DosPacket *)msg->mn_Node.ln_Name;
}
dlist = (struct DosList *)BADDR(pkt->dp_Arg3);
/* path in BADDR(pkt->dp_Arg1), configuration in pkt->dp_Arg2 */
DOSBase = (struct DosLibrary *)OpenLibrary("dos.library", 37);
if (DOSBase == NULL) {
/* no ReplyPkt() without dos.library: reply by hand */
pkt->dp_Res1 = DOSFALSE;
pkt->dp_Res2 = ERROR_INVALID_RESIDENT_LIBRARY;
PutMsg(pkt->dp_Port, pkt->dp_Link);
return 0;
}
/* Single-instance handlers would set: dlist->dol_Task = &proc->pr_MsgPort; */
(void)dlist;
ReplyPkt(pkt, DOSTRUE, 0);
do {
LONG res1 = DOSFALSE;
LONG res2 = ERROR_ACTION_NOT_KNOWN;
WaitPort(&proc->pr_MsgPort);
msg = GetMsg(&proc->pr_MsgPort);
pkt = (struct DosPacket *)msg->mn_Node.ln_Name;
switch (pkt->dp_Type) {
case ACTION_FINDINPUT:
case ACTION_FINDUPDATE:
case ACTION_FINDOUTPUT:
open++;
res1 = DOSTRUE; res2 = 0;
break;
case ACTION_END:
open--;
res1 = DOSTRUE; res2 = 0;
break;
case ACTION_READ:
res1 = 0; res2 = 0; /* end of file */
break;
case ACTION_WRITE:
res1 = pkt->dp_Arg3; res2 = 0; /* everything "written" */
break;
case ACTION_SEEK: /* unimplemented, but Res1 = -1 */
case ACTION_SET_FILE_SIZE:
case ACTION_STACK:
case ACTION_QUEUE:
case ACTION_FORCE:
res1 = -1;
break;
default: /* unimplemented: 0 + ACTION_NOT_KNOWN */
break;
}
ReplyPkt(pkt, res1, res2);
} while (open);
CloseLibrary((struct Library *)DOSBase);
return 0;
}
Because this handler leaves dol_Task NULL, each open of the device name starts a new process, which ends when its last file is closed. A real handler must also set fh_Arg1 in the FileHandle passed in dp_Arg1 of the open packets to recognise the file later, and an interactive handler must set fh_Port to DOSTRUE.
Unimplemented packets¶
Every packet the handler does not implement must still be replied, with dp_Res2 = ERROR_ACTION_NOT_KNOWN and:
dp_Type |
dp_Res1 |
|---|---|
ACTION_READ, ACTION_WRITE, ACTION_SEEK, ACTION_SET_FILE_SIZE, ACTION_STACK, ACTION_QUEUE, ACTION_FORCE |
−1 |
| all others | 0 |
dos.library relies on ERROR_ACTION_NOT_KNOWN to fall back: ExAll() emulates ACTION_EXAMINE_ALL with ACTION_EXAMINE_OBJECT/ACTION_EXAMINE_NEXT, IsFileSystem() tries Lock(":"), and SameLock() compares fl_Key. Implement ACTION_EXAMINE_ALL_END whenever you implement ACTION_EXAMINE_ALL: the emulation of the former is defective in every dos.library version.
Internal packets for device I/O¶
A handler can make its own device I/O arrive through the same loop: link each IORequest to a DosPacket (io_Message.mn_Node.ln_Name → packet, dp_Link → IORequest), set dp_Type to ACTION_READ_RETURN (1001), ACTION_WRITE_RETURN (1002) or ACTION_TIMER (30), and send it with SendIO() with the reply port set to the packet port. Completed requests then show up as packets of these types. Never ReplyPkt() them.
Files and locks¶
File handles are allocated by dos.library (or by the client with AllocDosObject(DOS_FILEHANDLE, ...)). In the open packets the handler:
- stores its own identifier in
fh_Arg1— all later file packets carry only this value. Since a file may be opened several times in shared mode, the manual suggests a per-handle object (holding e.g. the file position) that points to a shared per-file object (size, use count); - may replace
fh_Typewith another port to which later packets for the file are sent; - sets
fh_PorttoDOSTRUEif it is interactive (implementsACTION_WAIT_CHAR).
Locks are allocated, filled and freed by the file system itself:
| Field | Content |
|---|---|
fl_Link |
BPTR chain used for dol_LockList when the volume is ejected |
fl_Key |
Private key identifying the object (SameLock() compares it if ACTION_SAME_LOCK is not implemented) |
fl_Access |
SHARED_LOCK or EXCLUSIVE_LOCK |
fl_Task |
Port clients use for this lock (usually the handler's pr_MsgPort) |
fl_Volume |
BPTR to the volume's DosList entry |
Locks never point at links; links are resolved when the lock is obtained. On ACTION_FREE_LOCK, clear fl_Task before freeing — dos.library ignores locks with a NULL fl_Task, which makes double UnLock() calls harmless.
Directory scans must survive concurrent changes: between two ACTION_EXAMINE_NEXT packets the client may pass a different lock on the same directory and a different FileInfoBlock; only fib_DiskKey and fib_DirEntryType are preserved. Keep the full state in the lock and enough in fib_DiskKey (e.g. an entry counter) to rebuild it; never a block number or memory pointer of an object that may be deleted. Clients may stop a scan at any point. See the DOS Packet Reference.
Error codes¶
Return codes from dos/dos.h (and dos/dosasl.h), not raw device errors. Some file systems pass trackdisk.device errors straight through, which callers cannot interpret; translate them instead. Several handlers use ERROR_INVALID_RESIDENT_LIBRARY when a library or device they need cannot be opened.
Volume management¶
A file system on removable media watches its drive, typically with TD_ADDCHANGEINT, whose interrupt code runs on every media change.
On insertion:
- Run a quick validation and determine the state reported by
ACTION_INFO/ACTION_DISK_INFO(readable, not a DOS disk, write-protected...). - Read the volume name and creation date and look for a
DLT_VOLUMEentry with the samedol_Name,dol_VolumeDateand adol_DiskTypeyour file system supports. If one exists, adopt it: take over itsdol_LockList(patchingfl_Taskof each lock to your port) and, throughnr_Handler, its notification requests. This lets locks survive moving a disk from DF0: to DF1:. The FFS leavesdol_DiskTypeat 0 and skips this type check; other file systems should fill it. - Otherwise create a new volume node:
if (AttemptLockDosList(LDF_VOLUMES | LDF_WRITE)) {
struct DosList *volume = FindMatchingEntry(name /* , date, type */);
if (volume == NULL) {
volume = MakeDosEntry(name, DLT_VOLUME);
/* fill dol_Task, dol_VolumeDate, dol_DiskType ... */
AddDosEntry(volume);
}
UnLockDosList(LDF_VOLUMES | LDF_WRITE);
} else {
/* list busy: retry later, never block in LockDosList() */
}
FindMatchingEntry() stands for your own search. Up to V39, AttemptLockDosList() could return either NULL or (struct DosList *)1 on failure (fixed in V40); check for both if you support those versions.
On removal: if locks on the volume remain, chain them into dol_LockList of the volume node and clear dol_Task (Workbench keeps showing the icon); otherwise remove the node with RemDosEntry() and free it with FreeDosEntry(), using the same AttemptLockDosList() precaution. Then update the state returned by ACTION_INFO/ACTION_DISK_INFO. Open files are not carried over: the FFS asks the user to put the volume back into the original drive.
Shutdown and ACTION_DIE¶
Handlers without dol_Task (one process per open, like the CON-Handler) count every object they hand out — file handles, locks, notification requests — and exit when the count drops to zero. Otherwise processes accumulate that nobody can reach any more.
File systems with dol_Task stay reachable after the last object is closed and stop only on ACTION_DIE. The manual describes two strategies:
- Fail early. If any lock, file or notification request still exists, reply
DOSFALSEwith e.g.ERROR_OBJECT_IN_USEand carry on. Otherwise get the device list withAttemptLockDosList()(fail if it is busy), remove the volume node, setdol_Taskof the device node to NULL —dos.librarywill start a fresh process fromdol_SegListon the next access — replyDOSTRUEand terminate. Packets that arrive afterACTION_DIEmust still be replied, with the default results for unimplemented packets. - Shut down gradually. Remove the volume node and acknowledge
ACTION_DIE. Then answerACTION_INFO/ACTION_DISK_INFOwith "no disk" (Workbench closes the volume's windows and removes its icon), refuse new locks, opens and notifications withERROR_NO_DISK, but still honour unlocks, closes andACTION_REMOVE_NOTIFY. When the last object is gone, cleardol_Taskand terminate.
Neither is completely safe: a client may have cached the handler's port from GetDeviceProc() or DeviceProc() without holding any object.
Soft-link resolution¶
A file system that supports soft links returns ERROR_IS_SOFT_LINK when a path walk meets one, and implements ACTION_READ_LINK (arguments: lock, C-string path, buffer, size). The algorithm, from §14.4.2:
- Walk the path from the lock component by component (
:= root, a lone/= parent) until the end or the first soft link. - Read the link's target.
- If the target is absolute (contains
:), drop what was parsed so far; if it starts with:, insert the current volume name. If it is relative, append it to the path parsed so far. - Append the rest of the original path after the link.
- Return the length; −2 with
ERROR_LINE_TOO_LONGif the buffer is too small; −1 withERROR_OBJECT_NOT_FOUNDif no link was found (this letsdos.librarymove on to the next directory of a multi-assign).
Only one link is expanded per packet; dos.library loops, and stops after 15 links. The manual's skeleton, with the error code kept consistently in res2:
#include <string.h>
#include <dos/dos.h>
#include <dos/dosextens.h>
#include <proto/dos.h>
struct node {
LONG type; /* ST_FILE, ST_USERDIR, ST_SOFTLINK, ... */
LONG size; /* length of the soft-link target */
/* ... */
};
/* File-system-specific helpers. locatenode() walks 'path' from 'lock'.
** It returns the node at the end of the path, or NULL with IoErr() set.
** *path_before: length of the part resolved so far;
** *linknode: the soft link found in the middle of the path;
** *path_pos: offset of the '/' following that link. */
extern struct node *locatenode(BPTR lock, const UBYTE *path, LONG *path_before,
struct node **linknode, LONG *path_pos);
extern LONG readsoftlink(const struct node *, UBYTE *buffer, LONG size);
extern LONG res2; /* becomes dp_Res2 */
extern struct DosList *volumenode; /* this volume's DosList entry */
LONG readlink(BPTR lock, UBYTE *string, UBYTE *buffer, ULONG size)
{
struct node *node, *linknode;
UBYTE *src = string;
LONG is_dir = FALSE, path_before, path_pos, res, len;
node = locatenode(lock, string, &path_before, &linknode, &path_pos);
if (node == NULL) {
if (IoErr() == ERROR_IS_SOFT_LINK) { /* link in the middle */
is_dir = TRUE;
node = linknode;
} else {
res2 = IoErr();
return -1;
}
}
if (node->type != ST_SOFTLINK) { /* no link in the path */
res2 = ERROR_OBJECT_NOT_FOUND;
return -1;
}
if (is_dir) {
string += path_pos; /* remainder after the link */
len = strlen((char *)string) + 1;
} else
len = 0;
if (node->size + 1 + path_before + len >= size) {
res2 = ERROR_LINE_TOO_LONG;
return -2;
}
memcpy(buffer, src, path_before); /* head of the path */
res = readsoftlink(node, buffer + path_before, size - path_before);
if (res < 0)
return res;
src = (UBYTE *)strchr((char *)buffer + path_before, ':');
if (src) { /* absolute target */
if (src == buffer + path_before) { /* ":..." = this volume */
UBYTE *volname = (UBYTE *)BADDR(volumenode->dol_Name);
LONG vlen = *volname; /* BSTR length */
if (vlen + res >= size) {
res2 = ERROR_LINE_TOO_LONG;
return -2;
}
memmove(buffer + vlen, buffer + path_before, res + 1);
memcpy(buffer, volname + 1, vlen);
} else {
memmove(buffer, buffer + path_before, res + 1);
}
}
if (is_dir && !AddPart((STRPTR)buffer, (STRPTR)string, size)) {
res2 = ERROR_LINE_TOO_LONG;
return -2;
}
res2 = 0;
return (LONG)strlen((char *)buffer);
}
Gotchas¶
ACTION_CHANGE_MODEpasses theFileHandle, notfh_Arg1, indp_Arg2— the only packet that does.dp_Arg1is unused inACTION_SET_PROTECT,ACTION_SET_COMMENT,ACTION_SET_DATEandACTION_SET_OWNER.- Names and comments in
FileInfoBlocks are BSTRs at the packet level;dos.libraryconverts them. AttemptLockDosList()does not changeIoErr()and cannot tell "busy" from "bad flags".- Accept bad modes: treat unknown lock modes as
SHARED_LOCK, negative seek modes asOFFSET_BEGINNING, positive ones asOFFSET_END. ACTION_COPY_DIRon ZERO must return a real lock on the root, not ZERO.ACTION_PARENTon the root returns ZERO withdp_Res2 = 0.- No I/O to other DOS objects from the handler process, especially during startup.
ACTION_MORE_CACHEcallers expect the buffer count indp_Res1(as the FFS does).
See Also¶
- DOS Packet Reference
- DOS Packets — lifecycle,
ReplyPkt() - ROM Handlers: CON, Port, Queue and RAM
- The AmigaDOS Device List
- IFF Format and Mountlist Reference — mountlist keywords,
GlobVec - Hunk Version and Stack Cookies —
$STACK:cookie - AmigaDOS Filesystem On-Disk Structures
- BPTR, BSTR, and BCPL Legacy
Sources: Thomas Richter, Amiga ROM Kernel Reference Manual: AmigaDOS (2024, v1.27), §8.1 (pp. 96–99), §10.2.9 (pp. 154–156), §11.5.4 (pp. 192–194), §13.1 (pp. 212–219), §14.1–14.4 (pp. 254–282), §14.9.5 (pp. 307–308), §14.10 (pp. 309–310); NDK 3.2 R4 Include_H/dos/dosextens.h, dos/filehandler.h, clib/dos_protos.h Updated: 2026-11-13