From def5dfddeb169adb92f1815efbe3c8b63a850180 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sat, 11 Jul 2026 02:42:02 +0900 Subject: [PATCH 01/17] filesys: mount host directories as HOSTFS volumes A Copperline services board (0x1448 product 5, 64K Zorro II) carries a tiny guest handler (guest/filesys/, C, built reloc-free with dockerized m68k-amigaos GCC 16) that mounts each [[filesys]] config entry via the DiagArea and pumps every DosPacket to the emulator through an A-line trap; src/filesys.rs implements the ACTION_* semantics on the host side. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 + assets/services/services_rom.bin | Bin 0 -> 324 bytes guest/services/Makefile | 41 ++ guest/services/README.md | 37 ++ guest/services/copperline_board.h | 47 ++ guest/services/entry.s | 18 + guest/services/handler.c | 139 ++++++ src/config.rs | 54 +++ src/cpu.rs | 12 +- src/emulator.rs | 1 + src/filesys.rs | 693 ++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/zorro.rs | 56 +++ 13 files changed, 1103 insertions(+), 3 deletions(-) create mode 100644 assets/services/services_rom.bin create mode 100644 guest/services/Makefile create mode 100644 guest/services/README.md create mode 100644 guest/services/copperline_board.h create mode 100644 guest/services/entry.s create mode 100644 guest/services/handler.c create mode 100644 src/filesys.rs diff --git a/.gitignore b/.gitignore index 0a3d6cc..62ed02b 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/assets/services/services_rom.bin b/assets/services/services_rom.bin new file mode 100644 index 0000000000000000000000000000000000000000..338b55b582a934429a8b71a11ae8e71f9a993a2b GIT binary patch literal 324 zcmYdbU=T`RU}#9mFV@S+Oe#t&s$_`l_jqn$q+-FqqT}TJ>;JO?6~A?VZ>TX$KIyma z-z}3GhM21dbet5tfV>7M-Q(4e{zkz;ErWqeiCf8Q36s7DgMHK!lbaaPi2kN}z-m9PtBqm7b6K7)b+&{lm11_{>}7EBfz77RQt z3WE;d9@`CZgZ3w2Z;a$buoAtw?@Tb6Sc{K7&Mo z-i2l*8wN%OM!$9ctrQ%9Zo5>VbD=}fnL%gr$<2q;oSarLuqf6rFeorEh!k-tIxG)twRp@>l_pFsm?(w(4I*H;V*2~9!G2|a?FW-!dq L4eCtTz`y_ibQWtD literal 0 HcmV?d00001 diff --git a/guest/services/Makefile b/guest/services/Makefile new file mode 100644 index 0000000..af650c4 --- /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 0000000..684df8d --- /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 0000000..aaa7563 --- /dev/null +++ b/guest/services/copperline_board.h @@ -0,0 +1,47 @@ +// 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 mount entry (called by the DiagArea shim at expansion init +// with C args on the stack: board, ExpansionBase, ConfigDev) +// 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", ...) +// 0x4000 DiagArea (hand-built by the emulator, see src/filesys.rs) +// 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 + +// 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 + +#endif // COPPERLINE_BOARD_H diff --git a/guest/services/entry.s b/guest/services/entry.s new file mode 100644 index 0000000..c0cb75b --- /dev/null +++ b/guest/services/entry.s @@ -0,0 +1,18 @@ +| SPDX-License-Identifier: GPL-3.0-or-later +| +| Entry table of the handler ROM. Linked first, so these instructions sit at +| ROM_OFFSET in the board window (see copperline_board.h). Everything must stay +| PC-relative: the ROM runs at whatever base autoconfig assigns the board. + + .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: mount entry. The DiagArea shim has already pushed the C arguments + | (board, ExpansionBase, ConfigDev) and jsr'd here, so the stack is + | exactly a C call frame: tail-branch into the C function. + bra.w _mount_boards diff --git a/guest/services/handler.c b/guest/services/handler.c new file mode 100644 index 0000000..4890401 --- /dev/null +++ b/guest/services/handler.c @@ -0,0 +1,139 @@ +// 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" + +#define HANDLER_STACK 8192 + +// 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); + } + } +} + +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; + dn->dn_Startup = i; // mount table index; read back at ACTION_STARTUP + dn->dn_SegList = MKBADDR(board + 4); + dn->dn_GlobalVec = -1; // C handler: no BCPL global vector + dn->dn_Name = MKBADDR(bname); + + // Priority -128: mounted at DOS init but never a boot candidate. + // ADNF_STARTPROC: start the handler process at mount time rather + // than on first reference, so problems surface at boot. + AddBootNode(-128, ADNF_STARTPROC, dn, cd); + } +} diff --git a/src/config.rs b/src/config.rs index ba28f09..ead7740 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,27 @@ 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()); + } + } + chain.add_board_with_rom( + BoardSpec::copperline_services(), + &crate::filesys::board_image(&self.filesys), + )?; + } Ok(chain) } } @@ -1175,6 +1201,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 +1241,18 @@ 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, +} + /// `[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 +2011,19 @@ 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()) + }), + }) + .collect(), chipset, agnus_revision, denise_revision, diff --git a/src/cpu.rs b/src/cpu.rs index 995d8cd..dc0f6a4 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.mount]]`). + 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 240474c..6901270 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 0000000..4d1ee78 --- /dev/null +++ b/src/filesys.rs @@ -0,0 +1,693 @@ +//! 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 +//! mount entry with the documented 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}; + +/// 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). +pub const DIAG_OFFSET: u16 = 0x4000; +/// 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; +/// 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; + +/// 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; + +// AmigaDOS packet types (dos/dosextens.h). +const ACTION_LOCATE_OBJECT: i32 = 8; +const ACTION_FREE_LOCK: i32 = 15; +const ACTION_EXAMINE_OBJECT: i32 = 23; +const ACTION_EXAMINE_NEXT: i32 = 24; +const ACTION_DISK_INFO: i32 = 25; +const ACTION_INFO: i32 = 26; +const ACTION_IS_FILESYSTEM: i32 = 1027; + +// dos/dos.h. +const DOSTRUE: u32 = 0xFFFF_FFFF; +const DOSFALSE: u32 = 0; +const ERROR_OBJECT_NOT_FOUND: u32 = 205; +const ERROR_ACTION_NOT_KNOWN: u32 = 209; +const ERROR_NO_MORE_ENTRIES: u32 = 232; +/// '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; +const ID_VALIDATED: u32 = 82; // id_DiskState: validated, read/write + // fib_DirEntryType values (dos/dosextens.h ST_*). +const ST_ROOT: i32 = 1; +const ST_USERDIR: i32 = 2; +const ST_FILE: i32 = -3; + +/// 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, +} + +/// 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 + handler ROM, the mount +/// table, and the DiagArea whose DiagPoint calls the handler's mount entry. +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()); + } + + // struct DiagArea (libraries/configregs.h). 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. + let d = DIAG_OFFSET as usize; + img[d] = 0x90; // da_Config = DAC_WORDWIDE | DAC_CONFIGTIME + img[d + 1] = 0x00; // da_Flags + img[d + 2..d + 4].copy_from_slice(&0x0040u16.to_be_bytes()); // da_Size + img[d + 4..d + 6].copy_from_slice(&0x0010u16.to_be_bytes()); // da_DiagPoint + img[d + 6..d + 8].copy_from_slice(&0x0024u16.to_be_bytes()); // da_BootPoint + img[d + 8..d + 10].copy_from_slice(&0x0030u16.to_be_bytes()); // da_Name + + // DiagPoint routine at +0x10, run from the RAM copy with the documented + // context (A0 = board base, A3 = ConfigDev, A5 = ExpansionBase). It traps + // to the host (which captures the board base), calls the handler's mount + // entry as a C function -- mount_boards(board, ExpansionBase, ConfigDev), + // args pushed right to left -- and returns D0 = 0 so Kickstart frees the + // copy: nothing references it afterwards. + #[rustfmt::skip] + let diag_point: [u8; 20] = [ + (TRAP_DIAG_ENTRY >> 8) as u8, TRAP_DIAG_ENTRY as u8, + 0x2F, 0x0B, // move.l a3,-(sp) ConfigDev + 0x2F, 0x0D, // move.l a5,-(sp) ExpansionBase + 0x2F, 0x08, // move.l a0,-(sp) board base + 0x4E, 0xA8, 0x00, 0x0C, // jsr 12(a0) handler mount entry + 0x4F, 0xEF, 0x00, 0x0C, // lea 12(sp),sp + 0x70, 0x00, // moveq #0,d0 free the diag copy + 0x4E, 0x75, // rts + ]; + img[d + 0x10..d + 0x10 + diag_point.len()].copy_from_slice(&diag_point); + // da_BootPoint must be non-zero (see above) but is never usefully + // called: point it at a harmless "return 0". + img[d + 0x24..d + 0x28].copy_from_slice(&[0x70, 0x00, 0x4E, 0x75]); // moveq #0,d0 ; rts + let name = b"Copperline\0"; + img[d + 0x30..d + 0x30 + name.len()].copy_from_slice(name); + + img +} + +/// 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`. 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. + ports: HashMap, + /// Mount unit -> guest address of its volume DosList node. + volumes: HashMap, + /// 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 volume = self.volumes.get(&rec.unit).copied().unwrap_or(0); + bus.write_long(addr, 0); // fl_Link + bus.write_long(addr + 4, addr); // fl_Key: unique, opaque to DOS + bus.write_long(addr + 8, access); // fl_Access + bus.write_long(addr + 12, port); // fl_Task: the handler port + bus.write_long(addr + 16, volume >> 2); // fl_Volume (BPTR) + self.locks.insert(addr, rec); + Some(addr) + } + + /// Resolve a DOS path (BPTR lock + name) to a lock record. AmigaDOS path + /// semantics: an optional `device:` prefix restarts at the root, `/` + /// 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':') { + // "DEVICE:" or "Volume:" prefix: absolute from the root. The + // packet reached this handler, so the prefix already named it. + rest = &name[colon + 1..]; + rel = PathBuf::new(); + } + 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 }); + } + for comp in rest.split(|&b| b == b'/') { + 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 + }; + + bus.write_long(fib, disk_key); // fib_DiskKey: enumeration cursor + bus.write_long(fib + 4, entry_type as u32); // fib_DirEntryType + // fib_FileName: BCPL-style, length byte + chars (max 107). + let bytes: Vec = name.bytes().take(107).collect(); + bus.write_byte(fib + 8, bytes.len() as u8); + for (i, &b) in bytes.iter().enumerate() { + bus.write_byte(fib + 9 + i as u32, b); + } + bus.write_byte(fib + 9 + bytes.len() as u32, 0); + bus.write_long(fib + 116, 0); // fib_Protection: rwed + bus.write_long(fib + 120, entry_type as u32); // fib_EntryType + bus.write_long(fib + 124, meta.len().min(u32::MAX as u64) as u32); // fib_Size + bus.write_long( + fib + 128, + meta.len().div_ceil(512).min(u32::MAX as u64) as u32, // fib_NumBlocks + ); + let (days, mins, ticks) = amiga_datestamp(meta.modified().ok()); + bus.write_long(fib + 132, days); // fib_Date + bus.write_long(fib + 136, mins); + bus.write_long(fib + 140, ticks); + bus.write_byte(fib + 144, 0); // fib_Comment: empty + Ok(()) + } + + /// Sorted directory listing used by EXAMINE_NEXT. 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()) + .collect(); + names.sort(); + names + } + + /// 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 (days, mins, ticks) = amiga_datestamp(Some(std::time::SystemTime::now())); + bus.write_long(vol, 0); // dol_Next + bus.write_long(vol + 4, 2); // dol_Type = DLT_VOLUME + bus.write_long(vol + 8, port); // dol_Task: the handler port + bus.write_long(vol + 12, 0); // dol_Lock + bus.write_long(vol + 16, days); // dol_VolumeDate + bus.write_long(vol + 20, mins); + bus.write_long(vol + 24, ticks); + bus.write_long(vol + 28, 0); // dol_LockList + bus.write_long(vol + 32, ID_CLFS_DISK); // dol_DiskType + bus.write_long(vol + 36, 0); // dol_unused + bus.write_long(vol + 40, (vol + 44) >> 2); // dol_Name (BSTR) + let name: Vec = self.mounts[unit].volume.bytes().take(30).collect(); + bus.write_byte(vol + 44, name.len() as u8); + for (i, &b) in name.iter().enumerate() { + bus.write_byte(vol + 45 + i as u32, b); + } + bus.write_byte(vol + 45 + name.len() as u32, 0); + self.volumes.insert(unit, vol); + vol + } + + /// Handle one DosPacket; returns (dp_Res1, dp_Res2). When the packet + /// creates a volume node the guest must AddDosEntry, its address is + /// stored in `add_volume`. + fn handle_packet( + &mut self, + bus: &mut dyn AddressBus, + port: u32, + pkt: u32, + add_volume: &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 + let unit = bus.read_long(dn + 28) as usize; // dn_Startup: mount index + if unit >= self.mounts.len() { + log::warn!("filesys: startup packet for unknown unit {unit}"); + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + } + bus.write_long(dn + 8, port); // dn_Task = handler port + self.ports.insert(port, unit); + *add_volume = Some(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); + bus.write_long(id, 0); // id_NumSoftErrors + bus.write_long(id + 4, unit as u32); // id_UnitNumber + bus.write_long(id + 8, ID_VALIDATED); // id_DiskState + bus.write_long(id + 12, numblocks); // id_NumBlocks + bus.write_long(id + 16, inuse); // id_NumBlocksUsed + bus.write_long(id + 20, blocksize); // id_BytesPerBlock + bus.write_long(id + 24, ID_CLFS_DISK); // id_DiskType + let volume = self.volumes.get(&unit).copied().unwrap_or(0); + bus.write_long(id + 28, volume >> 2); // id_VolumeNode (BPTR) + bus.write_long(id + 32, if locks_open { DOSTRUE } else { 0 }); // id_InUse + 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); + 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), + } + } + _ => { + 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 => { + self.board_base = Some(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 add_volume = None; + let (res1, res2) = self.handle_packet(bus, port, pkt, &mut add_volume); + log::debug!( + "filesys: packet type {dp_type} at {pkt:#010X} -> \ + res1={res1:#X} res2={res2}" + ); + 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, + // and for ADDVOLUME also AddDosEntry the node passed in A0). + cpu.dar[0] = match add_volume { + Some(vol) => { + cpu.dar[8] = vol; // A0 + TRAP_RES_ADDVOLUME + } + None => TRAP_RES_REPLY, + }; + true + } + _ => { + log::warn!( + "filesys: unexpected trap opcode {opcode:#06X} at PC={:#010X}", + cpu.pc + ); + true + } + } + } +} + +/// 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 { + 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)) +} + +/// Host mtime -> AmigaDOS DateStamp (days/minutes/ticks since 1978-01-01). +fn amiga_datestamp(time: Option) -> (u32, u32, u32) { + /// 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, + ) +} + +/// 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, + ) +} + +/// Read a BSTR (BPTR to length-prefixed string) from guest memory. +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() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_mounts() -> Vec { + vec![MountSpec { + path: "/nonexistent".into(), + volume: "Test".into(), + }] + } + + #[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: two bra.w instructions (process entry at + // +0, mount entry at +4 -- the DiagArea jsr's board+12). + assert_eq!(img[ROM_OFFSET], 0x60); + assert_eq!(img[ROM_OFFSET + 1], 0x00); + assert_eq!(img[ROM_OFFSET + 4], 0x60); + assert_eq!(img[ROM_OFFSET + 5], 0x00); + + // 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 (libraries/configregs.h layout). + let d = DIAG_OFFSET as usize; + assert_eq!(img[d], 0x90); // DAC_WORDWIDE | DAC_CONFIGTIME + assert_eq!(u16::from_be_bytes([img[d + 2], img[d + 3]]), 0x0040); // da_Size + assert_eq!(u16::from_be_bytes([img[d + 4], img[d + 5]]), 0x0010); // da_DiagPoint + // DAC_CONFIGTIME requires a non-zero da_BootPoint (Kickstart 3.x + // rejects the whole DiagArea otherwise). + assert_ne!(u16::from_be_bytes([img[d + 6], img[d + 7]]), 0); + + // DiagPoint code: the trap opcode first, and the jsr into the handler's + // mount entry at board+12 (= ROM_OFFSET + 4). + assert_eq!( + u16::from_be_bytes([img[d + 0x10], img[d + 0x11]]), + TRAP_DIAG_ENTRY + ); + assert_eq!(&img[d + 0x18..d + 0x1C], &[0x4E, 0xA8, 0x00, 0x0C]); + assert_eq!((ROM_OFFSET + 4) as u16, 0x000C); + // The trap opcode must be A-line (group 0xA) to reach handle_aline. + assert_eq!(TRAP_BASE >> 12, 0xA); + } + + #[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); + } + + #[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/lib.rs b/src/lib.rs index c56be8c..63986f5 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 05b64f1..ea6735b 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))] From 80591d57b2f0a5cc018867870368be4d6bee0ea2 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sat, 11 Jul 2026 16:03:24 +0900 Subject: [PATCH 02/17] filesys: file I/O, FFS path semantics, and a typed DOS ABI layer ACTION_COPY_DIR/PARENT/SET_PROTECT plus read-only file I/O (FINDINPUT/READ/SEEK/END), enough for list, type, and copy-from. A single trailing slash means the directory itself, not its parent (verified against FFS: "Prefs/" lists Prefs, "Prefs//" its parent). The NDK dos.h/dosextens.h constants and structures move to amigaos::dos as repr(C) structs of big-endian fields (zerocopy), so the definition is the guest layout and one write_bytes serializes it. All hand-assembled bytes leave board_image(): the DiagArea now lives inside the handler ROM (entry.s), like real autoboot board ROMs. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + Cargo.toml | 1 + assets/services/services_rom.bin | Bin 324 -> 416 bytes guest/services/copperline_board.h | 9 +- guest/services/entry.s | 55 ++++- src/amigaos.rs | 2 + src/amigaos/dos.rs | 186 ++++++++++++++ src/filesys.rs | 398 ++++++++++++++++++------------ 8 files changed, 477 insertions(+), 175 deletions(-) create mode 100644 src/amigaos/dos.rs diff --git a/Cargo.lock b/Cargo.lock index 5d1ef45..5537dbc 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 2195706..2aa0d02 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 index 338b55b582a934429a8b71a11ae8e71f9a993a2b..3dbf7202c663925711ad6ca2b5e49c18fb43fcb9 100644 GIT binary patch delta 109 zcmX@Yw17D!fq@}!34=bjKCeDUA_Kz}|Mv_$1q^; + +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 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() +} + +/// Host mtime -> AmigaDOS DateStamp (days/minutes/ticks since 1978-01-01). +pub fn amiga_datestamp(time: Option) -> (u32, u32, u32) { + /// 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); + // 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/filesys.rs b/src/filesys.rs index 4d1ee78..e910e14 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -4,7 +4,7 @@ //! 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 -//! mount entry with the documented DiagPoint context; the handler builds one +//! 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 @@ -20,6 +20,8 @@ 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"); @@ -32,8 +34,10 @@ pub const ROM_OFFSET: usize = 0x0008; 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). -pub const DIAG_OFFSET: u16 = 0x4000; +/// 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; @@ -58,30 +62,10 @@ const TRAP_DIAG_ENTRY: u16 = 0xA400; /// DosPacket from the handler: D1 = packet APTR, A1 = handler MsgPort. const TRAP_PACKET: u16 = 0xA402; -// AmigaDOS packet types (dos/dosextens.h). -const ACTION_LOCATE_OBJECT: i32 = 8; -const ACTION_FREE_LOCK: i32 = 15; -const ACTION_EXAMINE_OBJECT: i32 = 23; -const ACTION_EXAMINE_NEXT: i32 = 24; -const ACTION_DISK_INFO: i32 = 25; -const ACTION_INFO: i32 = 26; -const ACTION_IS_FILESYSTEM: i32 = 1027; - -// dos/dos.h. -const DOSTRUE: u32 = 0xFFFF_FFFF; -const DOSFALSE: u32 = 0; -const ERROR_OBJECT_NOT_FOUND: u32 = 205; -const ERROR_ACTION_NOT_KNOWN: u32 = 209; -const ERROR_NO_MORE_ENTRIES: u32 = 232; /// '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; -const ID_VALIDATED: u32 = 82; // id_DiskState: validated, read/write - // fib_DirEntryType values (dos/dosextens.h ST_*). -const ST_ROOT: i32 = 1; -const ST_USERDIR: i32 = 2; -const ST_FILE: i32 = -3; /// One `[[filesys]]` entry: a host directory exported as an AmigaDOS /// device `HOSTFS:` with the given volume name. @@ -96,8 +80,8 @@ pub fn device_name(unit: usize) -> String { format!("HOSTFS{unit}") } -/// Build the 64K board window: fake seglist header + handler ROM, the mount -/// table, and the DiagArea whose DiagPoint calls the handler's mount entry. +/// 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); @@ -119,41 +103,6 @@ pub fn board_image(mounts: &[MountSpec]) -> Vec { img[at..at + name.len()].copy_from_slice(name.as_bytes()); } - // struct DiagArea (libraries/configregs.h). 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. - let d = DIAG_OFFSET as usize; - img[d] = 0x90; // da_Config = DAC_WORDWIDE | DAC_CONFIGTIME - img[d + 1] = 0x00; // da_Flags - img[d + 2..d + 4].copy_from_slice(&0x0040u16.to_be_bytes()); // da_Size - img[d + 4..d + 6].copy_from_slice(&0x0010u16.to_be_bytes()); // da_DiagPoint - img[d + 6..d + 8].copy_from_slice(&0x0024u16.to_be_bytes()); // da_BootPoint - img[d + 8..d + 10].copy_from_slice(&0x0030u16.to_be_bytes()); // da_Name - - // DiagPoint routine at +0x10, run from the RAM copy with the documented - // context (A0 = board base, A3 = ConfigDev, A5 = ExpansionBase). It traps - // to the host (which captures the board base), calls the handler's mount - // entry as a C function -- mount_boards(board, ExpansionBase, ConfigDev), - // args pushed right to left -- and returns D0 = 0 so Kickstart frees the - // copy: nothing references it afterwards. - #[rustfmt::skip] - let diag_point: [u8; 20] = [ - (TRAP_DIAG_ENTRY >> 8) as u8, TRAP_DIAG_ENTRY as u8, - 0x2F, 0x0B, // move.l a3,-(sp) ConfigDev - 0x2F, 0x0D, // move.l a5,-(sp) ExpansionBase - 0x2F, 0x08, // move.l a0,-(sp) board base - 0x4E, 0xA8, 0x00, 0x0C, // jsr 12(a0) handler mount entry - 0x4F, 0xEF, 0x00, 0x0C, // lea 12(sp),sp - 0x70, 0x00, // moveq #0,d0 free the diag copy - 0x4E, 0x75, // rts - ]; - img[d + 0x10..d + 0x10 + diag_point.len()].copy_from_slice(&diag_point); - // da_BootPoint must be non-zero (see above) but is never usefully - // called: point it at a harmless "return 0". - img[d + 0x24..d + 0x28].copy_from_slice(&[0x70, 0x00, 0x4E, 0x75]); // moveq #0,d0 ; rts - let name = b"Copperline\0"; - img[d + 0x30..d + 0x30 + name.len()].copy_from_slice(name); - img } @@ -167,9 +116,13 @@ struct LockRec { } /// Host side of the filesys trap gateway: implements the AmigaDOS packet -/// ACTION_* semantics against the host directories in `mounts`. 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. +/// 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, @@ -179,6 +132,9 @@ pub struct FilesysHle { ports: HashMap, /// Mount unit -> guest address of its volume DosList node. volumes: HashMap, + /// Open files by fh_Arg1 cookie (host-side only, no guest structure). + files: HashMap, + next_file_key: u32, /// Guest FileLock address -> what it locks. locks: HashMap, /// Free slots in the board-window lock pool (guest addresses). @@ -213,12 +169,14 @@ impl FilesysHle { base + next }) })?; - let volume = self.volumes.get(&rec.unit).copied().unwrap_or(0); - bus.write_long(addr, 0); // fl_Link - bus.write_long(addr + 4, addr); // fl_Key: unique, opaque to DOS - bus.write_long(addr + 8, access); // fl_Access - bus.write_long(addr + 12, port); // fl_Task: the handler port - bus.write_long(addr + 16, volume >> 2); // fl_Volume (BPTR) + 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) } @@ -246,7 +204,14 @@ impl FilesysHle { // below would yield one empty component = "parent", wrong.) return Some(LockRec { unit, rel }); } - for comp in rest.split(|&b| b == b'/') { + // 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() { @@ -287,27 +252,19 @@ impl FilesysHle { ST_USERDIR }; - bus.write_long(fib, disk_key); // fib_DiskKey: enumeration cursor - bus.write_long(fib + 4, entry_type as u32); // fib_DirEntryType - // fib_FileName: BCPL-style, length byte + chars (max 107). - let bytes: Vec = name.bytes().take(107).collect(); - bus.write_byte(fib + 8, bytes.len() as u8); - for (i, &b) in bytes.iter().enumerate() { - bus.write_byte(fib + 9 + i as u32, b); - } - bus.write_byte(fib + 9 + bytes.len() as u32, 0); - bus.write_long(fib + 116, 0); // fib_Protection: rwed - bus.write_long(fib + 120, entry_type as u32); // fib_EntryType - bus.write_long(fib + 124, meta.len().min(u32::MAX as u64) as u32); // fib_Size - bus.write_long( - fib + 128, - meta.len().div_ceil(512).min(u32::MAX as u64) as u32, // fib_NumBlocks - ); let (days, mins, ticks) = amiga_datestamp(meta.modified().ok()); - bus.write_long(fib + 132, days); // fib_Date - bus.write_long(fib + 136, mins); - bus.write_long(fib + 140, ticks); - bus.write_byte(fib + 144, 0); // fib_Comment: empty + 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(0), // rwed + 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: [0; 80], // empty BCPL string + }; + write_bytes(bus, fib, fib_data.as_bytes()); Ok(()) } @@ -330,24 +287,22 @@ impl FilesysHle { 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())); - bus.write_long(vol, 0); // dol_Next - bus.write_long(vol + 4, 2); // dol_Type = DLT_VOLUME - bus.write_long(vol + 8, port); // dol_Task: the handler port - bus.write_long(vol + 12, 0); // dol_Lock - bus.write_long(vol + 16, days); // dol_VolumeDate - bus.write_long(vol + 20, mins); - bus.write_long(vol + 24, ticks); - bus.write_long(vol + 28, 0); // dol_LockList - bus.write_long(vol + 32, ID_CLFS_DISK); // dol_DiskType - bus.write_long(vol + 36, 0); // dol_unused - bus.write_long(vol + 40, (vol + 44) >> 2); // dol_Name (BSTR) + 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(); - bus.write_byte(vol + 44, name.len() as u8); - for (i, &b) in name.iter().enumerate() { - bus.write_byte(vol + 45 + i as u32, b); - } - bus.write_byte(vol + 45 + name.len() as u32, 0); + write_bytes(bus, vol + fixed, &bcpl::<32>(&name)); self.volumes.insert(unit, vol); vol } @@ -369,12 +324,12 @@ impl FilesysHle { // 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 - let unit = bus.read_long(dn + 28) as usize; // dn_Startup: mount index + let unit = bus.read_long(dn + DEVICENODE_STARTUP) 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 + 8, port); // dn_Task = handler port + bus.write_long(dn + DEVICENODE_TASK, port); self.ports.insert(port, unit); *add_volume = Some(self.build_volume_node(bus, unit, port)); log::info!( @@ -400,16 +355,18 @@ impl FilesysHle { 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); - bus.write_long(id, 0); // id_NumSoftErrors - bus.write_long(id + 4, unit as u32); // id_UnitNumber - bus.write_long(id + 8, ID_VALIDATED); // id_DiskState - bus.write_long(id + 12, numblocks); // id_NumBlocks - bus.write_long(id + 16, inuse); // id_NumBlocksUsed - bus.write_long(id + 20, blocksize); // id_BytesPerBlock - bus.write_long(id + 24, ID_CLFS_DISK); // id_DiskType - let volume = self.volumes.get(&unit).copied().unwrap_or(0); - bus.write_long(id + 28, volume >> 2); // id_VolumeNode (BPTR) - bus.write_long(id + 32, if locks_open { DOSTRUE } else { 0 }); // id_InUse + let info = InfoData { + num_soft_errors: long(0), + unit_number: long(unit as u32), + disk_state: long(ID_VALIDATED), + 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})", @@ -420,6 +377,12 @@ impl FilesysHle { 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); }; @@ -470,6 +433,134 @@ impl FilesysHle { 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). + 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); + 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); + bus.write_long(fh + FILEHANDLE_ARG1, key); + (DOSTRUE, 0) + } + Err(_) => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + 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 + }; + let mut data = vec![0u8; len]; + match f.read(&mut data) { + Ok(n) => { + for (i, &b) in data[..n].iter().enumerate() { + bus.write_byte(buf + i as u32, b); + } + (n as u32, 0) + } + Err(_) => (DOSTRUE, ERROR_SEEK_ERROR), // res1 = -1 + } + } + 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) + } _ => { log::debug!("filesys: {}: unhandled action {dp_type}", device_name(unit)); (DOSFALSE, ERROR_ACTION_NOT_KNOWN) @@ -542,22 +633,6 @@ fn match_component(dir: &Path, comp: &str) -> Option { .find(|n| n.to_string_lossy().eq_ignore_ascii_case(comp)) } -/// Host mtime -> AmigaDOS DateStamp (days/minutes/ticks since 1978-01-01). -fn amiga_datestamp(time: Option) -> (u32, u32, u32) { - /// 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, - ) -} - /// Total and available bytes of the host filesystem containing `path`. #[cfg(unix)] fn host_fs_usage(path: &Path) -> Option<(u64, u64)> { @@ -596,13 +671,6 @@ fn scale_blocks(total: u64, avail: u64) -> (u32, u32, u32) { ) } -/// Read a BSTR (BPTR to length-prefixed string) from guest memory. -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() -} - #[cfg(test)] mod tests { use super::*; @@ -624,35 +692,44 @@ mod tests { &img[ROM_OFFSET..ROM_OFFSET + FILESYS_HANDLER.len()], FILESYS_HANDLER ); - // The handler ROM's entry table: two bra.w instructions (process entry at - // +0, mount entry at +4 -- the DiagArea jsr's board+12). + // 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!(img[ROM_OFFSET + 4], 0x60); - assert_eq!(img[ROM_OFFSET + 5], 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 (libraries/configregs.h layout). + // 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 - assert_eq!(u16::from_be_bytes([img[d + 2], img[d + 3]]), 0x0040); // da_Size - assert_eq!(u16::from_be_bytes([img[d + 4], img[d + 5]]), 0x0010); // da_DiagPoint - // DAC_CONFIGTIME requires a non-zero da_BootPoint (Kickstart 3.x - // rejects the whole DiagArea otherwise). - assert_ne!(u16::from_be_bytes([img[d + 6], img[d + 7]]), 0); - - // DiagPoint code: the trap opcode first, and the jsr into the handler's - // mount entry at board+12 (= ROM_OFFSET + 4). + 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!( - u16::from_be_bytes([img[d + 0x10], img[d + 0x11]]), - TRAP_DIAG_ENTRY + &img[d + da_diag..d + da_diag + 6], + &[0x4E, 0xA8, 0x00, 0x0C, 0x4E, 0x75] ); - assert_eq!(&img[d + 0x18..d + 0x1C], &[0x4E, 0xA8, 0x00, 0x0C]); 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); } @@ -683,11 +760,4 @@ mod tests { let (total, avail) = host_fs_usage(Path::new("/")).expect("statvfs /"); assert!(total > 0 && avail <= total); } - - #[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)); - } } From cb83b02fb1b9c5c52d17d133b58d8334c4c87ca4 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sat, 11 Jul 2026 20:51:47 +0900 Subject: [PATCH 03/17] filesys: file attributes from .uaem sidecars and host permissions Read the UAE .uaem metadata sidecars (same grammar as the Amiberry fsdb: hsparwed presence flags with the rwed group flipped to the FIB deny convention, centisecond timestamp, optional comment) for protection bits, exact datestamps, and file comments; hide the sidecars from listings. Without a sidecar, a read-only host file denies w, and e stays allowed, both matching the UAE fsdb. Sidecar timestamps convert civil-date-direct, with no timezone round trip. Co-Authored-By: Claude Fable 5 --- src/amigaos/dos.rs | 11 +++- src/filesys.rs | 139 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index 884c708..a5f0a16 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -49,6 +49,10 @@ pub const ID_VALIDATED: u32 = 82; 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). @@ -145,8 +149,11 @@ pub fn read_bstr(bus: &mut dyn AddressBus, bptr: u32) -> Vec { (0..len).map(|i| bus.read_byte(addr + 1 + i)).collect() } -/// Host mtime -> AmigaDOS DateStamp (days/minutes/ticks since 1978-01-01). -pub fn amiga_datestamp(time: Option) -> (u32, u32, u32) { +/// 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 diff --git a/src/filesys.rs b/src/filesys.rs index e910e14..3155d73 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -129,6 +129,8 @@ pub struct FilesysHle { /// Board base address, captured from A0 at the DiagPoint trap. board_base: Option, /// Handler MsgPort address -> mount unit, learned from startup packets. + /// TODO(codewiz): clear all per-boot state (ports, locks, files, pool) + /// on guest reset -- it goes stale across a warm reboot. ports: HashMap, /// Mount unit -> guest address of its volume DosList node. volumes: HashMap, @@ -252,30 +254,50 @@ impl FilesysHle { ST_USERDIR }; - let (days, mins, ticks) = amiga_datestamp(meta.modified().ok()); + // 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(0), // rwed + 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: [0; 80], // empty BCPL string + comment: bcpl::<80>(&comment), }; write_bytes(bus, fib, fib_data.as_bytes()); Ok(()) } - /// Sorted directory listing used by EXAMINE_NEXT. Recomputed per call: - /// simple and correct for interactive use; cache if it ever shows up. + /// 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 @@ -477,6 +499,8 @@ impl FilesysHle { // 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) { @@ -619,6 +643,86 @@ impl HleHandler for FilesysHle { } } +/// 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). @@ -734,6 +838,31 @@ mod tests { assert_eq!(TRAP_BASE >> 12, 0xA); } + #[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. From 836148d536d3b93f0059ec17ec86a4816d7c4830 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sat, 11 Jul 2026 21:23:16 +0900 Subject: [PATCH 04/17] filesys: survive warm reboot and give the boot menu a real FSSM Flush all per-boot handler state (ports, volumes, locks, open files, lock pool) at expansion init, so a warm reboot starts clean instead of misrouting packets to stale MsgPort addresses. Point dn_Startup at a per-unit FileSysStartupMsg (plus shared DosEnvec and device BSTR) written into the board window, so Early Startup shows 'CLFS hostfs-N' instead of dereferencing a raw integer as a BPTR. Co-Authored-By: Claude Fable 5 --- assets/services/services_rom.bin | Bin 416 -> 440 bytes guest/services/copperline_board.h | 5 +++ guest/services/handler.c | 5 ++- src/amigaos/dos.rs | 38 ++++++++++++++++ src/filesys.rs | 72 ++++++++++++++++++++++++++++-- 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/assets/services/services_rom.bin b/assets/services/services_rom.bin index 3dbf7202c663925711ad6ca2b5e49c18fb43fcb9..591d692a610c3ebdcf1e9bc32c0bd025c44e8bf0 100644 GIT binary patch delta 222 zcmZ3$yn}hdqx!&pkLPwu3i%8g3Kk43`VI^-RvH!zJgy7^$xKxO8XRmb3=9ASI;ds)t%Poz==e|o#K)+w90?UO4m3#(;s|U&$yo@Xu(%A~+H#DbZ z>EtuW6zE-OR>Aoq}+NZ4GH2PAeE#6l)k56c`vp z3b+&<7$g)}o-_by8AT@sg|h#O&I~F~8WkNGG=K)`6f_huD&;d60FAp7Nas8vAa4L5AUFU7FF*hoMd&yJI4C#(3`77BV*+>(0)zkn z|NnXbScoJD5kmkhlgYEgWHr{Mf>MKEj1jV!0GKUOiD0k*0CkBy AEdT%j diff --git a/guest/services/copperline_board.h b/guest/services/copperline_board.h index 651711b..7e242ac 100644 --- a/guest/services/copperline_board.h +++ b/guest/services/copperline_board.h @@ -33,6 +33,11 @@ #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. +#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) diff --git a/guest/services/handler.c b/guest/services/handler.c index 4890401..a605455 100644 --- a/guest/services/handler.c +++ b/guest/services/handler.c @@ -126,7 +126,10 @@ void mount_boards(UBYTE *board, struct Library *_expbase, struct ConfigDev *cd) dn->dn_Type = DLT_DEVICE; dn->dn_StackSize = HANDLER_STACK; dn->dn_Priority = 10; - dn->dn_Startup = i; // mount table index; read back at ACTION_STARTUP + // The emulator prepared one FileSysStartupMsg per unit 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); diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index a5f0a16..53c2c67 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -110,6 +110,42 @@ pub struct VolumeNode { 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)] @@ -178,6 +214,8 @@ mod tests { 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); diff --git a/src/filesys.rs b/src/filesys.rs index 3155d73..f02d92e 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -42,6 +42,14 @@ pub const DIAG_OFFSET: u16 = ROM_OFFSET as u16 + 0x40; /// 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; +const FSSM_ENVEC_OFFSET: u32 = 0x7940; /// 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; @@ -129,8 +137,8 @@ pub struct FilesysHle { /// Board base address, captured from A0 at the DiagPoint trap. board_base: Option, /// Handler MsgPort address -> mount unit, learned from startup packets. - /// TODO(codewiz): clear all per-boot state (ports, locks, files, pool) - /// on guest reset -- it goes stale across a warm reboot. + /// 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, @@ -303,6 +311,48 @@ impl FilesysHle { names } + /// Write one FileSysStartupMsg per mount into the board window, plus the + /// shared display device name and DosEnvec they reference. dn_Startup + /// points at these so the Early Startup boot menu shows "CLFS hostfs-N" + /// instead of dereferencing garbage, and ACTION_STARTUP reads the unit + /// back from fssm_Unit. + fn write_startup_msgs(&self, bus: &mut dyn AddressBus, base: 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(-128i32 as u32), // matches the AddBootNode priority + dos_type: long(ID_CLFS_DISK), + }; + write_bytes(bus, base + FSSM_ENVEC_OFFSET, envec.as_bytes()); + write_bytes(bus, base + FSSM_DEVNAME_OFFSET, &bcpl::<32>(b"hostfs")); + for unit in 0..self.mounts.len() as u32 { + let fssm = FileSysStartupMsg { + unit: long(unit), + device: long((base + FSSM_DEVNAME_OFFSET) >> 2), + environ: long((base + FSSM_ENVEC_OFFSET) >> 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. @@ -346,7 +396,10 @@ impl FilesysHle { // 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 - let unit = bus.read_long(dn + DEVICENODE_STARTUP) as usize; + // 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); @@ -601,7 +654,20 @@ impl HleHandler for FilesysHle { // 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. self.board_base = Some(cpu.dar[8]); + self.ports.clear(); + self.volumes.clear(); + self.locks.clear(); + self.files.clear(); + self.free_slots.clear(); + self.pool_next = 0; + self.write_startup_msgs(bus, cpu.dar[8]); log::info!( "filesys: expansion init at board {:#010X}, {} mount(s)", cpu.dar[8], From 3bb0aba26fdbdac0ba9447f552388be1075bf2ff Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sat, 11 Jul 2026 22:36:05 +0900 Subject: [PATCH 05/17] filesys: boot from HOSTFS volumes (bootpri config option) [[filesys]] entries take bootpri (-128..127, default -128 = never a boot candidate), carried in a per-unit DosEnvec whose de_BootPri the guest handler passes to AddBootNode. The DiagArea's BootPoint is now the standard autoboot code (FindResident dos.library, jsr rt_Init), and DiagPoint returns success so Kickstart keeps the diag copy strap boots through. Booting a real Workbench off a host directory flushed out packet-level gaps, all fixed: names with an "Assign:" prefix must resolve relative to the supplied lock, not the root (broke SetPatch's LIBS: opens); ACTION_FH_FROM_LOCK (broke the ENVARC: copy), ACTION_SAME_LOCK, ACTION_EXAMINE_FH, ACTION_PARENT_FH, and ACTION_FLUSH. Co-Authored-By: Claude Fable 5 --- assets/services/services_rom.bin | Bin 440 -> 500 bytes guest/services/copperline_board.h | 4 +- guest/services/entry.s | 25 +++- guest/services/handler.c | 14 +- src/amigaos/dos.rs | 5 + src/config.rs | 5 + src/filesys.rs | 211 +++++++++++++++++++++++++----- 7 files changed, 217 insertions(+), 47 deletions(-) diff --git a/assets/services/services_rom.bin b/assets/services/services_rom.bin index 591d692a610c3ebdcf1e9bc32c0bd025c44e8bf0..440725706fd508403933913dd93d15abe05b380a 100644 GIT binary patch delta 176 zcmdnN{DoN}fq`Mt5(a&4eO`TzLQgNQ6PE$s)l`91I+~K@AC07#IKr$TZsk delta 116 zcmeyuyn|UHfq@}!34=bjKCeDUA_Kz}|Mv_$1q^*T;A0SBkYE4; zzZDETex+dPn&*sJlT#Q~89gRdn_Type = DLT_DEVICE; dn->dn_StackSize = HANDLER_STACK; dn->dn_Priority = 10; - // The emulator prepared one FileSysStartupMsg per unit at expansion - // init; the boot menu displays it, and the emulator reads the unit - // back from fssm_Unit at ACTION_STARTUP. + // 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); - // Priority -128: mounted at DOS init but never a boot candidate. + // 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. - AddBootNode(-128, ADNF_STARTPROC, dn, cd); + 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/dos.rs b/src/amigaos/dos.rs index 53c2c67..050750b 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -21,12 +21,17 @@ 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_PARENT: i32 = 29; +pub const ACTION_SAME_LOCK: i32 = 40; pub const ACTION_READ: i32 = 82; // 'R' pub const ACTION_FINDINPUT: i32 = 1005; // Open(..., MODE_OLDFILE) pub const ACTION_END: i32 = 1007; // Close() pub const ACTION_SEEK: i32 = 1008; +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; diff --git a/src/config.rs b/src/config.rs index ead7740..8f492f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1251,6 +1251,10 @@ pub(crate) struct RawFilesysMount { /// 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 @@ -2022,6 +2026,7 @@ impl TryFrom for Config { .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "HostFS".into()) }), + boot_pri: m.bootpri.unwrap_or(-128), }) .collect(), chipset, diff --git a/src/filesys.rs b/src/filesys.rs index f02d92e..9b76ec0 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -49,7 +49,9 @@ const VOLUME_SLOT_SIZE: u32 = 128; 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; @@ -81,6 +83,9 @@ const ID_CLFS_DISK: u32 = 0x434C_4653; 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`, ...). @@ -143,7 +148,8 @@ pub struct FilesysHle { /// Mount unit -> guest address of its volume DosList node. volumes: HashMap, /// Open files by fh_Arg1 cookie (host-side only, no guest structure). - files: HashMap, + /// 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, @@ -192,8 +198,9 @@ impl FilesysHle { } /// Resolve a DOS path (BPTR lock + name) to a lock record. AmigaDOS path - /// semantics: an optional `device:` prefix restarts at the root, `/` - /// goes to the parent, and names are case-insensitive. + /// 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))?; @@ -204,10 +211,15 @@ impl FilesysHle { let mut rest = name; if let Some(colon) = name.iter().position(|&b| b == b':') { - // "DEVICE:" or "Volume:" prefix: absolute from the root. The - // packet reached this handler, so the prefix already named it. - rest = &name[colon + 1..]; - rel = PathBuf::new(); + 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() @@ -312,37 +324,40 @@ impl FilesysHle { } /// Write one FileSysStartupMsg per mount into the board window, plus the - /// shared display device name and DosEnvec they reference. dn_Startup - /// points at these so the Early Startup boot menu shows "CLFS hostfs-N" - /// instead of dereferencing garbage, and ACTION_STARTUP reads the unit - /// back from fssm_Unit. + /// 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) { - 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(-128i32 as u32), // matches the AddBootNode priority - dos_type: long(ID_CLFS_DISK), - }; - write_bytes(bus, base + FSSM_ENVEC_OFFSET, envec.as_bytes()); write_bytes(bus, base + FSSM_DEVNAME_OFFSET, &bcpl::<32>(b"hostfs")); - for unit in 0..self.mounts.len() as u32 { + 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((base + FSSM_ENVEC_OFFSET) >> 2), + environ: long(envec_at >> 2), flags: long(0), }; write_bytes( @@ -567,6 +582,12 @@ impl FilesysHle { 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); }; @@ -578,13 +599,96 @@ impl FilesysHle { Ok(f) => { self.next_file_key += 1; let key = self.next_file_key; - self.files.insert(key, f); + 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. @@ -592,7 +696,7 @@ impl FilesysHle { 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 { + let Some((f, _)) = self.files.get_mut(&key) else { return (DOSTRUE, ERROR_INVALID_LOCK); // res1 = -1 }; let mut data = vec![0u8; len]; @@ -613,7 +717,7 @@ impl FilesysHle { 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 { + 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()) { @@ -849,6 +953,7 @@ mod tests { vec![MountSpec { path: "/nonexistent".into(), volume: "Test".into(), + boot_pri: -128, }] } @@ -902,6 +1007,40 @@ mod tests { 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")); + + std::fs::remove_dir_all(&root).unwrap(); } #[test] From f68e4a74c3d02fecf8c3a08f4d59f3f84804736b Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 02:52:04 +0200 Subject: [PATCH 06/17] [[filesys.mount]] -> [[filesys]] Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/cpu.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cpu.rs b/src/cpu.rs index dc0f6a4..2b44455 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1276,7 +1276,7 @@ impl M68kMachine { } /// Configure the host directories served by the filesys trap gateway - /// (`[[filesys.mount]]`). + /// (`[[filesys]]`). pub fn set_filesys_mounts(&mut self, mounts: Vec) { self.hle.set_mounts(mounts); } From d4649decfe70b24f6caf8dd1d768b77abed47554 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 10:17:02 +0900 Subject: [PATCH 07/17] filesys: reject "." and ".." path components (host path escape) A guest could Lock("HOSTFS:..") and walk out of the mounted directory onto the host filesystem: AmigaDOS gives dots no special meaning, but match_component passed them to the host, which does. Found by Copilot review on PR #159. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/filesys.rs b/src/filesys.rs index 9b76ec0..ccc233d 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -897,6 +897,11 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { /// 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()); } @@ -1040,6 +1045,13 @@ mod tests { 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(); } From 1aeed932faf368beb3ec77857e5d025483e11f61 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 10:20:24 +0900 Subject: [PATCH 08/17] filesys: refuse write actions with ERROR_DISK_WRITE_PROTECTED Deleting a file popped "unknown packet" (209); a write-protected FFS disk answers every write-family action with error 214 instead, so do the same until write support lands. id_DiskState now reports ID_WRITE_PROTECTED, so C:Info shows the volumes as Read Only. Co-Authored-By: Claude Fable 5 --- src/amigaos/dos.rs | 13 +++++++++++++ src/filesys.rs | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index 050750b..a76cb79 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -14,20 +14,30 @@ pub use zerocopy::IntoBytes; // Packet types (dos/dosextens.h ACTION_*). 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() @@ -40,6 +50,7 @@ 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_*). @@ -48,6 +59,8 @@ 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_*). diff --git a/src/filesys.rs b/src/filesys.rs index ccc233d..05ae241 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -448,7 +448,8 @@ impl FilesysHle { let info = InfoData { num_soft_errors: long(0), unit_number: long(unit as u32), - disk_state: long(ID_VALIDATED), + // 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), @@ -742,6 +743,18 @@ impl FilesysHle { self.files.remove(&arg(bus, 1)); (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) From feb11c3ee9206eee145456f2a10fc62181a1ecaf Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 10:47:35 +0900 Subject: [PATCH 09/17] filesys: rustfmt Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 05ae241..9924c2e 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -747,14 +747,10 @@ impl FilesysHle { // 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), + 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) From a519b8daddd4629a33d30d584715297b319f4247 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 11:21:08 +0900 Subject: [PATCH 10/17] docs: document [[filesys]] mounts in the example config and guide Co-Authored-By: Claude Fable 5 --- copperline.example.toml | 15 +++++++++++++++ docs/guide/configuration.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/copperline.example.toml b/copperline.example.toml index 403ff0c..b2f1dab 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 4d6a2c3..6ce4103 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 From 4dc213c782e5e890203f69cd887d64882c8382da Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 12:16:42 +0900 Subject: [PATCH 11/17] filesys: implement ACTION_DIE (volume dismount) A dismount tool sends ACTION_DIE to shut a handler down; stock Assign DISMOUNT only unlinks the DeviceNode silently. Refused with ERROR_OBJECT_IN_USE while locks or files are open (protects the boot volume). On success the host frees the unit state and clears dn_Task, and the guest RemDosEntry-s the volume and exits the process, so the next reference to the device simply restarts the handler and remounts. Co-Authored-By: Claude Fable 5 --- assets/services/services_rom.bin | Bin 500 -> 548 bytes guest/services/copperline_board.h | 4 ++ guest/services/handler.c | 14 +++++++ src/amigaos/dos.rs | 2 + src/filesys.rs | 60 +++++++++++++++++++++++++----- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/assets/services/services_rom.bin b/assets/services/services_rom.bin index 440725706fd508403933913dd93d15abe05b380a..f59e104c66b5ce4a9f2f0fb75e86453986f17981 100644 GIT binary patch delta 192 zcmeyuyo5y{fq`Mt5(a&4eO`TzLI2CdEDX;rm1eQzy ga@PIL(Q#IC0%}f5VA;`>_Q~hIkBwjHWH!ck0OR;b6#xJL delta 125 zcmZ3&@`YI-fq`Mt5(a&4eO`TzLH!@m1ur14LC0z00Wm#&4+aShh62V7E$Md@vKd&^y%<=Of`Rgr cLR->Kc{QgW({WbPXOIAz6P2)QvKiw$0P>11WdHyG diff --git a/guest/services/copperline_board.h b/guest/services/copperline_board.h index 7cca8cf..b81e0a3 100644 --- a/guest/services/copperline_board.h +++ b/guest/services/copperline_board.h @@ -51,5 +51,9 @@ #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/handler.c b/guest/services/handler.c index 7ddb133..3908e0f 100644 --- a/guest/services/handler.c +++ b/guest/services/handler.c @@ -95,6 +95,20 @@ void handler_main(void) // 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; + } } } } diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index a76cb79..ba83d67 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -13,6 +13,7 @@ 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; @@ -46,6 +47,7 @@ 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; diff --git a/src/filesys.rs b/src/filesys.rs index 9924c2e..16810f5 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -62,6 +62,7 @@ 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 @@ -119,6 +120,16 @@ pub fn board_image(mounts: &[MountSpec]) -> Vec { 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. @@ -147,6 +158,9 @@ pub struct FilesysHle { 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, @@ -394,15 +408,15 @@ impl FilesysHle { vol } - /// Handle one DosPacket; returns (dp_Res1, dp_Res2). When the packet - /// creates a volume node the guest must AddDosEntry, its address is - /// stored in `add_volume`. + /// 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, - add_volume: &mut Option, + 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)); @@ -421,7 +435,8 @@ impl FilesysHle { } bus.write_long(dn + DEVICENODE_TASK, port); self.ports.insert(port, unit); - *add_volume = Some(self.build_volume_node(bus, unit, port)); + 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), @@ -743,6 +758,26 @@ impl FilesysHle { 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. @@ -776,6 +811,7 @@ impl HleHandler for FilesysHle { self.board_base = Some(cpu.dar[8]); self.ports.clear(); self.volumes.clear(); + self.device_nodes.clear(); self.locks.clear(); self.files.clear(); self.free_slots.clear(); @@ -792,8 +828,8 @@ impl HleHandler for FilesysHle { let pkt = cpu.dar[1]; // D1 let port = cpu.dar[9]; // A1 let dp_type = bus.read_long(pkt + 8) as i32; - let mut add_volume = None; - let (res1, res2) = self.handle_packet(bus, port, pkt, &mut add_volume); + let mut guest_op = None; + let (res1, res2) = self.handle_packet(bus, port, pkt, &mut guest_op); log::debug!( "filesys: packet type {dp_type} at {pkt:#010X} -> \ res1={res1:#X} res2={res2}" @@ -801,12 +837,16 @@ impl HleHandler for FilesysHle { 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, - // and for ADDVOLUME also AddDosEntry the node passed in A0). - cpu.dar[0] = match add_volume { - Some(vol) => { + // 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 From b1cb7926351436fcd9c97c3b8812fe745d644c34 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 12:17:14 +0900 Subject: [PATCH 12/17] filesys: shrink the handler stack to 2K The packet pump needs well under 200 bytes; 8K per handler process was just wasted guest RAM. Co-Authored-By: Claude Fable 5 --- assets/services/services_rom.bin | Bin 548 -> 548 bytes guest/services/handler.c | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/services/services_rom.bin b/assets/services/services_rom.bin index f59e104c66b5ce4a9f2f0fb75e86453986f17981..51c8fc3ce2d773a7f93c6c18c968124c918cd7e7 100644 GIT binary patch delta 13 UcmZ3&vV>*BAx1`y$%h#e0U*BAx1`p$%h#e0U@dcod5s; diff --git a/guest/services/handler.c b/guest/services/handler.c index 3908e0f..40b5539 100644 --- a/guest/services/handler.c +++ b/guest/services/handler.c @@ -41,7 +41,9 @@ #include "copperline_board.h" -#define HANDLER_STACK 8192 +// 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 From 0aa0c2f89a1d17e72b687a4857ecfc56aac47f8f Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 19:17:35 +0900 Subject: [PATCH 13/17] filesys: prefix packet traces with the device name So multi-mount debug logs say which HOSTFS unit a packet belongs to. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 16810f5..341b636 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -830,9 +830,11 @@ impl HleHandler for FilesysHle { 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}" + "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 From f316eee199bf695593af31b58b1b22b3b55c443c Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 19:19:45 +0900 Subject: [PATCH 14/17] filesys: bound the ACTION_READ transfer buffer The read length comes straight from the guest packet, so allocating it up front let a bogus multi-GB read force an unbounded host allocation. Read in 64 KiB chunks instead; DOS callers see identical results. Reported by Copilot on PR #159. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 341b636..2aacaee 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -715,16 +715,28 @@ impl FilesysHle { let Some((f, _)) = self.files.get_mut(&key) else { return (DOSTRUE, ERROR_INVALID_LOCK); // res1 = -1 }; - let mut data = vec![0u8; len]; - match f.read(&mut data) { - Ok(n) => { - for (i, &b) in data[..n].iter().enumerate() { - bus.write_byte(buf + i as u32, b); + // 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; } - (n as u32, 0) + Err(_) => return (DOSTRUE, ERROR_SEEK_ERROR), // res1 = -1 } - Err(_) => (DOSTRUE, ERROR_SEEK_ERROR), // res1 = -1 } + (done as u32, 0) } ACTION_SEEK => { // Arg1 = fh_Arg1, Arg2 = position, Arg3 = OFFSET_* mode. From 36779b6b84d6cec54f14f312fcb0e30de7f361ff Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 19:21:02 +0900 Subject: [PATCH 15/17] filesys: let unrecognized A-line opcodes reach the guest handle_aline claimed the whole 0xA400-0xA4FF range and returned true for any opcode, swallowing the CPU A-line exception. We only emit 0xA400 and 0xA402; return false for the rest so guest software using its own A-line traps takes the exception as on hardware. Reported by Copilot on PR #159. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 2aacaee..d2a0df8 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -866,11 +866,15 @@ impl HleHandler for FilesysHle { true } _ => { - log::warn!( - "filesys: unexpected trap opcode {opcode:#06X} at PC={:#010X}", + // 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 ); - true + false } } } From 4540ff159b29135bf93aa4048b93aa2e29a2eca3 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 20:28:06 +0900 Subject: [PATCH 16/17] config: validate [[filesys]] volume names The volume name becomes an AmigaDOS DosList BSTR, so reject up front rather than silently truncating or building a broken volume node. Three separate checks with their own errors: empty, over 30 bytes, and containing ":" "/" or NUL. Adds a rejection test. Reported by Copilot on PR #159. Co-Authored-By: Claude Fable 5 --- src/config.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/config.rs b/src/config.rs index 8f492f7..9996a83 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1002,6 +1002,24 @@ impl Config { 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(), @@ -2728,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) From c9452c7ef9eeefd4758bce395970f43180d09337 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 12 Jul 2026 20:28:06 +0900 Subject: [PATCH 17/17] filesys: rebuild per-boot state from a fresh default on reboot TRAP_DIAG_ENTRY reset the per-boot maps field by field and missed next_file_key, which could (in theory) wrap and alias an open file. Reassign *self from a default, preserving only the configured mounts, so a newly added per-boot field can never be forgotten. Reported by Copilot on PR #159. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index d2a0df8..f33ec9c 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -819,15 +819,16 @@ impl HleHandler for FilesysHle { // 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. - self.board_base = Some(cpu.dar[8]); - self.ports.clear(); - self.volumes.clear(); - self.device_nodes.clear(); - self.locks.clear(); - self.files.clear(); - self.free_slots.clear(); - self.pool_next = 0; + // 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)",