Skip to content

TR block device: serve a disk image to the C64 through registers beside GeoRAM - #69

Merged
SensoriumEmbedded merged 81 commits into
SensoriumEmbedded:mainfrom
kfox:geos-mailbox
Oct 26, 2026
Merged

SensoriumEmbedded merged 81 commits into
SensoriumEmbedded:mainfrom
kfox:geos-mailbox

Conversation

@kfox

@kfox kfox commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Part of the GEOS 64 package; stacked on #67, so until it merges the diff here includes #61, #66 and #67. Lets software on the C64 read and write the blocks of a disk image (D64, D71 or D81) on the TR's SD card with no disk drive attached. It is what GEOS 64 (#70) starts and saves through. It is not an IEC drive: programs that talk to the IEC bus cannot see it.

How it works. The registers sit beside the USB mouse's, in the IO2 page GeoRAM leaves free. The C64 writes a command, a block address and a token, then reads a status byte. The firmware reads or writes that block of the image from its main loop and moves the 256 bytes through a 64-byte window, four pages per block. It writes each block to the file at once and flushes the file a quarter second after the last write, or on FLUSH. docs/TR_Block_Device.md is the full contract: registers, commands, error codes, and what is not promised.

What is in it

  • The register state machine and the D64/D71/D81 geometry as plain C, so host tests can run them.
  • Mounting an image for the handler. A card restart (SDFullInit) closes the image first and reopens it after; if the card in the slot is a different one, the image is left unmounted rather than serving another card's file under GEOS's cached directory.
  • Tests for the geometry and register behavior, including an image of unknown size (mounts as no image) and a failed write.

The idea for a mailbox of this kind, and its handshake rules, come from Wes Biggs' (@wesbiggs) teensyrom-vdrive (Apache-2.0). This is a reimplementation, not a copy.

Tested through #70's GEOS 64 on a TR+ in a C64 (NTSC and PAL): files saved from the DeskTop survive a reset to the menu and a remote launch of another program 2 s later; copy, delete and validate leave the D81 consistent (block map checked against every file chain); pulling the card mid-session gives GEOS a disk error, not a hang; swapping in another card with an image at the same path leaves that image byte-identical.

kfox added 30 commits October 15, 2026 14:31
Moves the RAM1/RAM2 8K bank allocator, the image preload and the numbered
button save out of IOH_REU.c into RAMBanks.c, so the GeoRAM handler can
use the same code. The REU keeps its own bank array and count: SerUSBIO's
busy check reads NumREU_Banks to mean the REU's DMA is live, which a
GeoRAM must not trip. Behavior is unchanged except that the save writes
whole banks instead of a byte at a time.
A Berkeley Softworks geoRAM: the $DE00 page window onto RAM banks from
RAMBanks.c, with the page ($DFFE) and 16K block ($DFFF) registers
decoded across $DF80-$DFFF the way VICE does, leaving $DF00-$DF7F
free. No DMA, so it is in every full build, TR and TR+, and serves C64
and C128 mode alike. Up to 512K, as much as fits beside a loaded crt;
preloads SD:/georam.ram, and the special button saves the next free
georamNNN.ram. Freed with the REU wherever the menu reclaims RAM.
A short write (card full, drive pulled) printed "Saved" and flashed the
save LED. Load stops at a read error rather than adding -1 to its count.
Placed before Debug it moved Debug's number, and that number is what
eepAdNextIOHndlr stores, so a board saved with Debug as its default came
up on GeoRAM. After Debug, with LastSelectableIOH moved to it, every
selectable handler keeps the number it had.
The TR's USB host port takes a mouse; the main loop adds up its movement
and the IO handlers hand it to the C64 as signed steps, at $DF00 beside
GeoRAM and $DE00 beside the REU, in the halves of the IO area each one
leaves free. A 1351 can't be emulated: the SID's POT lines are not on the
cartridge port, so this is for software with a TR driver.

Each read of X or Y returns the movement since the last read of it, at
most 127 steps, and keeps the rest for the next one, so a fast mouse is
not lost between polls. Only the main loop writes the USB totals and only
the IO ISR writes what it has handed out, so neither needs a lock.
trmouse.py assembles trmouse128.asm and writes it to a GEOS 128 boot
D81 as TR USB MOUSE, in the directory entry ahead of the other input
drivers, because GEOS loads the first one it finds. The driver finds the
TR's registers by their ID at $DF03, else uses $DE00, doubles X in 80
columns as the stock 1351 driver does, and leaves clamping to the
kernal, which flags a push past an edge for apps that scroll on it.
…and drains movement while the pointer is off

Without a TR handler serving either page, the driver read open bus at
$DE00 as motion and a held button, so the pointer jumped and clicked on
its own. Movement made while mouseOn was clear stayed in the TR's
counters and swept the pointer across the screen once it came back on.
…d directory chain

GEOS reads the permanent name's version from offset 12, where the class
string had a space. A directory sector that links back to an earlier one
made dir_slots loop forever when no slot was free.
…ort an unplug strands

On the Cortex-M7 an NVIC_ICER write is not guaranteed to block the IRQ until a DSB/ISB, and without a compiler barrier the inlined getMouseX/Y loads may be hoisted above the disable, so the window 78cbc05 closes could stay open. A report landing just before an unplug was left pending and delivered (buttons included) on the next plug. IRQ_USBHS is in scope through USBHost_t36.h, so use the library's name.
…piler too

mouseDataClear() keeps its stores ahead of NVIC_ENABLE_IRQ only because it lives in another translation unit; with LTO (opt=o2lto) it inlines, and its plain stores may sink past the volatile NVIC write, reopening the race on the way out.
A 1581 directory lives only on track 40, sectors 3-39. A chain into a
free data sector on any other track let allocate() hand that sector to
the driver while the new directory entry was written over it.

D81_SIZE is derived from the geometry it stands for.
…r's directory link, and grows a full directory
… undercounts a track, and a header with no directory link
…ry sector the BAM frees, not only the first free one
The firmware will put the same file on a boot disk that trmouse.py does, so the bytes come from one place. A test regenerates the header and fails when the committed copy drifts from trmouse128.asm.
…its arrays in flash

Python's write_text writes CRLF on Windows, which the drift test compared against an LF checkout. A Teensy 4 places const data without PROGMEM in RAM1.
…r block by block

It reads and writes 256-byte blocks through callbacks, so it can patch an image on SD that is too large for RAM. A host test compiles it and holds it byte for byte to trmouse.py on D64, D71 and D81 boot disks, refusals included.
… its first write, and links from more than one file

A refusal used to come after blocks were already written, which on an image patched in place on SD leaves it half patched. The harness now fails a refusal that changed the image, and refuses an image too big for any format rather than reading its first 800K.
…ory again, and a test holds the full-directory refusal to before the first write
…tor, so its own refusal leaves the image as it was
…get, and tells a boot disk from a data disk

GEOS 64 needs only its own driver and a second target to use the same patch.
kfox added 17 commits October 22, 2026 19:50
SD.h's File::flush() is void and drops FsFile::sync()'s result, and getWriteError() reads a Print flag the SD library never sets, so the image is held as an FsFile opened on SD.sdfs and FLUSH returns sync()'s result. Closing at unmount reports a failed final write on the serial console instead of dropping it. The image opens O_RDWR: a missing path no longer creates an empty file.
…ens it

SD.begin rebuilds the SdFat volume in place: the image's dirty cached sector is dropped rather than written, and the open FsFile is left pointing into the rebuilt volume. Host file commands (GetStorageDevice), a GeoRAM save from the special button (SDFullInit and FSfromSourceID) and a remote launch can all begin the card while GEOS has the image mounted. Patching each caller would leave the next one to forget, so the main image's SD.begin calls all go through SDBegin(), which syncs and closes the image first and reopens it from its saved path after, keeping a running GEOS session's disk.
…en it ends

The block device only answers under GeoRAM, so its polling moves from every main loop pass to GeoRAM's polling handler, beside the USB mouse. Nothing would then flush or close the image after GeoRAM ends, so leaving it (SetUpMainMenuROM, RemoteLaunch) unmounts the image beside FreeGeoRAM: close() syncs it. RemoteLaunch's SDFullInit before that point is safe since SDBegin closes and reopens the image.
Only TRPageIO and the inline register helpers in TRBlock.h serve the IO ISR. PollTRBlock and the file and image backends run from the main loop at SD card speed, so they move to flash; TRBlockImage.h gets FLASHMEM's host fallback through GeosDiskPatch.h.
The REU no longer serves IO1, so the REU row, the $DE00 probe and the checklist's either-base item go.
…nd puts it back if the save cannot take its place

If the last rename failed, georam.ram was gone: the next session preloaded
nothing, and saving that blank RAM removed georam.bak, the last good image.
… running handler's memory

A launch is accepted while a handler is busy. One naming a TR menu path that
does not exist returned after the CRT chips, REU, GeoRAM and SwiftLink
buffers were freed, without resetting to the menu, leaving the running
program on freed memory.
A C++ harness includes USBMouse.c with the USB controller, the NVIC and the data port stubbed, and runs it from a script of USB reports, polls, inits and IO reads. The tests hold the register map: a large movement arrives as 127s and then the remainder, X and Y apart, sub-step counts carry, init discards movement from before the handler, and the buttons follow the report and clear on unplug. An IO read can be landed at the poll's atomic write of the ready word, and a weak compare-exchange can be made to fail, to show neither repeats nor drops movement.

The source is C++ (static_cast on the controller), so the harness is built as gnu++17 rather than C99. The Cortex-M barrier in PollUSBMouse does not assemble on the host, so the harness turns asm volatile(...) into asm("").
The image is mounted only while GeoRAM is the handler, and under GeoRAM the
card is begun only through SDFullInit (the special button's save, a host or
NFC launch) and FSfromSourceID (the same save, an NFC tap). FSfromSourceID now
begins through SDFullInit, which only adds its retry for a card that is present
but fails to start. Every other SD.begin goes back to calling SD.begin.
Every D64 is the same size, so after a card swap the reopen took the new
card's file at the same path and GEOS's cached BAM and directory overwrote it.
The mount records the file's first sector and the card's serial number; a
reopen that finds either changed leaves the image unmounted and logs it.
GEOS never sends FLUSH, so the idle flush is its only way to the card, and it
cleared the dirty flag whether or not the sync succeeded. A failed sync now
leaves the image dirty, retries a quarter second later and logs once.
…he image is reopened

The flush-failure log flag was a function static that only a successful sync cleared, so after an image was closed with its sync still failing, a later mount's failures went unlogged. It is file scope now and TRBlockClose clears it with the dirty flag.
# Conflicts:
#	Source/Teensy/RemoteControl.ino
@kfox kfox changed the title TR block device: serve a disk image to the C64 through registers beside GeoRAM and the REU TR block device: serve a disk image to the C64 through registers beside GeoRAM Oct 26, 2026
@kfox

kfox commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the close read of the flush path.

  • FLASHMEM on PollTRBlock and the backend callbacks: done.

1. FLUSH always reports success

Fixed: a FLUSH the card fails returns $E4. A failed idle flush now also leaves the image dirty and is retried every quarter second. Before, it cleared the flag, and GEOS, which never sends FLUSH, lost the write. TR_Block_Device.md now describes the quarter second as measured from the last WRITE.

2. Polling from GeoRAM, and closing the image at teardown

Done. The block device is polled from GeoRAM's polling handler, and TRBlockUnmount() runs beside FreeGeoRAM() in SetUpMainMenuROM and RemoteLaunch. Also:

  • every SD restart (SDFullInit) closes the image first and reopens it after;
  • a reopen checks the card's serial number and the file's first sector, and leaves the image unmounted if either changed. Every D64 (and every D81) is the same size, so after a swap, GEOS's cached directory would otherwise have been written onto another card's image at the same path.

In the merge, main's new SDBegin() is kept, and SDFullInit reaches it through the close and reopen.

Hardware testing

All on GEOS 64:

  • a save from GEOS, a reset straight to the menu, an SD browse, then a relaunch: the save is there;
  • a remote launch of another program 2 s after a save;
  • copy, delete and validate: the D81's BAM matches its file chains;
  • pulling the card mid-session: GEOS reports a disk error instead of hanging;
  • a card swap at the menu: the other card's image at the same path stays byte-identical;
  • a plain TR, and PAL.

D64 and D71 are covered by the host tests only, since the GEOS 64 launch now takes D81 alone (see #70).

@kfox
kfox marked this pull request as ready for review October 26, 2026 03:11
@SensoriumEmbedded
SensoriumEmbedded merged commit 30b9ab2 into SensoriumEmbedded:main Oct 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants