The disk subsystem emulates a CoCo Disk Controller (WD1793-based FDC) with up to 4 floppy drives. It supports .DSK (JVC) and .VDK disk image f
ormats loaded from SD card. The implementation matches XRoar's rsdos.c for DSKREG bit assignments, HALT/DRQ synchronization, and NMI/INTRQ gatin
g.
Source files:
src/supervisor/sv_disk.h— Data structures and public APIsrc/supervisor/sv_disk.cpp— WD1793 FDC emulation + DSKREG logicsrc/supervisor/supervisor.cpp— Wiring of NMI/HALT callbacks to MC6809src/hal/hal_storage.cpp— SD card access (dedicated HSPI bus)src/core/machine.cpp— FDC tick integration in scanline loop
With the DriveWire bus in External mode, HDB-DOS talks to a remote server instead and these drives are not used; see drivewire.md.
$FF40 DSKREG (drive select latch)
│
┌──────────┬──────────┼──────────────────┐
│ │ │ │
Drive Select Side HALT Enable Density
(bits 0-2) (bit 6) (bit 7) (bit 5)
│ │ │
v v v
┌─────────┐ DRQ ──┤ NMI gating
│ WD1793 │──INTRQ───────►├──► CPU NMI
│ FDC │──DRQ──────────┤──► CPU HALT
│ │ │
│ $FF48-4B│ │
└─────────┘ │
CPU 6809E
| Address | R/W | Register | Description |
|---|---|---|---|
| $FF40-$FF47 | W | DSKREG | Drive select latch (active-low drive lines) |
| $FF48 | R | Status | WD1793 status (clears INTRQ on read) |
| $FF48 | W | Command | WD1793 command register |
| $FF49 | R/W | Track | Current track number |
| $FF4A | R/W | Sector | Current sector number (1-based!) |
| $FF4B | R/W | Data | Data transfer register |
Matches XRoar rsdos.c — NOT the commonly-seen but incorrect "bit 7 = NMI enable":
| Bit | Function | Notes |
|---|---|---|
| 0 | Drive 0 select | Active high, directly selects drive |
| 1 | Drive 1 select | |
| 2 | Drive 2 select | |
| 3 | Motor on | |
| 4 | Write precomp | (not emulated) |
| 5 | Density | XOR'd internally; gates NMI path |
| 6 | Side select | Selects floppy head (0=side 0, 1=side 1) |
| 7 | HALT enable | Connects DRQ to CPU HALT line |
This is the "hardware trick" that makes CoCo disk I/O work. Without it, the CPU free-runs through the data transfer loop and NMI timing is unrelia ble.
- DSKCON writes DSKREG with bit 7 = 1 →
halt_enable = true - FDC asserts DRQ when a data byte is ready →
signal_halt(false)→ CPU runs - CPU reads byte via
LDA $FF4B→ FDC clears DRQ →signal_halt(true)→ CPU halted - FDC prepares next byte → DRQ asserts again → CPU released
- After 256 bytes: FDC asserts INTRQ → HALT disabled, CPU released, NMI fires
// DRQ callback — used by DSKREG write and WRITE TRACK/SECTOR paths
// NOT used during READ SECTOR byte delivery (see note below)
static void set_drq(SV_DiskController* fdc, bool value) {
fdc->drq = value;
if (value) {
signal_halt(fdc, false); // Data ready → release CPU
} else {
if (fdc->halt_enable)
signal_halt(fdc, true); // No data → halt CPU
}
}
// INTRQ callback (matching XRoar rsdos.c set_intrq)
static void set_intrq(SV_DiskController* fdc, bool value) {
fdc->intrq = value;
if (value) {
fdc->halt_enable = false; // INTRQ disables HALT
signal_halt(fdc, false); // Release CPU
}
// NMI gating runs unconditionally (matches XRoar exactly)
if (!fdc->density && fdc->intrq) {
signal_nmi(fdc, true); // Fire NMI via density gating
} else {
signal_nmi(fdc, false); // Deassert NMI for edge detection
}
}Note on READ SECTOR: During byte-by-byte reads, fdc->drq is set directly (not via set_drq()) to avoid engaging HALT. Our non-cycle-accurat
e emulation cannot model the propagation delay between DRQ clearing and HALT engaging — calling set_drq(false) would halt the CPU before STA sto
res the byte. The CPU free-runs through the read loop, and the byte-257 mechanism handles INTRQ timing.
The HALT callback is wired to cpu.halted in supervisor.cpp. When mc6809_run() sees halted = true, it burns remaining cycle budget, effectively pausing the CPU.
Bug: fdc_write_drive_select() called signal_halt(fdc, halt_enable && !drq) unconditionally. The standard DSKCON sequence writes DSKREG (enabling HALT) before issuing the FDC command. On real hardware, the CPU completes the next instruction (the command write to $FF48) before HALT takes effect. In our scanline-based model, cpu->halted = true takes effect at the next loop iteration — the command instruction never executes, DRQ never goes true, and the CPU is permanently deadlocked.
Fix: Added && fdc->busy to the halt condition:
signal_halt(fdc, fdc->halt_enable && !fdc->drq && fdc->busy);HALT now only engages when an FDC command is actually in progress. Since our FDC completes commands instantly (entire sector available immediately), DRQ is always true during active transfers, so HALT effectively never engages — which is correct for an instant-completion FDC model.
NMI is NOT controlled by a dedicated "NMI enable" bit. Instead, it fires automatically when INTRQ asserts, gated by the density bit (bit 5 of DSKREG, XOR'd).
Both set_intrq() and fdc_write_drive_select() use the same NMI condition, exactly matching XRoar:
if (!fdc->density && fdc->intrq) {
signal_nmi(fdc, true);
} else {
signal_nmi(fdc, false);
}Path 1 — INTRQ callback (normal CoCo case, density bit 5 = 1 in original write):
- DSKREG write
$A9: original bit 5 = 1 → after XOR →density = false - When INTRQ asserts:
!density && intrq→ true →signal_nmi()fires immediately
Path 2 — DSKREG write (density bit 5 = 0 in original write):
- Original bit 5 = 0 → after XOR →
density = true - In
fdc_write_drive_select:!densityis false → NMI not fired from this path - NMI fires later when
set_intrq(true)is called with!densitytrue
The else branch (deassert NMI) is critical for proper edge detection — without it, a stale NMI assertion can persist across operations.
Command written ($FF48) → set_intrq(false) — clear any stale INTRQ
Command completes (Type I) → set_intrq(true) — immediate NMI
Sector read byte 256 → intrq_defer = 5 — defer INTRQ
CPU reads $FF4B (byte 257) → set_intrq(true) — NMI fires (primary path)
sv_disk_tick() countdown → set_intrq(true) — safety-net fallback (5 scanlines)
Sector write byte 256 → set_intrq(true) — immediate NMI (last byte already written)
Write track ~6250 bytes → set_intrq(true) — immediate NMI (track format complete)
Status read ($FF48) → set_intrq(false) — DSKCON reads result
After the CPU reads byte 256 (last byte of a sector), INTRQ is NOT fired immediately. Instead, intrq_defer = 5 is set. The DSKCON read loop (LD A $FF4B / STA ,X+ / BRA) naturally loops back to read $FF4B again (the "byte 257 attempt"). This triggers fdc_read_data() which detects intr q_defer > 0 and fires set_intrq(true) immediately.
Why this works: By the time the CPU reads byte 257, the STA ,X+ for byte 256 has already executed — the last byte is safely stored in RAM. NMI then preempts the stale byte 257's STA, which is exactly what the DSKCON NMI handler expects (it manipulates the stack to skip the read loop).
Why not fire INTRQ immediately on byte 256? Because INTRQ/NMI would preempt the STA ,X+ for byte 256 — the last byte would be lost.
Why not defer by just 1 scanline (the old approach)? With intrq_defer = 1, sv_disk_tick() could fire at the scanline boundary between the
LDA (byte 256) and STA ,X+, causing the same last-byte corruption. The byte-257 $FF4B read fires INTRQ in ~14 CPU cycles, well within a single s
canline.
Safety net: sv_disk_tick() counts down intrq_defer once per scanline. With a defer of 5 (~285 CPU cycles), the byte-257 path always wins t
he race. The tick only fires if the CPU somehow doesn't loop back to $FF4B.
Why not use HALT synchronization for reads? In real hardware, DRQ→HALT has a propagation delay that allows STA to complete before HALT engages
. Our non-cycle-accurate emulation cannot model this delay — set_drq(false) sets halted=true instantly, which would prevent STA from executing
. MAME's update_lines() uses combinational HALT (!DRQ && HALT_ENABLE) but relies on cycle-accurate scheduling. We bypass HALT for reads by set
ting drq directly (no set_drq callback).
DSKCON:
1. Write DSKREG ($FF40): motor + drive, HALT=0
2. RESTORE ($FF48 = $03): FDC → INTRQ → NMI
3. Write DSKREG ($FF40): motor + drive + HALT=1
→ INTRQ pending → HALT disabled (INTRQ overrides)
→ NMI fires from set_intrq
4. NMI handler returns to DSKCON
5. SEEK ($FF48 = $13): same INTRQ → NMI pattern
6. Set sector ($FF4A), READ SECTOR ($FF48 = $80)
→ INTRQ cleared, reading=true, read_pending=true
→ Sector data NOT loaded yet (deferred — see below)
7. Write DSKREG ($FF40): side + HALT=1
→ Side select applied to fdc->side
→ INTRQ=false, DRQ=true → HALT not engaged yet
8. CPU enters tight loop:
LDA $FF4B ; FIRST read triggers deferred sector load
; (side select now correct from step 7)
STA ,X+
BRA loop
9. After byte 256: intrq_defer=5 (deferred — STA not yet done)
10. CPU executes STA ,X+ (stores byte 256), BRA loop
11. CPU reads $FF4B (byte 257 attempt) → fdc_read_data fires set_intrq(true)
→ HALT disabled, NMI fires
12. NMI handler (LEAS 12,S / RTS): returns to DSKCON
13. DSKCON checks status, prepares next sector or exits
Bug: The CoCo DSKCON (and NitrOS-9 CC3Disk driver) writes the FDC command register ($FF48) BEFORE writing DSKREG ($FF40) with the side select bit. On real hardware, the FDC takes time to seek and find the sector, so the side select wire is set before data arrives. In our instant-completion model, the sector was loaded immediately in fdc_execute_command(), using the stale side value from the previous DSKREG write.
Symptom: Every first sector after a side switch read the wrong side's data. NitrOS-9 360K boot corrupted every module that spanned a side boundary, crashing before Phase 5 (user handoff) with "Çp" garbage on screen.
Fix: READ SECTOR now sets read_pending = true without loading data. The actual sector_offset() calculation and memcpy() happen in fdc_read_data() on the first $FF4B read. By that time, DSKREG has been written with the correct side select.
DSKCON (WRITE):
1. Write DSKREG ($FF40): motor + drive, HALT=0
2. RESTORE / SEEK as needed (same as read)
3. Set sector ($FF4A), WRITE SECTOR ($FF48 = $A0)
→ INTRQ cleared, writing=true, DRQ=true
4. Write DSKREG ($FF40): HALT=1
→ HALT engaged (DRQ syncs byte pacing)
5. CPU enters tight loop: LDA ,X+ / STA $FF4B / BRA loop
→ Each STA $FF4B calls fdc_write_data()
→ DRQ cleared after write, HALT engaged
→ DRQ asserted for next byte, CPU released
6. After byte 256: set_intrq(true) IMMEDIATELY
→ HALT disabled, NMI fires
→ Safe because CPU has already written the last byte
(unlike READ, where STA of last byte is still pending)
7. NMI handler returns to DSKCON
8. Dirty flag set — cache flushed to SD on eject
Why WRITE SECTOR uses immediate INTRQ (not deferred): In READ SECTOR, INTRQ must be deferred because the CPU reads the last byte (LDA $FF4B) but hasn't stored it yet (STA ,X+). In WRITE SECTOR, the CP U has already written the last byte (STA $FF4B) by the time the FDC processes it — the data is safely in the sector buffer. So INTRQ/NMI can fire immediately.
Why WRITE SECTOR uses DRQ/HALT (but READ does not):
WRITE SECTOR uses set_drq() for proper DRQ/HALT synchronization because the HALT→release timing is safe: the CPU writes a byte (STA $FF4B), DRQ
clears (HALT engages), FDC accepts it and asserts DRQ (HALT releases). There's no timing race because the CPU has already completed the STA. For R
EAD SECTOR, set_drq(false) would halt the CPU before STA stores the byte, so reads bypass HALT entirely.
DSKINI uses the WD1793 WRITE TRACK command ($F0) to format each track. Unlike READ/WRITE SECTOR which transfer a single 256-byte sector, WRITE TRA CK accepts a raw byte stream (~6250 bytes) representing the entire track including gaps, address marks, ID fields, and data fields.
For each of 35 tracks, DSKINI sends a raw format stream containing 18 sectors:
Per sector in the stream:
GAP bytes ($4E)
Sync bytes ($00)
$F5 $F5 $F5 — address mark prefix (FDC converts to $A1 with clock violation)
$FE — ID Address Mark
[track] [side] [sector] [size_code=01] — 4-byte ID field
$F7 — CRC (FDC generates 2 CRC bytes)
GAP bytes
$F5 $F5 $F5 — address mark prefix
$FB — Data Address Mark
256 × $FF — sector data (fill byte)
$F7 — CRC
Since our disk images are sector-based (not raw track data), WRITE TRACK parses the incoming byte stream to extract sector data and writes it to t he PSRAM cache:
States: WT_IDLE → WT_ID_FIELD → WT_WAIT_DAM → WT_DATA_FIELD → WT_IDLE
WT_IDLE: Count $F5 bytes; on $FE after ≥3 $F5 → WT_ID_FIELD
WT_ID_FIELD: Read 4 bytes (track, side, sector, size) → WT_WAIT_DAM
WT_WAIT_DAM: Count $F5 bytes; on $FB after ≥3 $F5 → WT_DATA_FIELD
WT_DATA_FIELD: Buffer 256 bytes → memcpy to PSRAM cache → WT_IDLE
After 6250 bytes (one full track revolution), the command completes with deferred INTRQ, same as WRITE SECTOR.
- The
write_trackflag is separate fromwriting(WRITE SECTOR) to keep the two paths independent - Sector number is captured from byte 2 of the ID field (the sector number DSKINI assigns)
- Each sector's 256 data bytes are written to
disk->cacheviasector_offset(track, side, sector) disk->dirty = trueis set so the formatted image flushes to SD on eject- State is cleared on FORCE INTERRUPT, new command, or reset
Entire .DSK images are loaded into PSRAM at mount time. All sector reads/writes during emulation go to the in-memory cache — zero SD card access
.
- Standard SS .DSK: 35T × 18S × 256B = 161,280 bytes
- Double-sided 360K: 40T × 2H × 18S × 256B = 368,640 bytes
- 4 drives max (DS): ~1.4 MB total
- Available PSRAM: 4 MB on TTGO VGA32 (ESP32-WROVER) / 8 MB on the original S3 target — plenty of headroom either way
1. Open .DSK file on SD card (FILE_READ)
2. Detect geometry (JVC header, track count, sector size)
3. Allocate PSRAM buffer: heap_caps_malloc(cache_size, MALLOC_CAP_SPIRAM)
4. Load via bounce buffer (512-byte internal RAM buffer):
file.read(bounce, 512) → memcpy(psram_cache, bounce, got)
Bounce buffer avoids ESP32 SPI DMA issues with PSRAM destinations.
5. Close the file; probe once with an "r+" open to set read_only
6. Cache is now the sole data source during emulation — no file stays open
(a FatFS handle costs ~5 KB of internal RAM, so flushes reopen it)
When a mounted disk is dirty (sectors written):
0. Open the image "r+" (on failure: log, stay dirty, return)
1. For each run of consecutive dirty sectors (per-image dirty bitmap):
seek to header_size + first_sector * 256
write via bounce buffer: memcpy(bounce, psram, 512) → file.write(bounce, 512)
2. file.close()
3. Clear the dirty bitmap and dirty flag
Only changed sectors are written (a SAVE is typically 2–3 sectors). If the
bitmap could not be allocated at mount, the whole image is written. Mount,
eject and flush hold sv_disk_lock(), which GET /api/disk (core 0) also takes to read a consistent drive table.
Flush happens on:
sv_disk_eject()— when user unmounts a disk (U key in Disk Manager); flushes implicitly if dirtysv_disk_flush()— explicit single-drive flush (F key in Disk Manager)sv_disk_flush_all()— all dirty drives at once; called automatically before machine reset (Reset Machine confirm dialog) and before machine type change (supervisor_set_machine_type()→esp_restart())
The ESP32 SPI DMA controller (both classic ESP32 and ESP32-S3) cannot reliably read from or write to PSRAM addresses. The SD library's file.read() uses SPI DMA internally. Reading directly into a PSRAM pointer can produce corrupted data. The 512-byte stack-allocated bounce buffer sits in internal RAM (DMA-safe), then memcpy moves data to/from PSRAM safely.
The SD card uses a dedicated HSPI (SPI3) bus.
// hal_storage.cpp
static SPIClass sd_spi(HSPI); // SPI3 peripheral
sd_spi.begin(PIN_SD_SCLK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
SD.begin(PIN_SD_CS, sd_spi, 4000000);Pin assignments (board-specific):
| Function | TTGO VGA32 |
|---|---|
| SD SCLK | GPIO14 |
| SD MOSI | GPIO12 |
| SD MISO | GPIO2 (strapping pin — must float/high at boot) |
| SD CS | GPIO13 |
There is no SPI display bus on the VGA32, so HSPI is dedicated entirely to the SD card. The dedicated bus is required by the board's fixed wiring.
| Command | Opcode | Behavior |
|---|---|---|
| RESTORE | $00-$0F | Track=0, INTRQ immediate |
| SEEK | $10-$1F | Track=data reg, INTRQ immediate |
| STEP | $20-$3F | Track ± step_direction, INTRQ immediate |
| STEP IN | $40-$5F | Track++, INTRQ immediate |
| STEP OUT | $60-$7F | Track--, INTRQ immediate |
| READ SECTOR | $80-$9F | Deferred 256-byte transfer from cache (loads on first $FF4B read), INTRQ deferred |
| WRITE SECTOR | $A0-$BF | 256-byte transfer to cache, INTRQ immediate (uses DRQ/HALT sync) |
| READ ADDRESS | $C0-$DF | Returns 6-byte ID field (track, side, sector, size, CRC, CRC) |
| READ TRACK | $E0-$EF | Returns immediate success (not fully emulated) |
| WRITE TRACK | $F0-$FF | Format track — parses raw stream, writes sectors to cache, INTRQ immediate |
| FORCE INTERRUPT | $D0-$DF | Abort command, INTRQ if bit 3 set |
| Bit | Type I | Type II/III |
|---|---|---|
| 7 | NOT READY | NOT READY |
| 6 | WRITE PROTECT | WRITE PROTECT |
| 5 | HEAD LOADED | RECORD TYPE |
| 4 | SEEK ERROR | RNF |
| 3 | CRC ERROR | CRC ERROR |
| 2 | TRACK 0 | LOST DATA |
| 1 | INDEX PULSE | DRQ |
| 0 | BUSY | BUSY |
- Header size =
file_size % 256(0 for standard images) - Data: raw sector data, 256 bytes per sector, 18 sectors per track
- Double-sided layout: interleaved (T0S0, T0S1, T1S0, T1S1, ...)
- Common sizes: 161,280 (35T SS), 184,320 (40T SS), 368,640 (40T DS), 737,280 (80T DS)
- 12-byte header (always)
- Same sector/track layout as JVC after header
data_size = image_size - header_size
linear_tracks = data_size / (sectors_per_track × sector_size)
if linear_tracks > 40 → double-sided (tracks = linear_tracks/2, sides = 2)
else → single-sided (tracks = linear_tracks, sides = 1)
linear_track = physical_track × sides + side
offset = linear_track × sectors_per_track × sector_size + (sector - 1) × sector_size
For single-sided images, sides=1 and side=0, so this reduces to the simple flat offset.
void machine_run_scanline(Machine* m) {
sv_disk_tick(&m->fdc); // Deferred INTRQ countdown
mc6809_run(&m->cpu, cycles); // CPU executes (may be halted by FDC)
// ... VDG rendering, SAM counter ...
}// $FF40-$FF5F: Disk controller
if (addr < 0xFF60) {
return sv_disk_read(&m->fdc, addr); // reads
sv_disk_write(&m->fdc, addr, val); // writes
}m->fdc.nmi_callback = [](void* ctx, bool active) {
Machine* mach = (Machine*)ctx;
mc6809_nmi(&mach->cpu, active); // NMI edge-triggered → CPU
};
m->fdc.halt_callback = [](void* ctx, bool halted) {
Machine* mach = (Machine*)ctx;
mach->cpu.halted = halted; // HALT → CPU
};
m->fdc.callback_context = m;-
Bit 7 of DSKREG is HALT, not NMI. Many CoCo references get this wrong. XRoar's
rsdos.cis the authoritative source. -
NMI fires via density gating, not a dedicated enable bit. The XOR on bit 5 creates two NMI paths that cover both density settings.
-
NMI must be edge-triggered. The real MC6809 latches NMI on 0→1 transitions only. Without edge detection, a single INTRQ assertion can cause repeated NMI re-entry. The
nmi_linefield tracks pin state;nmi_pendingis set only on rising edges. After servicing,nmi_lineis cleared t o require a new edge. -
HALT cannot be used for READ SECTOR byte synchronization in a non-cycle-accurate emulator. In real hardware, DRQ→HALT has a propagation del ay that allows the CPU to complete STA ,X+ before halting. Our emulation sets
halted=trueinstantly via callbacks, which would prevent STA from executing. Solution: bypass HALT during reads (setdrqdirectly), use the byte-257 mechanism for INTRQ. -
The byte-257 INTRQ mechanism prevents last-byte corruption. After byte 256 is read, INTRQ is deferred. When the DSKCON loop reads $FF4B aga in (byte 257), INTRQ fires — guaranteed to be after STA stored byte 256. A scanline-tick safety net (
intrq_defer=5) provides a fallback. -
intrq_defer=1caused intermittent last-byte corruption. The scanline tick could fire between the LDA (byte 256) and STA ,X+ instructions, causing NMI to preempt the store. Increasing to 5 ensures the byte-257 path always fires first (~14 cycles vs ~285 cycles). -
ESP32 SPI DMA cannot target PSRAM directly. Use a bounce buffer in internal RAM for all SD↔PSRAM transfers.
-
Caching entire disk images in PSRAM eliminates SD reliability issues. At 161 KB per disk, even 4 drives use less than 1 MB of the 8 MB PSRA M.
-
WRITE TRACK is essential for DSKINI. Disk formatting uses the Type III WRITE TRACK command, not individual WRITE SECTOR calls. The raw byte stream must be parsed to extract sector data for sector-based image formats.
-
READ and WRITE SECTOR have different INTRQ/HALT strategies. READ SECTOR bypasses HALT (sets
drqdirectly) and defers INTRQ via the byte-257 mechanism, because instant HALT would prevent STA from storing the last byte. WRITE SECTOR uses proper DRQ/HALT sync (viaset_drq()) and fires INTRQ immediately, because the CPU has already completed the STA by the time the FDC processes the last byte. -
NMI gating must use the same condition in both paths. Both
set_intrq()andfdc_write_drive_select()must check!density && intrq(matching XRoar). An earlier bug hadfdc_write_drive_selectusingdensity && intrq(inverted polarity). Both paths also need theelse { signal_nmi(false); }branch to properly deassert NMI for edge detection. -
Double-sided disk support requires side select (DSKREG bit 6) and correct geometry detection. A 360K NitrOS-9 image (368,640 bytes) is 40 tracks × 2 sides × 18 sectors, not 80 tracks × 1 side. JVC images use interleaved side layout: T0S0, T0S1, T1S0, T1S1, etc. Offset =
(track * sides + side) * SPT * sector_size + (sector-1) * sector_size. -
READ SECTOR must defer sector load until first data read. The CoCo DSKCON writes the command register ($FF48) BEFORE writing DSKREG ($FF40) with the side select. An instant-completion FDC model that loads the sector at command time uses the stale side from the previous DSKREG write. Fix: set
read_pending=trueand load the sector infdc_read_data()on the first$FF4Bread, after DSKREG has been written. -
OS-9 requires SAM all-RAM mode. The OS-9 bootstrap (loaded from Track 34 via the DOS command) writes to $FFDF to set SAM MAP TYPE=1, mapping the full 64KB as RAM. Without all-RAM support in
machine_read()/machine_write(), the bootstrap's writes to $8000+ are silently dropped and reads return ROM data instead of the loaded OS-9 kernel, causing an immediate crash. The I/O space ($FF00–$FFFF) must remain hardware-decoded regardless of MAP TYPE.
The Disk BASIC DOS command (DOSCOM at $DF00 in disk11.rom) boots OS-9:
- SWI3 — no-op hook (vectors through $0100 → RTI)
- Read 18 sectors from Track 34, Drive 0 into $2600–$37FF via DSKCON
- Check first 2 bytes at $2600 for "OS" ($4F $53)
- Jump to $2602 if found (LBEQ with 16-bit offset wrapping to $2602)
The OS-9 bootstrap at $2602 then:
- Disables PIA0 CB1 interrupt (CLR $FF03) — stops 60Hz timer
- Enables SAM all-RAM mode (STA $FFDF) — unmaps ROMs
- Configures SAM VDG/memory registers
- Loads the OS-9 kernel from disk into upper RAM
- Transfers control to the kernel
Key dependency: SAM all-RAM mode support in machine_read()/machine_write() (see core.md Memory Map section).