diff --git a/.gitignore b/.gitignore index 0a3d6cc5..62ed02bc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ # and ships with the build, so it is committed despite the *.bin rule. !/assets/aros/aros-amiga-m68k-rom.bin !/assets/aros/aros-amiga-m68k-ext.bin +!/assets/services/services_rom.bin # Local run artifacts (screenshots, recordings, save states, input scripts). copperline-screenshot-*.png @@ -55,3 +56,9 @@ copperline-video-*.avi # macOS disk image build artifact (packaging/macos/build-dmg.sh); the .app # bundle stages under /target, already ignored above. /*.dmg + +# Guest-code build intermediates (the committed artifact is the .bin blob). +/guest/services/handler.* + +# Local clangd configs (machine-specific include paths). +.clangd diff --git a/Cargo.lock b/Cargo.lock index 5d1ef457..5537dbc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,7 @@ dependencies = [ "wasmtime", "wat", "winit", + "zerocopy", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 21957065..2aa0d02a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ wasmtime = { version = "=27.0.0", default-features = false, features = ["craneli # time-zone filename stamps) and, on macOS, the pthread QoS class used for the # optional realtime-priority feature (see src/priority.rs). libc = "0.2" +zerocopy = { version = "0.8", features = ["derive"] } # macOS dock icon: winit's with_window_icon is a no-op on macOS, so the dock # icon must be set at runtime via NSApplication. These objc2 versions match the diff --git a/assets/services/services_rom.bin b/assets/services/services_rom.bin new file mode 100644 index 00000000..51c8fc3c Binary files /dev/null and b/assets/services/services_rom.bin differ diff --git a/copperline.example.toml b/copperline.example.toml index 403ff0c9..b2f1daba 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -225,6 +225,21 @@ video = "PAL" # slave = "data.hdf" +# Host directories mounted directly as AmigaDOS volumes (HOSTFS0:, +# HOSTFS1:, ...), served live with no disk image in between. Read-only for +# now: write operations fail with "disk is write-protected". Up to 16 +# mounts. volume defaults to the directory name. bootpri (-128..127, +# default -128 = never boot) enters the boot-device vote: hard-disk boot +# partitions typically sit at 0 and DF0: at 5. +# [[filesys]] +# path = "/data/amiga/Workbench" +# volume = "Workbench" +# bootpri = 6 +# +# [[filesys]] +# path = "/data/amiga/downloads" + + # Presentation options. overscan: "tv" (default; mask deep horizontal # overscan like a CRT bezel) or "full". pixel_aspect: "tv" (default; the # non-square pixel aspect of a 4:3 CRT) or "square" (one host row per diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4d6a2c39..6ce4103d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -511,6 +511,37 @@ in-memory FFS volumes -- including the `{ path = "...", name = "..." }` table form that overrides a directory mount's volume name. The HDD activity LED covers SCSI traffic too. +## `[[filesys]]` -- host directories as live volumes + +```toml +[[filesys]] +path = "/data/amiga/Workbench" +volume = "Workbench" # optional, defaults to the directory name +bootpri = 6 # optional boot priority; default -128 = never boot + +[[filesys]] +path = "/data/amiga/downloads" +``` + +Each `[[filesys]]` entry exports a host directory to the guest as an +AmigaDOS volume on its own `HOSTFS:` device, served live by the +emulator: no disk image is built, and guest reads always see the current +host contents. This differs from giving `[ide]`/`[scsi]` a directory +path, which snapshots the tree into an in-memory FFS volume at startup. +Up to 16 mounts. + +The volumes are read-only for now: write operations fail with the same +"disk is write-protected" error a physical write-protected disk gives. +Protection bits, comments, and exact datestamps are taken from UAE-style +`.uaem` sidecar files when present (the sidecars stay hidden from +listings). + +`volume` sets the AmigaDOS volume name (up to 30 characters, no `:` or +`/`). `bootpri` enters the volume in the boot-device vote (-128..127; +the default -128 means mounted but never booted from): hard-disk boot +partitions typically sit at priority 0 and DF0: at 5, so a bootable +Workbench directory with `bootpri = 6` boots ahead of both. + ## `[cd]` -- CDTV and CD32 ```toml diff --git a/guest/services/Makefile b/guest/services/Makefile new file mode 100644 index 00000000..af650c42 --- /dev/null +++ b/guest/services/Makefile @@ -0,0 +1,41 @@ +# Builds the guest-side handler ROM for Copperline's services board with the +# dockerized m68k-amigaos cross-GCC, and installs it as the committed artifact +# assets/services/services_rom.bin (rebuilding needs docker; a plain cargo +# build just embeds the committed ROM). +# +# The ROM must be position-independent and self-contained: -mpcrel makes all +# code/rodata references PC-relative, and the checks below fail the build if +# the linked executable still contains relocations or data/bss hunks +# (objcopy would silently emit a ROM that breaks at any other base). + +ROM = ../../assets/services/services_rom.bin + +IMAGE = stefanreinauer/amiga-gcc:gcc-v16.1 +DOCKER = docker run --rm --user $(shell id -u):$(shell id -g) \ + -v "$(CURDIR):/src" -w /src $(IMAGE) +CC = $(DOCKER) m68k-amigaos-gcc +OBJDUMP = $(DOCKER) m68k-amigaos-objdump +OBJCOPY = $(DOCKER) m68k-amigaos-objcopy +CFLAGS = -Os -m68000 -mpcrel -fomit-frame-pointer -fno-jump-tables \ + -Wall -Wextra -nostdlib -nostartfiles + +$(ROM): handler.bin + cp handler.bin $(ROM) + +# The docker mount only covers this directory, so objcopy writes a local +# intermediate and the install step above copies it into assets/. +handler.bin: handler.exe + @! $(OBJDUMP) -r handler.exe | grep -q RELOC \ + || { echo "handler.exe: has relocations; code must be PC-relative"; exit 1; } + @! $(OBJDUMP) -h handler.exe | grep -Eq '[.](data|bss)' \ + || { echo "handler.exe: has data/bss; keep state on the stack"; exit 1; } + $(OBJCOPY) -O binary handler.exe $@ + +# entry.s first: the entry table must sit at the start of the code hunk. +handler.exe: entry.s handler.c copperline_board.h + $(CC) $(CFLAGS) entry.s handler.c -o $@ + +clean: + rm -f handler.exe handler.bin + +.PHONY: clean diff --git a/guest/services/README.md b/guest/services/README.md new file mode 100644 index 00000000..684df8db --- /dev/null +++ b/guest/services/README.md @@ -0,0 +1,37 @@ +# Guest-side filesys handler + +`services_rom.bin` is the m68k code Copperline maps into its services board +(Zorro II, manufacturer 0x1448 "dec0de Consulting", product 5) to provide +host-directory mounts (`HOSTFS0:`, `HOSTFS1:`, ...). It is deliberately tiny: +all filesystem semantics live in the emulator (`src/filesys.rs`); the handler +only mounts the DOS devices and pumps DosPackets to the host through one +reserved A-line trap per packet. + +Two entry points (see `entry.s` and `copperline_board.h`): + +- `mount_boards()` -- called at expansion init from the board's DiagArea + with the documented DiagPoint context. Builds one DeviceNode per entry in + the mount table the emulator wrote into the board window and adds it with + `AddBootNode` (priority -128: mounted at DOS init, never a boot candidate). +- `handler_main()` -- the DOS handler process, started by DOS on first + reference to a mount. `WaitPort`/`GetMsg`, trap to the emulator (which + fills `dp_Res1`/`dp_Res2`), reply the packet. + +## Rebuilding + +The ROM is a committed artifact, rebuilt by hand when the handler changes: + +```sh +make # dockerized m68k-amigaos-gcc -> handler.exe -> services_rom.bin +``` + +The Makefile uses the `stefanreinauer/amiga-gcc` container (GCC 16 with the +AmigaOS hunk patches; https://github.com/reinauer/container-amiga-gcc), so no +local cross-toolchain is needed. The ROM must be position-independent: it +runs at whatever base autoconfig assigns, so everything is compiled with +`-mpcrel`, and the Makefile fails the build if the linked executable contains +relocations or data/bss hunks (checked with objdump before objcopy extracts +the flat code hunk). + +`copperline_board.h` pins the board-window layout and trap opcodes shared with +`src/filesys.rs`; keep the two in sync (Rust unit tests lock the layout). diff --git a/guest/services/copperline_board.h b/guest/services/copperline_board.h new file mode 100644 index 00000000..b81e0a31 --- /dev/null +++ b/guest/services/copperline_board.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Board-window layout and trap opcodes shared between the guest handler and +// the emulator. Keep in sync with the constants in src/filesys.rs; the Rust +// unit tests lock the layout. +// +// 64K window layout: +// 0x0000 u32: fake seglist length (longwords), for tools that look at it +// 0x0004 u32: 0 (seglist next pointer); dn_SegList = MKBADDR(board + 4) +// 0x0008 handler code (services_rom.bin). Entry table: +// +0 process entry (DOS RunHandler starts the handler here) +// +4 expansion-init entry (jsr-ed by the DiagArea stub +// with the DiagPoint registers; mounts the volumes) +// +0x40 struct DiagArea (er_InitDiagVec points here; the +// DiagPoint stub reaches the ROM via jsr 12(a0)) +// 0x3800 mount table, written by the emulator: +// u16 count, then count fixed-size entries of the DOS device name +// as a NUL-terminated string ("HOSTFS0", ...) +// 0x7000 per-unit volume DosList nodes, built by the emulator at startup +// and AddDosEntry'd by the handler (TRAP_RES_ADDVOLUME) +// 0x8000 emulator-managed guest object pool (FileLocks etc.); the handler +// never touches it + +#ifndef COPPERLINE_BOARD_H +#define COPPERLINE_BOARD_H + +#define BOARD_MANUFACTURER 0x1448 // dec0de Consulting +#define BOARD_PRODUCT 0x05 // Copperline services board + +#define ROM_OFFSET 0x0008 +#define MOUNTS_OFFSET 0x3800 +#define MOUNT_ENTRY_SIZE 32 +#define MOUNT_MAX_COUNT 16 +#define VOLUMES_OFFSET 0x7000 +#define VOLUME_SLOT_SIZE 128 +// Per-unit FileSysStartupMsg, written by the emulator at expansion init; +// dn_Startup points here so the Early Startup boot menu can display the +// device name, unit, and dostype instead of dereferencing garbage. Each +// FSSM references a per-unit DosEnvec whose de_BootPri carries the +// configured AddBootNode priority. +#define FSSM_OFFSET 0x7800 +#define FSSM_SLOT_SIZE 16 + +// A-line opcodes reserved for host traps (see FilesysHle in src/filesys.rs). +#define TRAP_DIAG_ENTRY 0xA400 // DiagPoint entered (logged by the host) +#define TRAP_PACKET 0xA402 // D1 = struct DosPacket *, A1 = handler port + +// trap_packet return values (D0). +#define TRAP_RES_REPLY 0 // packet complete: reply it to the sender +#define TRAP_RES_NOREPLY 1 // host keeps the packet (reserved, not yet used) +#define TRAP_RES_ADDVOLUME 2 // reply, then AddDosEntry the volume DosList + // node the host built (returned in A0): only + // guest code may take the DosList semaphore +#define TRAP_RES_DIE 3 // ACTION_DIE accepted: reply, RemDosEntry the + // volume node (in A0), and exit the process + // (dn_Task is already cleared, so the next + // reference restarts the handler) + +#endif // COPPERLINE_BOARD_H diff --git a/guest/services/entry.s b/guest/services/entry.s new file mode 100644 index 00000000..3e600716 --- /dev/null +++ b/guest/services/entry.s @@ -0,0 +1,74 @@ +| SPDX-License-Identifier: GPL-3.0-or-later +| +| Entry table and DiagArea of the handler ROM. Linked first, so this sits at +| ROM_OFFSET in the board window (see copperline_board.h). Everything must +| stay PC-relative: the ROM runs at whatever base autoconfig assigns. + + .text + .globl _entry_table + .globl _handler_main + .globl _mount_boards + +_entry_table: + | +0: handler process entry (DOS RunHandler jumps here via dn_SegList) + bra.w _handler_main + + | +4: expansion-init entry. The DiagArea's DiagPoint jsr's here from + | the diag copy with the documented DiagPoint registers still live: + | A0 = board base, A3 = ConfigDev, A5 = ExpansionBase. Trap to the + | host (which captures the board base), mount the configured volumes, + | and return D0 != 0 so Kickstart keeps the diag copy: strap calls + | da_BootPoint from it if one of our mounts wins the boot vote. +_diag_entry: + .short 0xA400 | TRAP_DIAG_ENTRY + move.l a3,-(sp) | ConfigDev + move.l a5,-(sp) | ExpansionBase + move.l a0,-(sp) | board base + bsr.w _mount_boards | mount_boards(board, ExpansionBase, ConfigDev) + lea 12(sp),sp + moveq #1,d0 + rts + + | struct DiagArea (libraries/configregs.h), at the fixed ROM offset + | DIAG_AREA_IN_ROM: er_InitDiagVec points here and Kickstart copies + | da_Size bytes to RAM before calling da_DiagPoint. All code offsets + | are relative to the copy, so the DiagPoint stub reaches the ROM + | through A0 (the board base) -- a bsr would aim into the copy. + | Hard-won Kickstart 3.x gotchas: da_Config needs a DAC_BOOTTIME bit + | or the area is abandoned after one read, and DAC_CONFIGTIME + | requires a non-zero da_BootPoint. + .org 0x40 | errors out if the code above grows past this +_diag_area: + .byte 0x90, 0x00 | da_Config = DAC_WORDWIDE + | | DAC_CONFIGTIME; da_Flags + .short _diag_area_end-_diag_area | da_Size + .short _diag_point-_diag_area | da_DiagPoint + .short _boot_point-_diag_area | da_BootPoint + .short _diag_name-_diag_area | da_Name + .short 0, 0 | da_Reserved01/02 +_diag_point: + jsr (_diag_entry-_entry_table+8)(a0) | +8 = ROM_OFFSET + rts +_boot_point: + | Called by strap (with A6 = ExecBase) when one of our BootNodes has + | the highest boot priority. The standard autoboot boot code, same as + | real autoboot ROMs: fire up dos.library, whose init then mounts the + | highest-priority BootNode -- ours -- as SYS:. Returns (boot failed, + | strap tries the next candidate) only if dos.library is missing. + lea _dos_name(pc),a1 + jsr -96(a6) | FindResident("dos.library") + tst.l d0 + beq.s 1f + move.l d0,a0 + move.l 22(a0),d0 | rt_Init + beq.s 1f + move.l d0,a0 + jsr (a0) | boots DOS; does not return on success +1: moveq #0,d0 + rts +_dos_name: + .asciz "dos.library" +_diag_name: + .asciz "Copperline" + .balign 2 +_diag_area_end: diff --git a/guest/services/handler.c b/guest/services/handler.c new file mode 100644 index 00000000..40b5539b --- /dev/null +++ b/guest/services/handler.c @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Guest-side handler for Copperline's services board. +// +// Two entry points (see entry.s and copperline_board.h): +// +// - mount_boards(): called at expansion init from the board's DiagArea with +// the documented DiagPoint context (ExpansionBase, ConfigDev). For each +// entry in the mount table the emulator wrote into the board window, it +// builds a DeviceNode whose dn_SegList points back into this ROM and +// adds it to the mount list. DOS mounts the nodes at boot; the handler +// process is started on first reference. +// +// - handler_main(): the DOS handler process. A pure packet pump: every +// DosPacket is handed to the emulator with one reserved A-line opcode +// (TRAP_PACKET); the emulator implements the ACTION_* semantics against +// the host filesystem and fills dp_Res1/dp_Res2 before the trap returns. +// +// The ROM must stay position-independent (compiled with -mpcrel) and free +// of data/bss sections; the Makefile fails the build if the linked +// executable contains relocations or data/bss hunks. + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#define EXEC_BASE_NAME _sysbase +#define EXPANSION_BASE_NAME _expbase +#define DOS_BASE_NAME _dosbase +#include +#include +#include + +#include "copperline_board.h" + +// The pump itself needs well under 200 bytes; 2K leaves headroom for the +// OS calls and a future printf(). +#define HANDLER_STACK 2048 + +// AbsExecBase. A plain *(struct ExecBase **)4 works too (a constant address +// needs no relocation) but trips GCC's array-bounds warning, which treats any +// dereference near address 0 as a null-pointer bug; the asm hides it and +// move.l 4.w is the canonical instruction anyway. +static struct ExecBase *sysbase(void) +{ + struct ExecBase *base; + __asm("move.l 4.w,%0" : "=r"(base)); + return base; +} + +// Hand the packet to the emulator, which fills dp_Res1/dp_Res2. Returns a +// TRAP_RES_* code; for TRAP_RES_ADDVOLUME the host also returns the volume +// DosList node it built in *vol (via A0). +static ULONG trap_packet(struct DosPacket *pkt, struct MsgPort *port, + struct DosList **vol) +{ + register ULONG res __asm("d0"); + register struct DosList *_vol __asm("a0"); + register struct DosPacket *_pkt __asm("d1") = pkt; + register struct MsgPort *_port __asm("a1") = port; + __asm volatile(".short 0xA402" // TRAP_PACKET + : "=r"(res), "=r"(_vol), "+r"(_pkt), "+r"(_port) + : + : "cc", "memory"); + *vol = _vol; + return res; +} + +void handler_main(void) +{ + struct ExecBase *_sysbase = sysbase(); + struct Library *_dosbase = OpenLibrary((STRPTR) "dos.library", 36); + struct Process *me = (struct Process *)FindTask(NULL); + struct MsgPort *port = &me->pr_MsgPort; + + for (;;) { + WaitPort(port); + struct Message *msg; + while ((msg = GetMsg(port)) != NULL) { + struct DosPacket *pkt = (struct DosPacket *)msg->mn_Node.ln_Name; + struct DosList *vol; + ULONG res = trap_packet(pkt, port, &vol); + if (res != TRAP_RES_NOREPLY) { + struct MsgPort *reply = pkt->dp_Port; + pkt->dp_Port = port; + PutMsg(reply, pkt->dp_Link); + } + // After replying, so DOS is not blocked on us while we take + // the DosList semaphore. + if (res == TRAP_RES_ADDVOLUME && _dosbase != NULL) + AddDosEntry(vol); + else if (res == TRAP_RES_DIE) { + // ACTION_DIE: the emulator already cleared dn_Task and + // dropped this unit's state. Take the volume off the + // DosList (AddDosEntry locks internally, RemDosEntry + // does not) and end the process. + if (vol != NULL && _dosbase != NULL) { + LockDosList(LDF_VOLUMES | LDF_WRITE); + RemDosEntry(vol); + UnLockDosList(LDF_VOLUMES | LDF_WRITE); + } + if (_dosbase != NULL) + CloseLibrary(_dosbase); + return; + } + } + } +} + +void mount_boards(UBYTE *board, struct Library *_expbase, struct ConfigDev *cd) +{ + struct ExecBase *_sysbase = sysbase(); + UWORD count = *(UWORD *)(board + MOUNTS_OFFSET); + + if (count > MOUNT_MAX_COUNT) + count = MOUNT_MAX_COUNT; + for (UWORD i = 0; i < count; i++) { + const UBYTE *name = + board + MOUNTS_OFFSET + 2 + (ULONG)i * MOUNT_ENTRY_SIZE; + ULONG len = 0; + while (name[len] != '\0' && len < MOUNT_ENTRY_SIZE - 1) + len++; + + // DeviceNode and its BSTR name in one public allocation. + struct DeviceNode *dn = AllocMem(sizeof(*dn) + 1 + len + 1, + MEMF_PUBLIC | MEMF_CLEAR); + if (dn == NULL) + break; + UBYTE *bname = (UBYTE *)(dn + 1); + bname[0] = len; + for (ULONG c = 0; c < len; c++) + bname[1 + c] = name[c]; + + dn->dn_Type = DLT_DEVICE; + dn->dn_StackSize = HANDLER_STACK; + dn->dn_Priority = 10; + // The emulator prepared one FileSysStartupMsg (with a per-unit + // DosEnvec) per mount at expansion init; the boot menu displays it, + // and the emulator reads the unit back from fssm_Unit at + // ACTION_STARTUP. + dn->dn_Startup = MKBADDR(board + FSSM_OFFSET + i * FSSM_SLOT_SIZE); + dn->dn_SegList = MKBADDR(board + 4); + dn->dn_GlobalVec = -1; // C handler: no BCPL global vector + dn->dn_Name = MKBADDR(bname); + + // Boot priority comes from the config via de_BootPri; the default + // -128 mounts at DOS init but is never a boot candidate. + // ADNF_STARTPROC: start the handler process at mount time rather + // than on first reference, so problems surface at boot. + struct FileSysStartupMsg *fssm = BADDR(dn->dn_Startup); + struct DosEnvec *env = BADDR(fssm->fssm_Environ); + AddBootNode((BYTE)env->de_BootPri, ADNF_STARTPROC, dn, cd); + } +} diff --git a/src/amigaos.rs b/src/amigaos.rs index 8868b6db..ffac4c28 100644 --- a/src/amigaos.rs +++ b/src/amigaos.rs @@ -7,6 +7,8 @@ //! 1.x through 3.x and AROS, so no version sniffing is needed -- only //! pointer plausibility checks, since the OS may simply not be up yet. +pub mod dos; + /// ExecBase field offsets (execbase.h). const EXECBASE_PTR: u32 = 4; const CHKBASE: u32 = 0x26; diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs new file mode 100644 index 00000000..ba83d672 --- /dev/null +++ b/src/amigaos/dos.rs @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! AmigaDOS ABI mirror (NDK dos/dos.h and dos/dosextens.h): the packet +//! types, error codes, and guest-memory structures a DOS handler works +//! with. Structures are `#[repr(C)]` with big-endian fields, so the Rust +//! definition IS the guest layout (offsets guaranteed, no padding possible) +//! and one `write_bytes(bus, at, x.as_bytes())` serializes it. BCPL strings +//! are fixed in-struct arrays or appended BSTRs. + +use m68k::AddressBus; +use zerocopy::byteorder::{BigEndian, U32}; +use zerocopy::Immutable; +pub use zerocopy::IntoBytes; + +// Packet types (dos/dosextens.h ACTION_*). +pub const ACTION_DIE: i32 = 5; +pub const ACTION_LOCATE_OBJECT: i32 = 8; +pub const ACTION_RENAME_DISK: i32 = 9; +pub const ACTION_FREE_LOCK: i32 = 15; +pub const ACTION_DELETE_OBJECT: i32 = 16; +pub const ACTION_RENAME_OBJECT: i32 = 17; +pub const ACTION_COPY_DIR: i32 = 19; // DupLock() +pub const ACTION_SET_PROTECT: i32 = 21; +pub const ACTION_CREATE_DIR: i32 = 22; +pub const ACTION_EXAMINE_OBJECT: i32 = 23; +pub const ACTION_EXAMINE_NEXT: i32 = 24; +pub const ACTION_DISK_INFO: i32 = 25; +pub const ACTION_INFO: i32 = 26; +pub const ACTION_FLUSH: i32 = 27; +pub const ACTION_SET_COMMENT: i32 = 28; +pub const ACTION_PARENT: i32 = 29; +pub const ACTION_SET_DATE: i32 = 34; +pub const ACTION_SAME_LOCK: i32 = 40; +pub const ACTION_READ: i32 = 82; // 'R' +pub const ACTION_WRITE: i32 = 87; // 'W' +pub const ACTION_FINDUPDATE: i32 = 1004; // Open(..., MODE_READWRITE) +pub const ACTION_FINDINPUT: i32 = 1005; // Open(..., MODE_OLDFILE) +pub const ACTION_FINDOUTPUT: i32 = 1006; // Open(..., MODE_NEWFILE) +pub const ACTION_END: i32 = 1007; // Close() +pub const ACTION_SEEK: i32 = 1008; +pub const ACTION_SET_FILE_SIZE: i32 = 1022; +pub const ACTION_FH_FROM_LOCK: i32 = 1026; // OpenFromLock() +pub const ACTION_IS_FILESYSTEM: i32 = 1027; +pub const ACTION_PARENT_FH: i32 = 1031; // ParentOfFH() +pub const ACTION_EXAMINE_FH: i32 = 1034; // ExamineFH() + +// dos/dos.h. +pub const DOSTRUE: u32 = 0xFFFF_FFFF; +pub const DOSFALSE: u32 = 0; +pub const ERROR_OBJECT_IN_USE: u32 = 202; +pub const ERROR_OBJECT_NOT_FOUND: u32 = 205; +pub const ERROR_ACTION_NOT_KNOWN: u32 = 209; +pub const ERROR_INVALID_LOCK: u32 = 211; +pub const ERROR_OBJECT_WRONG_TYPE: u32 = 212; +pub const ERROR_DISK_WRITE_PROTECTED: u32 = 214; +pub const ERROR_SEEK_ERROR: u32 = 219; +pub const ERROR_NO_MORE_ENTRIES: u32 = 232; +/// ACTION_SEEK Arg3 modes (dos/dos.h OFFSET_*). +pub const OFFSET_BEGINNING: i32 = -1; +pub const OFFSET_CURRENT: i32 = 0; +pub const OFFSET_END: i32 = 1; +/// fl_Access shared ("read") lock (dos/dos.h ACCESS_READ = -2). +pub const ACCESS_READ: u32 = 0xFFFF_FFFE; +/// id_DiskState: write-protected (hardware or software). +pub const ID_WRITE_PROTECTED: u32 = 80; +/// id_DiskState: validated, read/write. +pub const ID_VALIDATED: u32 = 82; +// fib_DirEntryType values (dos/dosextens.h ST_*). +pub const ST_ROOT: i32 = 1; +pub const ST_USERDIR: i32 = 2; +pub const ST_FILE: i32 = -3; +// fib_Protection bits (dos/dos.h FIBF_*). The rwed group is inverted: +// a SET bit means the operation is DENIED. +pub const FIBF_DELETE: u32 = 1 << 0; +pub const FIBF_WRITE: u32 = 1 << 2; + +// The isolated fields poked in DOS structures the guest owns. +/// `dn_Task` in `struct DeviceNode` (dos/dosextens.h). +pub const DEVICENODE_TASK: u32 = 8; +/// `dn_Startup` in `struct DeviceNode`. +pub const DEVICENODE_STARTUP: u32 = 28; +/// `fh_Arg1` in `struct FileHandle` (dos/dosextens.h). +pub const FILEHANDLE_ARG1: u32 = 36; + +/// A big-endian LONG as the guest sees it. +pub type Long = U32; + +pub fn long(v: u32) -> Long { + U32::new(v) +} + +/// `struct InfoData` (dos/dos.h). +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct InfoData { + pub num_soft_errors: Long, + pub unit_number: Long, + pub disk_state: Long, + pub num_blocks: Long, + pub num_blocks_used: Long, + pub bytes_per_block: Long, + pub disk_type: Long, + pub volume_node: Long, // BPTR + pub in_use: Long, +} + +/// `struct FileLock` (dos/dosextens.h). +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct FileLock { + pub link: Long, // BPTR + pub key: Long, // opaque to DOS + pub access: Long, // ACCESS_READ / ACCESS_WRITE + pub task: Long, // APTR: the handler's MsgPort + pub volume: Long, // BPTR to the volume DosList node +} + +/// `struct DosList`, DLT_VOLUME flavor (dos/dosextens.h struct DeviceList). +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct VolumeNode { + pub next: Long, // BPTR + pub r#type: Long, + pub task: Long, // APTR: the handler's MsgPort + pub lock: Long, // BPTR + pub volume_date: [Long; 3], + pub lock_list: Long, // BPTR + pub disk_type: Long, + pub unused: Long, + pub name: Long, // BSTR +} + +/// `struct FileSysStartupMsg` (dos/filehandler.h). The boot menu (Early +/// Startup) dereferences dn_Startup as a BPTR to this and displays +/// fssm_Device, so even a virtual handler wants a well-formed one. +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct FileSysStartupMsg { + pub unit: Long, + pub device: Long, // BSTR: exec device name (display-only for us) + pub environ: Long, // BPTR to a DosEnvec + pub flags: Long, +} + +/// `struct DosEnvec` (dos/filehandler.h), the fixed 17-long geometry table +/// (de_TableSize = 16, counting entries after itself through de_DosType). +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct DosEnvec { + pub table_size: Long, + pub size_block: Long, // in longwords + pub sec_org: Long, + pub surfaces: Long, + pub sectors_per_block: Long, + pub blocks_per_track: Long, + pub reserved: Long, + pub pre_alloc: Long, + pub interleave: Long, + pub low_cyl: Long, + pub high_cyl: Long, + pub num_buffers: Long, + pub buf_mem_type: Long, + pub max_transfer: Long, + pub mask: Long, + pub boot_pri: Long, + pub dos_type: Long, +} + +/// `struct FileInfoBlock` (dos/dos.h), through fib_Comment. The file name +/// and comment are BCPL strings embedded in the block. +#[derive(IntoBytes, Immutable)] +#[repr(C)] +pub struct FileInfoBlock { + pub disk_key: Long, + pub dir_entry_type: Long, + pub file_name: [u8; 108], + pub protection: Long, + pub entry_type: Long, + pub size: Long, + pub num_blocks: Long, + pub date: [Long; 3], + pub comment: [u8; 80], +} + +/// A BCPL string field: length byte + bytes + NUL (the NUL for 2.0+ tools). +pub fn bcpl(s: &[u8]) -> [u8; N] { + let mut out = [0u8; N]; + let len = s.len().min(N - 2); + out[0] = len as u8; + out[1..1 + len].copy_from_slice(&s[..len]); + out +} + +/// Copy a host byte slice into guest memory. +pub fn write_bytes(bus: &mut dyn AddressBus, at: u32, bytes: &[u8]) { + for (i, &b) in bytes.iter().enumerate() { + bus.write_byte(at + i as u32, b); + } +} + +/// Read a BSTR (BPTR to length-prefixed string) from guest memory. +pub fn read_bstr(bus: &mut dyn AddressBus, bptr: u32) -> Vec { + let addr = bptr << 2; + let len = bus.read_byte(addr) as u32; + (0..len).map(|i| bus.read_byte(addr + 1 + i)).collect() +} + +/// An AmigaDOS DateStamp: days/minutes/ticks since 1978-01-01. +pub type DateStamp = (u32, u32, u32); + +/// Host mtime -> AmigaDOS DateStamp. +pub fn amiga_datestamp(time: Option) -> DateStamp { + /// Seconds between the Unix epoch and the AmigaDOS epoch. + const AMIGA_EPOCH_OFFSET: u64 = 252_460_800; + let secs = time + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0) + .saturating_sub(AMIGA_EPOCH_OFFSET); + ( + (secs / 86_400) as u32, + (secs % 86_400 / 60) as u32, + (secs % 60) as u32 * 50, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structs_have_the_ndk_layout() { + assert_eq!(std::mem::size_of::(), 36); + assert_eq!(std::mem::size_of::(), 20); + assert_eq!(std::mem::size_of::(), 44); + assert_eq!(std::mem::size_of::(), 224); + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::size_of::(), 68); + // Spot-check a serialized field: id_DiskType at offset 24, BE. + let mut info: InfoData = unsafe { std::mem::zeroed() }; + info.disk_type = long(0x444F_5301); + assert_eq!(&info.as_bytes()[24..28], &[0x44, 0x4F, 0x53, 0x01]); + } + + #[test] + fn datestamp_of_known_moment() { + // 1978-01-01 00:01:30 UTC = day 0, minute 1, 1500 ticks. + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(252_460_800 + 90); + assert_eq!(amiga_datestamp(Some(t)), (0, 1, 30 * 50)); + } +} diff --git a/src/config.rs b/src/config.rs index ba28f096..9996a83c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,6 +74,10 @@ pub struct Config { /// identify.library can detect the emulator. Defaults to true; set /// `identify = false` for a chain with no emulator-identifying board. pub identify_board: bool, + /// `[[filesys]]` host directories exported to the guest as + /// AmigaDOS devices `HOSTFS0:`, `HOSTFS1:`, ... (experimental). Empty: + /// no services board on the autoconfig chain. + pub filesys: Vec, pub chipset: Chipset, /// Concrete chip revisions derived from the `[chipset] revision` preset, /// installed chip RAM, and the optional `agnus`/`denise` overrides. @@ -877,6 +881,7 @@ impl Default for Config { zorro_boards: Vec::new(), wasm_boards: Vec::new(), identify_board: true, + filesys: Vec::new(), // The no-[machine] default models the most common and most- // targeted Amiga: the A500 Rev 6A (the ECS "Fatter" 8372A Agnus // with the original OCS 8362 Denise). Selecting `[chipset] @@ -982,6 +987,45 @@ impl Config { if self.identify_board { chain.add_board(BoardSpec::copperline_id())?; } + // Copperline services board (experimental, `[[filesys]]`): + // carries the guest handler, the mount table, and the DiagArea that + // mounts the configured host directories at expansion init. See + // crate::filesys. + if !self.filesys.is_empty() { + if self.filesys.len() > crate::filesys::MOUNT_MAX_COUNT { + anyhow::bail!( + "[[filesys]]: at most {} mounts supported", + crate::filesys::MOUNT_MAX_COUNT + ); + } + for m in &self.filesys { + if !m.path.is_dir() { + anyhow::bail!("[[filesys]] path {} is not a directory", m.path.display()); + } + // The volume name becomes an AmigaDOS volume label (a DosList + // BSTR): 1-30 bytes, and no ':' '/' or NUL. + let vol = &m.volume; + if vol.is_empty() { + anyhow::bail!("[[filesys]] volume name must not be empty"); + } + if vol.len() > 30 { + anyhow::bail!( + "[[filesys]] volume name {vol:?} is too long ({} bytes; max 30)", + vol.len() + ); + } + if vol.contains([':', '/', '\0']) { + anyhow::bail!( + "[[filesys]] volume name {vol:?} contains an invalid \ + character (no ':' '/' or NUL)" + ); + } + } + chain.add_board_with_rom( + BoardSpec::copperline_services(), + &crate::filesys::board_image(&self.filesys), + )?; + } Ok(chain) } } @@ -1175,6 +1219,9 @@ pub struct RawConfig { pub(crate) input: RawInput, #[serde(default, skip_serializing_if = "is_default")] pub(crate) serial: RawSerial, + /// `[[filesys]]` host-directory mount entries, in file order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) filesys: Vec, /// `[[zorro]]` board entries, configured in file order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) zorro: Vec, @@ -1212,6 +1259,22 @@ pub(crate) struct RawDisplay { pub(crate) phosphor: Option, } +/// One `[[filesys]]` entry (experimental): a host directory exported to the +/// guest as the AmigaDOS device `HOSTFS:` (n = position in the config). +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RawFilesysMount { + /// Host directory to export. + pub(crate) path: String, + /// AmigaDOS volume name; defaults to the directory's name. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) volume: Option, + /// Boot priority (-128..=127); defaults to -128, which mounts the + /// volume but never offers it as a boot candidate. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) bootpri: Option, +} + /// `[input]` host-input preferences. Currently just the initial joystick input /// mode; the status-bar toggle and `Cmd+J` / `Alt+J` flip it live. #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -1970,6 +2033,20 @@ impl TryFrom for Config { zorro_boards, wasm_boards, identify_board: raw.identify.unwrap_or(defaults.identify_board), + filesys: raw + .filesys + .iter() + .map(|m| crate::filesys::MountSpec { + path: std::path::PathBuf::from(&m.path), + volume: m.volume.clone().unwrap_or_else(|| { + std::path::Path::new(&m.path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "HostFS".into()) + }), + boot_pri: m.bootpri.unwrap_or(-128), + }) + .collect(), chipset, agnus_revision, denise_revision, @@ -2669,6 +2746,28 @@ mod tests { } } + #[test] + fn filesys_volume_name_is_validated() { + use crate::filesys::MountSpec; + let with_volume = |volume: &str| Config { + filesys: vec![MountSpec { + path: std::path::PathBuf::from("."), + volume: volume.to_string(), + boot_pri: -128, + }], + ..Config::default() + }; + // A sane label mounts (the services board is added). + assert!(with_volume("Work").build_zorro_chain().is_ok()); + // The three failure modes each report their own error. + let err = |v: &str| format!("{:#}", with_volume(v).build_zorro_chain().unwrap_err()); + assert!(err("").contains("must not be empty")); + assert!(err("this-volume-name-is-far-too-long-to-fit").contains("too long")); + for bad in ["a:b", "a/b", "a\0b"] { + assert!(err(bad).contains("invalid character"), "volume {bad:?}"); + } + } + #[test] fn raw_config_serialize_round_trips() { // A populated raw config (the kind the configuration screen builds) diff --git a/src/cpu.rs b/src/cpu.rs index 995d8cde..2b444553 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -10,7 +10,7 @@ use crate::memory::{ }; use anyhow::{anyhow, Result}; use log::{debug, trace}; -use m68k::{AddressBus, CpuCore, CpuType, NoOpHleHandler, StepResult}; +use m68k::{AddressBus, CpuCore, CpuType, StepResult}; pub const CIA_A_BASE: u32 = 0x00BF_E000; pub const CIA_A_SIZE: u32 = 0x0000_1000; @@ -96,7 +96,7 @@ struct UiTrace { pub struct M68kMachine { cpu: CpuCore, bus: CpuBus, - hle: NoOpHleHandler, + hle: crate::filesys::FilesysHle, fpu_enabled: bool, /// Last CACR value pushed into the cache models, so the per-instruction /// sync is a single compare when nothing changed. @@ -333,7 +333,7 @@ impl M68kMachine { ipl_sample_held: false, diag_current_pc: 0, }, - hle: NoOpHleHandler, + hle: crate::filesys::FilesysHle::default(), fpu_enabled, last_cacr: 0, dbg_ipl_main_cyc: 0, @@ -1275,6 +1275,12 @@ impl M68kMachine { } } + /// Configure the host directories served by the filesys trap gateway + /// (`[[filesys]]`). + pub fn set_filesys_mounts(&mut self, mounts: Vec) { + self.hle.set_mounts(mounts); + } + pub fn bus(&self) -> &Bus { &self.bus.bus } diff --git a/src/emulator.rs b/src/emulator.rs index 240474c5..6901270c 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -2008,6 +2008,7 @@ pub fn build_machine( )?; emu.set_cache_emulation(cfg.cpu_icache, cfg.cpu_dcache); emu.set_machine_descriptor(cfg.descriptor()); + emu.machine.set_filesys_mounts(cfg.filesys.clone()); Ok(emu) } diff --git a/src/filesys.rs b/src/filesys.rs new file mode 100644 index 00000000..f33ec9c1 --- /dev/null +++ b/src/filesys.rs @@ -0,0 +1,1177 @@ +//! Host-filesystem service: mount host directories as AmigaDOS volumes +//! (`HOSTFS0:`, `HOSTFS1:`, ...). +//! +//! The guest side is a tiny handler (see `guest/services/`) mapped into the +//! Copperline services board together with a mount table and a hand-built +//! DiagArea. At expansion init the DiagArea's DiagPoint calls the handler's +//! expansion-init entry with the DiagPoint context; the handler builds one +//! DeviceNode per mount table entry and `AddBootNode`s it, so DOS mounts the +//! devices at boot and starts the handler process on first reference. +//! The handler forwards every DosPacket to [`FilesysHle`] through a reserved +//! A-line trap; all ACTION_* semantics are implemented here against the host +//! filesystem, with results written straight into guest memory. +//! +//! Guest-visible objects the handler must hand out (FileLocks) are allocated +//! from a pool inside the board window, so the host never has to call +//! AllocMem in the guest. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use m68k::{AddressBus, CpuCore, HleHandler}; + +use crate::amigaos::dos::*; + +/// The guest-side handler ROM (see `guest/services/README.md`). +pub const FILESYS_HANDLER: &[u8] = include_bytes!("../assets/services/services_rom.bin"); + +// Board-window layout. Keep in sync with `guest/services/copperline_board.h`; the unit +// tests lock it. +/// Handler code offset; the two longwords before it are a fake seglist header +/// (length, next = 0) so `dn_SegList = (base + 4) >> 2`. +pub const ROM_OFFSET: usize = 0x0008; +/// Mount table: u16 count, then fixed-size NUL-terminated device names. +pub const MOUNTS_OFFSET: usize = 0x3800; +pub const MOUNT_ENTRY_SIZE: usize = 32; +pub const MOUNT_MAX_COUNT: usize = 16; +/// The DiagArea (`BoardSpec::copperline_services` points er_InitDiagVec +/// here): embedded in the handler ROM at +0x40, like real autoboot boards +/// carry theirs in the device ROM (see `_diag_area` in entry.s). +pub const DIAG_OFFSET: u16 = ROM_OFFSET as u16 + 0x40; +/// Per-unit volume DosList nodes, built by the host at handler startup and +/// AddDosEntry'd by the guest handler (TRAP_RES_ADDVOLUME). +const VOLUMES_OFFSET: u32 = 0x7000; +const VOLUME_SLOT_SIZE: u32 = 128; +/// Per-unit FileSysStartupMsg (dn_Startup points here), written by the host +/// at expansion init so the Early Startup boot menu displays a device name, +/// unit, and dostype instead of dereferencing garbage. The shared display +/// device name (BSTR) and DosEnvec follow the 16 slots. +const FSSM_OFFSET: u32 = 0x7800; +const FSSM_SLOT_SIZE: u32 = 16; +const FSSM_DEVNAME_OFFSET: u32 = 0x7900; +/// Per-unit DosEnvec slots (each mount has its own de_BootPri). +const FSSM_ENVEC_OFFSET: u32 = 0x7940; +const ENVEC_SLOT_SIZE: u32 = 68; // sizeof(DosEnvec) +/// Host-managed pool for guest-visible objects (FileLocks), through the end +/// of the 64K window. The guest never touches it. +const POOL_OFFSET: u32 = 0x8000; +const POOL_END: u32 = 0x1_0000; +/// FileLock is 20 bytes; keep slots longword-aligned. +const LOCK_SLOT_SIZE: u32 = 24; + +// trap_packet return values (D0); see guest/services/copperline_board.h. +const TRAP_RES_REPLY: u32 = 0; +const TRAP_RES_ADDVOLUME: u32 = 2; +const TRAP_RES_DIE: u32 = 3; + +/// Base of the reserved A-line opcode range for filesys host traps. A-line +/// (LINE 1010, exception vector 10) is unused by AmigaOS, so these never +/// collide with guest code. +pub const TRAP_BASE: u16 = 0xA400; +/// DiagPoint entered: logged, and A0 (the board base) is captured. +const TRAP_DIAG_ENTRY: u16 = 0xA400; +/// DosPacket from the handler: D1 = packet APTR, A1 = handler MsgPort. +const TRAP_PACKET: u16 = 0xA402; + +/// 'CLFS' -- our own id_DiskType, honest about not being an FFS at all. +/// (The UAE filesys reports 'DOS\1' here instead; if some tool turns out to +/// insist on a DOS\x dostype, reconsider.) +const ID_CLFS_DISK: u32 = 0x434C_4653; + +/// One `[[filesys]]` entry: a host directory exported as an AmigaDOS +/// device `HOSTFS:` with the given volume name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MountSpec { + pub path: PathBuf, + pub volume: String, + /// AddBootNode priority: -128 = mounted but never a boot candidate + /// (the default); higher beats other boot devices in strap's ranking. + pub boot_pri: i8, +} + +/// DOS device name of mount `unit` (`HOSTFS0`, `HOSTFS1`, ...). +pub fn device_name(unit: usize) -> String { + format!("HOSTFS{unit}") +} + +/// Build the 64K board window: fake seglist header, the handler ROM (which +/// embeds the DiagArea), and the mount table. +pub fn board_image(mounts: &[MountSpec]) -> Vec { + assert!(ROM_OFFSET + FILESYS_HANDLER.len() <= MOUNTS_OFFSET); + assert!(mounts.len() <= MOUNT_MAX_COUNT); + let mut img = vec![0u8; 0x1_0000]; + + // Fake seglist: length in longwords at +0 (unused by DOS for handlers, + // but kept sane), next-segment BPTR = 0 at +4, code at +8. + let seg_longs = ((ROM_OFFSET + FILESYS_HANDLER.len()) / 4) as u32; + img[0..4].copy_from_slice(&seg_longs.to_be_bytes()); + img[ROM_OFFSET..ROM_OFFSET + FILESYS_HANDLER.len()].copy_from_slice(FILESYS_HANDLER); + + // Mount table: u16 count, then MOUNT_ENTRY_SIZE-byte NUL-terminated + // device names. + let m = MOUNTS_OFFSET; + img[m..m + 2].copy_from_slice(&(mounts.len() as u16).to_be_bytes()); + for (i, _) in mounts.iter().enumerate() { + let name = device_name(i); + let at = m + 2 + i * MOUNT_ENTRY_SIZE; + img[at..at + name.len()].copy_from_slice(name.as_bytes()); + } + + img +} + +/// DosList surgery the guest handler performs after replying a packet +/// (only guest code may take the DosList semaphore); maps to a TRAP_RES_* +/// code with the node address in A0. +enum GuestOp { + /// AddDosEntry the volume node the host built. + AddVolume(u32), + /// ACTION_DIE: RemDosEntry the volume node, then exit the process. + Die(u32), +} + +/// A handed-out FileLock: which mount it belongs to and the path relative to +/// the mount root (empty = the root itself). Keyed by the guest address of +/// the FileLock structure in the board-window pool. +#[derive(Debug, Clone)] +struct LockRec { + unit: usize, + rel: PathBuf, +} + +/// Host side of the filesys trap gateway: implements the AmigaDOS packet +/// ACTION_* semantics against the host directories in `mounts`. +/// +/// "Hle" is the m68k crate's HleHandler trait: High-Level Emulation, the +/// hook that intercepts reserved opcodes on the host side instead of letting +/// the guest take the exception. Installed as the CPU's HLE handler; it +/// reacts only to the reserved [`TRAP_BASE`] range, so leaving it installed +/// with no mounts configured changes nothing. +#[derive(Default)] +pub struct FilesysHle { + mounts: Vec, + /// Board base address, captured from A0 at the DiagPoint trap. + board_base: Option, + /// Handler MsgPort address -> mount unit, learned from startup packets. + /// All per-boot state is cleared at the TRAP_DIAG_ENTRY of the next + /// boot (expansion init runs exactly once per boot). + ports: HashMap, + /// Mount unit -> guest address of its volume DosList node. + volumes: HashMap, + /// Mount unit -> guest address of its DeviceNode (from the startup + /// packet), for clearing dn_Task at ACTION_DIE. + device_nodes: HashMap, + /// Open files by fh_Arg1 cookie (host-side only, no guest structure). + /// The LockRec remembers what the handle refers to, for EXAMINE_FH. + files: HashMap, + next_file_key: u32, + /// Guest FileLock address -> what it locks. + locks: HashMap, + /// Free slots in the board-window lock pool (guest addresses). + free_slots: Vec, + /// Bump allocator behind `free_slots`, board-relative. + pool_next: u32, +} + +impl FilesysHle { + pub fn set_mounts(&mut self, mounts: Vec) { + self.mounts = mounts; + } + + /// Host path a lock refers to. + fn lock_path(&self, rec: &LockRec) -> PathBuf { + self.mounts[rec.unit].path.join(&rec.rel) + } + + /// Allocate a FileLock in the board-window pool and register it. + fn alloc_lock( + &mut self, + bus: &mut dyn AddressBus, + port: u32, + access: u32, + rec: LockRec, + ) -> Option { + let base = self.board_base?; + let addr = self.free_slots.pop().or_else(|| { + let next = POOL_OFFSET + self.pool_next; + (next + LOCK_SLOT_SIZE <= POOL_END).then(|| { + self.pool_next += LOCK_SLOT_SIZE; + base + next + }) + })?; + let lock = FileLock { + link: long(0), + key: long(addr), + access: long(access), + task: long(port), + volume: long(self.volumes.get(&rec.unit).copied().unwrap_or(0) >> 2), + }; + write_bytes(bus, addr, lock.as_bytes()); + self.locks.insert(addr, rec); + Some(addr) + } + + /// Resolve a DOS path (BPTR lock + name) to a lock record. AmigaDOS path + /// semantics: an optional `prefix:` is stripped (the supplied lock is + /// already the base it named), `/` goes to the parent, and names are + /// case-insensitive. + fn resolve(&self, unit: usize, lock_bptr: u32, name: &[u8]) -> Option { + let (unit, mut rel) = if lock_bptr != 0 { + let rec = self.locks.get(&(lock_bptr << 2))?; + (rec.unit, rec.rel.clone()) + } else { + (unit, PathBuf::new()) + }; + + let mut rest = name; + if let Some(colon) = name.iter().position(|&b| b == b':') { + if !name[..colon].contains(&b'/') { + // "Volume:" or "Assign:" prefix: DOS routes the packet by + // the prefix but passes the user's string through unmodified, + // and the supplied lock is already the right base -- for an + // assign like LIBS: it IS the target directory. Strip the + // prefix and stay at the lock (matches UAE's get_aino; + // restarting at the root instead broke "LIBS:foo" opens). + rest = &name[colon + 1..]; + } + } + if rest.is_empty() { + // Bare "DEVICE:" or an empty name: the base itself. (split() + // below would yield one empty component = "parent", wrong.) + return Some(LockRec { unit, rel }); + } + // A single trailing '/' does not mean parent: "Sub/" is Sub itself + // (the "directory part" convention; verified against FFS, where + // "Prefs/" lists Prefs but "Prefs//" lists its parent). + let mut comps: Vec<&[u8]> = rest.split(|&b| b == b'/').collect(); + if comps.last() == Some(&&b""[..]) { + comps.pop(); + } + for comp in comps { + if comp.is_empty() { + // Leading or doubled '/': up to the parent. + if !rel.pop() { + return None; + } + continue; + } + let comp = String::from_utf8_lossy(comp).into_owned(); + let dir = self.mounts.get(unit)?.path.join(&rel); + rel.push(match_component(&dir, &comp)?); + } + Some(LockRec { unit, rel }) + } + + /// Fill a FileInfoBlock from a host path. + fn fill_fib( + &self, + bus: &mut dyn AddressBus, + fib: u32, + rec: &LockRec, + disk_key: u32, + ) -> Result<(), u32> { + let path = self.lock_path(rec); + let meta = std::fs::metadata(&path).map_err(|_| ERROR_OBJECT_NOT_FOUND)?; + let name: String = if rec.rel.as_os_str().is_empty() { + self.mounts[rec.unit].volume.clone() + } else { + rec.rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + let entry_type: i32 = if !meta.is_dir() { + ST_FILE + } else if rec.rel.as_os_str().is_empty() { + ST_ROOT + } else { + ST_USERDIR + }; + + // Protection, datestamp, and comment come from the UAE `.uaem` + // sidecar when one exists (written by UAE-family emulators for the + // attributes a host filesystem cannot hold: script/pure/archive, + // comments, exact datestamps). Otherwise fall back to what the host + // can express: read-only denies `w`; `e` stays allowed (mapping + // Unix x to it would stop most copied binaries from running) -- + // both matching the UAE fsdb. + let uaem = read_uaem(&path); + let protection = match &uaem { + Some(u) => u.protection, + None if meta.permissions().readonly() => FIBF_WRITE, + None => 0, + }; + let (days, mins, ticks) = uaem + .as_ref() + .and_then(|u| u.date) + .unwrap_or_else(|| amiga_datestamp(meta.modified().ok())); + let comment = uaem.map(|u| u.comment).unwrap_or_default(); + let fib_data = FileInfoBlock { + disk_key: long(disk_key), + dir_entry_type: long(entry_type as u32), + file_name: bcpl::<108>(name.as_bytes()), + protection: long(protection), + entry_type: long(entry_type as u32), + size: long(meta.len().min(u32::MAX as u64) as u32), + num_blocks: long(meta.len().div_ceil(512).min(u32::MAX as u64) as u32), + date: [long(days), long(mins), long(ticks)], + comment: bcpl::<80>(&comment), + }; + write_bytes(bus, fib, fib_data.as_bytes()); + Ok(()) + } + + /// Sorted directory listing used by EXAMINE_NEXT, hiding the `.uaem` + /// metadata sidecars (their contents surface as the companion file's + /// attributes instead). Recomputed per call: simple and correct for + /// interactive use; cache if it ever shows up. + fn dir_listing(&self, rec: &LockRec) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(self.lock_path(rec)) + .into_iter() + .flatten() + .flatten() + .map(|e| e.file_name()) + .filter(|n| !n.as_encoded_bytes().ends_with(b".uaem")) + .collect(); + names.sort(); + names + } + + /// Write one FileSysStartupMsg per mount into the board window, plus the + /// shared display device name and the per-unit DosEnvecs they reference. + /// dn_Startup points at these so the Early Startup boot menu shows + /// "CLFS hostfs-N" instead of dereferencing garbage, ACTION_STARTUP reads + /// the unit back from fssm_Unit, and the guest handler passes de_BootPri + /// to AddBootNode. + fn write_startup_msgs(&self, bus: &mut dyn AddressBus, base: u32) { + write_bytes(bus, base + FSSM_DEVNAME_OFFSET, &bcpl::<32>(b"hostfs")); + for (unit, mount) in self.mounts.iter().enumerate() { + let unit = unit as u32; + let envec = DosEnvec { + table_size: long(16), // entries after this one, through dos_type + size_block: long(128), // longwords = 512-byte blocks + sec_org: long(0), + surfaces: long(1), + sectors_per_block: long(1), + blocks_per_track: long(1), + reserved: long(2), + pre_alloc: long(0), + interleave: long(0), + low_cyl: long(0), + high_cyl: long(0), + num_buffers: long(1), + buf_mem_type: long(1), // MEMF_PUBLIC + max_transfer: long(0x7FFF_FFFF), + mask: long(0xFFFF_FFFE), + boot_pri: long(mount.boot_pri as i32 as u32), + dos_type: long(ID_CLFS_DISK), + }; + let envec_at = base + FSSM_ENVEC_OFFSET + unit * ENVEC_SLOT_SIZE; + write_bytes(bus, envec_at, envec.as_bytes()); + let fssm = FileSysStartupMsg { + unit: long(unit), + device: long((base + FSSM_DEVNAME_OFFSET) >> 2), + environ: long(envec_at >> 2), + flags: long(0), + }; + write_bytes( + bus, + base + FSSM_OFFSET + unit * FSSM_SLOT_SIZE, + fssm.as_bytes(), + ); + } + } + + /// Build the volume DosList node for `unit` in the board window; the + /// guest handler AddDosEntry's it (only guest code may take the DosList + /// semaphore). Returns the node's guest address. + fn build_volume_node(&mut self, bus: &mut dyn AddressBus, unit: usize, port: u32) -> u32 { + let base = self.board_base.expect("startup packet before DiagPoint"); + let vol = base + VOLUMES_OFFSET + unit as u32 * VOLUME_SLOT_SIZE; + let fixed = std::mem::size_of::() as u32; + let (days, mins, ticks) = amiga_datestamp(Some(std::time::SystemTime::now())); + let node = VolumeNode { + next: long(0), + r#type: long(2), // DLT_VOLUME + task: long(port), + lock: long(0), + volume_date: [long(days), long(mins), long(ticks)], + lock_list: long(0), + disk_type: long(ID_CLFS_DISK), + unused: long(0), + name: long((vol + fixed) >> 2), // BSTR right after the struct + }; + write_bytes(bus, vol, node.as_bytes()); + let name: Vec = self.mounts[unit].volume.bytes().take(30).collect(); + write_bytes(bus, vol + fixed, &bcpl::<32>(&name)); + self.volumes.insert(unit, vol); + vol + } + + /// Handle one DosPacket; returns (dp_Res1, dp_Res2). Some packets also + /// need DosList surgery only the guest may perform (the semaphore); + /// `guest_op` tells the handler what to do after replying. + fn handle_packet( + &mut self, + bus: &mut dyn AddressBus, + port: u32, + pkt: u32, + guest_op: &mut Option, + ) -> (u32, u32) { + let dp_type = bus.read_long(pkt + 8) as i32; + let arg = |bus: &mut dyn AddressBus, n: u32| bus.read_long(pkt + 20 + 4 * (n - 1)); + + // The first packet on a port is the startup packet DOS sends when it + // starts the handler process (its dp_Type is not meaningful). + if !self.ports.contains_key(&port) { + let dn = arg(bus, 3) << 2; // dp_Arg3: BPTR DeviceNode + // dn_Startup is a BPTR to the unit's FileSysStartupMsg (written + // by write_startup_msgs); fssm_Unit is its first field. + let fssm = bus.read_long(dn + DEVICENODE_STARTUP) << 2; + let unit = bus.read_long(fssm) as usize; + if unit >= self.mounts.len() { + log::warn!("filesys: startup packet for unknown unit {unit}"); + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + } + bus.write_long(dn + DEVICENODE_TASK, port); + self.ports.insert(port, unit); + self.device_nodes.insert(unit, dn); + *guest_op = Some(GuestOp::AddVolume(self.build_volume_node(bus, unit, port))); + log::info!( + "filesys: {}: handler started ({}: -> {})", + device_name(unit), + self.mounts[unit].volume, + self.mounts[unit].path.display() + ); + return (DOSTRUE, 0); + } + let unit = self.ports[&port]; + + match dp_type { + ACTION_IS_FILESYSTEM => (DOSTRUE, 0), + ACTION_DISK_INFO | ACTION_INFO => { + // DISK_INFO: Arg1 = BPTR InfoData; INFO: Arg2 (Arg1 is a lock). + let n = if dp_type == ACTION_DISK_INFO { 1 } else { 2 }; + let id = arg(bus, n) << 2; + // Like the UAE filesys, report the size and free space of the + // host filesystem holding the mount (statvfs), scaled so the + // block counts survive AmigaDOS's 32-bit arithmetic. + let (total, avail) = + host_fs_usage(&self.mounts[unit].path).unwrap_or((1 << 30, 1 << 29)); + let (blocksize, numblocks, inuse) = scale_blocks(total, avail); + let locks_open = self.locks.values().any(|l| l.unit == unit); + let info = InfoData { + num_soft_errors: long(0), + unit_number: long(unit as u32), + // Read-only for now: shows as "Read Only" in C:Info. + disk_state: long(ID_WRITE_PROTECTED), + num_blocks: long(numblocks), + num_blocks_used: long(inuse), + bytes_per_block: long(blocksize), + disk_type: long(ID_CLFS_DISK), + volume_node: long(self.volumes.get(&unit).copied().unwrap_or(0) >> 2), + in_use: long(if locks_open { DOSTRUE } else { 0 }), + }; + write_bytes(bus, id, info.as_bytes()); + log::debug!( + "filesys: {}: InfoData at {id:#010X}: blocks={numblocks} \ + used={inuse} bs={blocksize} (host total={total} avail={avail})", + device_name(unit) + ); + (DOSTRUE, 0) + } + ACTION_LOCATE_OBJECT => { + let name_bptr = arg(bus, 2); + let name = read_bstr(bus, name_bptr); + log::debug!( + "filesys: {}: locate \"{}\" (lock {:#X})", + device_name(unit), + String::from_utf8_lossy(&name), + arg(bus, 1) + ); + let Some(rec) = self.resolve(unit, arg(bus, 1), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + if !self.lock_path(&rec).exists() { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + } + let access = arg(bus, 3); + match self.alloc_lock(bus, port, access, rec) { + Some(addr) => (addr >> 2, 0), + None => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_FREE_LOCK => { + let addr = arg(bus, 1) << 2; + if addr != 0 && self.locks.remove(&addr).is_some() { + self.free_slots.push(addr); + } + (DOSTRUE, 0) + } + ACTION_EXAMINE_OBJECT => { + let Some(rec) = self.locks.get(&(arg(bus, 1) << 2)).cloned() else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let fib = arg(bus, 2) << 2; + match self.fill_fib(bus, fib, &rec, 0) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, e), + } + } + ACTION_EXAMINE_NEXT => { + let Some(rec) = self.locks.get(&(arg(bus, 1) << 2)).cloned() else { + return (DOSFALSE, ERROR_NO_MORE_ENTRIES); + }; + let fib = arg(bus, 2) << 2; + // The enumeration cursor lives in fib_DiskKey, where the + // previous EXAMINE_OBJECT/EXAMINE_NEXT left it. + let index = bus.read_long(fib) as usize; + let names = self.dir_listing(&rec); + let Some(name) = names.get(index) else { + return (DOSFALSE, ERROR_NO_MORE_ENTRIES); + }; + let child = LockRec { + unit: rec.unit, + rel: rec.rel.join(name), + }; + match self.fill_fib(bus, fib, &child, index as u32 + 1) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, e), + } + } + ACTION_COPY_DIR => { + // DupLock(): Arg1 = lock (0 = the root); result is a new + // shared lock on the same object. + let bptr = arg(bus, 1); + let rec = if bptr == 0 { + LockRec { + unit, + rel: PathBuf::new(), + } + } else { + match self.locks.get(&(bptr << 2)) { + Some(r) => r.clone(), + None => return (DOSFALSE, ERROR_INVALID_LOCK), + } + }; + match self.alloc_lock(bus, port, ACCESS_READ, rec) { + Some(addr) => (addr >> 2, 0), + None => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_PARENT => { + // Arg1 = lock; result is a shared lock on its parent, or 0 + // with no error for the root. + let Some(rec) = self.locks.get(&(arg(bus, 1) << 2)).cloned() else { + return (DOSFALSE, ERROR_INVALID_LOCK); + }; + if rec.rel.as_os_str().is_empty() { + return (DOSFALSE, 0); // the root has no parent + } + let mut rel = rec.rel; + rel.pop(); + let parent = LockRec { + unit: rec.unit, + rel, + }; + match self.alloc_lock(bus, port, ACCESS_READ, parent) { + Some(addr) => (addr >> 2, 0), + None => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_SET_PROTECT => { + // Arg2 = lock, Arg3 = BSTR name, Arg4 = mask. The host + // filesystem has nowhere faithful to keep Amiga protection + // bits; accept and ignore (like a FAT filesystem would). + // TODO(codewiz): persist to the .uaem sidecar once write + // support lands, so protection round-trips. + let name_bptr = arg(bus, 3); + let name = read_bstr(bus, name_bptr); + match self.resolve(unit, arg(bus, 2), &name) { + Some(rec) if self.lock_path(&rec).exists() => (DOSTRUE, 0), + _ => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_FINDINPUT => { + // Open(MODE_OLDFILE): Arg1 = BPTR FileHandle, Arg2 = lock, + // Arg3 = BSTR name. On success fh_Arg1 carries our cookie. + let fh = arg(bus, 1) << 2; + let name_bptr = arg(bus, 3); + let name = read_bstr(bus, name_bptr); + log::debug!( + "filesys: {}: open \"{}\" (lock {:#X})", + device_name(unit), + String::from_utf8_lossy(&name), + arg(bus, 2) + ); + let Some(rec) = self.resolve(unit, arg(bus, 2), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + if path.is_dir() { + return (DOSFALSE, ERROR_OBJECT_WRONG_TYPE); + } + match std::fs::File::open(&path) { + Ok(f) => { + self.next_file_key += 1; + let key = self.next_file_key; + self.files.insert(key, (f, rec)); + bus.write_long(fh + FILEHANDLE_ARG1, key); + (DOSTRUE, 0) + } + Err(_) => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_SAME_LOCK => { + // SameLock(): Arg1/Arg2 = locks (0 = the root). DOSTRUE if + // they reference the same object. + let rec_of = |hle: &Self, bptr: u32| -> Option { + if bptr == 0 { + Some(LockRec { + unit, + rel: PathBuf::new(), + }) + } else { + hle.locks.get(&(bptr << 2)).cloned() + } + }; + let (Some(a), Some(b)) = (rec_of(self, arg(bus, 1)), rec_of(self, arg(bus, 2))) + else { + return (DOSFALSE, ERROR_INVALID_LOCK); + }; + if a.unit == b.unit && a.rel == b.rel { + (DOSTRUE, 0) + } else { + (DOSFALSE, 0) + } + } + ACTION_FH_FROM_LOCK => { + // OpenFromLock(): Arg1 = BPTR FileHandle, Arg2 = lock. On + // success the handle absorbs the lock (the caller must not + // free it); on failure the lock stays valid. + let fh = arg(bus, 1) << 2; + let lock_addr = arg(bus, 2) << 2; + let Some(rec) = self.locks.get(&lock_addr).cloned() else { + return (DOSFALSE, ERROR_INVALID_LOCK); + }; + let path = self.lock_path(&rec); + if path.is_dir() { + return (DOSFALSE, ERROR_OBJECT_WRONG_TYPE); + } + match std::fs::File::open(&path) { + Ok(f) => { + self.next_file_key += 1; + let key = self.next_file_key; + self.files.insert(key, (f, rec)); + bus.write_long(fh + FILEHANDLE_ARG1, key); + self.locks.remove(&lock_addr); + self.free_slots.push(lock_addr); + (DOSTRUE, 0) + } + Err(_) => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_PARENT_FH => { + // ParentOfFH(): Arg1 = fh_Arg1 cookie; result is a shared + // lock on the directory containing the open file. + let rec = match self.files.get(&arg(bus, 1)) { + Some((_, rec)) => rec.clone(), + None => return (DOSFALSE, ERROR_INVALID_LOCK), + }; + let mut rel = rec.rel; + rel.pop(); + let parent = LockRec { + unit: rec.unit, + rel, + }; + match self.alloc_lock(bus, port, ACCESS_READ, parent) { + Some(addr) => (addr >> 2, 0), + None => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_EXAMINE_FH => { + // ExamineFH(): Arg1 = fh_Arg1 cookie, Arg2 = BPTR FIB. + let rec = match self.files.get(&arg(bus, 1)) { + Some((_, rec)) => rec.clone(), + None => return (DOSFALSE, ERROR_INVALID_LOCK), + }; + let fib = arg(bus, 2) << 2; + match self.fill_fib(bus, fib, &rec, 0) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, e), + } + } + ACTION_FLUSH => { + // Nothing buffered on the host side; success by definition. + (DOSTRUE, 0) + } + ACTION_READ => { + // Arg1 = fh_Arg1 cookie, Arg2 = buffer APTR, Arg3 = length. + // Res1 = bytes read (0 = EOF), or -1 with Res2 = error. + use std::io::Read; + let key = arg(bus, 1); + let buf = arg(bus, 2); + let len = arg(bus, 3) as usize; + let Some((f, _)) = self.files.get_mut(&key) else { + return (DOSTRUE, ERROR_INVALID_LOCK); // res1 = -1 + }; + // Transfer in bounded chunks: the length is guest-supplied, so + // allocating it up front would let a bogus multi-GB read force + // an unbounded host allocation (OOM/DoS). DOS reads a regular + // file fully, so loop until len bytes or EOF -- same result, + // capped memory. + const READ_CHUNK: usize = 64 * 1024; + let mut chunk = vec![0u8; len.min(READ_CHUNK)]; + let mut done = 0usize; + while done < len { + let want = (len - done).min(READ_CHUNK); + match f.read(&mut chunk[..want]) { + Ok(0) => break, // EOF + Ok(n) => { + for (i, &b) in chunk[..n].iter().enumerate() { + bus.write_byte(buf + (done + i) as u32, b); + } + done += n; + } + Err(_) => return (DOSTRUE, ERROR_SEEK_ERROR), // res1 = -1 + } + } + (done as u32, 0) + } + ACTION_SEEK => { + // Arg1 = fh_Arg1, Arg2 = position, Arg3 = OFFSET_* mode. + // Res1 = previous position, or -1 with Res2 = error. + use std::io::{Seek, SeekFrom}; + let key = arg(bus, 1); + let pos = arg(bus, 2) as i64; + let mode = arg(bus, 3) as i32; + let Some((f, _)) = self.files.get_mut(&key) else { + return (DOSTRUE, ERROR_INVALID_LOCK); // res1 = -1 + }; + let (old, end) = match (f.stream_position(), f.metadata()) { + (Ok(o), Ok(m)) => (o, m.len() as i64), + _ => return (DOSTRUE, ERROR_SEEK_ERROR), + }; + let target = match mode { + OFFSET_BEGINNING => pos, + OFFSET_CURRENT => old as i64 + pos, + OFFSET_END => end + pos, + _ => -1, + }; + if target < 0 || target > end { + return (DOSTRUE, ERROR_SEEK_ERROR); // res1 = -1 + } + match f.seek(SeekFrom::Start(target as u64)) { + Ok(_) => (old as u32, 0), + Err(_) => (DOSTRUE, ERROR_SEEK_ERROR), + } + } + ACTION_END => { + self.files.remove(&arg(bus, 1)); + (DOSTRUE, 0) + } + ACTION_DIE => { + // Shut the handler down (dismount tools send this; stock + // Assign DISMOUNT only unlinks the DeviceNode). Refuse + // while anything is held open, like a real handler. + let in_use = self.locks.values().any(|l| l.unit == unit) + || self.files.values().any(|(_, r)| r.unit == unit); + if in_use { + return (DOSFALSE, ERROR_OBJECT_IN_USE); + } + // Clear dn_Task so the next reference to the device simply + // restarts the handler (and re-adds the volume). + if let Some(dn) = self.device_nodes.remove(&unit) { + bus.write_long(dn + DEVICENODE_TASK, 0); + } + self.ports.remove(&port); + let vol = self.volumes.remove(&unit).unwrap_or(0); + *guest_op = Some(GuestOp::Die(vol)); + log::info!("filesys: {}: ACTION_DIE, handler exits", device_name(unit)); + (DOSTRUE, 0) + } + // Write-family actions: mounts are read-only for now, so the + // proper refusal is "write protected", not "unknown packet". + // Write() and SetFileSize() signal failure with Res1 = -1. + ACTION_WRITE | ACTION_SET_FILE_SIZE => (DOSTRUE, ERROR_DISK_WRITE_PROTECTED), + ACTION_FINDOUTPUT | ACTION_FINDUPDATE | ACTION_CREATE_DIR | ACTION_DELETE_OBJECT + | ACTION_RENAME_OBJECT | ACTION_SET_COMMENT | ACTION_SET_DATE | ACTION_RENAME_DISK => { + (DOSFALSE, ERROR_DISK_WRITE_PROTECTED) + } + _ => { + log::debug!("filesys: {}: unhandled action {dp_type}", device_name(unit)); + (DOSFALSE, ERROR_ACTION_NOT_KNOWN) + } + } + } +} + +impl HleHandler for FilesysHle { + fn handle_aline(&mut self, cpu: &mut CpuCore, bus: &mut dyn AddressBus, opcode: u16) -> bool { + if opcode & 0xFF00 != TRAP_BASE { + return false; // not ours: fall back to the real A-line exception + } + // dar[0..8] = D0-D7, dar[8..16] = A0-A7. + match opcode { + TRAP_DIAG_ENTRY => { + // Expansion init runs exactly once per boot: (re)capture the + // board base and drop every per-boot structure -- after a + // warm reboot the old ports, locks, and open files are + // stale, and exec tends to reallocate the new handler ports + // at the same addresses, which would misroute the startup + // packets of the new boot. Rebuild from a fresh default, + // keeping only the configured mounts, so a newly added + // per-boot field can never be left un-reset (this is how + // next_file_key came to be missed). + let mounts = std::mem::take(&mut self.mounts); + *self = FilesysHle { + mounts, + board_base: Some(cpu.dar[8]), + ..Self::default() + }; + self.write_startup_msgs(bus, cpu.dar[8]); + log::info!( + "filesys: expansion init at board {:#010X}, {} mount(s)", + cpu.dar[8], + self.mounts.len() + ); + true + } + TRAP_PACKET => { + let pkt = cpu.dar[1]; // D1 + let port = cpu.dar[9]; // A1 + let dp_type = bus.read_long(pkt + 8) as i32; + let mut guest_op = None; + let (res1, res2) = self.handle_packet(bus, port, pkt, &mut guest_op); + let unit = self.ports.get(&port).copied(); + log::debug!( + "filesys: {}: packet type {dp_type} at {pkt:#010X} -> \ + res1={res1:#X} res2={res2}", + unit.map_or("?".into(), device_name), + ); + bus.write_long(pkt + 12, res1); // dp_Res1 + bus.write_long(pkt + 16, res2); // dp_Res2 + // D0 tells the handler what to do next (reply the packet, + // then Add/RemDosEntry the node passed in A0). + cpu.dar[0] = match guest_op { + Some(GuestOp::AddVolume(vol)) => { + cpu.dar[8] = vol; // A0 + TRAP_RES_ADDVOLUME + } + Some(GuestOp::Die(vol)) => { + cpu.dar[8] = vol; // A0 + TRAP_RES_DIE + } + None => TRAP_RES_REPLY, + }; + true + } + _ => { + // Not one of our two traps (0xA400/0xA402): hand the opcode + // back so the CPU takes the real A-line exception, as on + // hardware. Any other 0xA4xx is the guest's own trap, not + // ours to swallow. + log::trace!( + "filesys: passing through A-line {opcode:#06X} at PC={:#010X}", + cpu.pc + ); + false + } + } + } +} + +/// Metadata from a UAE `.uaem` sidecar file: the attributes a host +/// filesystem cannot hold (script/pure/archive bits, file comment, exact +/// datestamp), written by UAE-family emulators next to the real file. +struct UaemInfo { + /// fib_Protection value (deny-style rwed like the FIB wants). + protection: u32, + /// DateStamp, when the sidecar's timestamp parses. + date: Option, + comment: Vec, +} + +/// Read and parse the `.uaem` sidecar of `path`, if any. +fn read_uaem(path: &Path) -> Option { + let mut side = path.as_os_str().to_owned(); + side.push(".uaem"); + parse_uaem(&std::fs::read(Path::new(&side)).ok()?) +} + +/// Parse a `.uaem` sidecar: one line, eight flag letters ("hsparwed", a +/// letter means the bit is on), a "YYYY-MM-DD HH:MM:SS.CC" timestamp, and +/// an optional comment. Same grammar as the UAE fsdb, including the +/// `^ 0xF` flip of the rwed group into the FIB's deny convention. +fn parse_uaem(data: &[u8]) -> Option { + let data = data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data); // BOM + let flags = data.get(..8)?; + let mut mode = 0u32; + for (i, (&c, &l)) in flags.iter().zip(b"hsparwed").enumerate() { + if c == l { + mode |= 1 << (7 - i); + } + } + let mut rest = &data[8..]; + while let Some(r) = rest.strip_prefix(b" ") { + rest = r; + } + let (date, after) = match parse_uaem_date(rest) { + Some((d, a)) => (Some(d), a), + None => (None, rest), + }; + let comment = after + .strip_prefix(b" ") + .and_then(|c| c.split(|&b| b == b'\n' || b == b'\r').next()) + .unwrap_or_default() + .to_vec(); + Some(UaemInfo { + protection: mode ^ 0xF, + date, + comment, + }) +} + +/// Parse "YYYY-MM-DD HH:MM:SS.CC" into a DateStamp, returning the rest. +/// Converted directly from the civil date (no timezone round trip): the +/// sidecar records the guest's original DateStamp rendered as text. +fn parse_uaem_date(s: &[u8]) -> Option<(DateStamp, &[u8])> { + let t = std::str::from_utf8(s.get(..22)?).ok()?; + let b = t.as_bytes(); + if !(b[4] == b'-' && b[7] == b'-' && b[10] == b' ' && b[13] == b':') { + return None; + } + let num = |r: std::ops::Range| t[r].parse::().ok(); + let days = days_from_civil(num(0..4)?, num(5..7)?, num(8..10)?) - days_from_civil(1978, 1, 1); + let mins = num(11..13)? * 60 + num(14..16)?; + let ticks = num(17..19)? * 50 + num(20..22)? / 2; + if days < 0 { + return None; // before the AmigaDOS epoch + } + Some(((days as u32, mins as u32, ticks as u32), &s[22..])) +} + +/// Days since 1970-01-01 of a civil date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719_468 +} + +/// Case-insensitive component match: prefer the exact host name, else scan +/// the directory for a case-insensitive match (AmigaDOS names are +/// case-insensitive but case-preserving). +fn match_component(dir: &Path, comp: &str) -> Option { + // "." and ".." are not directory shortcuts in AmigaDOS ("/" is the + // parent), but the host would honor them and ".." escapes the mount. + if comp == "." || comp == ".." { + return None; + } + if dir.join(comp).exists() { + return Some(comp.into()); + } + std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|e| e.file_name()) + .find(|n| n.to_string_lossy().eq_ignore_ascii_case(comp)) +} + +/// Total and available bytes of the host filesystem containing `path`. +#[cfg(unix)] +fn host_fs_usage(path: &Path) -> Option<(u64, u64)> { + use std::os::unix::ffi::OsStrExt; + let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { + return None; + } + let frsize = st.f_frsize as u64; + Some((st.f_blocks as u64 * frsize, st.f_bavail as u64 * frsize)) +} + +#[cfg(not(unix))] +fn host_fs_usage(_path: &Path) -> Option<(u64, u64)> { + None +} + +/// Fit a host filesystem's size into InfoData block counts. AmigaDOS does +/// 32-bit arithmetic on id_NumBlocks (e.g. multiplies by the block size, and +/// C:Info by 100), so double id_BytesPerBlock until the count is comfortable +/// -- the same trick as the UAE filesys, which also caps the doubling at +/// 32K blocks (beyond ~64 TiB the reported size saturates; nothing Amiga +/// cares). +fn scale_blocks(total: u64, avail: u64) -> (u32, u32, u32) { + let mut blocksize: u64 = 512; + while blocksize < 32768 && total / blocksize >= 0x0200_0000 { + blocksize *= 2; + } + let numblocks = (total / blocksize).min(u32::MAX as u64).max(10); + let free = (avail / blocksize).min(numblocks); + ( + blocksize as u32, + numblocks as u32, + (numblocks - free) as u32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_mounts() -> Vec { + vec![MountSpec { + path: "/nonexistent".into(), + volume: "Test".into(), + boot_pri: -128, + }] + } + + #[test] + fn board_image_lays_out_rom_mounts_and_diagarea() { + let img = board_image(&test_mounts()); + assert_eq!(img.len(), 0x1_0000); + // Fake seglist header: next pointer zero, ROM code at ROM_OFFSET. + assert_eq!(&img[4..8], &[0, 0, 0, 0]); + assert_eq!( + &img[ROM_OFFSET..ROM_OFFSET + FILESYS_HANDLER.len()], + FILESYS_HANDLER + ); + // The handler ROM's entry table: process entry (bra.w) at +0, the + // expansion-init entry at +4 starting with the TRAP_DIAG_ENTRY + // opcode (see guest/services/entry.s). + assert_eq!(img[ROM_OFFSET], 0x60); + assert_eq!(img[ROM_OFFSET + 1], 0x00); + assert_eq!( + u16::from_be_bytes([img[ROM_OFFSET + 4], img[ROM_OFFSET + 5]]), + TRAP_DIAG_ENTRY + ); + + // Mount table. + let m = MOUNTS_OFFSET; + assert_eq!(u16::from_be_bytes([img[m], img[m + 1]]), 1); + assert_eq!(&img[m + 2..m + 9], b"HOSTFS0"); + + // DiagArea header, embedded in the ROM at DIAG_OFFSET (see + // `_diag_area` in guest/services/entry.s). + let d = DIAG_OFFSET as usize; + assert_eq!(img[d], 0x90); // DAC_WORDWIDE | DAC_CONFIGTIME + let da_size = u16::from_be_bytes([img[d + 2], img[d + 3]]) as usize; + let da_diag = u16::from_be_bytes([img[d + 4], img[d + 5]]) as usize; + let da_boot = u16::from_be_bytes([img[d + 6], img[d + 7]]) as usize; + let da_name = u16::from_be_bytes([img[d + 8], img[d + 9]]) as usize; + // DAC_CONFIGTIME requires a non-zero da_BootPoint (Kickstart 3.x + // rejects the whole DiagArea otherwise), and everything referenced + // must lie inside the copied da_Size bytes. + assert!(da_diag != 0 && da_diag < da_size); + assert!(da_boot != 0 && da_boot < da_size); + assert!(da_name != 0 && da_name < da_size); + assert!(d + da_size <= ROM_OFFSET + FILESYS_HANDLER.len()); + // The DiagPoint stub reaches the ROM's expansion-init entry through + // the board base: jsr 12(a0) (12 = ROM_OFFSET + 4), then rts. + assert_eq!( + &img[d + da_diag..d + da_diag + 6], + &[0x4E, 0xA8, 0x00, 0x0C, 0x4E, 0x75] + ); + assert_eq!((ROM_OFFSET + 4) as u16, 0x000C); + assert_eq!(&img[d + da_name..d + da_name + 11], b"Copperline\0"); + // The trap opcode must be A-line (group 0xA) to reach handle_aline. + assert_eq!(TRAP_BASE >> 12, 0xA); + // The per-unit DosEnvec array must not run into the lock pool. + assert!(FSSM_ENVEC_OFFSET + MOUNT_MAX_COUNT as u32 * ENVEC_SLOT_SIZE <= POOL_OFFSET); + assert_eq!(ENVEC_SLOT_SIZE as usize, std::mem::size_of::()); + } + + #[test] + fn resolve_strips_the_prefix_and_keeps_the_lock_base() { + let root = std::env::temp_dir().join(format!("clfs-resolve-{}", std::process::id())); + std::fs::create_dir_all(root.join("Libs")).unwrap(); + std::fs::write(root.join("Libs/68040.library"), b"x").unwrap(); + + let mut hle = FilesysHle::default(); + hle.set_mounts(vec![MountSpec { + path: root.clone(), + volume: "Test".into(), + boot_pri: -128, + }]); + // A lock on Libs, as DOS supplies with opens through the LIBS: + // assign. The name still carries the user's "LIBS:" prefix; it + // must be stripped without resetting to the root. + hle.locks.insert( + 0x1000, + LockRec { + unit: 0, + rel: "Libs".into(), + }, + ); + let rec = hle.resolve(0, 0x1000 >> 2, b"LIBS:68040.library").unwrap(); + assert_eq!(rec.rel, PathBuf::from("Libs/68040.library")); + // A volume prefix with no lock starts at the root as before. + let rec = hle.resolve(0, 0, b"Test:Libs/68040.library").unwrap(); + assert_eq!(rec.rel, PathBuf::from("Libs/68040.library")); + + // Host dot-dirs must not act as path components: ".." would + // escape the mount root ("." and ".." are legal-ish AmigaDOS + // names with no special meaning; "/" is the parent). + assert!(hle.resolve(0, 0, b"..").is_none()); + assert!(hle.resolve(0, 0, b"Libs/../../etc").is_none()); + assert!(hle.resolve(0, 0, b"Libs/.").is_none()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn uaem_sidecar_parses_flags_date_and_comment() { + // A real line written by Amiberry for S/Shell-Startup: script bit + // set, execute denied (rwed stored as "allowed" letters, flipped + // into the FIB's deny convention by ^ 0xF). + let info = parse_uaem(b"-s--rw-d 2026-07-10 16:16:51.32").unwrap(); + assert_eq!(info.protection, 0x42); // FIBF_SCRIPT | FIBF_EXECUTE + // 2026-07-10 is 17722 days after 1978-01-01. + assert_eq!(info.date, Some((17722, 16 * 60 + 16, 51 * 50 + 32 / 2))); + assert!(info.comment.is_empty()); + + let info = parse_uaem(b"----rwed 1985-07-23 12:00:00.00 hello world\n").unwrap(); + assert_eq!(info.protection, 0); + assert_eq!(info.comment, b"hello world"); + + // Pure (resident-able) tool: p bit, all of rwed allowed. + let info = parse_uaem(b"--p-rwed 1992-05-01 00:00:00.00").unwrap(); + assert_eq!(info.protection, 0x20); // FIBF_PURE + + // Flags but no parsable date: attributes still apply. + let info = parse_uaem(b"---arwed").unwrap(); + assert_eq!(info.protection, 0x10); // FIBF_ARCHIVE + assert_eq!(info.date, None); + } + + #[test] + fn block_scaling_keeps_counts_32bit_sane() { + // A small filesystem keeps 512-byte blocks. + let (bs, blocks, used) = scale_blocks(64 << 20, 16 << 20); + assert_eq!((bs, blocks), (512, 131072)); + assert_eq!(used, 131072 - 32768); + // A 512 GB partition scales the block size up so the count stays + // below the UAE overflow guard (0x02000000 blocks)... + let (bs, blocks, _) = scale_blocks(1 << 39, 1 << 38); + assert!(bs > 512 && blocks < 0x0200_0000, "bs={bs} blocks={blocks}"); + assert_eq!(bs as u64 * blocks as u64, 1 << 39); + // ...and past that the block size caps at 32K, exactly like UAE. + let (bs, blocks, _) = scale_blocks(8 << 40, 1 << 40); + assert_eq!(bs, 32768); + assert_eq!(bs as u64 * blocks as u64, 8 << 40); + // Free space never exceeds the total. + let (_, blocks, used) = scale_blocks(1 << 20, u64::MAX); + assert_eq!(used, 0); + assert!(blocks >= 10); + } + + #[test] + fn host_fs_usage_of_root_is_sane() { + let (total, avail) = host_fs_usage(Path::new("/")).expect("statvfs /"); + assert!(total > 0 && avail <= total); + } +} diff --git a/src/lib.rs b/src/lib.rs index c56be8c2..63986f5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ pub mod dms; pub mod drive_sounds; pub mod emulator; pub mod envcfg; +pub mod filesys; pub mod floppy; pub mod gamepad; pub mod gayle; diff --git a/src/zorro.rs b/src/zorro.rs index 05b64f18..ea6735ba 100644 --- a/src/zorro.rs +++ b/src/zorro.rs @@ -60,6 +60,8 @@ pub const COPPERLINE_MANUFACTURER_ID: u16 = 0x1448; const PRODUCT_COPPERLINE_ID: u8 = 0x02; const PRODUCT_FAST_RAM: u8 = 0x03; const PRODUCT_Z3_RAM: u8 = 0x04; +/// The services board (host filesystem and, later, other guest services). +const PRODUCT_SERVICES: u8 = 0x05; #[derive(Debug, Clone, Copy, PartialEq, Eq)] // "II"/"III" are the bus generations' proper names (roman numerals), not @@ -210,6 +212,30 @@ impl BoardSpec { } } + /// The Copperline services board: a 64K Zorro II window through which the + /// emulator offers host-backed services to the guest -- currently the + /// host filesystem (`HOSTFS0:`...), with room for more (RTG, mouse, + /// clipboard) later. The window carries the guest-side handler, the mount + /// table, and a DiagArea (`diag_vec` points at + /// [`crate::filesys::DIAG_OFFSET`]) whose DiagPoint mounts the configured + /// host directories at expansion init; its packet pump then forwards + /// every DosPacket to the emulator through a reserved A-line trap. See + /// `guest/services/` and [`crate::filesys`]. RAM-backed and pre-seeded by + /// [`crate::filesys::board_image`]. + pub fn copperline_services() -> Self { + Self { + name: "Copperline services".into(), + version: ZorroVersion::II, + manufacturer: COPPERLINE_MANUFACTURER_ID, + product: PRODUCT_SERVICES, + serial: 0, + size_bytes: 0x1_0000, + backing: BoardBacking::Ram, + memlist: false, + diag_vec: Some(crate::filesys::DIAG_OFFSET), + } + } + fn validate(&self) -> Result<()> { match self.version { ZorroVersion::II => { @@ -279,6 +305,36 @@ impl ZorroChain { Ok(()) } + /// Add a RAM-backed board with its window pre-seeded from `rom` (copied at + /// offset 0, the remainder left zero). Used for the filesys rtarea, + /// whose handler ROM must be present in the window from power-on. The + /// board is still writable RAM, as the real rtarea is. + pub fn add_board_with_rom(&mut self, spec: BoardSpec, rom: &[u8]) -> Result<()> { + spec.validate()?; + if !matches!(spec.backing, BoardBacking::Ram) { + bail!( + "board {:?}: add_board_with_rom requires RAM backing", + spec.name + ); + } + if rom.len() > spec.size_bytes { + bail!( + "board {:?}: ROM image {} bytes exceeds the {}-byte window", + spec.name, + rom.len(), + spec.size_bytes + ); + } + let mut ram = vec![0u8; spec.size_bytes]; + ram[..rom.len()].copy_from_slice(rom); + self.boards.push(Board { + spec, + state: BoardState::Unconfigured, + ram, + }); + Ok(()) + } + /// Add a board already configured at a fixed base, bypassing the /// autoconfig handshake (for fixtures and non-autoconfig mappings). #[cfg_attr(not(test), allow(dead_code))]