From 2db2ca51fc79eac8a6c1ddc6535c32ef869e80c0 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:31:22 -0500 Subject: [PATCH 01/51] firmware: share the REU's RAM bank allocation, preload and save 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. --- Source/Teensy/MinimalBoot/Common/IOHandlers.h | 1 + .../MinimalBoot/Common/IO_Handlers/IOH_REU.c | 182 ++--------------- .../MinimalBoot/Common/IO_Handlers/RAMBanks.c | 184 ++++++++++++++++++ 3 files changed, 196 insertions(+), 171 deletions(-) create mode 100644 Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c diff --git a/Source/Teensy/MinimalBoot/Common/IOHandlers.h b/Source/Teensy/MinimalBoot/Common/IOHandlers.h index 88bc75b7..c3f060ef 100644 --- a/Source/Teensy/MinimalBoot/Common/IOHandlers.h +++ b/Source/Teensy/MinimalBoot/Common/IOHandlers.h @@ -109,6 +109,7 @@ struct stcIOHandlers #ifndef MinimumBuild #include "IO_Handlers/IOH_MIDI.c" #include "IO_Handlers/IOH_Debug.c" + #include "IO_Handlers/RAMBanks.c" #ifdef Fab04_REU #include "IO_Handlers/IOH_REU.c" #endif diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c index fe43dcac..b602bf91 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c @@ -29,8 +29,7 @@ #ifdef USE_RAM12 - #define REU_RAM_Bank_Size 0x2000 - #define REU_Size (NumREU_Banks * REU_RAM_Bank_Size) //Dynamically allocated based on NumREU_Banks, each bank is 8k (0x2000) + #define REU_Size (NumREU_Banks * RAM_Bank_Size) //Dynamically allocated based on NumREU_Banks, each bank is 8k (0x2000) #define REU_RAM_READ(a,d) d=REU_Bank[a>>13][a&0x1fff] #define REU_RAM_WRITE(a,d) REU_Bank[a>>13][a&0x1fff]=d #define MIN_REU_BANKS 16 // 128K, smallest real REU (1700) @@ -76,11 +75,6 @@ stcIOHandlers IOHndlr_REU = extern bool PerformDMA(DMA_Trans_RnW RnW, uint16_t StartAddr, uint8_t *Buffer, uint32_t Length, DMA_Addr_Mode FixC64Addr); extern bool CloseDMA(); extern void (*fSpecialBtnChange)(bool Up_nDn); //Pointer to function called when Special Button Changes -extern void EEPreadStr(uint16_t addr, char* buf); -extern RegMenuTypes RegMenuTypeFromFileName(char** ptrptrFileName); -extern bool SDFullInit(); -extern bool USBFileSystemWait(); -extern FS *FSfromSourceID(RegMenuTypes SourceID); //ref: https://codebase64.net/doku.php?id=base:reu_registers //tests: @@ -145,18 +139,12 @@ extern bool DMA_RnW, DMA_FixC64Addr; extern uint32_t DMA_Length, DMA_Count, DMA_StartAddr; extern bool DMAByte(uint8_t *Data); -extern void FreeDriveDirMenu(); -extern uint8_t RAM_Image[]; extern StructCrtChip CrtChips[]; // Removal breaks downstream code, so keep it here for now. -extern uint8_t NumCrtChips; extern StructMenuItem *DriveDirMenu; -extern uint8_t *ptrRAM_ImageEnd; // points to end of CRT-used RAM1 buffer void FreeREU() -{ //free banks allocated in RAM2 and reset NumREUBanks - for(uint16_t cnt=0; cnt < NumREU_Banks; cnt++) - if((uint32_t)REU_Bank[cnt] >= 0x20200000) free(REU_Bank[cnt]); - NumREU_Banks = 0; +{ + FreeRAMBanks(REU_Bank, &NumREU_Banks); } void DMAByte_BASkip(uint8_t *Data1) @@ -456,83 +444,12 @@ FLASHMEM void ReadWriteREU(bool RnW, uint32_t REUAddr, uint8_t *REUBuf, uint16_t FLASHMEM void Save_REU() { - if (NumREU_Banks == 0) return; // nothing to save: probably due to failure to allocate 128K - - //Create filename based on current. reu000.reu becomes reu001.reu, etc +#ifndef USE_RAM12 + #error Save_REU saves RAM1/2 banks only +#endif char Filename[MaxPathLength]; EEPreadStr(eepAdREUFilename, Filename); - char *ptrFileName = Filename; //pointer to move past SD/USB/TR: - RegMenuTypes MenuSourceID = RegMenuTypeFromFileName(&ptrFileName); - - if (MenuSourceID == rmtSD) SDFullInit(); // SD.begin(BUILTIN_SDCARD); with retry if presence detected - if (MenuSourceID == rmtUSBDrive) USBFileSystemWait(); //wait up to 1.5 sec in case USB drive just changed or powered up - //rmtTeensy not allowed, no reu files in Teensy Mem - - FS *sourceFS = FSfromSourceID(MenuSourceID); - - char Extension[20]; - char *pDot = strrchr(ptrFileName, '.'); //find last dot - if (pDot == NULL) pDot = ptrFileName + strlen(ptrFileName); //if no extension, set to end of filename - strcpy(Extension, pDot); //copy extension - *pDot = 0; //terminate at dot/extension - - bool IsNum = true; //are last 3digits of filename all numbers? - if (strlen(ptrFileName)>=3) - { - for(char *charloc=pDot-3; charloc'9') IsNum = false; - //Serial.printf("%08x: %c %02x\n", (uint32_t)charloc, *charloc, *charloc); - } - } - else IsNum = false; - - - uint16_t NewNum = 0; - if (IsNum) - { //find current number and then terminate it - NewNum = atoi(pDot-3); - *(pDot-3) = 0; //terminate number - } - - //now see if the file exists, or itterate until it doesn't - char NewFilename[MaxPathLength]; - - do sprintf(NewFilename, "%s%03d%s", ptrFileName, NewNum++, Extension); - while (sourceFS->exists(NewFilename)); - - Serial.printf("Saving REU: %s\n", NewFilename); - File SaveFile = sourceFS->open(NewFilename, FILE_WRITE); - if (!SaveFile) - { - Serial.println("Unable to open!"); - return; - } - - //uint32_t StartmS = millis(); - uint8_t NextByte; - - for (uint32_t CharNum=0; CharNum= 0x20200000) - free(REU_Bank[unused_bank]); - NumREU_Banks = NewNumREU_Banks; - } - - Printf_dbg_reu("After resizing to power of 2, %luK bytes allocated\n", (uint32_t)NumREU_Banks * REU_RAM_Bank_Size / 1024); - - if (NumREU_Banks < MIN_REU_BANKS) // If REU is not 128k or larger + NumREU_Banks = AllocRAMBanks(REU_Bank, MAX_REU_BANKS, MIN_REU_BANKS); + if (NumREU_Banks == 0) { - Serial.printf("Unable to allocate minimum REU size of 128K\n" - "REU is disabled\n"); - FreeREU(); + Serial.printf("REU is disabled\n"); return; } @@ -623,37 +491,9 @@ uint8_t *pRAM_Image = ptrRAM_ImageEnd; //start at end of any CRT-used RAM1 fSpecialBtnChange = &SpecialBtn_REU; //REU RAM is ready; enable button now so a failed/missing preload below doesn't skip it Serial.printf("REU Size: %luK bytes\n", (uint32_t)REU_Size / 1024); - //pre-load REU into RAM here: char Filename[MaxPathLength]; EEPreadStr(eepAdREUFilename, Filename); - char *ptrFileName = Filename; //pointer to move past SD/USB/TR: - Serial.printf("Loading REU: %s\n", Filename); - RegMenuTypes MenuSourceID = RegMenuTypeFromFileName(&ptrFileName); - - if (MenuSourceID == rmtSD) SDFullInit(); // SD.begin(BUILTIN_SDCARD); with retry if presence detected - if (MenuSourceID == rmtUSBDrive) USBFileSystemWait(); //wait up to 1.5 sec in case USB drive just changed or powered up - //rmtTeensy not allowed, no reu files in Teensy Mem - - FS *sourceFS = FSfromSourceID(MenuSourceID); - File LoadFile = sourceFS->open(ptrFileName, FILE_READ); - if (!LoadFile) - { - Serial.println("Not found!"); - return; - } - - if (LoadFile.size() > REU_Size) - Serial.printf("Too Large! Loading first %luK\n", (uint32_t)REU_Size/1024); - - //uint32_t StartmS = millis(); - uint32_t CharNum = 0; - for (uint8_t Bank = 0; Bank < NumREU_Banks && LoadFile.available(); Bank++) - CharNum += LoadFile.read(REU_Bank[Bank], REU_RAM_Bank_Size); - //Serial.printf("Read %lu Bytes in %lumS\n", CharNum, millis()-StartmS); - Serial.printf("Read %lu Bytes into REU\n", CharNum); - LoadFile.close(); - //delay(250); - + LoadRAMBanks(REU_Bank, NumREU_Banks, Filename); #elif defined(USE_PSRAM) //higher clock speed and shorter CS setup/hold do help, but still not enough for NUVIEs w/ sound diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c new file mode 100644 index 00000000..7f2f01b5 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -0,0 +1,184 @@ +// MIT License +// +// Copyright (c) 2026 Travis Smith +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom +// the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +// RAM expansion memory (REU, GeoRAM): 8k banks taken from the free end of RAM1's RAM_Image, +// then from RAM2, preloaded from and saved to an image file. + +#define RAM_Bank_Size 0x2000 + +extern void EEPreadStr(uint16_t addr, char* buf); +extern RegMenuTypes RegMenuTypeFromFileName(char** ptrptrFileName); +extern bool SDFullInit(); +extern bool USBFileSystemWait(); +extern FS *FSfromSourceID(RegMenuTypes SourceID); +extern void FreeDriveDirMenu(); +extern uint8_t RAM_Image[]; +extern uint8_t NumCrtChips; +extern uint8_t *ptrRAM_ImageEnd; // points to end of CRT-used RAM1 buffer + +FLASHMEM void FreeRAMBanks(uint8_t *Bank[], uint8_t *NumBanks) +{ //free banks allocated in RAM2 + for(uint16_t cnt=0; cnt < *NumBanks; cnt++) + if((uint32_t)Bank[cnt] >= 0x20200000) free(Bank[cnt]); + *NumBanks = 0; +} + +// Returns the number of banks allocated: a power of 2 up to MaxBanks, or 0 if fewer than MinBanks fit. +FLASHMEM uint8_t AllocRAMBanks(uint8_t *Bank[], uint8_t MaxBanks, uint8_t MinBanks) +{ + uint8_t NumBanks = 0; + FreeDriveDirMenu(); //free/clear prev loaded directory to make space. Doing it regardless to preserve continuity + + if (NumCrtChips == 0) ptrRAM_ImageEnd = RAM_Image; //If no CRT loaded, RAM1 is entirely free + uint8_t *pRAM_Image = ptrRAM_ImageEnd; //start at end of any CRT-used RAM1 + + //First use RAM_Image from RAM1: + while (((uint32_t)pRAM_Image - (uint32_t)RAM_Image + RAM_Bank_Size <= RAM_ImageSize) + && (NumBanks < MaxBanks)) + { + Bank[NumBanks] = pRAM_Image; + pRAM_Image += RAM_Bank_Size; + NumBanks++; + } + + //allocate the rest from RAM2 + while(NumBanks < MaxBanks && (NULL != (Bank[NumBanks] = (uint8_t*)malloc(RAM_Bank_Size)))) + NumBanks++; + + //resize to a power of 2 if needed and free unused banks + if (NumBanks & (NumBanks-1)) //not a power of 2 + { + // largest power less than or equal to NumBanks + uint8_t NewNumBanks = 1 << (7 - (__builtin_clz(NumBanks) - 24)); + Serial.printf("Resizing from %d banks to %d banks\n", NumBanks, NewNumBanks); + + for (uint8_t unused_bank = NewNumBanks; unused_bank < NumBanks; unused_bank++) + if((uint32_t)Bank[unused_bank] >= 0x20200000) + free(Bank[unused_bank]); + NumBanks = NewNumBanks; + } + + if (NumBanks < MinBanks) + { + Serial.printf("Unable to allocate minimum size of %dK\n", MinBanks * RAM_Bank_Size / 1024); + FreeRAMBanks(Bank, &NumBanks); + } + return NumBanks; +} + +// Path is source-prefixed ("SD:/reu.reu") +FLASHMEM static FS *RAMBanksFS(char *Path, char **ptrFileName) +{ + *ptrFileName = Path; //pointer to move past SD/USB/TR: + RegMenuTypes MenuSourceID = RegMenuTypeFromFileName(ptrFileName); + + if (MenuSourceID == rmtSD) SDFullInit(); // SD.begin(BUILTIN_SDCARD); with retry if presence detected + if (MenuSourceID == rmtUSBDrive) USBFileSystemWait(); //wait up to 1.5 sec in case USB drive just changed or powered up + //rmtTeensy not allowed, no RAM image files in Teensy Mem + + return FSfromSourceID(MenuSourceID); +} + +FLASHMEM void LoadRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *SourcePath) +{ + char Filename[MaxPathLength]; + strlcpy(Filename, SourcePath, MaxPathLength); + Serial.printf("Loading: %s\n", Filename); + char *ptrFileName; + FS *sourceFS = RAMBanksFS(Filename, &ptrFileName); + File LoadFile = sourceFS->open(ptrFileName, FILE_READ); + if (!LoadFile) + { + Serial.println("Not found!"); + return; + } + + if (LoadFile.size() > (uint32_t)NumBanks * RAM_Bank_Size) + Serial.printf("Too Large! Loading first %luK\n", (uint32_t)NumBanks * RAM_Bank_Size / 1024); + + uint32_t CharNum = 0; + for (uint8_t BankNum = 0; BankNum < NumBanks && LoadFile.available(); BankNum++) + CharNum += LoadFile.read(Bank[BankNum], RAM_Bank_Size); + Serial.printf("Read %lu Bytes\n", CharNum); + LoadFile.close(); +} + +// Saves to the first free numbered name: reu.reu becomes reu000.reu, reu000.reu becomes reu001.reu, etc +FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *SourcePath) +{ + if (NumBanks == 0) return; // nothing to save: probably due to failure to allocate + + char Filename[MaxPathLength]; + strlcpy(Filename, SourcePath, MaxPathLength); + char *ptrFileName; + FS *sourceFS = RAMBanksFS(Filename, &ptrFileName); + + char Extension[20]; + char *pDot = strrchr(ptrFileName, '.'); //find last dot + if (pDot == NULL) pDot = ptrFileName + strlen(ptrFileName); //if no extension, set to end of filename + strlcpy(Extension, pDot, sizeof(Extension)); //copy extension + *pDot = 0; //terminate at dot/extension + + bool IsNum = true; //are last 3digits of filename all numbers? + if (strlen(ptrFileName)>=3) + { + for(char *charloc=pDot-3; charloc'9') IsNum = false; + } + else IsNum = false; + + uint16_t NewNum = 0; + if (IsNum) + { //find current number and then terminate it + NewNum = atoi(pDot-3); + *(pDot-3) = 0; //terminate number + } + + //now see if the file exists, or itterate until it doesn't + char NewFilename[MaxPathLength]; + + do sprintf(NewFilename, "%s%03d%s", ptrFileName, NewNum++, Extension); + while (sourceFS->exists(NewFilename)); + + Serial.printf("Saving: %s\n", NewFilename); + File SaveFile = sourceFS->open(NewFilename, FILE_WRITE); + if (!SaveFile) + { + Serial.println("Unable to open!"); + return; + } + + for (uint8_t BankNum = 0; BankNum < NumBanks; BankNum++) + SaveFile.write(Bank[BankNum], RAM_Bank_Size); + Serial.printf("Saved %dKBytes\n", NumBanks * RAM_Bank_Size / 1024); + SaveFile.close(); + + //Flash LED to ack save + uint32_t LastmS = millis(); + bool LEDState = true; + + for (uint32_t FlashCount=0; FlashCount<20; FlashCount++) + { + while(millis()-LastmS < 75); + LastmS = millis(); + if ((LEDState = !LEDState)) SetLEDOn; + else SetLEDOff; + } +} From 2759010b488e3c5798ffd0d71b88b7bb5a2139c6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:47:01 -0500 Subject: [PATCH 02/51] firmware: GeoRAM IO handler 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. --- Source/Teensy/MinimalBoot/Common/IOHandlers.h | 2 + .../Common/IO_Handlers/IOH_GeoRAM.c | 97 +++++++++++++++++++ Source/Teensy/MinimalBoot/Common/Menu_Regs.h | 1 + Source/Teensy/RemoteControl.ino | 3 +- Source/Teensy/Teensy.ino | 3 +- 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c diff --git a/Source/Teensy/MinimalBoot/Common/IOHandlers.h b/Source/Teensy/MinimalBoot/Common/IOHandlers.h index c3f060ef..7aaa3a8d 100644 --- a/Source/Teensy/MinimalBoot/Common/IOHandlers.h +++ b/Source/Teensy/MinimalBoot/Common/IOHandlers.h @@ -110,6 +110,7 @@ struct stcIOHandlers #include "IO_Handlers/IOH_MIDI.c" #include "IO_Handlers/IOH_Debug.c" #include "IO_Handlers/RAMBanks.c" + #include "IO_Handlers/IOH_GeoRAM.c" #ifdef Fab04_REU #include "IO_Handlers/IOH_REU.c" #endif @@ -158,6 +159,7 @@ stcIOHandlers* IOHandler[] = //Synch order/qty with enum enumIOHandlers #ifdef Fab04_KernalReplace &IOHndlr_KernalReplace, //IOH_KernalReplace, #endif + &IOHndlr_GeoRAM, //IOH_GeoRAM, &IOHndlr_Debug, //IOH_Debug, //*not* manually selectable, see LastSelectableIOH diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c new file mode 100644 index 00000000..845f2006 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT + +// Berkeley Softworks geoRAM: a 256-byte window at $DE00 onto the page selected by two +// write-only registers, $DFFE (page 0-63 within a 16K block) and $DFFF (16K block). +// No DMA, so it runs on every TR, in C64 and C128 mode. +// ref: VICE src/c64/cart/georam.c + +void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn); +void IO2Hndlr_GeoRAM(uint8_t Address, bool R_Wn); +void InitHndlr_GeoRAM(); +extern void (*fSpecialBtnChange)(bool Up_nDn); + +stcIOHandlers IOHndlr_GeoRAM = +{ + "GeoRAM", //Name of handler + &InitHndlr_GeoRAM, //Called once at handler startup + &IO1Hndlr_GeoRAM, //IO1 R/W handler + &IO2Hndlr_GeoRAM, //IO2 R/W handler + NULL, //ROML Read handler, in addition to any ROM data sent + NULL, //ROMH Read handler, in addition to any ROM data sent + NULL, //Polled in main routine + NULL, //called at the end of EVERY c64 cycle +}; + +#define GeoRAM_Filename "SD:/georam.ram" +#define MAX_GEORAM_BANKS 64 // 512K, the original geoRAM +#define MIN_GEORAM_BANKS 16 // 128K +#define GeoRAM_PagesPerBlock 64 +#define GeoRAM_BanksPerBlock (16384 / RAM_Bank_Size) + +// VICE decodes the registers across $DF80-$DFFF (A0 picks which); $DF00-$DF7F is left free. +#define GeoRAM_RegsBase 0x80 +#define GeoRAM_RegBlockBit 0x01 + +uint8_t NumGeoRAM_Banks = 0; +uint8_t *GeoRAM_Bank[MAX_GEORAM_BANKS]; +uint8_t GeoRAM_Block, GeoRAM_PageNum; +static uint8_t GeoRAM_NoRAM[256]; // the window while no RAM is allocated +uint8_t *GeoRAM_Page = GeoRAM_NoRAM; + +static void GeoRAM_SelectPage() +{ + if (NumGeoRAM_Banks == 0) return; + // the unused high register bits wrap, as on real hardware + uint32_t Offset = ((GeoRAM_Block & (NumGeoRAM_Banks / GeoRAM_BanksPerBlock - 1)) * GeoRAM_PagesPerBlock + + (GeoRAM_PageNum & (GeoRAM_PagesPerBlock - 1))) * 256; + GeoRAM_Page = GeoRAM_Bank[Offset / RAM_Bank_Size] + (Offset % RAM_Bank_Size); +} + +FLASHMEM void FreeGeoRAM() +{ + FreeRAMBanks(GeoRAM_Bank, &NumGeoRAM_Banks); + GeoRAM_Page = GeoRAM_NoRAM; +} + +FLASHMEM void SpecialBtn_GeoRAM(bool Up_nDn) +{ + // on button release save GeoRAM contents + if(Up_nDn) SaveRAMBanks(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); +} + +FLASHMEM void InitHndlr_GeoRAM() +{ + FreeGeoRAM(); + GeoRAM_Block = GeoRAM_PageNum = 0; + NumGeoRAM_Banks = AllocRAMBanks(GeoRAM_Bank, MAX_GEORAM_BANKS, MIN_GEORAM_BANKS); + if (NumGeoRAM_Banks == 0) + { + Serial.printf("GeoRAM is disabled\n"); + return; + } + GeoRAM_SelectPage(); + fSpecialBtnChange = &SpecialBtn_GeoRAM; + Serial.printf("GeoRAM Size: %dK bytes\n", NumGeoRAM_Banks * RAM_Bank_Size / 1024); + LoadRAMBanks(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); +} + +void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn) +{ + if (R_Wn) DataPortWriteWaitLog(GeoRAM_Page[Address]); + else + { + uint8_t Data = DataPortWaitRead(); + GeoRAM_Page[Address] = Data; + TraceLogAddValidData(Data); + } +} + +void IO2Hndlr_GeoRAM(uint8_t Address, bool R_Wn) +{ + if (R_Wn || Address < GeoRAM_RegsBase) return; // write-only registers + uint8_t Data = DataPortWaitRead(); + if (Address & GeoRAM_RegBlockBit) GeoRAM_Block = Data; + else GeoRAM_PageNum = Data; + GeoRAM_SelectPage(); + TraceLogAddValidData(Data); +} diff --git a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h index 166ecfe0..63aaa8ba 100644 --- a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h +++ b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h @@ -437,6 +437,7 @@ enum enumIOHandlers //Synch order/qty with IOHandler[] (IOHandlers.h) #ifdef Fab04_KernalReplace IOH_KernalReplace, #endif + IOH_GeoRAM, IOH_Debug, //last manually selectable, see LastSelectableIOH //*not* manually selectable, see LastSelectableIOH diff --git a/Source/Teensy/RemoteControl.ino b/Source/Teensy/RemoteControl.ino index e3076195..0fc6d965 100644 --- a/Source/Teensy/RemoteControl.ino +++ b/Source/Teensy/RemoteControl.ino @@ -230,7 +230,8 @@ void RemoteLaunch(RegMenuTypes MenuSourceID, const char *FileNamePath, bool DoCa FreeCrtChips(); #ifdef Fab04_REU FreeREU(); -#endif +#endif + FreeGeoRAM(); FreeSwiftlinkBuffs(); InitDriveDirMenu(); diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index 99d05145..7cc1cf90 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -420,7 +420,8 @@ FLASHMEM void SetUpMainMenuROM() FreeCrtChips(); #ifdef Fab04_REU FreeREU(); -#endif +#endif + FreeGeoRAM(); FreeSwiftlinkBuffs(); RedirectEmptyDriveDirMenu(); free((void*)MIDIRxBuf); MIDIRxBuf=NULL; From 3bfb688cbd80545637f4875bc4a0a9b281636c4e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:49:06 -0500 Subject: [PATCH 03/51] firmware: RAM bank save reports a failed write instead of acking it 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. --- .../MinimalBoot/Common/IO_Handlers/RAMBanks.c | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c index 7f2f01b5..856581d0 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -115,7 +115,11 @@ FLASHMEM void LoadRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source uint32_t CharNum = 0; for (uint8_t BankNum = 0; BankNum < NumBanks && LoadFile.available(); BankNum++) - CharNum += LoadFile.read(Bank[BankNum], RAM_Bank_Size); + { + int BytesRead = LoadFile.read(Bank[BankNum], RAM_Bank_Size); + if (BytesRead <= 0) break; + CharNum += BytesRead; + } Serial.printf("Read %lu Bytes\n", CharNum); LoadFile.close(); } @@ -166,7 +170,14 @@ FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source } for (uint8_t BankNum = 0; BankNum < NumBanks; BankNum++) - SaveFile.write(Bank[BankNum], RAM_Bank_Size); + { + if (SaveFile.write(Bank[BankNum], RAM_Bank_Size) != RAM_Bank_Size) + { + Serial.printf("Write failed after %dKBytes!\n", BankNum * RAM_Bank_Size / 1024); + SaveFile.close(); + return; + } + } Serial.printf("Saved %dKBytes\n", NumBanks * RAM_Bank_Size / 1024); SaveFile.close(); From 6ecc7db082f8d1e836eea9c957a2454025c61efa Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:49:17 -0500 Subject: [PATCH 04/51] firmware: drop RAMBanks.c comments that restate the code --- .../MinimalBoot/Common/IO_Handlers/RAMBanks.c | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c index 856581d0..e15d37e2 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -34,7 +34,7 @@ extern uint8_t NumCrtChips; extern uint8_t *ptrRAM_ImageEnd; // points to end of CRT-used RAM1 buffer FLASHMEM void FreeRAMBanks(uint8_t *Bank[], uint8_t *NumBanks) -{ //free banks allocated in RAM2 +{ for(uint16_t cnt=0; cnt < *NumBanks; cnt++) if((uint32_t)Bank[cnt] >= 0x20200000) free(Bank[cnt]); *NumBanks = 0; @@ -44,12 +44,11 @@ FLASHMEM void FreeRAMBanks(uint8_t *Bank[], uint8_t *NumBanks) FLASHMEM uint8_t AllocRAMBanks(uint8_t *Bank[], uint8_t MaxBanks, uint8_t MinBanks) { uint8_t NumBanks = 0; - FreeDriveDirMenu(); //free/clear prev loaded directory to make space. Doing it regardless to preserve continuity + FreeDriveDirMenu(); if (NumCrtChips == 0) ptrRAM_ImageEnd = RAM_Image; //If no CRT loaded, RAM1 is entirely free uint8_t *pRAM_Image = ptrRAM_ImageEnd; //start at end of any CRT-used RAM1 - //First use RAM_Image from RAM1: while (((uint32_t)pRAM_Image - (uint32_t)RAM_Image + RAM_Bank_Size <= RAM_ImageSize) && (NumBanks < MaxBanks)) { @@ -58,11 +57,9 @@ FLASHMEM uint8_t AllocRAMBanks(uint8_t *Bank[], uint8_t MaxBanks, uint8_t MinBan NumBanks++; } - //allocate the rest from RAM2 while(NumBanks < MaxBanks && (NULL != (Bank[NumBanks] = (uint8_t*)malloc(RAM_Bank_Size)))) NumBanks++; - //resize to a power of 2 if needed and free unused banks if (NumBanks & (NumBanks-1)) //not a power of 2 { // largest power less than or equal to NumBanks @@ -86,7 +83,7 @@ FLASHMEM uint8_t AllocRAMBanks(uint8_t *Bank[], uint8_t MaxBanks, uint8_t MinBan // Path is source-prefixed ("SD:/reu.reu") FLASHMEM static FS *RAMBanksFS(char *Path, char **ptrFileName) { - *ptrFileName = Path; //pointer to move past SD/USB/TR: + *ptrFileName = Path; RegMenuTypes MenuSourceID = RegMenuTypeFromFileName(ptrFileName); if (MenuSourceID == rmtSD) SDFullInit(); // SD.begin(BUILTIN_SDCARD); with retry if presence detected @@ -135,10 +132,10 @@ FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source FS *sourceFS = RAMBanksFS(Filename, &ptrFileName); char Extension[20]; - char *pDot = strrchr(ptrFileName, '.'); //find last dot + char *pDot = strrchr(ptrFileName, '.'); if (pDot == NULL) pDot = ptrFileName + strlen(ptrFileName); //if no extension, set to end of filename - strlcpy(Extension, pDot, sizeof(Extension)); //copy extension - *pDot = 0; //terminate at dot/extension + strlcpy(Extension, pDot, sizeof(Extension)); + *pDot = 0; bool IsNum = true; //are last 3digits of filename all numbers? if (strlen(ptrFileName)>=3) @@ -155,7 +152,6 @@ FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source *(pDot-3) = 0; //terminate number } - //now see if the file exists, or itterate until it doesn't char NewFilename[MaxPathLength]; do sprintf(NewFilename, "%s%03d%s", ptrFileName, NewNum++, Extension); @@ -181,7 +177,6 @@ FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source Serial.printf("Saved %dKBytes\n", NumBanks * RAM_Bank_Size / 1024); SaveFile.close(); - //Flash LED to ack save uint32_t LastmS = millis(); bool LEDState = true; From 89c578cfcbceaf946b7e3dbd91dbda29c8e7b3d7 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 15:41:45 -0500 Subject: [PATCH 05/51] firmware: a disabled GeoRAM leaves $DE00 open instead of answering from one shared page --- Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 845f2006..90d0ad35 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -77,6 +77,7 @@ FLASHMEM void InitHndlr_GeoRAM() void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn) { + if (NumGeoRAM_Banks == 0) return; // disabled: $DE00 stays open bus, so software sees no geoRAM if (R_Wn) DataPortWriteWaitLog(GeoRAM_Page[Address]); else { From 8686274b1d3c0e6e72bc0f286279bcaa544c1130 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 15:41:55 -0500 Subject: [PATCH 06/51] firmware: FreeGeoRAM stops the IO handlers using the banks before freeing them --- Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 90d0ad35..9f9fed67 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -35,7 +35,7 @@ stcIOHandlers IOHndlr_GeoRAM = uint8_t NumGeoRAM_Banks = 0; uint8_t *GeoRAM_Bank[MAX_GEORAM_BANKS]; uint8_t GeoRAM_Block, GeoRAM_PageNum; -static uint8_t GeoRAM_NoRAM[256]; // the window while no RAM is allocated +static uint8_t GeoRAM_NoRAM[256]; // the window until a page of allocated RAM is selected uint8_t *GeoRAM_Page = GeoRAM_NoRAM; static void GeoRAM_SelectPage() @@ -49,8 +49,12 @@ static void GeoRAM_SelectPage() FLASHMEM void FreeGeoRAM() { - FreeRAMBanks(GeoRAM_Bank, &NumGeoRAM_Banks); + // the handler is still installed and the C64 may be running: the IO ISRs must stop + // touching the banks before the first one is freed + uint8_t NumBanks = NumGeoRAM_Banks; + NumGeoRAM_Banks = 0; GeoRAM_Page = GeoRAM_NoRAM; + FreeRAMBanks(GeoRAM_Bank, &NumBanks); } FLASHMEM void SpecialBtn_GeoRAM(bool Up_nDn) From 1c8cf3217c31093398f217b62eaaa9be270ac00f Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 15:42:06 -0500 Subject: [PATCH 07/51] firmware: GeoRAM state is file-local --- Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 9f9fed67..787b86b3 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -32,11 +32,11 @@ stcIOHandlers IOHndlr_GeoRAM = #define GeoRAM_RegsBase 0x80 #define GeoRAM_RegBlockBit 0x01 -uint8_t NumGeoRAM_Banks = 0; -uint8_t *GeoRAM_Bank[MAX_GEORAM_BANKS]; -uint8_t GeoRAM_Block, GeoRAM_PageNum; +static uint8_t NumGeoRAM_Banks = 0; +static uint8_t *GeoRAM_Bank[MAX_GEORAM_BANKS]; +static uint8_t GeoRAM_Block, GeoRAM_PageNum; static uint8_t GeoRAM_NoRAM[256]; // the window until a page of allocated RAM is selected -uint8_t *GeoRAM_Page = GeoRAM_NoRAM; +static uint8_t *GeoRAM_Page = GeoRAM_NoRAM; static void GeoRAM_SelectPage() { From b27c082c2ce4c24525f049459359bac6bb03c447 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 15:42:06 -0500 Subject: [PATCH 08/51] firmware: drop trailing spaces after the FreeGeoRAM calls --- Source/Teensy/RemoteControl.ino | 2 +- Source/Teensy/Teensy.ino | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Teensy/RemoteControl.ino b/Source/Teensy/RemoteControl.ino index 0fc6d965..cc3dfd46 100644 --- a/Source/Teensy/RemoteControl.ino +++ b/Source/Teensy/RemoteControl.ino @@ -231,7 +231,7 @@ void RemoteLaunch(RegMenuTypes MenuSourceID, const char *FileNamePath, bool DoCa #ifdef Fab04_REU FreeREU(); #endif - FreeGeoRAM(); + FreeGeoRAM(); FreeSwiftlinkBuffs(); InitDriveDirMenu(); diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index 7cc1cf90..0dd28899 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -421,7 +421,7 @@ FLASHMEM void SetUpMainMenuROM() #ifdef Fab04_REU FreeREU(); #endif - FreeGeoRAM(); + FreeGeoRAM(); FreeSwiftlinkBuffs(); RedirectEmptyDriveDirMenu(); free((void*)MIDIRxBuf); MIDIRxBuf=NULL; From 9b91191b2a9f15be1661734253897128d6c84eed Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 16:23:12 -0500 Subject: [PATCH 09/51] firmware: GeoRAM is the last selectable handler, after Debug 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. --- Source/Teensy/MinimalBoot/Common/IOHandlers.h | 2 +- Source/Teensy/MinimalBoot/Common/Menu_Regs.h | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IOHandlers.h b/Source/Teensy/MinimalBoot/Common/IOHandlers.h index 7aaa3a8d..722267c2 100644 --- a/Source/Teensy/MinimalBoot/Common/IOHandlers.h +++ b/Source/Teensy/MinimalBoot/Common/IOHandlers.h @@ -159,8 +159,8 @@ stcIOHandlers* IOHandler[] = //Synch order/qty with enum enumIOHandlers #ifdef Fab04_KernalReplace &IOHndlr_KernalReplace, //IOH_KernalReplace, #endif - &IOHndlr_GeoRAM, //IOH_GeoRAM, &IOHndlr_Debug, //IOH_Debug, + &IOHndlr_GeoRAM, //IOH_GeoRAM, //*not* manually selectable, see LastSelectableIOH diff --git a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h index 63aaa8ba..a3c616da 100644 --- a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h +++ b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h @@ -437,8 +437,8 @@ enum enumIOHandlers //Synch order/qty with IOHandler[] (IOHandlers.h) #ifdef Fab04_KernalReplace IOH_KernalReplace, #endif - IOH_GeoRAM, - IOH_Debug, //last manually selectable, see LastSelectableIOH + IOH_Debug, + IOH_GeoRAM, //last manually selectable, see LastSelectableIOH //*not* manually selectable, see LastSelectableIOH @@ -472,4 +472,4 @@ enum enumIOHandlers //Synch order/qty with IOHandler[] (IOHandlers.h) IOH_Num_Handlers //always last }; -#define LastSelectableIOH IOH_Debug //127 max. Used in IOH_TeensyROM.c for Special IO display/select +#define LastSelectableIOH IOH_GeoRAM //127 max. Used in IOH_TeensyROM.c for Special IO display/select From 1330587868f25f9206ebac5e6677583b2e399b46 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 12:15:29 -0500 Subject: [PATCH 10/51] firmware: a USB mouse read through registers beside GeoRAM and the REU 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. --- Source/Teensy/MinimalBoot/Common/IOHandlers.h | 2 + .../Common/IO_Handlers/IOH_GeoRAM.c | 10 +++- .../MinimalBoot/Common/IO_Handlers/IOH_REU.c | 9 ++- .../MinimalBoot/Common/IO_Handlers/USBMouse.c | 56 +++++++++++++++++++ Source/Teensy/Teensy.ino | 1 + 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c diff --git a/Source/Teensy/MinimalBoot/Common/IOHandlers.h b/Source/Teensy/MinimalBoot/Common/IOHandlers.h index 722267c2..0d6da33c 100644 --- a/Source/Teensy/MinimalBoot/Common/IOHandlers.h +++ b/Source/Teensy/MinimalBoot/Common/IOHandlers.h @@ -36,6 +36,7 @@ USBHIDParser hid1(myusbHost); USBHIDParser hid2(myusbHost); USBHIDParser hid3(myusbHost); //need all 3? + MouseController usbHostMouse(myusbHost); USBSerial USBHostSerial(myusbHost); //update HostSerialType to match, defined in PN532_UHSU.h //USBSerial USBHostSerial(myusbHost); // only for those Serial devices who transfer <=64 bytes (like T3.x, FTDI...) @@ -110,6 +111,7 @@ struct stcIOHandlers #include "IO_Handlers/IOH_MIDI.c" #include "IO_Handlers/IOH_Debug.c" #include "IO_Handlers/RAMBanks.c" + #include "IO_Handlers/USBMouse.c" #include "IO_Handlers/IOH_GeoRAM.c" #ifdef Fab04_REU #include "IO_Handlers/IOH_REU.c" diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 787b86b3..afaeeb31 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -28,7 +28,7 @@ stcIOHandlers IOHndlr_GeoRAM = #define GeoRAM_PagesPerBlock 64 #define GeoRAM_BanksPerBlock (16384 / RAM_Bank_Size) -// VICE decodes the registers across $DF80-$DFFF (A0 picks which); $DF00-$DF7F is left free. +// VICE decodes the registers across $DF80-$DFFF (A0 picks which); $DF00-$DF7F is the USB mouse's. #define GeoRAM_RegsBase 0x80 #define GeoRAM_RegBlockBit 0x01 @@ -67,6 +67,7 @@ FLASHMEM void InitHndlr_GeoRAM() { FreeGeoRAM(); GeoRAM_Block = GeoRAM_PageNum = 0; + InitUSBMouse(); NumGeoRAM_Banks = AllocRAMBanks(GeoRAM_Bank, MAX_GEORAM_BANKS, MIN_GEORAM_BANKS); if (NumGeoRAM_Banks == 0) { @@ -93,7 +94,12 @@ void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn) void IO2Hndlr_GeoRAM(uint8_t Address, bool R_Wn) { - if (R_Wn || Address < GeoRAM_RegsBase) return; // write-only registers + if (Address < GeoRAM_RegsBase) + { + if (R_Wn) USBMouseIORead(Address); + return; + } + if (R_Wn) return; // write-only registers uint8_t Data = DataPortWaitRead(); if (Address & GeoRAM_RegBlockBit) GeoRAM_Block = Data; else GeoRAM_PageNum = Data; diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c index b602bf91..9a76752c 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c @@ -51,6 +51,7 @@ __attribute__((always_inline)) inline void Printf_dbg_reu(...) {}; #endif +void IO1Hndlr_REU(uint8_t Address, bool R_Wn); void IO2Hndlr_REU(uint8_t Address, bool R_Wn); void PollingHndlr_REU(); void InitHndlr_REU(); @@ -59,7 +60,7 @@ stcIOHandlers IOHndlr_REU = { "512KB REU", //Name of handler &InitHndlr_REU, //Called once at handler startup - NULL, //IO1 R/W handler + &IO1Hndlr_REU, //IO1 R/W handler &IO2Hndlr_REU, //IO2 R/W handler NULL, //ROML Read handler, in addition to any ROM data sent NULL, //ROMH Read handler, in addition to any ROM data sent @@ -469,6 +470,7 @@ FLASHMEM void InitHndlr_REU() //set reg defaults: uint8_t REURegsInit[REUReg_NumRegs]={0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x3f}; memcpy(REURegs, REURegsInit, REUReg_NumRegs); + InitUSBMouse(); #ifdef Direct_REU Printf_dbg_reu("Direct REU mode\n"); @@ -550,6 +552,11 @@ FLASHMEM void InitHndlr_REU() } //InitHndlr_REU +void IO1Hndlr_REU(uint8_t Address, bool R_Wn) +{ + if (R_Wn) USBMouseIORead(Address); +} + void IO2Hndlr_REU(uint8_t Address, bool R_Wn) { #ifdef DbgIOTraceLog diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c new file mode 100644 index 00000000..c8cd84be --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +// A USB mouse on the TR's USB host port, read through registers that a RAM expansion handler +// serves from the IO page it leaves free: $DF00 beside GeoRAM, $DE00 beside the REU. +// It cannot be a 1351: the SID's POT lines are not on the cartridge port, so only software +// with a TR driver sees it (tools/geos). +// +// +0 X movement since the last read of +0, signed, right is positive +// +1 Y movement since the last read of +1, signed, down is positive +// +2 buttons, 1 = pressed: bit 0 left, bit 1 right, bit 2 middle +// +3..5 "TRM", for a driver to find the base it is at + +#define USBMouse_RegButtons 2 +#define USBMouse_RegID 3 +#define USBMouse_StepShift 2 // USB mice count ~1000 per inch; GEOS's screen is 320 wide +#define USBMouse_MaxStep 127 + +static const char USBMouse_ID[] = "TRM"; +static volatile int32_t USBMouse_Counts[2]; // USB counts, written only by the main loop +static int32_t USBMouse_StepsRead[2]; // written only by the IO ISR, after InitUSBMouse +static volatile uint8_t USBMouse_Buttons; + +static int32_t USBMouse_Steps(uint8_t Axis) +{ + return USBMouse_Counts[Axis] >> USBMouse_StepShift; // floors, so no wider dead zone at 0 +} + +void PollUSBMouse() +{ + if (!usbHostMouse.available()) return; + USBMouse_Counts[0] += usbHostMouse.getMouseX(); + USBMouse_Counts[1] += usbHostMouse.getMouseY(); + USBMouse_Buttons = usbHostMouse.getButtons(); + usbHostMouse.mouseDataClear(); +} + +// Movement from before the handler started (at the TR menu) is not the C64's to see +FLASHMEM void InitUSBMouse() +{ + for (uint8_t Axis = 0; Axis < 2; Axis++) USBMouse_StepsRead[Axis] = USBMouse_Steps(Axis); +} + +void USBMouseIORead(uint8_t Address) +{ + if (Address < 2) + { + int32_t Step = USBMouse_Steps(Address) - USBMouse_StepsRead[Address]; + if (Step > USBMouse_MaxStep) Step = USBMouse_MaxStep; + else if (Step < -USBMouse_MaxStep) Step = -USBMouse_MaxStep; + USBMouse_StepsRead[Address] += Step; + DataPortWriteWaitLog((uint8_t)Step); + } + else if (Address == USBMouse_RegButtons) DataPortWriteWaitLog(USBMouse_Buttons); + else if (Address < USBMouse_RegID + sizeof(USBMouse_ID) - 1) + DataPortWriteWaitLog(USBMouse_ID[Address - USBMouse_RegID]); +} diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index 0dd28899..ffe7b2df 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -370,6 +370,7 @@ void loop() if (Serial.available()) ServiceSerial(&Serial); myusbHost.Task(); + PollUSBMouse(); if (nfcState == nfcStateEnabled) nfcCheck(); From d631ef34fc64c944cf7de10f2a263d183f99c926 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 12:15:30 -0500 Subject: [PATCH 11/51] tools: a GEOS 128 input driver for the TR's USB mouse 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. --- tools/geos/trmouse.py | 191 ++++++++++++++++++++++++++++++++++++++ tools/geos/trmouse128.asm | 149 +++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 tools/geos/trmouse.py create mode 100644 tools/geos/trmouse128.asm diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py new file mode 100644 index 00000000..29fa8981 --- /dev/null +++ b/tools/geos/trmouse.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Put the TR USB mouse driver on a GEOS 128 boot disk. + + python3 tools/geos/trmouse.py boot.d81 out.d81 + +GEOS loads the first input driver in the boot disk's directory, so the driver +goes into the entry ahead of every other one, and the input driver that was +first moves to the slot the new file would have taken. Needs acme on PATH. +""" + +import argparse +import datetime +import pathlib +import subprocess +import sys +import tempfile + +SOURCE = pathlib.Path(__file__).with_name('trmouse128.asm') +NAME = b'TR USB MOUSE' +LOAD = 0xfd00 +MAX_SIZE = 0x180 # GEOS 128's input driver area, $FD00-$FE7F + +D81_SIZE = 819200 +SECTORS = 40 +DIR_TRACK = 40 +BAM_SECTORS = (1, 2) # tracks 1-40, 41-80 +FIRST_DIR_SECTOR = 3 +BAM_ENTRIES = 0x10 +BAM_ENTRY_SIZE = 6 + +USR_CLOSED = 0x83 +GEOS_SEQUENTIAL = 0 +INPUT_128 = 15 +INFO_ICON_BITMAP = 0xbf # an uncompressed 63-byte icon follows + +ICON = """ +........................ +.........######......... +.......##..#...##....... +......#....#.....#...... +.....#.....#......#..... +.....#.....#......#..... +.....#.....#......#..... +.....##############..... +.....#............#..... +.....#............#..... +.....#............#..... +.....#............#..... +.....#............#..... +.....#............#..... +......#..........#...... +.......##......##....... +.........######......... +........................ +...####.####.####....... +.....#..#..#.#..#....... +.....#..###..####....... +""" + + +def assemble(): + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp) / 'trmouse128.bin' + subprocess.run(['acme', '-f', 'plain', '-o', str(out), str(SOURCE)], check=True) + code = out.read_bytes() + if len(code) > MAX_SIZE: + sys.exit(f'the driver is {len(code)} bytes; GEOS 128 has room for {MAX_SIZE}') + return code + + +def icon_bits(): + rows = ICON.split() + assert len(rows) == 21 and all(len(r) == 24 for r in rows) + out = bytearray() + for row in rows: + bits = int(row.replace('#', '1').replace('.', '0'), 2) + out += bits.to_bytes(3, 'big') + return bytes(out) + + +def padded(text, size, pad=b'\0'): + assert len(text) <= size + return text + pad * (size - len(text)) + + +class D81: + def __init__(self, image): + if len(image) != D81_SIZE: + sys.exit(f'not a D81 without error bytes ({len(image)} bytes, expected {D81_SIZE})') + self.image = bytearray(image) + + def offset(self, track, sector): + return ((track - 1) * SECTORS + sector) * 256 + + def bam_entry(self, track): + sector = BAM_SECTORS[(track - 1) // SECTORS] + return self.offset(DIR_TRACK, sector) + BAM_ENTRIES + ((track - 1) % SECTORS) * BAM_ENTRY_SIZE + + def allocate(self): + for track in range(1, 81): + if track == DIR_TRACK: + continue + entry = self.bam_entry(track) + for sector in range(SECTORS): + byte, bit = entry + 1 + sector // 8, 1 << (sector % 8) + if self.image[byte] & bit: + self.image[byte] &= ~bit + self.image[entry] -= 1 + return track, sector + sys.exit('the disk is full') + + def dir_slots(self): + track, sector = DIR_TRACK, FIRST_DIR_SECTOR + while track: + base = self.offset(track, sector) + for i in range(8): + yield base + i * 32 + track, sector = self.image[base], self.image[base + 1] + + def write_file(self, data, info): + """Write a GEOS sequential file; returns its first data sector.""" + sectors = [self.allocate() for _ in range((len(data) + 253) // 254)] + for i, (track, sector) in enumerate(sectors): + chunk = data[i * 254:(i + 1) * 254] + if i + 1 < len(sectors): + link = bytes(sectors[i + 1]) + else: + link = bytes((0, len(chunk) + 1)) + o = self.offset(track, sector) + self.image[o:o + 256] = padded(link + chunk, 256) + info_ts = self.allocate() + o = self.offset(*info_ts) + self.image[o:o + 256] = info + return sectors[0], info_ts, len(sectors) + 1 + + +def info_block(size): + info = bytearray(256) + info[0:2] = b'\x00\xff' + info[2:5] = bytes((3, 21, INFO_ICON_BITMAP)) + info[5:0x44] = icon_bits() + info[0x44:0x47] = bytes((USR_CLOSED, INPUT_128, GEOS_SEQUENTIAL)) + info[0x47:0x49] = LOAD.to_bytes(2, 'little') + info[0x49:0x4b] = (LOAD + size).to_bytes(2, 'little') + info[0x4d:0x61] = padded(b'TR USB Mouse V1.0', 20) + info[0x61:0x75] = padded(b'TeensyROM', 20) + info[0xa0:0x100] = padded(b'A USB mouse plugged into a TeensyROM.', 0x60) + return bytes(info) + + +def install(image, code): + disk = D81(image) + slots = list(disk.dir_slots()) + entries = [s for s in slots if disk.image[s + 2] & 7] + if any(disk.image[s + 5:s + 21].rstrip(b'\xa0') == NAME for s in entries): + sys.exit(f'{NAME.decode()} is already on this disk') + free = next((s for s in slots if disk.image[s + 2] == 0), None) + if free is None: + sys.exit('the directory is full') + + first, info_ts, blocks = disk.write_file(code, info_block(len(code))) + now = datetime.datetime.now() + entry = bytearray(30) + entry[0] = USR_CLOSED + entry[1:3] = bytes(first) + entry[3:19] = padded(NAME, 16, b'\xa0') + entry[19:21] = bytes(info_ts) + entry[21:23] = bytes((GEOS_SEQUENTIAL, INPUT_128)) + entry[23:28] = bytes((now.year % 100, now.month, now.day, now.hour, now.minute)) + entry[28:30] = blocks.to_bytes(2, 'little') + disk.image[free + 2:free + 32] = entry + + drivers = [s for s in entries if disk.image[s + 24] == INPUT_128] + if drivers and slots.index(drivers[0]) < slots.index(free): + a, b = drivers[0], free + disk.image[a + 2:a + 32], disk.image[b + 2:b + 32] = \ + disk.image[b + 2:b + 32], disk.image[a + 2:a + 32] + return bytes(disk.image) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('disk', type=pathlib.Path) + parser.add_argument('out', type=pathlib.Path) + args = parser.parse_args() + args.out.write_bytes(install(args.disk.read_bytes(), assemble())) + + +if __name__ == '__main__': + main() diff --git a/tools/geos/trmouse128.asm b/tools/geos/trmouse128.asm new file mode 100644 index 00000000..d816bb39 --- /dev/null +++ b/tools/geos/trmouse128.asm @@ -0,0 +1,149 @@ +; SPDX-License-Identifier: MIT +; +; GEOS 128 input driver for a USB mouse on a TeensyROM (USBMouse.c has the registers). + +!cpu 6502 + +mouseOn = $30 ; bit 7: the pointer is on +pressFlag = $39 +mouseXPos = $3a ; 16 bits +mouseYPos = $3c +graphMode = $3f ; bit 7: 80 columns +mouseData = $8505 ; bit 7: button up +inputData = $8506 ; a joystick direction, or $ff + +MOUSE_BIT = $20 ; in pressFlag: mouseData changed +BUTTON_UP = $80 +NO_DIRECTION = $ff +OFF_TOP = 228 ; the kernal reads a mouseYPos this high as above the top + +regX = 0 +regY = 1 +regButtons = 2 +regID = 3 +LEFT_BUTTON = $01 + + * = $fd00 ; where GEOS 128 loads an input driver + + jmp MouseInit + jmp SlowMouse + jmp UpdateMouse + jmp SetMouse + +lastButton !byte BUTTON_UP +deltaX !word 0 +deltaY !byte 0 + +MouseInit + lda #0 + sta mouseXPos+1 + lda #8 + sta mouseXPos + sta mouseYPos +SlowMouse +SetMouse + rts + +; The registers are at $DF00 beside GeoRAM and $DE00 beside the REU; the ID tells which. +; $DF03-$DF05 are REU address registers, which have no side effects when read. +FindRegs + ldx #2 +- lda $df00+regID,x + cmp trID,x + bne + + dex + bpl - + lda #$df + bne ++ ++ lda #$de +++ sta readX+2 + sta readY+2 + sta readButtons+2 + rts + +trID !text "TRM" + +UpdateMouse + bit mouseOn + bmi + + rts ++ jsr FindRegs + +readButtons + lda $df00+regButtons + and #LEFT_BUTTON + beq + + lda #0 ; pressed + beq ++ ++ lda #BUTTON_UP +++ cmp lastButton + beq ++ + sta lastButton + sta mouseData + lda pressFlag + ora #MOUSE_BIT + sta pressFlag +++ + +readX + lda $df00+regX + sta deltaX + ldx #0 + cmp #0 + bpl + + dex ++ stx deltaX+1 + bit graphMode + bpl + + asl deltaX ; 640 pixels across in 80 columns + rol deltaX+1 ++ clc + lda mouseXPos + adc deltaX + sta mouseXPos + lda mouseXPos+1 + adc deltaX+1 + sta mouseXPos+1 ; the kernal clamps it to the screen + +readY + lda $df00+regY + sta deltaY + clc + adc mouseYPos + bit deltaY + bmi up + bcs bottom + cmp #OFF_TOP + bcc storeY +bottom lda #OFF_TOP-1 ; below the bottom, for the kernal to clamp + bne storeY +up bcs storeY + lda #$ff ; above the top +storeY sta mouseYPos + +; inputData: 0 right, 1 up-right, 2 up ... 7 down-right, as a joystick driver gives + ldx #1 ; column: 0 left, 1 none, 2 right + lda deltaX+1 + bmi + + ora deltaX + beq ++ + inx + bne ++ ++ dex +++ lda deltaY ; row: 0 up, 1 none, 2 down + beq + + bmi up1 + inx + inx + inx ++ inx + inx + inx +up1 lda directions,x + sta inputData + rts + +directions + !byte 3, 2, 1 ; up + !byte 4, NO_DIRECTION, 0 + !byte 5, 6, 7 ; down From 78cbc0523759ae34d17f101e5cfa58d0891f25c0 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 12:21:34 -0500 Subject: [PATCH 12/51] firmware: the USB mouse poll keeps reports the USB ISR lands mid-read, and drops buttons on unplug --- .../MinimalBoot/Common/IO_Handlers/USBMouse.c | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index c8cd84be..41ce13c9 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -27,11 +27,25 @@ static int32_t USBMouse_Steps(uint8_t Axis) void PollUSBMouse() { + // MouseController is also a BTHIDInput, so its own bool conversion is ambiguous + if (!static_cast(usbHostMouse)) + { + USBMouse_Buttons = 0; // unplugging leaves the library's last report in place + return; + } if (!usbHostMouse.available()) return; - USBMouse_Counts[0] += usbHostMouse.getMouseX(); - USBMouse_Counts[1] += usbHostMouse.getMouseY(); - USBMouse_Buttons = usbHostMouse.getButtons(); + // the USB ISR writes each report straight into the controller, and mouseDataClear() + // would drop one that lands between these reads and the clear. IRQ_USB2 is the host + // port's, which USBHost_t36 calls IRQ_USBHS in a header of its own + NVIC_DISABLE_IRQ(IRQ_USB2); + int32_t X = usbHostMouse.getMouseX(); + int32_t Y = usbHostMouse.getMouseY(); + uint8_t Buttons = usbHostMouse.getButtons(); usbHostMouse.mouseDataClear(); + NVIC_ENABLE_IRQ(IRQ_USB2); + USBMouse_Counts[0] += X; + USBMouse_Counts[1] += Y; + USBMouse_Buttons = Buttons; } // Movement from before the handler started (at the TR menu) is not the C64's to see From d3990d304f61384bc51028261d2d5bb71409714c Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 12:28:34 -0500 Subject: [PATCH 13/51] tools: the GEOS 128 mouse driver ignores a page without the TR's ID, 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. --- tools/geos/trmouse128.asm | 43 +++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tools/geos/trmouse128.asm b/tools/geos/trmouse128.asm index d816bb39..bc2a226f 100644 --- a/tools/geos/trmouse128.asm +++ b/tools/geos/trmouse128.asm @@ -46,28 +46,40 @@ SetMouse ; The registers are at $DF00 beside GeoRAM and $DE00 beside the REU; the ID tells which. ; $DF03-$DF05 are REU address registers, which have no side effects when read. +; Carry set: neither page has the ID, so what reads there is not a mouse. FindRegs + ldy #$df +nextPage + sty readID+2 ldx #2 -- lda $df00+regID,x +readID lda $df00+regID,x cmp trID,x bne + dex - bpl - - lda #$df - bne ++ -+ lda #$de -++ sta readX+2 - sta readY+2 - sta readButtons+2 + bpl readID + sty readX+2 + sty readY+2 + sty readButtons+2 + clc + rts ++ dey + cpy #$dd + bne nextPage + sec rts trID !text "TRM" UpdateMouse + jsr FindRegs + bcs noMouse +readX lda $df00+regX + sta deltaX +readY lda $df00+regY + sta deltaY bit mouseOn - bmi + - rts -+ jsr FindRegs + bmi readButtons +noMouse rts ; movement while the pointer is off is dropped, as a 1351's is readButtons lda $df00+regButtons @@ -85,11 +97,8 @@ readButtons sta pressFlag ++ -readX - lda $df00+regX - sta deltaX ldx #0 - cmp #0 + lda deltaX bpl + dex + stx deltaX+1 @@ -105,9 +114,7 @@ readX adc deltaX+1 sta mouseXPos+1 ; the kernal clamps it to the screen -readY - lda $df00+regY - sta deltaY + lda deltaY clc adc mouseYPos bit deltaY From 50a74e61a1d11d4361052d3999f55ae0db6d1ecf Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 12:28:34 -0500 Subject: [PATCH 14/51] tools: trmouse.py puts the class version at +12, and stops on a looped 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. --- tools/geos/trmouse.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index 29fa8981..7894dfdc 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -18,6 +18,7 @@ SOURCE = pathlib.Path(__file__).with_name('trmouse128.asm') NAME = b'TR USB MOUSE' +CLASS_NAME = b'TR USB Mouse' LOAD = 0xfd00 MAX_SIZE = 0x180 # GEOS 128's input driver area, $FD00-$FE7F @@ -112,7 +113,11 @@ def allocate(self): def dir_slots(self): track, sector = DIR_TRACK, FIRST_DIR_SECTOR + seen = set() while track: + if (track, sector) in seen or not 1 <= track <= 80 or sector >= SECTORS: + sys.exit(f'the directory chain is broken at {track}/{sector}') + seen.add((track, sector)) base = self.offset(track, sector) for i in range(8): yield base + i * 32 @@ -143,7 +148,7 @@ def info_block(size): info[0x44:0x47] = bytes((USR_CLOSED, INPUT_128, GEOS_SEQUENTIAL)) info[0x47:0x49] = LOAD.to_bytes(2, 'little') info[0x49:0x4b] = (LOAD + size).to_bytes(2, 'little') - info[0x4d:0x61] = padded(b'TR USB Mouse V1.0', 20) + info[0x4d:0x61] = padded(CLASS_NAME.ljust(12) + b'V1.0', 20) # GEOS reads the version at +12 info[0x61:0x75] = padded(b'TeensyROM', 20) info[0xa0:0x100] = padded(b'A USB mouse plugged into a TeensyROM.', 0x60) return bytes(info) From 2564b7846a5d8147766c59b0228c513e86f64eb0 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:05:32 -0500 Subject: [PATCH 15/51] firmware: the USB mouse poll fences the IRQ disable, and clears a report 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 78cbc052 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. --- .../MinimalBoot/Common/IO_Handlers/USBMouse.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index 41ce13c9..5b0bacc5 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -30,19 +30,22 @@ void PollUSBMouse() // MouseController is also a BTHIDInput, so its own bool conversion is ambiguous if (!static_cast(usbHostMouse)) { - USBMouse_Buttons = 0; // unplugging leaves the library's last report in place + // unplugging leaves the library's last report in place, for a replug to deliver + usbHostMouse.mouseDataClear(); + USBMouse_Buttons = 0; return; } if (!usbHostMouse.available()) return; // the USB ISR writes each report straight into the controller, and mouseDataClear() - // would drop one that lands between these reads and the clear. IRQ_USB2 is the host - // port's, which USBHost_t36 calls IRQ_USBHS in a header of its own - NVIC_DISABLE_IRQ(IRQ_USB2); + // would drop one that lands between these reads and the clear + NVIC_DISABLE_IRQ(IRQ_USBHS); + // the Cortex-M7 can still take the IRQ after the NVIC write until a DSB and ISB + asm volatile("dsb\n\tisb" ::: "memory"); int32_t X = usbHostMouse.getMouseX(); int32_t Y = usbHostMouse.getMouseY(); uint8_t Buttons = usbHostMouse.getButtons(); usbHostMouse.mouseDataClear(); - NVIC_ENABLE_IRQ(IRQ_USB2); + NVIC_ENABLE_IRQ(IRQ_USBHS); USBMouse_Counts[0] += X; USBMouse_Counts[1] += Y; USBMouse_Buttons = Buttons; From b89f417b33fbb700be7c3e67c427e6138beea56e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:07:57 -0500 Subject: [PATCH 16/51] tools: the mouse driver's off-pointer comment drops a 1351 comparison that does not hold --- tools/geos/trmouse128.asm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/geos/trmouse128.asm b/tools/geos/trmouse128.asm index bc2a226f..d18bcd93 100644 --- a/tools/geos/trmouse128.asm +++ b/tools/geos/trmouse128.asm @@ -79,7 +79,7 @@ readY lda $df00+regY sta deltaY bit mouseOn bmi readButtons -noMouse rts ; movement while the pointer is off is dropped, as a 1351's is +noMouse rts ; movement while the pointer is off is dropped readButtons lda $df00+regButtons From 20aa9558781029d5037a49636204dd2b9a8699b4 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:09:21 -0500 Subject: [PATCH 17/51] tools: trmouse.py stops on a directory chain into the header or BAM, and holds the class name to its 12 bytes --- tools/geos/trmouse.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index 7894dfdc..ce318959 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -19,10 +19,12 @@ SOURCE = pathlib.Path(__file__).with_name('trmouse128.asm') NAME = b'TR USB MOUSE' CLASS_NAME = b'TR USB Mouse' +CLASS_NAME_SIZE = 12 # GEOS reads the version right after it LOAD = 0xfd00 MAX_SIZE = 0x180 # GEOS 128's input driver area, $FD00-$FE7F D81_SIZE = 819200 +TRACKS = 80 SECTORS = 40 DIR_TRACK = 40 BAM_SECTORS = (1, 2) # tracks 1-40, 41-80 @@ -99,7 +101,7 @@ def bam_entry(self, track): return self.offset(DIR_TRACK, sector) + BAM_ENTRIES + ((track - 1) % SECTORS) * BAM_ENTRY_SIZE def allocate(self): - for track in range(1, 81): + for track in range(1, TRACKS + 1): if track == DIR_TRACK: continue entry = self.bam_entry(track) @@ -115,7 +117,8 @@ def dir_slots(self): track, sector = DIR_TRACK, FIRST_DIR_SECTOR seen = set() while track: - if (track, sector) in seen or not 1 <= track <= 80 or sector >= SECTORS: + in_header = track == DIR_TRACK and sector < FIRST_DIR_SECTOR + if (track, sector) in seen or in_header or not 1 <= track <= TRACKS or sector >= SECTORS: sys.exit(f'the directory chain is broken at {track}/{sector}') seen.add((track, sector)) base = self.offset(track, sector) @@ -148,7 +151,7 @@ def info_block(size): info[0x44:0x47] = bytes((USR_CLOSED, INPUT_128, GEOS_SEQUENTIAL)) info[0x47:0x49] = LOAD.to_bytes(2, 'little') info[0x49:0x4b] = (LOAD + size).to_bytes(2, 'little') - info[0x4d:0x61] = padded(CLASS_NAME.ljust(12) + b'V1.0', 20) # GEOS reads the version at +12 + info[0x4d:0x61] = padded(padded(CLASS_NAME, CLASS_NAME_SIZE, b' ') + b'V1.0', 20) info[0x61:0x75] = padded(b'TeensyROM', 20) info[0xa0:0x100] = padded(b'A USB mouse plugged into a TeensyROM.', 0x60) return bytes(info) From caedc75b2ad28ec1dde64b4e654e2e08369a888b Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:14:41 -0500 Subject: [PATCH 18/51] firmware: the USB mouse poll fences the IRQ re-enable against the compiler 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. --- Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index 5b0bacc5..adbe925e 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -45,6 +45,7 @@ void PollUSBMouse() int32_t Y = usbHostMouse.getMouseY(); uint8_t Buttons = usbHostMouse.getButtons(); usbHostMouse.mouseDataClear(); + asm volatile("" ::: "memory"); NVIC_ENABLE_IRQ(IRQ_USBHS); USBMouse_Counts[0] += X; USBMouse_Counts[1] += Y; From 04de5c8560bf333e1e7d80b46003f4649d188fc8 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:17:18 -0500 Subject: [PATCH 19/51] tools: trmouse.py stops on a directory chain that leaves track 40 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. --- tools/geos/trmouse.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index ce318959..d05a9288 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -23,9 +23,9 @@ LOAD = 0xfd00 MAX_SIZE = 0x180 # GEOS 128's input driver area, $FD00-$FE7F -D81_SIZE = 819200 TRACKS = 80 SECTORS = 40 +D81_SIZE = TRACKS * SECTORS * 256 DIR_TRACK = 40 BAM_SECTORS = (1, 2) # tracks 1-40, 41-80 FIRST_DIR_SECTOR = 3 @@ -117,8 +117,8 @@ def dir_slots(self): track, sector = DIR_TRACK, FIRST_DIR_SECTOR seen = set() while track: - in_header = track == DIR_TRACK and sector < FIRST_DIR_SECTOR - if (track, sector) in seen or in_header or not 1 <= track <= TRACKS or sector >= SECTORS: + in_directory = track == DIR_TRACK and FIRST_DIR_SECTOR <= sector < SECTORS + if (track, sector) in seen or not in_directory: sys.exit(f'the directory chain is broken at {track}/{sector}') seen.add((track, sector)) base = self.offset(track, sector) From f18ee292f815c933c49117d5108acf514a5b927d Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:56:05 -0500 Subject: [PATCH 20/51] tools: trmouse.py takes D64 and D71 boot disks too, follows the header's directory link, and grows a full directory --- tools/geos/trmouse.py | 137 +++++++++++++++++++++++++++++++----------- 1 file changed, 102 insertions(+), 35 deletions(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index d05a9288..2d5ad915 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT """Put the TR USB mouse driver on a GEOS 128 boot disk. - python3 tools/geos/trmouse.py boot.d81 out.d81 + python3 tools/geos/trmouse.py boot.d81 out.d81 (or a .d64 or .d71) GEOS loads the first input driver in the boot disk's directory, so the driver goes into the entry ahead of every other one, and the input driver that was @@ -23,14 +23,8 @@ LOAD = 0xfd00 MAX_SIZE = 0x180 # GEOS 128's input driver area, $FD00-$FE7F -TRACKS = 80 -SECTORS = 40 -D81_SIZE = TRACKS * SECTORS * 256 -DIR_TRACK = 40 -BAM_SECTORS = (1, 2) # tracks 1-40, 41-80 -FIRST_DIR_SECTOR = 3 -BAM_ENTRIES = 0x10 -BAM_ENTRY_SIZE = 6 +SECTOR_SIZE = 256 +DIR_ENTRY_SIZE = 32 USR_CLOSED = 0x83 GEOS_SEQUENTIAL = 0 @@ -87,45 +81,116 @@ def padded(text, size, pad=b'\0'): return text + pad * (size - len(text)) -class D81: +def zone_sectors(track): + """A 1541's sectors per track, which a 1571 repeats on its second side.""" + track = (track - 1) % 35 + 1 + return 21 if track <= 17 else 19 if track <= 24 else 18 if track <= 30 else 17 + + +class Format: + def __init__(self, name, tracks, sectors, dir_track, dir_sectors, reserved, bam): + self.name, self.tracks, self.sectors = name, tracks, sectors + self.dir_track, self.dir_sectors = dir_track, dir_sectors + self.reserved = reserved # tracks DOS never hands out for file data + self.bam = bam # track -> (count, bitmap) as (track, sector, byte) + self.starts = [0] + for track in range(1, tracks + 1): + self.starts.append(self.starts[-1] + sectors(track)) + + @property + def size(self): + return self.starts[-1] * SECTOR_SIZE + + +def d64_bam(track): + return (18, 0, 4 + (track - 1) * 4), (18, 0, 5 + (track - 1) * 4) + + +def d71_bam(track): + if track <= 35: + return d64_bam(track) + return (18, 0, 0xdd + track - 36), (53, 0, (track - 36) * 3) + + +def d81_bam(track): + sector, entry = 1 + (track - 1) // 40, 0x10 + (track - 1) % 40 * 6 + return (40, sector, entry), (40, sector, entry + 1) + + +FORMATS = {f.size: f for f in ( + Format('D64', 35, zone_sectors, 18, range(1, 19), (18,), d64_bam), + Format('D71', 70, zone_sectors, 18, range(1, 19), (18, 53), d71_bam), + Format('D81', 80, lambda track: 40, 40, range(3, 40), (40,), d81_bam), +)} + + +class Disk: def __init__(self, image): - if len(image) != D81_SIZE: - sys.exit(f'not a D81 without error bytes ({len(image)} bytes, expected {D81_SIZE})') + self.format = FORMATS.get(len(image)) + if self.format is None: + sizes = ', '.join(f'{f.name} {f.size}' for f in FORMATS.values()) + sys.exit(f'not a D64, D71 or D81 without error bytes ({len(image)} bytes; expected {sizes})') self.image = bytearray(image) + if self.format.name == 'D71' and not self.image[self.offset(18, 0) + 3] & 0x80: + sys.exit('this D71 is formatted single-sided') def offset(self, track, sector): - return ((track - 1) * SECTORS + sector) * 256 + return (self.format.starts[track - 1] + sector) * SECTOR_SIZE - def bam_entry(self, track): - sector = BAM_SECTORS[(track - 1) // SECTORS] - return self.offset(DIR_TRACK, sector) + BAM_ENTRIES + ((track - 1) % SECTORS) * BAM_ENTRY_SIZE + def at(self, place): + track, sector, byte = place + return self.offset(track, sector) + byte + + def take(self, track): + """Mark the first free sector of a track in use; None if it has none.""" + count, bitmap = (self.at(p) for p in self.format.bam(track)) + for sector in range(self.format.sectors(track)): + byte, bit = bitmap + sector // 8, 1 << (sector % 8) + if self.image[byte] & bit: + self.image[byte] &= ~bit + self.image[count] -= 1 + return track, sector + return None def allocate(self): - for track in range(1, TRACKS + 1): - if track == DIR_TRACK: - continue - entry = self.bam_entry(track) - for sector in range(SECTORS): - byte, bit = entry + 1 + sector // 8, 1 << (sector % 8) - if self.image[byte] & bit: - self.image[byte] &= ~bit - self.image[entry] -= 1 - return track, sector + for track in range(1, self.format.tracks + 1): + if track not in self.format.reserved: + found = self.take(track) + if found: + return found sys.exit('the disk is full') - def dir_slots(self): - track, sector = DIR_TRACK, FIRST_DIR_SECTOR + def dir_sectors(self): + header = self.offset(self.format.dir_track, 0) + track, sector = self.image[header], self.image[header + 1] seen = set() while track: - in_directory = track == DIR_TRACK and FIRST_DIR_SECTOR <= sector < SECTORS + in_directory = track == self.format.dir_track and sector in self.format.dir_sectors if (track, sector) in seen or not in_directory: sys.exit(f'the directory chain is broken at {track}/{sector}') seen.add((track, sector)) + yield track, sector base = self.offset(track, sector) - for i in range(8): - yield base + i * 32 track, sector = self.image[base], self.image[base + 1] + def dir_slots(self): + for track, sector in self.dir_sectors(): + base = self.offset(track, sector) + for i in range(SECTOR_SIZE // DIR_ENTRY_SIZE): + yield base + i * DIR_ENTRY_SIZE + + def grow_directory(self): + """Chain one more directory sector, as DOS does when a directory fills; returns its slots.""" + last = list(self.dir_sectors())[-1] + new = self.take(self.format.dir_track) + if new is None or new[1] not in self.format.dir_sectors: + sys.exit('the directory is full') + o = self.offset(*last) + self.image[o:o + 2] = bytes(new) + o = self.offset(*new) + self.image[o:o + SECTOR_SIZE] = padded(b'\x00\xff', SECTOR_SIZE) + return [o + i * DIR_ENTRY_SIZE for i in range(SECTOR_SIZE // DIR_ENTRY_SIZE)] + def write_file(self, data, info): """Write a GEOS sequential file; returns its first data sector.""" sectors = [self.allocate() for _ in range((len(data) + 253) // 254)] @@ -136,10 +201,10 @@ def write_file(self, data, info): else: link = bytes((0, len(chunk) + 1)) o = self.offset(track, sector) - self.image[o:o + 256] = padded(link + chunk, 256) + self.image[o:o + SECTOR_SIZE] = padded(link + chunk, SECTOR_SIZE) info_ts = self.allocate() o = self.offset(*info_ts) - self.image[o:o + 256] = info + self.image[o:o + SECTOR_SIZE] = info return sectors[0], info_ts, len(sectors) + 1 @@ -158,14 +223,16 @@ def info_block(size): def install(image, code): - disk = D81(image) + disk = Disk(image) slots = list(disk.dir_slots()) entries = [s for s in slots if disk.image[s + 2] & 7] if any(disk.image[s + 5:s + 21].rstrip(b'\xa0') == NAME for s in entries): sys.exit(f'{NAME.decode()} is already on this disk') free = next((s for s in slots if disk.image[s + 2] == 0), None) if free is None: - sys.exit('the directory is full') + grown = disk.grow_directory() + slots += grown + free = grown[0] first, info_ts, blocks = disk.write_file(code, info_block(len(code))) now = datetime.datetime.now() From b17b18b0452ba2d947c3d2b34a3b206477d53388 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 13:58:50 -0500 Subject: [PATCH 21/51] tools: trmouse.py refuses a BAM that frees a live directory sector or undercounts a track, and a header with no directory link --- tools/geos/trmouse.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index 2d5ad915..5a36093c 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -147,6 +147,8 @@ def take(self, track): for sector in range(self.format.sectors(track)): byte, bit = bitmap + sector // 8, 1 << (sector % 8) if self.image[byte] & bit: + if not self.image[count]: + sys.exit(f'the BAM count for track {track} disagrees with its bitmap') self.image[byte] &= ~bit self.image[count] -= 1 return track, sector @@ -163,6 +165,8 @@ def allocate(self): def dir_sectors(self): header = self.offset(self.format.dir_track, 0) track, sector = self.image[header], self.image[header + 1] + if not track: + sys.exit('the disk header links to no directory') seen = set() while track: in_directory = track == self.format.dir_track and sector in self.format.dir_sectors @@ -173,23 +177,27 @@ def dir_sectors(self): base = self.offset(track, sector) track, sector = self.image[base], self.image[base + 1] + def slots(self, track, sector): + base = self.offset(track, sector) + return [base + i * DIR_ENTRY_SIZE for i in range(SECTOR_SIZE // DIR_ENTRY_SIZE)] + def dir_slots(self): for track, sector in self.dir_sectors(): - base = self.offset(track, sector) - for i in range(SECTOR_SIZE // DIR_ENTRY_SIZE): - yield base + i * DIR_ENTRY_SIZE + yield from self.slots(track, sector) def grow_directory(self): """Chain one more directory sector, as DOS does when a directory fills; returns its slots.""" - last = list(self.dir_sectors())[-1] + chain = list(self.dir_sectors()) new = self.take(self.format.dir_track) if new is None or new[1] not in self.format.dir_sectors: sys.exit('the directory is full') - o = self.offset(*last) + if new in chain: + sys.exit(f'the BAM shows directory sector {new[0]}/{new[1]} free') + o = self.offset(*chain[-1]) self.image[o:o + 2] = bytes(new) o = self.offset(*new) self.image[o:o + SECTOR_SIZE] = padded(b'\x00\xff', SECTOR_SIZE) - return [o + i * DIR_ENTRY_SIZE for i in range(SECTOR_SIZE // DIR_ENTRY_SIZE)] + return self.slots(*new) def write_file(self, data, info): """Write a GEOS sequential file; returns its first data sector.""" From ea12ae50b78dcf1ea0d58b9a17a1c1f5f498f981 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 14:01:14 -0500 Subject: [PATCH 22/51] tools: trmouse.py refuses any undercounted track and any live directory sector the BAM frees, not only the first free one --- tools/geos/trmouse.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index 5a36093c..d8a72f61 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -141,18 +141,23 @@ def at(self, place): track, sector, byte = place return self.offset(track, sector) + byte + def free_sectors(self, track): + _, bitmap = (self.at(p) for p in self.format.bam(track)) + return [s for s in range(self.format.sectors(track)) + if self.image[bitmap + s // 8] & 1 << (s % 8)] + def take(self, track): """Mark the first free sector of a track in use; None if it has none.""" count, bitmap = (self.at(p) for p in self.format.bam(track)) - for sector in range(self.format.sectors(track)): - byte, bit = bitmap + sector // 8, 1 << (sector % 8) - if self.image[byte] & bit: - if not self.image[count]: - sys.exit(f'the BAM count for track {track} disagrees with its bitmap') - self.image[byte] &= ~bit - self.image[count] -= 1 - return track, sector - return None + free = self.free_sectors(track) + if self.image[count] < len(free): + sys.exit(f'the BAM count for track {track} disagrees with its bitmap') + if not free: + return None + sector = free[0] + self.image[bitmap + sector // 8] &= ~(1 << (sector % 8)) + self.image[count] -= 1 + return track, sector def allocate(self): for track in range(1, self.format.tracks + 1): @@ -188,11 +193,13 @@ def dir_slots(self): def grow_directory(self): """Chain one more directory sector, as DOS does when a directory fills; returns its slots.""" chain = list(self.dir_sectors()) + free = self.free_sectors(self.format.dir_track) + for track, sector in chain: + if sector in free: + sys.exit(f'the BAM shows directory sector {track}/{sector} free') new = self.take(self.format.dir_track) if new is None or new[1] not in self.format.dir_sectors: sys.exit('the directory is full') - if new in chain: - sys.exit(f'the BAM shows directory sector {new[0]}/{new[1]} free') o = self.offset(*chain[-1]) self.image[o:o + 2] = bytes(new) o = self.offset(*new) From be36f993b99661bc116ea65983368222bd76449e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:14:05 -0500 Subject: [PATCH 23/51] tools: trmouse.py writes the driver file as a C header for the firmware 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. --- Source/Teensy/GEOS/TRMouse128.h | 41 +++++++++++++++++++++++++++++++++ tools/geos/trmouse.py | 30 ++++++++++++++++++++++-- tools/geos/trmouse.test.mjs | 31 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 Source/Teensy/GEOS/TRMouse128.h create mode 100644 tools/geos/trmouse.test.mjs diff --git a/Source/Teensy/GEOS/TRMouse128.h b/Source/Teensy/GEOS/TRMouse128.h new file mode 100644 index 00000000..0e5916f9 --- /dev/null +++ b/Source/Teensy/GEOS/TRMouse128.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit. +#pragma once +#include + +static const char TRMouse128Name[] = "TR USB MOUSE"; +static const uint8_t TRMouse128Code[226] = { + 0x4c, 0x10, 0xfd, 0x4c, 0x1a, 0xfd, 0x4c, 0x42, 0xfd, 0x4c, 0x1a, 0xfd, 0x80, 0x00, 0x00, 0x00, + 0xa9, 0x00, 0x85, 0x3b, 0xa9, 0x08, 0x85, 0x3a, 0x85, 0x3c, 0x60, 0xa0, 0xdf, 0x8c, 0x24, 0xfd, + 0xa2, 0x02, 0xbd, 0x03, 0xdf, 0xdd, 0x3f, 0xfd, 0xd0, 0x0e, 0xca, 0x10, 0xf5, 0x8c, 0x49, 0xfd, + 0x8c, 0x4f, 0xfd, 0x8c, 0x5a, 0xfd, 0x18, 0x60, 0x88, 0xc0, 0xdd, 0xd0, 0xe0, 0x38, 0x60, 0x54, + 0x52, 0x4d, 0x20, 0x1b, 0xfd, 0xb0, 0x10, 0xad, 0x00, 0xdf, 0x8d, 0x0d, 0xfd, 0xad, 0x01, 0xdf, + 0x8d, 0x0f, 0xfd, 0x24, 0x30, 0x30, 0x01, 0x60, 0xad, 0x02, 0xdf, 0x29, 0x01, 0xf0, 0x04, 0xa9, + 0x00, 0xf0, 0x02, 0xa9, 0x80, 0xcd, 0x0c, 0xfd, 0xf0, 0x0c, 0x8d, 0x0c, 0xfd, 0x8d, 0x05, 0x85, + 0xa5, 0x39, 0x09, 0x20, 0x85, 0x39, 0xa2, 0x00, 0xad, 0x0d, 0xfd, 0x10, 0x01, 0xca, 0x8e, 0x0e, + 0xfd, 0x24, 0x3f, 0x10, 0x06, 0x0e, 0x0d, 0xfd, 0x2e, 0x0e, 0xfd, 0x18, 0xa5, 0x3a, 0x6d, 0x0d, + 0xfd, 0x85, 0x3a, 0xa5, 0x3b, 0x6d, 0x0e, 0xfd, 0x85, 0x3b, 0xad, 0x0f, 0xfd, 0x18, 0x65, 0x3c, + 0x2c, 0x0f, 0xfd, 0x30, 0x0a, 0xb0, 0x04, 0xc9, 0xe4, 0x90, 0x08, 0xa9, 0xe3, 0xd0, 0x04, 0xb0, + 0x02, 0xa9, 0xff, 0x85, 0x3c, 0xa2, 0x01, 0xad, 0x0e, 0xfd, 0x30, 0x08, 0x0d, 0x0d, 0xfd, 0xf0, + 0x04, 0xe8, 0xd0, 0x01, 0xca, 0xad, 0x0f, 0xfd, 0xf0, 0x05, 0x30, 0x06, 0xe8, 0xe8, 0xe8, 0xe8, + 0xe8, 0xe8, 0xbd, 0xd9, 0xfd, 0x8d, 0x06, 0x85, 0x60, 0x03, 0x02, 0x01, 0x04, 0xff, 0x00, 0x05, + 0x06, 0x07 +}; +static const uint8_t TRMouse128Info[256] = { + 0x00, 0xff, 0x03, 0x15, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x00, 0x01, 0x91, 0x80, 0x02, 0x10, + 0x40, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x07, 0xff, 0xe0, 0x04, 0x00, 0x20, + 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x02, + 0x00, 0x40, 0x01, 0x81, 0x80, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xf7, 0x80, 0x04, 0x94, + 0x80, 0x04, 0xe7, 0x80, 0x83, 0x0f, 0x00, 0x00, 0xfd, 0xe2, 0xfd, 0x00, 0x00, 0x54, 0x52, 0x20, + 0x55, 0x53, 0x42, 0x20, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x56, 0x31, 0x2e, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x54, 0x65, 0x65, 0x6e, 0x73, 0x79, 0x52, 0x4f, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x41, 0x20, 0x55, 0x53, 0x42, 0x20, 0x6d, 0x6f, 0x75, 0x73, 0x65, 0x20, 0x70, 0x6c, 0x75, 0x67, + 0x67, 0x65, 0x64, 0x20, 0x69, 0x6e, 0x74, 0x6f, 0x20, 0x61, 0x20, 0x54, 0x65, 0x65, 0x6e, 0x73, + 0x79, 0x52, 0x4f, 0x4d, 0x2e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +}; diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index d8a72f61..7a5bfb5f 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -3,6 +3,7 @@ """Put the TR USB mouse driver on a GEOS 128 boot disk. python3 tools/geos/trmouse.py boot.d81 out.d81 (or a .d64 or .d71) + python3 tools/geos/trmouse.py --c-header out.h (the driver file, for the firmware) GEOS loads the first input driver in the boot disk's directory, so the driver goes into the entry ahead of every other one, and the input driver that was @@ -269,11 +270,36 @@ def install(image, code): return bytes(disk.image) +def c_array(name, data): + rows = (', '.join(f'0x{b:02x}' for b in data[i:i + 16]) for i in range(0, len(data), 16)) + body = ',\n'.join(f' {row}' for row in rows) + return f'static const uint8_t {name}[{len(data)}] = {{\n{body}\n}};\n' + + +def c_header(code): + return ( + '// SPDX-License-Identifier: MIT\n' + '// Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit.\n' + '#pragma once\n' + '#include \n\n' + f'static const char TRMouse128Name[] = "{NAME.decode()}";\n' + + c_array('TRMouse128Code', code) + + c_array('TRMouse128Info', info_block(len(code)))) + + def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument('disk', type=pathlib.Path) - parser.add_argument('out', type=pathlib.Path) + parser.add_argument('disk', type=pathlib.Path, nargs='?') + parser.add_argument('out', type=pathlib.Path, nargs='?') + parser.add_argument('--c-header', type=pathlib.Path, metavar='OUT') args = parser.parse_args() + if args.c_header: + if args.disk: + parser.error('--c-header takes no disk images') + args.c_header.write_text(c_header(assemble())) + return + if not args.out: + parser.error('needs a boot disk and an output image') args.out.write_bytes(install(args.disk.read_bytes(), assemble())) diff --git a/tools/geos/trmouse.test.mjs b/tools/geos/trmouse.test.mjs new file mode 100644 index 00000000..ed13d1ca --- /dev/null +++ b/tools/geos/trmouse.test.mjs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { commandExists } from '../lib/toolchain.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const trmouse = path.join(root, 'tools/geos/trmouse.py'); +const committedHeader = path.join(root, 'Source/Teensy/GEOS/TRMouse128.h'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const missingTool = ['acme', 'python3'].find((tool) => !commandExists(tool)); + +test('the committed driver header matches the driver source', { skip: missingTool && !inCi && `${missingTool} is not on PATH` }, () => { + assert.equal(missingTool, undefined, 'CI installs ACME and Python, so this test must not skip there'); + const out = fs.mkdtempSync(path.join(os.tmpdir(), 'trmouse-test-')); + try { + const header = path.join(out, 'TRMouse128.h'); + const r = spawnSync('python3', [trmouse, '--c-header', header], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + const committed = fs.readFileSync(committedHeader, 'utf8').replaceAll('\r\n', '\n'); + assert.equal(fs.readFileSync(header, 'utf8'), committed, 'rerun tools/geos/trmouse.py --c-header'); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } +}); From 29dc4448f4fc14fcf102d1424f8eae065aeb7943 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:15:54 -0500 Subject: [PATCH 24/51] tools: trmouse.py writes the driver header with LF endings and keeps 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. --- .gitattributes | 3 +++ Source/Teensy/GEOS/TRMouse128.h | 7 ++++--- tools/geos/trmouse.py | 9 +++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index ebeba5c5..f884fa2b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ # Generated by tools/build-c64.mjs with LF endings; check them out as LF too so a # rebuild on Windows (core.autocrlf=true) doesn't flag unchanged headers as modified Source/Teensy/TRMenuFiles/ROMs/*.h text eol=lf + +# Generated by tools/geos/trmouse.py --c-header with LF endings, kept LF for the same reason +Source/Teensy/GEOS/*.h text eol=lf diff --git a/Source/Teensy/GEOS/TRMouse128.h b/Source/Teensy/GEOS/TRMouse128.h index 0e5916f9..75f2bc5b 100644 --- a/Source/Teensy/GEOS/TRMouse128.h +++ b/Source/Teensy/GEOS/TRMouse128.h @@ -2,9 +2,10 @@ // Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit. #pragma once #include +#include -static const char TRMouse128Name[] = "TR USB MOUSE"; -static const uint8_t TRMouse128Code[226] = { +static const char TRMouse128Name[] PROGMEM = "TR USB MOUSE"; +static const uint8_t TRMouse128Code[226] PROGMEM = { 0x4c, 0x10, 0xfd, 0x4c, 0x1a, 0xfd, 0x4c, 0x42, 0xfd, 0x4c, 0x1a, 0xfd, 0x80, 0x00, 0x00, 0x00, 0xa9, 0x00, 0x85, 0x3b, 0xa9, 0x08, 0x85, 0x3a, 0x85, 0x3c, 0x60, 0xa0, 0xdf, 0x8c, 0x24, 0xfd, 0xa2, 0x02, 0xbd, 0x03, 0xdf, 0xdd, 0x3f, 0xfd, 0xd0, 0x0e, 0xca, 0x10, 0xf5, 0x8c, 0x49, 0xfd, @@ -21,7 +22,7 @@ static const uint8_t TRMouse128Code[226] = { 0xe8, 0xe8, 0xbd, 0xd9, 0xfd, 0x8d, 0x06, 0x85, 0x60, 0x03, 0x02, 0x01, 0x04, 0xff, 0x00, 0x05, 0x06, 0x07 }; -static const uint8_t TRMouse128Info[256] = { +static const uint8_t TRMouse128Info[256] PROGMEM = { 0x00, 0xff, 0x03, 0x15, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x00, 0x01, 0x91, 0x80, 0x02, 0x10, 0x40, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x07, 0xff, 0xe0, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x02, diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index 7a5bfb5f..e5b4d890 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -273,7 +273,7 @@ def install(image, code): def c_array(name, data): rows = (', '.join(f'0x{b:02x}' for b in data[i:i + 16]) for i in range(0, len(data), 16)) body = ',\n'.join(f' {row}' for row in rows) - return f'static const uint8_t {name}[{len(data)}] = {{\n{body}\n}};\n' + return f'static const uint8_t {name}[{len(data)}] PROGMEM = {{\n{body}\n}};\n' def c_header(code): @@ -281,8 +281,9 @@ def c_header(code): '// SPDX-License-Identifier: MIT\n' '// Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit.\n' '#pragma once\n' - '#include \n\n' - f'static const char TRMouse128Name[] = "{NAME.decode()}";\n' + '#include \n' + '#include \n\n' + f'static const char TRMouse128Name[] PROGMEM = "{NAME.decode()}";\n' + c_array('TRMouse128Code', code) + c_array('TRMouse128Info', info_block(len(code)))) @@ -296,7 +297,7 @@ def main(): if args.c_header: if args.disk: parser.error('--c-header takes no disk images') - args.c_header.write_text(c_header(assemble())) + args.c_header.write_bytes(c_header(assemble()).encode()) return if not args.out: parser.error('needs a boot disk and an output image') From d73741c8323170444301d4ee1716a3cf3318be8c Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:18:49 -0500 Subject: [PATCH 25/51] firmware: a GEOS 128 boot-disk patch that adds the TR USB mouse driver 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. --- Source/Teensy/GEOS/GeosDiskPatch.h | 445 ++++++++++++++++++ Source/Teensy/GEOS/TRMouse128.h | 4 + Source/Teensy/tests/geos-disk-patch.test.js | 216 +++++++++ Source/Teensy/tests/geos_disk_patch_harness.c | 51 ++ tools/geos/trmouse.py | 6 +- 5 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 Source/Teensy/GEOS/GeosDiskPatch.h create mode 100644 Source/Teensy/tests/geos-disk-patch.test.js create mode 100644 Source/Teensy/tests/geos_disk_patch_harness.c diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h new file mode 100644 index 00000000..471c43ea --- /dev/null +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: MIT +// Puts the TR USB mouse driver on a GEOS 128 boot disk image, block by block. +// Mirrors tools/geos/trmouse.py install(), which the host test holds it to byte for byte. +#pragma once +#include +#include +#include +#include + +#include "TRMouse128.h" + +#define GEOS_SECTOR_SIZE 256 +#define GEOS_DIR_ENTRY_SIZE 32 +#define GEOS_ENTRIES_PER_DIR (GEOS_SECTOR_SIZE / GEOS_DIR_ENTRY_SIZE) +#define GEOS_NAME_SIZE 16 +#define GEOS_MAX_DIR_SECTORS 40 +#define GEOS_ERROR_SIZE 80 + +#define CBM_NAME_PAD 0xa0 +#define CBM_TYPE_MASK 0x07 +#define CBM_USR_CLOSED 0x83 +#define GEOS_SEQUENTIAL 0 +#define GEOS_INPUT_128 15 +#define D71_DOUBLE_SIDED 0x80 + +// Byte offsets inside a 32-byte directory slot (the first two hold the sector link) +enum GeosSlot +{ + SlotType = 2, + SlotFirst = 3, + SlotName = 5, + SlotInfo = 21, + SlotStructure = 23, + SlotGeosType = 24, + SlotDate = 25, + SlotBlocks = 30, +}; + +struct GeosPlace { uint8_t Track, Sector, Byte; }; +struct GeosTS { uint8_t Track, Sector; }; + +struct GeosFormat +{ + const char *Name; + uint8_t Tracks, DirTrack, FirstDirSector, LastDirSector; + uint8_t Reserved[2]; + uint32_t Size; + bool FixedSectors; // D81: 40 per track; else the 1541's zones, repeated on a 1571's second side +}; + +struct GeosDate { uint8_t Year, Month, Day, Hour, Minute; }; + +// A boot disk image reached through 256-byte block reads and writes at byte offsets. +struct GeosDisk +{ + const struct GeosFormat *Format; + void *Context; + bool (*Read)(void *Context, uint32_t Offset, uint8_t *Block); + bool (*Write)(void *Context, uint32_t Offset, const uint8_t *Block); + char Error[GEOS_ERROR_SIZE]; +}; + +static const struct GeosFormat GeosFormats[] = +{ + { "D64", 35, 18, 1, 18, { 18, 0 }, 174848, false }, + { "D71", 70, 18, 1, 18, { 18, 53 }, 349696, false }, + { "D81", 80, 40, 3, 39, { 40, 0 }, 819200, true }, +}; + +static bool GeosFail(struct GeosDisk *Disk, const char *Message) +{ + snprintf(Disk->Error, sizeof Disk->Error, "%s", Message); + return false; +} + +static uint8_t GeosSectorsOnTrack(const struct GeosFormat *Format, uint8_t Track) +{ + uint8_t ZoneTrack = (Track - 1) % 35 + 1; + + if (Format->FixedSectors) return 40; + return ZoneTrack <= 17 ? 21 : ZoneTrack <= 24 ? 19 : ZoneTrack <= 30 ? 18 : 17; +} + +static uint32_t GeosOffset(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) +{ + uint32_t Blocks = Sector; + + for (uint8_t t = 1; t < Track; t++) Blocks += GeosSectorsOnTrack(Format, t); + return Blocks * GEOS_SECTOR_SIZE; +} + +static bool GeosReadTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, uint8_t *Block) +{ + if (Disk->Read(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; + return GeosFail(Disk, "the image could not be read"); +} + +static bool GeosWriteTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, const uint8_t *Block) +{ + if (Disk->Write(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; + return GeosFail(Disk, "the image could not be written"); +} + +static bool GeosIsReserved(const struct GeosFormat *Format, uint8_t Track) +{ + return Track == Format->Reserved[0] || Track == Format->Reserved[1]; +} + +static bool GeosIsDirSector(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) +{ + return Track == Format->DirTrack && Sector >= Format->FirstDirSector && Sector <= Format->LastDirSector; +} + +// Where a track's free count and free-sector bitmap live +static void GeosBamPlaces(const struct GeosFormat *Format, uint8_t Track, struct GeosPlace *Count, struct GeosPlace *Bitmap) +{ + if (Format->FixedSectors) + { + uint8_t Sector = 1 + (Track - 1) / 40, Entry = 0x10 + (Track - 1) % 40 * 6; + *Count = (struct GeosPlace){ 40, Sector, Entry }; + *Bitmap = (struct GeosPlace){ 40, Sector, (uint8_t)(Entry + 1) }; + } + else if (Track <= 35) + { + *Count = (struct GeosPlace){ 18, 0, (uint8_t)(4 + (Track - 1) * 4) }; + *Bitmap = (struct GeosPlace){ 18, 0, (uint8_t)(5 + (Track - 1) * 4) }; + } + else + { + *Count = (struct GeosPlace){ 18, 0, (uint8_t)(0xdd + Track - 36) }; + *Bitmap = (struct GeosPlace){ 53, 0, (uint8_t)((Track - 36) * 3) }; + } +} + +static bool GeosSectorIsFree(const uint8_t *Bitmap, uint8_t Sector) +{ + return Bitmap[Sector / 8] & (1 << (Sector % 8)); +} + +static uint8_t GeosCountFree(const struct GeosFormat *Format, uint8_t Track, const uint8_t *Bitmap, int *FirstFree) +{ + uint8_t Free = 0; + + *FirstFree = -1; + for (uint8_t s = 0; s < GeosSectorsOnTrack(Format, Track); s++) + { + if (!GeosSectorIsFree(Bitmap, s)) continue; + if (*FirstFree < 0) *FirstFree = s; + Free++; + } + return Free; +} + +// Marks the first free sector of a track in use; *Sector is -1 when the track has none. +static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) +{ + struct GeosPlace Count, Bitmap; + uint8_t CountBlock[GEOS_SECTOR_SIZE], BitmapBlock[GEOS_SECTOR_SIZE]; + bool Shared; + + GeosBamPlaces(Disk->Format, Track, &Count, &Bitmap); + Shared = Count.Track == Bitmap.Track && Count.Sector == Bitmap.Sector; + if (!GeosReadTS(Disk, Count.Track, Count.Sector, CountBlock)) return false; + if (!Shared && !GeosReadTS(Disk, Bitmap.Track, Bitmap.Sector, BitmapBlock)) return false; + uint8_t *Bits = (Shared ? CountBlock : BitmapBlock) + Bitmap.Byte; + + uint8_t Free = GeosCountFree(Disk->Format, Track, Bits, Sector); + if (CountBlock[Count.Byte] < Free) + { + snprintf(Disk->Error, sizeof Disk->Error, "the BAM count for track %u disagrees with its bitmap", Track); + return false; + } + if (*Sector < 0) return true; + + Bits[*Sector / 8] &= ~(1 << (*Sector % 8)); + CountBlock[Count.Byte]--; + if (!Shared && !GeosWriteTS(Disk, Bitmap.Track, Bitmap.Sector, BitmapBlock)) return false; + return GeosWriteTS(Disk, Count.Track, Count.Sector, CountBlock); +} + +static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) +{ + for (uint8_t Track = 1; Track <= Disk->Format->Tracks; Track++) + { + int Sector; + if (GeosIsReserved(Disk->Format, Track)) continue; + if (!GeosTake(Disk, Track, &Sector)) return false; + if (Sector < 0) continue; + *Where = (struct GeosTS){ Track, (uint8_t)Sector }; + return true; + } + return GeosFail(Disk, "the disk is full"); +} + +static bool GeosChainHas(const struct GeosTS *Chain, int Length, uint8_t Track, uint8_t Sector) +{ + for (int i = 0; i < Length; i++) + { + if (Chain[i].Track == Track && Chain[i].Sector == Sector) return true; + } + return false; +} + +static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +{ + uint8_t Block[GEOS_SECTOR_SIZE]; + uint8_t Track, Sector; + + *Length = 0; + if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Block)) return false; + Track = Block[0]; + Sector = Block[1]; + if (!Track) return GeosFail(Disk, "the disk header links to no directory"); + + while (Track) + { + if (GeosChainHas(Chain, *Length, Track, Sector) || !GeosIsDirSector(Disk->Format, Track, Sector) || + *Length == GEOS_MAX_DIR_SECTORS) + { + snprintf(Disk->Error, sizeof Disk->Error, "the directory chain is broken at %u/%u", Track, Sector); + return false; + } + Chain[(*Length)++] = (struct GeosTS){ Track, Sector }; + if (!GeosReadTS(Disk, Track, Sector, Block)) return false; + Track = Block[0]; + Sector = Block[1]; + } + return true; +} + +// Chains one more directory sector, as DOS does when a directory fills. +static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +{ + const struct GeosFormat *Format = Disk->Format; + struct GeosPlace Count, Bitmap; + uint8_t Block[GEOS_SECTOR_SIZE]; + int New; + + GeosBamPlaces(Format, Format->DirTrack, &Count, &Bitmap); + if (!GeosReadTS(Disk, Bitmap.Track, Bitmap.Sector, Block)) return false; + for (int i = 0; i < *Length; i++) + { + if (!GeosSectorIsFree(Block + Bitmap.Byte, Chain[i].Sector)) continue; + snprintf(Disk->Error, sizeof Disk->Error, "the BAM shows directory sector %u/%u free", Chain[i].Track, Chain[i].Sector); + return false; + } + + if (!GeosTake(Disk, Format->DirTrack, &New)) return false; + if (New < 0 || !GeosIsDirSector(Format, Format->DirTrack, New) || *Length == GEOS_MAX_DIR_SECTORS) + { + return GeosFail(Disk, "the directory is full"); + } + + struct GeosTS Last = Chain[*Length - 1]; + if (!GeosReadTS(Disk, Last.Track, Last.Sector, Block)) return false; + Block[0] = Format->DirTrack; + Block[1] = New; + if (!GeosWriteTS(Disk, Last.Track, Last.Sector, Block)) return false; + + memset(Block, 0, sizeof Block); + Block[1] = 0xff; + Chain[(*Length)++] = (struct GeosTS){ Format->DirTrack, (uint8_t)New }; + return GeosWriteTS(Disk, Format->DirTrack, New, Block); +} + +// Writes the driver's data chain and info block; returns the first data sector, the info sector and the block count. +static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) +{ + const uint16_t DataPerBlock = GEOS_SECTOR_SIZE - 2; + uint16_t DataBlocks = (sizeof TRMouse128Code + DataPerBlock - 1) / DataPerBlock; + struct GeosTS Here, Next; + uint8_t Block[GEOS_SECTOR_SIZE]; + + if (!GeosAllocate(Disk, &Here)) return false; + *First = Here; + for (uint16_t i = 0; i < DataBlocks; i++) + { + uint16_t Start = i * DataPerBlock, Length = sizeof TRMouse128Code - Start; + if (Length > DataPerBlock) Length = DataPerBlock; + bool Last = i + 1 == DataBlocks; + + if (!Last && !GeosAllocate(Disk, &Next)) return false; + memset(Block, 0, sizeof Block); + Block[0] = Last ? 0 : Next.Track; + Block[1] = Last ? Length + 1 : Next.Sector; + memcpy(Block + 2, TRMouse128Code + Start, Length); + if (!GeosWriteTS(Disk, Here.Track, Here.Sector, Block)) return false; + Here = Next; + } + + if (!GeosAllocate(Disk, Info)) return false; + *Blocks = DataBlocks + 1; + return GeosWriteTS(Disk, Info->Track, Info->Sector, TRMouse128Info); +} + +static void GeosBuildEntry(uint8_t *Slot, struct GeosTS First, struct GeosTS Info, uint16_t Blocks, const struct GeosDate *Date) +{ + memset(Slot + SlotType, 0, GEOS_DIR_ENTRY_SIZE - SlotType); + Slot[SlotType] = CBM_USR_CLOSED; + Slot[SlotFirst] = First.Track; + Slot[SlotFirst + 1] = First.Sector; + memset(Slot + SlotName, CBM_NAME_PAD, GEOS_NAME_SIZE); + memcpy(Slot + SlotName, TRMouse128Name, strlen(TRMouse128Name)); + Slot[SlotInfo] = Info.Track; + Slot[SlotInfo + 1] = Info.Sector; + Slot[SlotStructure] = GEOS_SEQUENTIAL; + Slot[SlotGeosType] = GEOS_INPUT_128; + Slot[SlotDate] = Date->Year % 100; + Slot[SlotDate + 1] = Date->Month; + Slot[SlotDate + 2] = Date->Day; + Slot[SlotDate + 3] = Date->Hour; + Slot[SlotDate + 4] = Date->Minute; + Slot[SlotBlocks] = Blocks & 0xff; + Slot[SlotBlocks + 1] = Blocks >> 8; +} + +static bool GeosSlotNamed(const uint8_t *Slot, const char *Name) +{ + size_t Length = strlen(Name); + + if (memcmp(Slot + SlotName, Name, Length)) return false; + for (size_t i = Length; i < GEOS_NAME_SIZE; i++) + { + if (Slot[SlotName + i] != CBM_NAME_PAD) return false; + } + return true; +} + +// A slot's place in directory order, as an index over the chain's sectors and their slots +struct GeosSlotRef { int Sector, Slot; }; + +struct GeosDirScan +{ + struct GeosSlotRef Free, FirstDriver; + bool HasFree, HasDriver, HasMouse; +}; + +static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length, struct GeosDirScan *Scan) +{ + uint8_t Block[GEOS_SECTOR_SIZE]; + + memset(Scan, 0, sizeof *Scan); + for (int i = 0; i < Length; i++) + { + if (!GeosReadTS(Disk, Chain[i].Track, Chain[i].Sector, Block)) return false; + for (int e = 0; e < GEOS_ENTRIES_PER_DIR; e++) + { + const uint8_t *Slot = Block + e * GEOS_DIR_ENTRY_SIZE; + struct GeosSlotRef Here = { i, e }; + bool InUse = Slot[SlotType] & CBM_TYPE_MASK; + + if (!Slot[SlotType] && !Scan->HasFree) { Scan->Free = Here; Scan->HasFree = true; } + if (!InUse) continue; + if (GeosSlotNamed(Slot, TRMouse128Name)) Scan->HasMouse = true; + if (Slot[SlotGeosType] == GEOS_INPUT_128 && !Scan->HasDriver) { Scan->FirstDriver = Here; Scan->HasDriver = true; } + } + } + return true; +} + +static bool GeosSlotBefore(struct GeosSlotRef a, struct GeosSlotRef b) +{ + return a.Sector < b.Sector || (a.Sector == b.Sector && a.Slot < b.Slot); +} + +static bool GeosSwapEntries(struct GeosDisk *Disk, const struct GeosTS *Chain, struct GeosSlotRef a, struct GeosSlotRef b) +{ + uint8_t BlockA[GEOS_SECTOR_SIZE], BlockB[GEOS_SECTOR_SIZE], Saved[GEOS_DIR_ENTRY_SIZE]; + bool Shared = a.Sector == b.Sector; + uint8_t *SlotA = BlockA + a.Slot * GEOS_DIR_ENTRY_SIZE + SlotType; + uint8_t *SlotB = (Shared ? BlockA : BlockB) + b.Slot * GEOS_DIR_ENTRY_SIZE + SlotType; + const size_t EntryBytes = GEOS_DIR_ENTRY_SIZE - SlotType; + + if (!GeosReadTS(Disk, Chain[a.Sector].Track, Chain[a.Sector].Sector, BlockA)) return false; + if (!Shared && !GeosReadTS(Disk, Chain[b.Sector].Track, Chain[b.Sector].Sector, BlockB)) return false; + memcpy(Saved, SlotA, EntryBytes); + memcpy(SlotA, SlotB, EntryBytes); + memcpy(SlotB, Saved, EntryBytes); + if (!Shared && !GeosWriteTS(Disk, Chain[b.Sector].Track, Chain[b.Sector].Sector, BlockB)) return false; + return GeosWriteTS(Disk, Chain[a.Sector].Track, Chain[a.Sector].Sector, BlockA); +} + +static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, const struct GeosDate *Date) +{ + struct GeosTS First, Info; + uint16_t Blocks; + uint8_t Block[GEOS_SECTOR_SIZE]; + + if (!GeosWriteDriver(Disk, &First, &Info, &Blocks)) return false; + if (!GeosReadTS(Disk, Where.Track, Where.Sector, Block)) return false; + GeosBuildEntry(Block + Slot * GEOS_DIR_ENTRY_SIZE, First, Info, Blocks, Date); + return GeosWriteTS(Disk, Where.Track, Where.Sector, Block); +} + +const struct GeosFormat *GeosFormatForSize(uint32_t Size) +{ + for (size_t i = 0; i < sizeof GeosFormats / sizeof GeosFormats[0]; i++) + { + if (GeosFormats[i].Size == Size) return &GeosFormats[i]; + } + return NULL; +} + +// Checks the image and returns whether it already holds the driver. +bool GeosHasMouseDriver(struct GeosDisk *Disk, bool *HasMouse) +{ + struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; + struct GeosDirScan Scan; + uint8_t Header[GEOS_SECTOR_SIZE]; + int Length; + + if (!Disk->Format) return GeosFail(Disk, "not a D64, D71 or D81 without error bytes"); + if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Header)) return false; + if (Disk->Format->Size == 349696 && !(Header[3] & D71_DOUBLE_SIDED)) + { + return GeosFail(Disk, "this D71 is formatted single-sided"); + } + if (!GeosDirChain(Disk, Chain, &Length)) return false; + if (!GeosScanDirectory(Disk, Chain, Length, &Scan)) return false; + *HasMouse = Scan.HasMouse; + return true; +} + +// Writes the driver ahead of every other input driver, since GEOS 128 loads the first one in the directory. +bool GeosInstallMouseDriver(struct GeosDisk *Disk, const struct GeosDate *Date) +{ + struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; + struct GeosDirScan Scan; + bool HasMouse; + int Length; + + if (!GeosHasMouseDriver(Disk, &HasMouse)) return false; + if (HasMouse) return GeosFail(Disk, "TR USB MOUSE is already on this disk"); + if (!GeosDirChain(Disk, Chain, &Length)) return false; + if (!GeosScanDirectory(Disk, Chain, Length, &Scan)) return false; + if (!Scan.HasFree) + { + if (!GeosGrowDirectory(Disk, Chain, &Length)) return false; + Scan.Free = (struct GeosSlotRef){ Length - 1, 0 }; + } + + if (!GeosWriteEntry(Disk, Chain[Scan.Free.Sector], Scan.Free.Slot, Date)) return false; + if (!Scan.HasDriver || !GeosSlotBefore(Scan.FirstDriver, Scan.Free)) return true; + return GeosSwapEntries(Disk, Chain, Scan.FirstDriver, Scan.Free); +} diff --git a/Source/Teensy/GEOS/TRMouse128.h b/Source/Teensy/GEOS/TRMouse128.h index 75f2bc5b..1658658c 100644 --- a/Source/Teensy/GEOS/TRMouse128.h +++ b/Source/Teensy/GEOS/TRMouse128.h @@ -2,7 +2,11 @@ // Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit. #pragma once #include +#ifdef ARDUINO #include +#else +#define PROGMEM +#endif static const char TRMouse128Name[] PROGMEM = "TR USB MOUSE"; static const uint8_t TRMouse128Code[226] PROGMEM = { diff --git a/Source/Teensy/tests/geos-disk-patch.test.js b/Source/Teensy/tests/geos-disk-patch.test.js new file mode 100644 index 00000000..f1a79222 --- /dev/null +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +// The firmware's GEOS boot-disk patch, held byte for byte to tools/geos/trmouse.py on synthetic boot disks. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { commandExists } from '../../../tools/lib/toolchain.mjs'; + +const FIRMWARE_ROOT = path.resolve(import.meta.dirname, '..'); +const TRMOUSE = path.resolve(FIRMWARE_ROOT, '../../tools/geos/trmouse.py'); +const HARNESS_SOURCE = path.join(FIRMWARE_ROOT, 'tests/geos_disk_patch_harness.c'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const missingTool = ['acme', 'python3', 'cc', 'c++'].find((tool) => !commandExists(tool)); +const skip = missingTool && !inCi && `${missingTool} is not on PATH`; + +const SECTOR = 256; +const PAD = 0xa0; +const USR_CLOSED = 0x83; +const INPUT_128 = 15; +const MOUSE_NAME = 'TR USB MOUSE'; +const DATE_IN_SLOT = 25; +const DATE_BYTES = 5; + +const zoneSectors = (track) => { + const zone = (track - 1) % 35 + 1; + return zone <= 17 ? 21 : zone <= 24 ? 19 : zone <= 30 ? 18 : 17; +}; + +const FORMATS = { + d64: { tracks: 35, sectors: zoneSectors, dirTrack: 18, firstDir: 1 }, + d71: { tracks: 70, sectors: zoneSectors, dirTrack: 18, firstDir: 1 }, + d81: { tracks: 80, sectors: () => 40, dirTrack: 40, firstDir: 3 }, +}; + +function offset(format, track, sector) { + let blocks = sector; + for (let t = 1; t < track; t++) blocks += format.sectors(t); + return blocks * SECTOR; +} + +function imageSize(format) { + return offset(format, format.tracks + 1, 0); +} + +function bamPlaces(kind, track) { + if (kind === 'd81') { + const entry = 0x10 + ((track - 1) % 40) * 6; + const sector = 1 + Math.floor((track - 1) / 40); + return { count: [40, sector, entry], bitmap: [40, sector, entry + 1] }; + } + if (track <= 35) return { count: [18, 0, 4 + (track - 1) * 4], bitmap: [18, 0, 5 + (track - 1) * 4] }; + return { count: [18, 0, 0xdd + track - 36], bitmap: [53, 0, (track - 36) * 3] }; +} + +function at(disk, [track, sector, byte]) { + return offset(disk.format, track, sector) + byte; +} + +function setTrackFree(disk, track, freeSectors) { + const { count, bitmap } = bamPlaces(disk.kind, track); + const bits = at(disk, bitmap); + for (let s = 0; s < disk.format.sectors(track); s++) { + const mask = 1 << (s % 8); + if (freeSectors.includes(s)) disk.image[bits + (s >> 3)] |= mask; + else disk.image[bits + (s >> 3)] &= ~mask; + } + disk.image[at(disk, count)] = freeSectors.length; +} + +function allSectors(disk, track) { + return Array.from({ length: disk.format.sectors(track) }, (_, s) => s); +} + +function writeHeader(disk) { + const { image, format, kind } = disk; + const header = offset(format, format.dirTrack, 0); + image.set([format.dirTrack, format.firstDir], header); + if (kind === 'd81') { + image.set([40, 2, 0x44, 0xbb], offset(format, 40, 1)); + image.set([0, 0xff, 0x44, 0xbb], offset(format, 40, 2)); + } + if (kind === 'd71') image[header + 3] = 0x80; + image[offset(format, format.dirTrack, format.firstDir) + 1] = 0xff; +} + +function blankDisk(kind) { + const format = FORMATS[kind]; + const disk = { kind, format, image: new Uint8Array(imageSize(format)) }; + writeHeader(disk); + for (let track = 1; track <= format.tracks; track++) setTrackFree(disk, track, allSectors(disk, track)); + const systemSectors = kind === 'd81' ? [0, 1, 2, 3] : [0, 1]; + setTrackFree(disk, format.dirTrack, allSectors(disk, format.dirTrack).filter((s) => !systemSectors.includes(s))); + if (kind === 'd71') setTrackFree(disk, 53, []); + return disk; +} + +function putEntry(disk, slot, { name, type = USR_CLOSED, geosType = 0 }) { + const base = offset(disk.format, disk.format.dirTrack, disk.format.firstDir) + slot * 32; + disk.image.fill(PAD, base + 5, base + 21); + disk.image.set(Buffer.from(name, 'latin1'), base + 5); + disk.image[base + 2] = type; + disk.image[base + 24] = geosType; +} + +function withDriver(kind) { + const disk = blankDisk(kind); + putEntry(disk, 0, { name: '128 JOYSTICK', geosType: INPUT_128 }); + putEntry(disk, 1, { name: '128 DESKTOP', geosType: 6 }); + return disk; +} + +function fullDirectory(kind) { + const disk = blankDisk(kind); + for (let slot = 0; slot < 8; slot++) { + putEntry(disk, slot, slot === 3 ? { name: '128 JOYSTICK', geosType: INPUT_128 } : { name: `FILE ${slot}` }); + } + return disk; +} + +const PATCHED_CASES = { + 'a blank boot disk': blankDisk, + 'a disk whose first input driver comes earlier': withDriver, + 'a full first directory sector': fullDirectory, +}; + +const REFUSED_CASES = { + 'a disk that already has the driver': (kind) => { + const disk = withDriver(kind); + putEntry(disk, 2, { name: MOUSE_NAME, geosType: INPUT_128 }); + return disk; + }, + 'a BAM count below its bitmap': (kind) => { + const disk = blankDisk(kind); + disk.image[at(disk, bamPlaces(kind, 1).count)] -= 1; + return disk; + }, + 'a header linking outside the directory': (kind) => { + const disk = blankDisk(kind); + disk.image[offset(disk.format, disk.format.dirTrack, 0)] = 1; + return disk; + }, + 'a full directory whose BAM frees its sector': (kind) => { + const disk = fullDirectory(kind); + const free = allSectors(disk, disk.format.dirTrack).filter((s) => s >= disk.format.firstDir); + setTrackFree(disk, disk.format.dirTrack, free); + return disk; + }, +}; + +function maskMouseDate(image) { + const name = Buffer.concat([Buffer.from(MOUSE_NAME, 'latin1'), Buffer.alloc(16 - MOUSE_NAME.length, PAD)]); + const found = Buffer.from(image).indexOf(name); + assert.notEqual(found, -1, 'the patched image has no TR USB MOUSE entry'); + const date = found - 5 + DATE_IN_SLOT; + const masked = Buffer.from(image); + masked.fill(0, date, date + DATE_BYTES); + return masked; +} + +function run(command, args) { + return spawnSync(command, args, { encoding: 'utf8' }); +} + +function patchBoth(dir, label, image) { + const input = path.join(dir, `${label}.in`); + const viaPython = path.join(dir, `${label}.py.out`); + const viaFirmware = path.join(dir, `${label}.c.out`); + fs.writeFileSync(input, image); + return { + python: { ...run('python3', [TRMOUSE, input, viaPython]), out: viaPython }, + firmware: { ...run(path.join(dir, 'harness'), [input, viaFirmware]), out: viaFirmware }, + }; +} + +test('the firmware patch matches trmouse.py', { skip }, async (t) => { + assert.equal(missingTool, undefined, 'CI installs every tool this test needs, so it must not skip there'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geos-disk-patch-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const built = run('cc', ['-std=c99', '-Wall', '-Wextra', '-Werror', '-o', path.join(dir, 'harness'), HARNESS_SOURCE]); + assert.equal(built.status, 0, built.stderr); + const asCpp = run('c++', ['-x', 'c++', '-std=gnu++17', '-Wall', '-Werror', '-fsyntax-only', HARNESS_SOURCE]); + assert.equal(asCpp.status, 0, `the firmware compiles this as C++: ${asCpp.stderr}`); + + for (const kind of Object.keys(FORMATS)) { + for (const [label, make] of Object.entries(PATCHED_CASES)) { + await t.test(`${kind}: ${label}`, () => { + const { python, firmware } = patchBoth(dir, `${kind}-${label}`, make(kind).image); + assert.equal(python.status, 0, python.stderr); + assert.equal(firmware.status, 0, firmware.stderr); + assert.deepEqual(maskMouseDate(fs.readFileSync(firmware.out)), maskMouseDate(fs.readFileSync(python.out))); + }); + } + for (const [label, make] of Object.entries(REFUSED_CASES)) { + await t.test(`${kind}: refuses ${label}`, () => { + const { python, firmware } = patchBoth(dir, `${kind}-${label}`, make(kind).image); + assert.equal(firmware.status, 1, `the firmware patched it: ${firmware.stderr}`); + assert.notEqual(python.status, 0, 'trmouse.py patched it'); + assert.equal(firmware.stderr.trim(), python.stderr.trim()); + }); + } + } + + await t.test('d71: refuses a single-sided disk', () => { + const disk = blankDisk('d71'); + disk.image[offset(disk.format, 18, 0) + 3] = 0; + const { python, firmware } = patchBoth(dir, 'd71-single', disk.image); + assert.equal(firmware.status, 1); + assert.equal(firmware.stderr.trim(), python.stderr.trim()); + }); +}); diff --git a/Source/Teensy/tests/geos_disk_patch_harness.c b/Source/Teensy/tests/geos_disk_patch_harness.c new file mode 100644 index 00000000..a6d04303 --- /dev/null +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// geos_disk_patch_harness IN OUT: the firmware's GEOS boot-disk patch, run on an image file. +// Exits 0 after writing OUT, 1 with the patcher's refusal on stderr. +#include + +#include "../GEOS/GeosDiskPatch.h" + +#define MAX_IMAGE 819200 + +static uint8_t Image[MAX_IMAGE]; + +static bool ReadBlock(void *Context, uint32_t Offset, uint8_t *Block) +{ + memcpy(Block, (uint8_t *)Context + Offset, GEOS_SECTOR_SIZE); + return true; +} + +static bool WriteBlock(void *Context, uint32_t Offset, const uint8_t *Block) +{ + memcpy((uint8_t *)Context + Offset, Block, GEOS_SECTOR_SIZE); + return true; +} + +int main(int argc, char **argv) +{ + const struct GeosDate Date = { 26, 10, 16, 12, 0 }; + struct GeosDisk Disk = { 0 }; + FILE *In, *Out; + size_t Size; + + if (argc != 3) return 2; + In = fopen(argv[1], "rb"); + if (!In) return 2; + Size = fread(Image, 1, sizeof Image, In); + fclose(In); + + Disk.Format = GeosFormatForSize(Size); + Disk.Context = Image; + Disk.Read = ReadBlock; + Disk.Write = WriteBlock; + if (!GeosInstallMouseDriver(&Disk, &Date)) + { + fprintf(stderr, "%s\n", Disk.Error); + return 1; + } + + Out = fopen(argv[2], "wb"); + if (!Out || fwrite(Image, 1, Size, Out) != Size) return 2; + fclose(Out); + return 0; +} diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py index e5b4d890..178d51d0 100644 --- a/tools/geos/trmouse.py +++ b/tools/geos/trmouse.py @@ -282,7 +282,11 @@ def c_header(code): '// Generated by tools/geos/trmouse.py --c-header from tools/geos/trmouse128.asm; do not edit.\n' '#pragma once\n' '#include \n' - '#include \n\n' + '#ifdef ARDUINO\n' + '#include \n' + '#else\n' + '#define PROGMEM\n' + '#endif\n\n' f'static const char TRMouse128Name[] PROGMEM = "{NAME.decode()}";\n' + c_array('TRMouse128Code', code) + c_array('TRMouse128Info', info_block(len(code)))) From 4be1f11ad9b38090b42302421494e71813aca559 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:19:37 -0500 Subject: [PATCH 26/51] gitattributes: pin LF only on the generated GEOS mouse driver header, not the hand-written patch header --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index f884fa2b..84883f63 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,4 @@ Source/Teensy/TRMenuFiles/ROMs/*.h text eol=lf # Generated by tools/geos/trmouse.py --c-header with LF endings, kept LF for the same reason -Source/Teensy/GEOS/*.h text eol=lf +Source/Teensy/GEOS/TRMouse128.h text eol=lf From 5825d1644118a39202126e6dc9db377b3227a375 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:25:41 -0500 Subject: [PATCH 27/51] firmware: the GEOS disk patch refuses a full disk or directory before 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. --- Source/Teensy/GEOS/GeosDiskPatch.h | 161 +++++++++++------- Source/Teensy/tests/geos-disk-patch.test.js | 13 ++ Source/Teensy/tests/geos_disk_patch_harness.c | 8 +- 3 files changed, 122 insertions(+), 60 deletions(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 471c43ea..e3c29292 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -15,6 +15,9 @@ #define GEOS_NAME_SIZE 16 #define GEOS_MAX_DIR_SECTORS 40 #define GEOS_ERROR_SIZE 80 +#define GEOS_DATA_PER_BLOCK (GEOS_SECTOR_SIZE - 2) +#define GEOS_DRIVER_DATA_BLOCKS ((sizeof TRMouse128Code + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK) +#define GEOS_DRIVER_BLOCKS (GEOS_DRIVER_DATA_BLOCKS + 1) #define CBM_NAME_PAD 0xa0 #define CBM_TYPE_MASK 0x07 @@ -46,6 +49,7 @@ struct GeosFormat uint8_t Reserved[2]; uint32_t Size; bool FixedSectors; // D81: 40 per track; else the 1541's zones, repeated on a 1571's second side + bool TwoSided; }; struct GeosDate { uint8_t Year, Month, Day, Hour, Minute; }; @@ -62,9 +66,9 @@ struct GeosDisk static const struct GeosFormat GeosFormats[] = { - { "D64", 35, 18, 1, 18, { 18, 0 }, 174848, false }, - { "D71", 70, 18, 1, 18, { 18, 53 }, 349696, false }, - { "D81", 80, 40, 3, 39, { 40, 0 }, 819200, true }, + { "D64", 35, 18, 1, 18, { 18, 0 }, 174848, false, false }, + { "D71", 70, 18, 1, 18, { 18, 53 }, 349696, false, true }, + { "D81", 80, 40, 3, 39, { 40, 0 }, 819200, true, false }, }; static bool GeosFail(struct GeosDisk *Disk, const char *Message) @@ -151,31 +155,66 @@ static uint8_t GeosCountFree(const struct GeosFormat *Format, uint8_t Track, con return Free; } +// A track's BAM blocks as read; Count and Bits point into them. +struct GeosTrackBam +{ + struct GeosPlace CountAt, BitmapAt; + bool Shared; + uint8_t CountBlock[GEOS_SECTOR_SIZE], BitmapBlock[GEOS_SECTOR_SIZE]; + uint8_t *Count, *Bits, Free; + int FirstFree; +}; + +static bool GeosReadTrackBam(struct GeosDisk *Disk, uint8_t Track, struct GeosTrackBam *Bam) +{ + GeosBamPlaces(Disk->Format, Track, &Bam->CountAt, &Bam->BitmapAt); + Bam->Shared = Bam->CountAt.Track == Bam->BitmapAt.Track && Bam->CountAt.Sector == Bam->BitmapAt.Sector; + if (!GeosReadTS(Disk, Bam->CountAt.Track, Bam->CountAt.Sector, Bam->CountBlock)) return false; + if (!Bam->Shared && !GeosReadTS(Disk, Bam->BitmapAt.Track, Bam->BitmapAt.Sector, Bam->BitmapBlock)) return false; + Bam->Count = Bam->CountBlock + Bam->CountAt.Byte; + Bam->Bits = (Bam->Shared ? Bam->CountBlock : Bam->BitmapBlock) + Bam->BitmapAt.Byte; + + Bam->Free = GeosCountFree(Disk->Format, Track, Bam->Bits, &Bam->FirstFree); + return true; +} + +static bool GeosCheckCount(struct GeosDisk *Disk, uint8_t Track, const struct GeosTrackBam *Bam) +{ + if (*Bam->Count >= Bam->Free) return true; + snprintf(Disk->Error, sizeof Disk->Error, "the BAM count for track %u disagrees with its bitmap", Track); + return false; +} + // Marks the first free sector of a track in use; *Sector is -1 when the track has none. static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) { - struct GeosPlace Count, Bitmap; - uint8_t CountBlock[GEOS_SECTOR_SIZE], BitmapBlock[GEOS_SECTOR_SIZE]; - bool Shared; + struct GeosTrackBam Bam; + + if (!GeosReadTrackBam(Disk, Track, &Bam) || !GeosCheckCount(Disk, Track, &Bam)) return false; + *Sector = Bam.FirstFree; + if (*Sector < 0) return true; - GeosBamPlaces(Disk->Format, Track, &Count, &Bitmap); - Shared = Count.Track == Bitmap.Track && Count.Sector == Bitmap.Sector; - if (!GeosReadTS(Disk, Count.Track, Count.Sector, CountBlock)) return false; - if (!Shared && !GeosReadTS(Disk, Bitmap.Track, Bitmap.Sector, BitmapBlock)) return false; - uint8_t *Bits = (Shared ? CountBlock : BitmapBlock) + Bitmap.Byte; + Bam.Bits[*Sector / 8] &= ~(1 << (*Sector % 8)); + (*Bam.Count)--; + if (!Bam.Shared && !GeosWriteTS(Disk, Bam.BitmapAt.Track, Bam.BitmapAt.Sector, Bam.BitmapBlock)) return false; + return GeosWriteTS(Disk, Bam.CountAt.Track, Bam.CountAt.Sector, Bam.CountBlock); +} + +// Refuses, before anything is written, a disk that GeosAllocate would run out on partway. +// Reads the tracks GeosAllocate would visit, in its order, so the refusals match trmouse.py's. +static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) +{ + struct GeosTrackBam Bam; + unsigned Found = 0; - uint8_t Free = GeosCountFree(Disk->Format, Track, Bits, Sector); - if (CountBlock[Count.Byte] < Free) + for (uint8_t Track = 1; Track <= Disk->Format->Tracks; Track++) { - snprintf(Disk->Error, sizeof Disk->Error, "the BAM count for track %u disagrees with its bitmap", Track); - return false; + if (GeosIsReserved(Disk->Format, Track)) continue; + if (!GeosReadTrackBam(Disk, Track, &Bam) || !GeosCheckCount(Disk, Track, &Bam)) return false; + Found += Bam.Free; + if (Found >= Needed) return true; } - if (*Sector < 0) return true; - - Bits[*Sector / 8] &= ~(1 << (*Sector % 8)); - CountBlock[Count.Byte]--; - if (!Shared && !GeosWriteTS(Disk, Bitmap.Track, Bitmap.Sector, BitmapBlock)) return false; - return GeosWriteTS(Disk, Count.Track, Count.Sector, CountBlock); + return GeosFail(Disk, "the disk is full"); } static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) @@ -228,28 +267,32 @@ static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, int *Lengt return true; } -// Chains one more directory sector, as DOS does when a directory fills. -static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +// Refuses, before anything is written, a directory that GeosGrowDirectory could not grow. +static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length) { const struct GeosFormat *Format = Disk->Format; - struct GeosPlace Count, Bitmap; - uint8_t Block[GEOS_SECTOR_SIZE]; - int New; + struct GeosTrackBam Bam; - GeosBamPlaces(Format, Format->DirTrack, &Count, &Bitmap); - if (!GeosReadTS(Disk, Bitmap.Track, Bitmap.Sector, Block)) return false; - for (int i = 0; i < *Length; i++) + if (!GeosReadTrackBam(Disk, Format->DirTrack, &Bam)) return false; + for (int i = 0; i < Length; i++) { - if (!GeosSectorIsFree(Block + Bitmap.Byte, Chain[i].Sector)) continue; + if (!GeosSectorIsFree(Bam.Bits, Chain[i].Sector)) continue; snprintf(Disk->Error, sizeof Disk->Error, "the BAM shows directory sector %u/%u free", Chain[i].Track, Chain[i].Sector); return false; } + if (!GeosCheckCount(Disk, Format->DirTrack, &Bam)) return false; + if (Bam.FirstFree >= 0 && GeosIsDirSector(Format, Format->DirTrack, Bam.FirstFree)) return true; + return GeosFail(Disk, "the directory is full"); +} + +// Chains one more directory sector, as DOS does when a directory fills, once GeosCheckDirectoryRoom has passed. +static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +{ + const struct GeosFormat *Format = Disk->Format; + uint8_t Block[GEOS_SECTOR_SIZE]; + int New; if (!GeosTake(Disk, Format->DirTrack, &New)) return false; - if (New < 0 || !GeosIsDirSector(Format, Format->DirTrack, New) || *Length == GEOS_MAX_DIR_SECTORS) - { - return GeosFail(Disk, "the directory is full"); - } struct GeosTS Last = Chain[*Length - 1]; if (!GeosReadTS(Disk, Last.Track, Last.Sector, Block)) return false; @@ -266,18 +309,16 @@ static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * // Writes the driver's data chain and info block; returns the first data sector, the info sector and the block count. static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) { - const uint16_t DataPerBlock = GEOS_SECTOR_SIZE - 2; - uint16_t DataBlocks = (sizeof TRMouse128Code + DataPerBlock - 1) / DataPerBlock; - struct GeosTS Here, Next; + struct GeosTS Here, Next = { 0, 0 }; uint8_t Block[GEOS_SECTOR_SIZE]; if (!GeosAllocate(Disk, &Here)) return false; *First = Here; - for (uint16_t i = 0; i < DataBlocks; i++) + for (uint16_t i = 0; i < GEOS_DRIVER_DATA_BLOCKS; i++) { - uint16_t Start = i * DataPerBlock, Length = sizeof TRMouse128Code - Start; - if (Length > DataPerBlock) Length = DataPerBlock; - bool Last = i + 1 == DataBlocks; + uint16_t Start = i * GEOS_DATA_PER_BLOCK, Length = sizeof TRMouse128Code - Start; + if (Length > GEOS_DATA_PER_BLOCK) Length = GEOS_DATA_PER_BLOCK; + bool Last = i + 1 == GEOS_DRIVER_DATA_BLOCKS; if (!Last && !GeosAllocate(Disk, &Next)) return false; memset(Block, 0, sizeof Block); @@ -289,7 +330,7 @@ static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct } if (!GeosAllocate(Disk, Info)) return false; - *Blocks = DataBlocks + 1; + *Blocks = GEOS_DRIVER_BLOCKS; return GeosWriteTS(Disk, Info->Track, Info->Sector, TRMouse128Info); } @@ -392,7 +433,7 @@ static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, return GeosWriteTS(Disk, Where.Track, Where.Sector, Block); } -const struct GeosFormat *GeosFormatForSize(uint32_t Size) +static inline const struct GeosFormat *GeosFormatForSize(uint32_t Size) { for (size_t i = 0; i < sizeof GeosFormats / sizeof GeosFormats[0]; i++) { @@ -401,38 +442,44 @@ const struct GeosFormat *GeosFormatForSize(uint32_t Size) return NULL; } -// Checks the image and returns whether it already holds the driver. -bool GeosHasMouseDriver(struct GeosDisk *Disk, bool *HasMouse) +static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length, struct GeosDirScan *Scan) { - struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; - struct GeosDirScan Scan; uint8_t Header[GEOS_SECTOR_SIZE]; - int Length; if (!Disk->Format) return GeosFail(Disk, "not a D64, D71 or D81 without error bytes"); if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Header)) return false; - if (Disk->Format->Size == 349696 && !(Header[3] & D71_DOUBLE_SIDED)) + if (Disk->Format->TwoSided && !(Header[3] & D71_DOUBLE_SIDED)) { return GeosFail(Disk, "this D71 is formatted single-sided"); } - if (!GeosDirChain(Disk, Chain, &Length)) return false; - if (!GeosScanDirectory(Disk, Chain, Length, &Scan)) return false; + if (!GeosDirChain(Disk, Chain, Length)) return false; + return GeosScanDirectory(Disk, Chain, *Length, Scan); +} + +// Checks the image and returns whether it already holds the driver. +static inline bool GeosHasMouseDriver(struct GeosDisk *Disk, bool *HasMouse) +{ + struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; + struct GeosDirScan Scan; + int Length; + + if (!GeosReadDirectory(Disk, Chain, &Length, &Scan)) return false; *HasMouse = Scan.HasMouse; return true; } // Writes the driver ahead of every other input driver, since GEOS 128 loads the first one in the directory. -bool GeosInstallMouseDriver(struct GeosDisk *Disk, const struct GeosDate *Date) +// Every refusal but a failed block read or write comes before the first write, leaving the image as it was. +static inline bool GeosInstallMouseDriver(struct GeosDisk *Disk, const struct GeosDate *Date) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; struct GeosDirScan Scan; - bool HasMouse; int Length; - if (!GeosHasMouseDriver(Disk, &HasMouse)) return false; - if (HasMouse) return GeosFail(Disk, "TR USB MOUSE is already on this disk"); - if (!GeosDirChain(Disk, Chain, &Length)) return false; - if (!GeosScanDirectory(Disk, Chain, Length, &Scan)) return false; + if (!GeosReadDirectory(Disk, Chain, &Length, &Scan)) return false; + if (Scan.HasMouse) return GeosFail(Disk, "TR USB MOUSE is already on this disk"); + if (!Scan.HasFree && !GeosCheckDirectoryRoom(Disk, Chain, Length)) return false; + if (!GeosCheckRoom(Disk, GEOS_DRIVER_BLOCKS)) return false; if (!Scan.HasFree) { if (!GeosGrowDirectory(Disk, Chain, &Length)) return false; diff --git a/Source/Teensy/tests/geos-disk-patch.test.js b/Source/Teensy/tests/geos-disk-patch.test.js index f1a79222..9148c6e3 100644 --- a/Source/Teensy/tests/geos-disk-patch.test.js +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -122,6 +122,14 @@ function fullDirectory(kind) { return disk; } +function withOneDataSector(disk) { + const reserved = disk.kind === 'd71' ? [18, 53] : [disk.format.dirTrack]; + for (let track = 1; track <= disk.format.tracks; track++) { + if (!reserved.includes(track)) setTrackFree(disk, track, track === 1 ? [0] : []); + } + return disk; +} + const PATCHED_CASES = { 'a blank boot disk': blankDisk, 'a disk whose first input driver comes earlier': withDriver, @@ -144,6 +152,9 @@ const REFUSED_CASES = { disk.image[offset(disk.format, disk.format.dirTrack, 0)] = 1; return disk; }, + 'a disk with room for the data block but not the info block': (kind) => withOneDataSector(blankDisk(kind)), + 'a full directory on a disk with room for the data block but not the info block': (kind) => + withOneDataSector(fullDirectory(kind)), 'a full directory whose BAM frees its sector': (kind) => { const disk = fullDirectory(kind); const free = allSectors(disk, disk.format.dirTrack).filter((s) => s >= disk.format.firstDir); @@ -199,6 +210,8 @@ test('the firmware patch matches trmouse.py', { skip }, async (t) => { for (const [label, make] of Object.entries(REFUSED_CASES)) { await t.test(`${kind}: refuses ${label}`, () => { const { python, firmware } = patchBoth(dir, `${kind}-${label}`, make(kind).image); + const REFUSED_AFTER_WRITING = 3; + assert.notEqual(firmware.status, REFUSED_AFTER_WRITING, `the refusal changed the image: ${firmware.stderr}`); assert.equal(firmware.status, 1, `the firmware patched it: ${firmware.stderr}`); assert.notEqual(python.status, 0, 'trmouse.py patched it'); assert.equal(firmware.stderr.trim(), python.stderr.trim()); diff --git a/Source/Teensy/tests/geos_disk_patch_harness.c b/Source/Teensy/tests/geos_disk_patch_harness.c index a6d04303..2b91c373 100644 --- a/Source/Teensy/tests/geos_disk_patch_harness.c +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -1,13 +1,14 @@ // SPDX-License-Identifier: MIT // geos_disk_patch_harness IN OUT: the firmware's GEOS boot-disk patch, run on an image file. -// Exits 0 after writing OUT, 1 with the patcher's refusal on stderr. +// Exits 0 after writing OUT, 1 with the patcher's refusal on stderr, 3 if a refusal changed the image. #include #include "../GEOS/GeosDiskPatch.h" #define MAX_IMAGE 819200 -static uint8_t Image[MAX_IMAGE]; +// One byte past the largest image, so an oversized file reads as a size no format has +static uint8_t Image[MAX_IMAGE + 1], Original[MAX_IMAGE + 1]; static bool ReadBlock(void *Context, uint32_t Offset, uint8_t *Block) { @@ -33,6 +34,7 @@ int main(int argc, char **argv) if (!In) return 2; Size = fread(Image, 1, sizeof Image, In); fclose(In); + memcpy(Original, Image, Size); Disk.Format = GeosFormatForSize(Size); Disk.Context = Image; @@ -41,7 +43,7 @@ int main(int argc, char **argv) if (!GeosInstallMouseDriver(&Disk, &Date)) { fprintf(stderr, "%s\n", Disk.Error); - return 1; + return memcmp(Original, Image, Size) ? 3 : 1; } Out = fopen(argv[2], "wb"); From a3e2cf0e98f3a44dfd8142ee8bc08f4dbd469c61 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:29:59 -0500 Subject: [PATCH 28/51] firmware: the GEOS directory grow refuses a sector outside the directory again, and a test holds the full-directory refusal to before the first write --- Source/Teensy/GEOS/GeosDiskPatch.h | 4 ++++ Source/Teensy/tests/geos-disk-patch.test.js | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index e3c29292..787e9bdd 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -293,6 +293,10 @@ static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * int New; if (!GeosTake(Disk, Format->DirTrack, &New)) return false; + if (New < 0 || !GeosIsDirSector(Format, Format->DirTrack, New) || *Length == GEOS_MAX_DIR_SECTORS) + { + return GeosFail(Disk, "the directory is full"); + } struct GeosTS Last = Chain[*Length - 1]; if (!GeosReadTS(Disk, Last.Track, Last.Sector, Block)) return false; diff --git a/Source/Teensy/tests/geos-disk-patch.test.js b/Source/Teensy/tests/geos-disk-patch.test.js index 9148c6e3..389ba44c 100644 --- a/Source/Teensy/tests/geos-disk-patch.test.js +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -25,6 +25,7 @@ const INPUT_128 = 15; const MOUSE_NAME = 'TR USB MOUSE'; const DATE_IN_SLOT = 25; const DATE_BYTES = 5; +const REFUSED_AFTER_WRITING = 3; const zoneSectors = (track) => { const zone = (track - 1) % 35 + 1; @@ -155,6 +156,11 @@ const REFUSED_CASES = { 'a disk with room for the data block but not the info block': (kind) => withOneDataSector(blankDisk(kind)), 'a full directory on a disk with room for the data block but not the info block': (kind) => withOneDataSector(fullDirectory(kind)), + 'a full directory whose track has only a system sector free': (kind) => { + const disk = fullDirectory(kind); + setTrackFree(disk, disk.format.dirTrack, [0]); + return disk; + }, 'a full directory whose BAM frees its sector': (kind) => { const disk = fullDirectory(kind); const free = allSectors(disk, disk.format.dirTrack).filter((s) => s >= disk.format.firstDir); @@ -210,7 +216,6 @@ test('the firmware patch matches trmouse.py', { skip }, async (t) => { for (const [label, make] of Object.entries(REFUSED_CASES)) { await t.test(`${kind}: refuses ${label}`, () => { const { python, firmware } = patchBoth(dir, `${kind}-${label}`, make(kind).image); - const REFUSED_AFTER_WRITING = 3; assert.notEqual(firmware.status, REFUSED_AFTER_WRITING, `the refusal changed the image: ${firmware.stderr}`); assert.equal(firmware.status, 1, `the firmware patched it: ${firmware.stderr}`); assert.notEqual(python.status, 0, 'trmouse.py patched it'); From f35c3be7c763ad6fcc77c1fed9ab8fb23a5ada51 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:32:07 -0500 Subject: [PATCH 29/51] firmware: the GEOS directory grow checks for room before taking a sector, so its own refusal leaves the image as it was --- Source/Teensy/GEOS/GeosDiskPatch.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 787e9bdd..56773d6e 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -285,18 +285,15 @@ static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *C return GeosFail(Disk, "the directory is full"); } -// Chains one more directory sector, as DOS does when a directory fills, once GeosCheckDirectoryRoom has passed. +// Chains one more directory sector, as DOS does when a directory fills. static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) { const struct GeosFormat *Format = Disk->Format; uint8_t Block[GEOS_SECTOR_SIZE]; int New; - if (!GeosTake(Disk, Format->DirTrack, &New)) return false; - if (New < 0 || !GeosIsDirSector(Format, Format->DirTrack, New) || *Length == GEOS_MAX_DIR_SECTORS) - { - return GeosFail(Disk, "the directory is full"); - } + if (*Length == GEOS_MAX_DIR_SECTORS) return GeosFail(Disk, "the directory is full"); + if (!GeosCheckDirectoryRoom(Disk, Chain, *Length) || !GeosTake(Disk, Format->DirTrack, &New)) return false; struct GeosTS Last = Chain[*Length - 1]; if (!GeosReadTS(Disk, Last.Track, Last.Sector, Block)) return false; From 5ef62c981ef2d51373381752ed2ab6d0cf4cc60c Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:35:49 -0500 Subject: [PATCH 30/51] firmware: the GEOS disk patch takes its DeskTop and driver from a target, 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. --- Source/Teensy/GEOS/GeosDiskPatch.h | 73 ++++++++++++------- Source/Teensy/GEOS/GeosTargets.h | 19 +++++ Source/Teensy/tests/geos-disk-patch.test.js | 18 +++++ Source/Teensy/tests/geos_disk_patch_harness.c | 24 +++++- 4 files changed, 105 insertions(+), 29 deletions(-) create mode 100644 Source/Teensy/GEOS/GeosTargets.h diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 56773d6e..42fc868f 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Puts the TR USB mouse driver on a GEOS 128 boot disk image, block by block. +// Puts a TR input driver on a GEOS boot disk image, block by block. // Mirrors tools/geos/trmouse.py install(), which the host test holds it to byte for byte. #pragma once #include @@ -7,8 +7,6 @@ #include #include -#include "TRMouse128.h" - #define GEOS_SECTOR_SIZE 256 #define GEOS_DIR_ENTRY_SIZE 32 #define GEOS_ENTRIES_PER_DIR (GEOS_SECTOR_SIZE / GEOS_DIR_ENTRY_SIZE) @@ -16,14 +14,11 @@ #define GEOS_MAX_DIR_SECTORS 40 #define GEOS_ERROR_SIZE 80 #define GEOS_DATA_PER_BLOCK (GEOS_SECTOR_SIZE - 2) -#define GEOS_DRIVER_DATA_BLOCKS ((sizeof TRMouse128Code + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK) -#define GEOS_DRIVER_BLOCKS (GEOS_DRIVER_DATA_BLOCKS + 1) #define CBM_NAME_PAD 0xa0 #define CBM_TYPE_MASK 0x07 #define CBM_USR_CLOSED 0x83 #define GEOS_SEQUENTIAL 0 -#define GEOS_INPUT_128 15 #define D71_DOUBLE_SIDED 0x80 // Byte offsets inside a 32-byte directory slot (the first two hold the sector link) @@ -54,10 +49,22 @@ struct GeosFormat struct GeosDate { uint8_t Year, Month, Day, Hour, Minute; }; +// A GEOS version the TR sets up: its boot disk's DeskTop and the input driver the TR adds +struct GeosTarget +{ + const char *DeskTopName, *DriverName; + const uint8_t *DriverCode, *DriverInfo; + uint16_t DriverSize; + uint8_t InputType; +}; + +struct GeosBootDisk { bool HasDeskTop, HasDriver; }; + // A boot disk image reached through 256-byte block reads and writes at byte offsets. struct GeosDisk { const struct GeosFormat *Format; + const struct GeosTarget *Target; void *Context; bool (*Read)(void *Context, uint32_t Offset, uint8_t *Block); bool (*Write)(void *Context, uint32_t Offset, const uint8_t *Block); @@ -307,46 +314,53 @@ static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * return GeosWriteTS(Disk, Format->DirTrack, New, Block); } +static uint16_t GeosDriverDataBlocks(const struct GeosTarget *Target) +{ + return (Target->DriverSize + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK; +} + // Writes the driver's data chain and info block; returns the first data sector, the info sector and the block count. static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) { + const struct GeosTarget *Target = Disk->Target; + const uint16_t DataBlocks = GeosDriverDataBlocks(Target); struct GeosTS Here, Next = { 0, 0 }; uint8_t Block[GEOS_SECTOR_SIZE]; if (!GeosAllocate(Disk, &Here)) return false; *First = Here; - for (uint16_t i = 0; i < GEOS_DRIVER_DATA_BLOCKS; i++) + for (uint16_t i = 0; i < DataBlocks; i++) { - uint16_t Start = i * GEOS_DATA_PER_BLOCK, Length = sizeof TRMouse128Code - Start; + uint16_t Start = i * GEOS_DATA_PER_BLOCK, Length = Target->DriverSize - Start; if (Length > GEOS_DATA_PER_BLOCK) Length = GEOS_DATA_PER_BLOCK; - bool Last = i + 1 == GEOS_DRIVER_DATA_BLOCKS; + bool Last = i + 1 == DataBlocks; if (!Last && !GeosAllocate(Disk, &Next)) return false; memset(Block, 0, sizeof Block); Block[0] = Last ? 0 : Next.Track; Block[1] = Last ? Length + 1 : Next.Sector; - memcpy(Block + 2, TRMouse128Code + Start, Length); + memcpy(Block + 2, Target->DriverCode + Start, Length); if (!GeosWriteTS(Disk, Here.Track, Here.Sector, Block)) return false; Here = Next; } if (!GeosAllocate(Disk, Info)) return false; - *Blocks = GEOS_DRIVER_BLOCKS; - return GeosWriteTS(Disk, Info->Track, Info->Sector, TRMouse128Info); + *Blocks = DataBlocks + 1; + return GeosWriteTS(Disk, Info->Track, Info->Sector, Target->DriverInfo); } -static void GeosBuildEntry(uint8_t *Slot, struct GeosTS First, struct GeosTS Info, uint16_t Blocks, const struct GeosDate *Date) +static void GeosBuildEntry(const struct GeosTarget *Target, uint8_t *Slot, struct GeosTS First, struct GeosTS Info, uint16_t Blocks, const struct GeosDate *Date) { memset(Slot + SlotType, 0, GEOS_DIR_ENTRY_SIZE - SlotType); Slot[SlotType] = CBM_USR_CLOSED; Slot[SlotFirst] = First.Track; Slot[SlotFirst + 1] = First.Sector; memset(Slot + SlotName, CBM_NAME_PAD, GEOS_NAME_SIZE); - memcpy(Slot + SlotName, TRMouse128Name, strlen(TRMouse128Name)); + memcpy(Slot + SlotName, Target->DriverName, strlen(Target->DriverName)); Slot[SlotInfo] = Info.Track; Slot[SlotInfo + 1] = Info.Sector; Slot[SlotStructure] = GEOS_SEQUENTIAL; - Slot[SlotGeosType] = GEOS_INPUT_128; + Slot[SlotGeosType] = Target->InputType; Slot[SlotDate] = Date->Year % 100; Slot[SlotDate + 1] = Date->Month; Slot[SlotDate + 2] = Date->Day; @@ -374,11 +388,12 @@ struct GeosSlotRef { int Sector, Slot; }; struct GeosDirScan { struct GeosSlotRef Free, FirstDriver; - bool HasFree, HasDriver, HasMouse; + bool HasFree, HasDriver, HasDeskTop, HasOurs; }; static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length, struct GeosDirScan *Scan) { + const struct GeosTarget *Target = Disk->Target; uint8_t Block[GEOS_SECTOR_SIZE]; memset(Scan, 0, sizeof *Scan); @@ -393,8 +408,9 @@ static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, if (!Slot[SlotType] && !Scan->HasFree) { Scan->Free = Here; Scan->HasFree = true; } if (!InUse) continue; - if (GeosSlotNamed(Slot, TRMouse128Name)) Scan->HasMouse = true; - if (Slot[SlotGeosType] == GEOS_INPUT_128 && !Scan->HasDriver) { Scan->FirstDriver = Here; Scan->HasDriver = true; } + if (GeosSlotNamed(Slot, Target->DriverName)) Scan->HasOurs = true; + if (GeosSlotNamed(Slot, Target->DeskTopName)) Scan->HasDeskTop = true; + if (Slot[SlotGeosType] == Target->InputType && !Scan->HasDriver) { Scan->FirstDriver = Here; Scan->HasDriver = true; } } } return true; @@ -430,7 +446,7 @@ static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, if (!GeosWriteDriver(Disk, &First, &Info, &Blocks)) return false; if (!GeosReadTS(Disk, Where.Track, Where.Sector, Block)) return false; - GeosBuildEntry(Block + Slot * GEOS_DIR_ENTRY_SIZE, First, Info, Blocks, Date); + GeosBuildEntry(Disk->Target, Block + Slot * GEOS_DIR_ENTRY_SIZE, First, Info, Blocks, Date); return GeosWriteTS(Disk, Where.Track, Where.Sector, Block); } @@ -457,30 +473,35 @@ static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * return GeosScanDirectory(Disk, Chain, *Length, Scan); } -// Checks the image and returns whether it already holds the driver. -static inline bool GeosHasMouseDriver(struct GeosDisk *Disk, bool *HasMouse) +// Checks the image and says whether it is the target's boot disk and already holds the TR driver. +static inline bool GeosInspect(struct GeosDisk *Disk, struct GeosBootDisk *Found) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; struct GeosDirScan Scan; int Length; if (!GeosReadDirectory(Disk, Chain, &Length, &Scan)) return false; - *HasMouse = Scan.HasMouse; + Found->HasDeskTop = Scan.HasDeskTop; + Found->HasDriver = Scan.HasOurs; return true; } -// Writes the driver ahead of every other input driver, since GEOS 128 loads the first one in the directory. +// Writes the driver ahead of every other input driver of its type: GEOS 128 boots with the first one in the directory. // Every refusal but a failed block read or write comes before the first write, leaving the image as it was. -static inline bool GeosInstallMouseDriver(struct GeosDisk *Disk, const struct GeosDate *Date) +static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struct GeosDate *Date) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; struct GeosDirScan Scan; int Length; if (!GeosReadDirectory(Disk, Chain, &Length, &Scan)) return false; - if (Scan.HasMouse) return GeosFail(Disk, "TR USB MOUSE is already on this disk"); + if (Scan.HasOurs) + { + snprintf(Disk->Error, sizeof Disk->Error, "%s is already on this disk", Disk->Target->DriverName); + return false; + } if (!Scan.HasFree && !GeosCheckDirectoryRoom(Disk, Chain, Length)) return false; - if (!GeosCheckRoom(Disk, GEOS_DRIVER_BLOCKS)) return false; + if (!GeosCheckRoom(Disk, GeosDriverDataBlocks(Disk->Target) + 1u)) return false; if (!Scan.HasFree) { if (!GeosGrowDirectory(Disk, Chain, &Length)) return false; diff --git a/Source/Teensy/GEOS/GeosTargets.h b/Source/Teensy/GEOS/GeosTargets.h new file mode 100644 index 00000000..144985eb --- /dev/null +++ b/Source/Teensy/GEOS/GeosTargets.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// The GEOS versions the TR sets up from a boot disk image the user puts on the SD card. +#pragma once +#include "GeosDiskPatch.h" +#include "TRMouse128.h" + +#define GEOS_INPUT_128 15 + +struct GeosLauncher +{ + const char *Title, *Folder; + struct GeosTarget Target; +}; + +static const struct GeosLauncher Geos128Launcher = +{ + "GEOS 128", "/GEOS128", + { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128 }, +}; diff --git a/Source/Teensy/tests/geos-disk-patch.test.js b/Source/Teensy/tests/geos-disk-patch.test.js index 389ba44c..77d2c93b 100644 --- a/Source/Teensy/tests/geos-disk-patch.test.js +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -224,6 +224,24 @@ test('the firmware patch matches trmouse.py', { skip }, async (t) => { } } + for (const kind of Object.keys(FORMATS)) { + await t.test(`${kind}: inspect tells a boot disk from a data disk, and sees the driver once added`, () => { + const inspect = (label, image) => { + const file = path.join(dir, `${kind}-inspect-${label}`); + fs.writeFileSync(file, image); + const result = run(path.join(dir, 'harness'), ['--inspect', file]); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + const bootDisk = withDriver(kind); + const { firmware } = patchBoth(dir, `${kind}-inspect-patched`, bootDisk.image); + + assert.equal(inspect('data', blankDisk(kind).image), 'desktop=0 driver=0'); + assert.equal(inspect('boot', bootDisk.image), 'desktop=1 driver=0'); + assert.equal(inspect('patched', fs.readFileSync(firmware.out)), 'desktop=1 driver=1'); + }); + } + await t.test('d71: refuses a single-sided disk', () => { const disk = blankDisk('d71'); disk.image[offset(disk.format, 18, 0) + 3] = 0; diff --git a/Source/Teensy/tests/geos_disk_patch_harness.c b/Source/Teensy/tests/geos_disk_patch_harness.c index 2b91c373..6d369821 100644 --- a/Source/Teensy/tests/geos_disk_patch_harness.c +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT // geos_disk_patch_harness IN OUT: the firmware's GEOS boot-disk patch, run on an image file. // Exits 0 after writing OUT, 1 with the patcher's refusal on stderr, 3 if a refusal changed the image. +// geos_disk_patch_harness --inspect IN: prints what GeosInspect found, or exits 1 with its refusal. #include -#include "../GEOS/GeosDiskPatch.h" +#include "../GEOS/GeosTargets.h" #define MAX_IMAGE 819200 @@ -22,25 +23,42 @@ static bool WriteBlock(void *Context, uint32_t Offset, const uint8_t *Block) return true; } +static int Inspect(struct GeosDisk *Disk) +{ + struct GeosBootDisk Found; + + if (!GeosInspect(Disk, &Found)) + { + fprintf(stderr, "%s\n", Disk->Error); + return 1; + } + printf("desktop=%d driver=%d\n", Found.HasDeskTop, Found.HasDriver); + return 0; +} + int main(int argc, char **argv) { const struct GeosDate Date = { 26, 10, 16, 12, 0 }; struct GeosDisk Disk = { 0 }; FILE *In, *Out; size_t Size; + bool Inspecting; if (argc != 3) return 2; - In = fopen(argv[1], "rb"); + Inspecting = !strcmp(argv[1], "--inspect"); + In = fopen(Inspecting ? argv[2] : argv[1], "rb"); if (!In) return 2; Size = fread(Image, 1, sizeof Image, In); fclose(In); memcpy(Original, Image, Size); Disk.Format = GeosFormatForSize(Size); + Disk.Target = &Geos128Launcher.Target; Disk.Context = Image; Disk.Read = ReadBlock; Disk.Write = WriteBlock; - if (!GeosInstallMouseDriver(&Disk, &Date)) + if (Inspecting) return Inspect(&Disk); + if (!GeosInstallDriver(&Disk, &Date)) { fprintf(stderr, "%s\n", Disk.Error); return memcmp(Original, Image, Size) ? 3 : 1; From 2bdf7faba6cb9a3ba9403d09f0fa16c87ac27f8b Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:38:10 -0500 Subject: [PATCH 31/51] firmware: the GEOS disk patch refuses a target whose names or driver cannot fit, and counts the driver's blocks in one place --- Source/Teensy/GEOS/GeosDiskPatch.h | 25 +++++++++++++++------ Source/Teensy/tests/geos-disk-patch.test.js | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 42fc868f..181dff72 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -319,6 +319,11 @@ static uint16_t GeosDriverDataBlocks(const struct GeosTarget *Target) return (Target->DriverSize + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK; } +static uint16_t GeosDriverBlocks(const struct GeosTarget *Target) +{ + return GeosDriverDataBlocks(Target) + 1; +} + // Writes the driver's data chain and info block; returns the first data sector, the info sector and the block count. static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) { @@ -345,7 +350,7 @@ static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct } if (!GeosAllocate(Disk, Info)) return false; - *Blocks = DataBlocks + 1; + *Blocks = GeosDriverBlocks(Target); return GeosWriteTS(Disk, Info->Track, Info->Sector, Target->DriverInfo); } @@ -387,8 +392,8 @@ struct GeosSlotRef { int Sector, Slot; }; struct GeosDirScan { - struct GeosSlotRef Free, FirstDriver; - bool HasFree, HasDriver, HasDeskTop, HasOurs; + struct GeosSlotRef Free, FirstInputDriver; + bool HasFree, HasInputDriver, HasDeskTop, HasOurs; }; static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length, struct GeosDirScan *Scan) @@ -410,7 +415,7 @@ static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, if (!InUse) continue; if (GeosSlotNamed(Slot, Target->DriverName)) Scan->HasOurs = true; if (GeosSlotNamed(Slot, Target->DeskTopName)) Scan->HasDeskTop = true; - if (Slot[SlotGeosType] == Target->InputType && !Scan->HasDriver) { Scan->FirstDriver = Here; Scan->HasDriver = true; } + if (Slot[SlotGeosType] == Target->InputType && !Scan->HasInputDriver) { Scan->FirstInputDriver = Here; Scan->HasInputDriver = true; } } } return true; @@ -463,6 +468,12 @@ static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * { uint8_t Header[GEOS_SECTOR_SIZE]; + const struct GeosTarget *Target = Disk->Target; + + if (strlen(Target->DeskTopName) > GEOS_NAME_SIZE || strlen(Target->DriverName) > GEOS_NAME_SIZE || !Target->DriverSize) + { + return GeosFail(Disk, "the GEOS target's names or driver do not fit a directory entry"); + } if (!Disk->Format) return GeosFail(Disk, "not a D64, D71 or D81 without error bytes"); if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Header)) return false; if (Disk->Format->TwoSided && !(Header[3] & D71_DOUBLE_SIDED)) @@ -501,7 +512,7 @@ static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struct GeosDat return false; } if (!Scan.HasFree && !GeosCheckDirectoryRoom(Disk, Chain, Length)) return false; - if (!GeosCheckRoom(Disk, GeosDriverDataBlocks(Disk->Target) + 1u)) return false; + if (!GeosCheckRoom(Disk, GeosDriverBlocks(Disk->Target))) return false; if (!Scan.HasFree) { if (!GeosGrowDirectory(Disk, Chain, &Length)) return false; @@ -509,6 +520,6 @@ static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struct GeosDat } if (!GeosWriteEntry(Disk, Chain[Scan.Free.Sector], Scan.Free.Slot, Date)) return false; - if (!Scan.HasDriver || !GeosSlotBefore(Scan.FirstDriver, Scan.Free)) return true; - return GeosSwapEntries(Disk, Chain, Scan.FirstDriver, Scan.Free); + if (!Scan.HasInputDriver || !GeosSlotBefore(Scan.FirstInputDriver, Scan.Free)) return true; + return GeosSwapEntries(Disk, Chain, Scan.FirstInputDriver, Scan.Free); } diff --git a/Source/Teensy/tests/geos-disk-patch.test.js b/Source/Teensy/tests/geos-disk-patch.test.js index 77d2c93b..e2a90ab5 100644 --- a/Source/Teensy/tests/geos-disk-patch.test.js +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -235,6 +235,7 @@ test('the firmware patch matches trmouse.py', { skip }, async (t) => { }; const bootDisk = withDriver(kind); const { firmware } = patchBoth(dir, `${kind}-inspect-patched`, bootDisk.image); + assert.equal(firmware.status, 0, firmware.stderr); assert.equal(inspect('data', blankDisk(kind).image), 'desktop=0 driver=0'); assert.equal(inspect('boot', bootDisk.image), 'desktop=1 driver=0'); From 95ca21e88134b6744b2c2472834f9448c5196462 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:39:53 -0500 Subject: [PATCH 32/51] firmware: a GEOS 128 menu item that finds the user's boot disk image in /GEOS128 and adds the mouse driver to a copy With no image there, it tells the user where to copy one. The original is left as it was. --- Source/C64/MainMenuCRT/source/Menu_Regs.i | 1 + Source/C64/MainMenuCRT/source/StringsMsgs.s | 1 + Source/Teensy/DriveDirLoad.ino | 6 + Source/Teensy/GeosLauncher.ino | 193 ++++++++++++++++++ Source/Teensy/MainMenuItems.h | 1 + Source/Teensy/MinimalBoot/Common/Menu_Regs.h | 1 + Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h | 2 +- Source/Teensy/Teensy.ino | 1 + 8 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 Source/Teensy/GeosLauncher.ino diff --git a/Source/C64/MainMenuCRT/source/Menu_Regs.i b/Source/C64/MainMenuCRT/source/Menu_Regs.i index 57d5b5df..fecb5b32 100644 --- a/Source/C64/MainMenuCRT/source/Menu_Regs.i +++ b/Source/C64/MainMenuCRT/source/Menu_Regs.i @@ -394,6 +394,7 @@ rtBinC128 = 18 rtFileREU = 19 rtFileTRH = 20 + rtGEOS = 21 ;63 max: rRegItemTypePlusIOH uses bit 7 (assigned IOH) and bit 6 (/VMS claim), ;and TblItemType's mult by 4 limits it to 63 as well diff --git a/Source/C64/MainMenuCRT/source/StringsMsgs.s b/Source/C64/MainMenuCRT/source/StringsMsgs.s index 44956125..3d4895d0 100644 --- a/Source/C64/MainMenuCRT/source/StringsMsgs.s +++ b/Source/C64/MainMenuCRT/source/StringsMsgs.s @@ -177,3 +177,4 @@ TblItemType: ;must match regItemTypes (rtNone, rtBin16k, etc) order! !tx 0, "128" ; rtBinC128 = 18 !tx 0, "REU" ; rtFileREU = 19 !tx 0, "TRH" ; rtFileTRH = 20 + !tx 0, "GEO" ; rtGEOS = 21 diff --git a/Source/Teensy/DriveDirLoad.ino b/Source/Teensy/DriveDirLoad.ino index 253a32ee..a60aaf63 100644 --- a/Source/Teensy/DriveDirLoad.ino +++ b/Source/Teensy/DriveDirLoad.ino @@ -248,6 +248,12 @@ FLASHMEM void HandleExecution() return; } + if (MenuSelCpy.ItemType == rtGEOS) + { + LaunchGeos((const struct GeosLauncher*)MenuSelCpy.Code_Image); + return; + } + SendMsgPrintfln(MenuSelCpy.Name); if (MenuSelCpy.ItemType == rtFileCrt) { //load the CRT into RAM diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino new file mode 100644 index 00000000..26e99ea8 --- /dev/null +++ b/Source/Teensy/GeosLauncher.ino @@ -0,0 +1,193 @@ +// A GEOS menu item: finds the user's boot disk image in the launcher's folder on the SD card and puts +// the TR input driver on a copy of it, leaving the original untouched. + +#include "GEOS/GeosTargets.h" + +#define GEOS_TEMP_NAME "PATCHING.TMP" +#define GEOS_COPY_TAG "-TR" +#define GEOS_EXT_LENGTH 4 +#define GEOS_MAX_IMAGES 8 + +static const char *const GeosImageExtensions[] = { ".d64", ".d71", ".d81" }; + +FLASHMEM static bool GeosFileRead(void *Context, uint32_t Offset, uint8_t *Block) +{ + File *Image = (File *)Context; + return Image->seek(Offset) && Image->read(Block, GEOS_SECTOR_SIZE) == GEOS_SECTOR_SIZE; +} + +FLASHMEM static bool GeosFileWrite(void *Context, uint32_t Offset, const uint8_t *Block) +{ + File *Image = (File *)Context; + return Image->seek(Offset) && Image->write(Block, GEOS_SECTOR_SIZE) == GEOS_SECTOR_SIZE; +} + +FLASHMEM static struct GeosDisk GeosDiskOn(const struct GeosLauncher *Launcher, File *Image) +{ + struct GeosDisk Disk = {}; + uint64_t Size = Image->size(); + + Disk.Format = Size <= UINT32_MAX ? GeosFormatForSize(Size) : NULL; + Disk.Target = &Launcher->Target; + Disk.Context = Image; + Disk.Read = GeosFileRead; + Disk.Write = GeosFileWrite; + return Disk; +} + +FLASHMEM static bool GeosHasImageExtension(const char *Name) +{ + size_t Length = strlen(Name); + + if (Length <= GEOS_EXT_LENGTH) return false; + for (const char *Extension : GeosImageExtensions) + { + if (!strcasecmp(Name + Length - GEOS_EXT_LENGTH, Extension)) return true; + } + return false; +} + +FLASHMEM static bool GeosIsPatchedCopy(const char *Name) +{ + size_t Base = strlen(Name) - GEOS_EXT_LENGTH, TagLength = strlen(GEOS_COPY_TAG); + return Base > TagLength && !strncasecmp(Name + Base - TagLength, GEOS_COPY_TAG, TagLength); +} + +FLASHMEM static void GeosPath(char *Path, size_t Size, const struct GeosLauncher *Launcher, const char *Name) +{ + snprintf(Path, Size, "%s/%s", Launcher->Folder, Name); +} + +FLASHMEM static void GeosCopyPath(char *Path, size_t Size, const struct GeosLauncher *Launcher, const char *Name) +{ + int Base = strlen(Name) - GEOS_EXT_LENGTH; + snprintf(Path, Size, "%s/%.*s" GEOS_COPY_TAG "%s", Launcher->Folder, Base, Name, Name + Base); +} + +// The originals in the folder, listed before anything is written to it +FLASHMEM static uint8_t GeosListImages(const struct GeosLauncher *Launcher, char Names[][MaxItemNameLength]) +{ + File Dir = SD.open(Launcher->Folder); + uint8_t Count = 0; + + if (!Dir) return 0; + for (File Entry = Dir.openNextFile(); Entry && Count < GEOS_MAX_IMAGES; Entry = Dir.openNextFile()) + { + const char *Name = Entry.name(); + if (Entry.isDirectory() || !GeosHasImageExtension(Name) || GeosIsPatchedCopy(Name)) continue; + if (strlen(Name) >= MaxItemNameLength) continue; + strcpy(Names[Count++], Name); + } + Dir.close(); + return Count; +} + +FLASHMEM static bool GeosInspectFile(const struct GeosLauncher *Launcher, const char *Path, struct GeosBootDisk *Found, + char *Error, size_t ErrorSize) +{ + File Image = SD.open(Path, FILE_READ); + struct GeosDisk Disk; + bool Inspected; + + if (!Image) return false; + Disk = GeosDiskOn(Launcher, &Image); + Inspected = GeosInspect(&Disk, Found); + if (!Inspected && Error) snprintf(Error, ErrorSize, "%s", Disk.Error); + Image.close(); + return Inspected; +} + +FLASHMEM static bool GeosCopyFile(const char *From, const char *To) +{ + File Source = SD.open(From, FILE_READ), Target; + uint8_t Buffer[GEOS_SECTOR_SIZE * 4]; + bool Copied = true; + int Length; + + SD.remove(To); + Target = SD.open(To, FILE_WRITE); + if (!Source || !Target) Copied = false; + while (Copied && (Length = Source.read(Buffer, sizeof Buffer)) > 0) + { + Copied = Target.write(Buffer, Length) == (size_t)Length; + } + Source.close(); + Target.close(); + return Copied; +} + +FLASHMEM static struct GeosDate GeosToday() +{ + return (struct GeosDate){ (uint8_t)(year() % 100), (uint8_t)month(), (uint8_t)day(), (uint8_t)hour(), (uint8_t)minute() }; +} + +FLASHMEM static bool GeosPatchFile(const struct GeosLauncher *Launcher, const char *Path, char *Error, size_t ErrorSize) +{ + File Image = SD.open(Path, FILE_WRITE_BEGIN); + struct GeosDisk Disk; + struct GeosDate Today = GeosToday(); + bool Patched; + + if (!Image) return false; + Disk = GeosDiskOn(Launcher, &Image); + Patched = GeosInstallDriver(&Disk, &Today); + if (!Patched) snprintf(Error, ErrorSize, "%s", Disk.Error); + Image.close(); + return Patched; +} + +// Patches a temporary copy and renames it into place, so a failure never leaves a half-patched copy behind +FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const char *Source, const char *Copy) +{ + char Temp[MaxNamePathLength], Error[GEOS_ERROR_SIZE] = "the SD card could not be written"; + + GeosPath(Temp, sizeof Temp, Launcher, GEOS_TEMP_NAME); + if (!GeosCopyFile(Source, Temp) || !GeosPatchFile(Launcher, Temp, Error, sizeof Error) || + (SD.exists(Copy) && !SD.remove(Copy)) || !SD.rename(Temp, Copy)) + { + SD.remove(Temp); + SendMsgPrintfln("Could not add the mouse driver:\r\n%s", Error); + return false; + } + return true; +} + +// Whether the image is a boot disk the TR can now start, saying what it found +FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const char *Name) +{ + char Source[MaxNamePathLength], Copy[MaxNamePathLength], Error[GEOS_ERROR_SIZE]; + struct GeosBootDisk Found; + + GeosPath(Source, sizeof Source, Launcher, Name); + GeosCopyPath(Copy, sizeof Copy, Launcher, Name); + if (!GeosInspectFile(Launcher, Source, &Found, Error, sizeof Error)) + { + SendMsgPrintfln("%s:\r\n%s", Name, Error); + return false; + } + if (!Found.HasDeskTop) return false; + + SendMsgPrintfln("%s boot disk:\r\n%s", Launcher->Title, Name); + if (Found.HasDriver) return true; + if (GeosInspectFile(Launcher, Copy, &Found, NULL, 0) && Found.HasDriver) return true; + if (!GeosPatchCopy(Launcher, Source, Copy)) return false; + SendMsgPrintfln("Added the TR USB mouse driver to\r\n%s", Copy); + return true; +} + +FLASHMEM void LaunchGeos(const struct GeosLauncher *Launcher) +{ + char Names[GEOS_MAX_IMAGES][MaxItemNameLength]; + uint8_t Count, Ready = 0; + + if (!SDFullInit()) + { + SendMsgPrintfln("%s starts from your boot disk\r\nimage on the SD card. Insert the card.", Launcher->Title); + return; + } + Count = GeosListImages(Launcher, Names); + for (uint8_t i = 0; i < Count; i++) Ready += GeosSetUpImage(Launcher, Names[i]); + if (Ready) return; + SendMsgPrintfln("Copy your %s boot disk image\r\n(.D64, .D71 or .D81) to %s\r\non the SD card, then select this again.", + Launcher->Title, Launcher->Folder); +} diff --git a/Source/Teensy/MainMenuItems.h b/Source/Teensy/MainMenuItems.h index 8d0ea642..f638ee5f 100644 --- a/Source/Teensy/MainMenuItems.h +++ b/Source/Teensy/MainMenuItems.h @@ -324,5 +324,6 @@ StructMenuItem TeensyROMMenu[] = /**7**/ rtDirectory, IOH_None , (char*)"/Utilities" , (uint8_t*)dirUtilities , sizeof(dirUtilities) , /* 8 */ rtDirectory, IOH_None , (char*)"/Text + PETSCII + Docs" , (uint8_t*)dirTEXT_PETSCII , sizeof(dirTEXT_PETSCII) , /**9**/ rtDirectory, IOH_None , (char*)"/TeensyROM Specific" , (uint8_t*)dirTeensyROMSpecific , sizeof(dirTeensyROMSpecific) , + /* 10*/ rtGEOS , IOH_None , (char*)"GEOS 128" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , }; diff --git a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h index a3c616da..9798d633 100644 --- a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h +++ b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h @@ -403,6 +403,7 @@ enum regItemTypes //synch with TblItemType rtBinC128 = 18, rtFileREU = 19, rtFileTRH = 20, + rtGEOS = 21, //63 max: rRegItemTypePlusIOH uses bit 7 (assigned IOH) and bit 6 (/VMS claim), //and TblItemType's mult by 4 limits it to 63 as well diff --git a/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h b/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h index 84078f17..a1de882c 100644 --- a/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h +++ b/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h @@ -388,7 +388,7 @@ static const unsigned char TeensyROMC64_bin[] = { 0xc9, 0xc4, 0x00, 0xcb, 0x4c, 0x41, 0x00, 0xc1, 0x52, 0x54, 0x00, 0xd4, 0x58, 0x54, 0x00, 0xd3, 0x45, 0x51, 0x00, 0x31, 0x36, 0x4b, 0x00, 0x38, 0xc8, 0x49, 0x00, 0x38, 0xcc, 0x4f, 0x00, 0x31, 0x32, 0x38, 0x00, 0xd2, - 0xc5, 0xd5, 0x00, 0xd4, 0xd2, 0xc8, 0x00, 0x00 + 0xc5, 0xd5, 0x00, 0xd4, 0xd2, 0xc8, 0x00, 0xc7, 0xc5, 0xcf, 0x00, 0x00 }; #endif /* TEENSYROMC64_BIN_H */ diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index ffe7b2df..a585279d 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -36,6 +36,7 @@ #include "MinimalBoot/Common/DMAControl.c" #include "MinimalBoot/Common/DriveDirLoad.h" #include "LoadedListing.h" +#include "GEOS/GeosTargets.h" #include "MainMenuItems.h" #ifdef VM_EXTENSIONS_ENABLED // Before IOHandlers.h: PollingHndlr_TeensyROM is where the record is shown. From 52799a52a93e0051b989f8613dc4b62b20c531e9 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:49:25 -0500 Subject: [PATCH 33/51] firmware: the GEOS launcher stamps the driver with the RTC's local date, keeps an unreadable -TR copy, skips ._ twins, and stops telling a user with a boot disk to copy one --- Source/Teensy/GeosLauncher.ino | 41 +++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index 26e99ea8..a7d98d13 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -74,7 +74,7 @@ FLASHMEM static uint8_t GeosListImages(const struct GeosLauncher *Launcher, char for (File Entry = Dir.openNextFile(); Entry && Count < GEOS_MAX_IMAGES; Entry = Dir.openNextFile()) { const char *Name = Entry.name(); - if (Entry.isDirectory() || !GeosHasImageExtension(Name) || GeosIsPatchedCopy(Name)) continue; + if (Entry.isDirectory() || IsDotfile(Name) || !GeosHasImageExtension(Name) || GeosIsPatchedCopy(Name)) continue; if (strlen(Name) >= MaxItemNameLength) continue; strcpy(Names[Count++], Name); } @@ -89,7 +89,11 @@ FLASHMEM static bool GeosInspectFile(const struct GeosLauncher *Launcher, const struct GeosDisk Disk; bool Inspected; - if (!Image) return false; + if (!Image) + { + if (Error) snprintf(Error, ErrorSize, "the image could not be opened"); + return false; + } Disk = GeosDiskOn(Launcher, &Image); Inspected = GeosInspect(&Disk, Found); if (!Inspected && Error) snprintf(Error, ErrorSize, "%s", Disk.Error); @@ -101,6 +105,7 @@ FLASHMEM static bool GeosCopyFile(const char *From, const char *To) { File Source = SD.open(From, FILE_READ), Target; uint8_t Buffer[GEOS_SECTOR_SIZE * 4]; + uint64_t Total = 0; bool Copied = true; int Length; @@ -110,15 +115,22 @@ FLASHMEM static bool GeosCopyFile(const char *From, const char *To) while (Copied && (Length = Source.read(Buffer, sizeof Buffer)) > 0) { Copied = Target.write(Buffer, Length) == (size_t)Length; + Total += Length; } + Copied = Copied && Total == Source.size(); Source.close(); Target.close(); return Copied; } +// TimeLib's year() and friends run from boot with no sync provider set, so the RTC is read directly FLASHMEM static struct GeosDate GeosToday() { - return (struct GeosDate){ (uint8_t)(year() % 100), (uint8_t)month(), (uint8_t)day(), (uint8_t)hour(), (uint8_t)minute() }; + int8_t Timezone = (int8_t)IO1[rwRegTimezone]; + tmElements_t Now; + + breakTime(Teensy3Clock.get() + (int16_t)Timezone * 30 * 60, Now); + return (struct GeosDate){ (uint8_t)(tmYearToCalendar(Now.Year) % 100), Now.Month, Now.Day, Now.Hour, Now.Minute }; } FLASHMEM static bool GeosPatchFile(const struct GeosLauncher *Launcher, const char *Path, char *Error, size_t ErrorSize) @@ -152,7 +164,7 @@ FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const ch return true; } -// Whether the image is a boot disk the TR can now start, saying what it found +// Whether the image is a boot disk, set up or not: only with none does the user need telling where to copy one FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const char *Name) { char Source[MaxNamePathLength], Copy[MaxNamePathLength], Error[GEOS_ERROR_SIZE]; @@ -169,16 +181,25 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c SendMsgPrintfln("%s boot disk:\r\n%s", Launcher->Title, Name); if (Found.HasDriver) return true; - if (GeosInspectFile(Launcher, Copy, &Found, NULL, 0) && Found.HasDriver) return true; - if (!GeosPatchCopy(Launcher, Source, Copy)) return false; - SendMsgPrintfln("Added the TR USB mouse driver to\r\n%s", Copy); + if (SD.exists(Copy)) + { + // The copy is the disk GEOS saves the user's work to: one that cannot be read is left alone, not replaced + if (!GeosInspectFile(Launcher, Copy, &Found, Error, sizeof Error)) + { + SendMsgPrintfln("%s:\r\n%s", Copy, Error); + return true; + } + if (Found.HasDriver) return true; + } + if (GeosPatchCopy(Launcher, Source, Copy)) SendMsgPrintfln("Added the TR USB mouse driver to\r\n%s", Copy); return true; } FLASHMEM void LaunchGeos(const struct GeosLauncher *Launcher) { char Names[GEOS_MAX_IMAGES][MaxItemNameLength]; - uint8_t Count, Ready = 0; + uint8_t Count; + bool BootDiskFound = false; if (!SDFullInit()) { @@ -186,8 +207,8 @@ FLASHMEM void LaunchGeos(const struct GeosLauncher *Launcher) return; } Count = GeosListImages(Launcher, Names); - for (uint8_t i = 0; i < Count; i++) Ready += GeosSetUpImage(Launcher, Names[i]); - if (Ready) return; + for (uint8_t i = 0; i < Count; i++) BootDiskFound |= GeosSetUpImage(Launcher, Names[i]); + if (BootDiskFound) return; SendMsgPrintfln("Copy your %s boot disk image\r\n(.D64, .D71 or .D81) to %s\r\non the SD card, then select this again.", Launcher->Title, Launcher->Folder); } From 42680427aa8c1af1e1ff0d5b9693cd2a41a5f4e6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 18:50:27 -0500 Subject: [PATCH 34/51] firmware: the GEOS launcher names the disk that will boot, the -TR copy rather than the original --- Source/Teensy/GeosLauncher.ino | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index a7d98d13..08e5c115 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -164,11 +164,18 @@ FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const ch return true; } +FLASHMEM static bool GeosReady(const struct GeosLauncher *Launcher, const char *Name) +{ + SendMsgPrintfln("%s boot disk:\r\n%s", Launcher->Title, Name); + return true; +} + // Whether the image is a boot disk, set up or not: only with none does the user need telling where to copy one FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const char *Name) { char Source[MaxNamePathLength], Copy[MaxNamePathLength], Error[GEOS_ERROR_SIZE]; struct GeosBootDisk Found; + const char *CopyName = Copy + strlen(Launcher->Folder) + 1; GeosPath(Source, sizeof Source, Launcher, Name); GeosCopyPath(Copy, sizeof Copy, Launcher, Name); @@ -178,21 +185,21 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c return false; } if (!Found.HasDeskTop) return false; + if (Found.HasDriver) return GeosReady(Launcher, Name); - SendMsgPrintfln("%s boot disk:\r\n%s", Launcher->Title, Name); - if (Found.HasDriver) return true; if (SD.exists(Copy)) { // The copy is the disk GEOS saves the user's work to: one that cannot be read is left alone, not replaced if (!GeosInspectFile(Launcher, Copy, &Found, Error, sizeof Error)) { - SendMsgPrintfln("%s:\r\n%s", Copy, Error); + SendMsgPrintfln("%s:\r\n%s", CopyName, Error); return true; } - if (Found.HasDriver) return true; + if (Found.HasDriver) return GeosReady(Launcher, CopyName); } - if (GeosPatchCopy(Launcher, Source, Copy)) SendMsgPrintfln("Added the TR USB mouse driver to\r\n%s", Copy); - return true; + if (!GeosPatchCopy(Launcher, Source, Copy)) return true; + SendMsgPrintfln("Added the TR USB mouse driver to\r\na copy of %s", Name); + return GeosReady(Launcher, CopyName); } FLASHMEM void LaunchGeos(const struct GeosLauncher *Launcher) From 03a63889c48344b4f3e39d23f3fcd19d38d2fdb6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 19:02:33 -0500 Subject: [PATCH 35/51] firmware: the GEOS launcher names the image a failed patch was for, and builds the -TR name once instead of slicing it out of the path --- Source/Teensy/GeosLauncher.ino | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index 08e5c115..9ca7726e 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -58,10 +58,10 @@ FLASHMEM static void GeosPath(char *Path, size_t Size, const struct GeosLauncher snprintf(Path, Size, "%s/%s", Launcher->Folder, Name); } -FLASHMEM static void GeosCopyPath(char *Path, size_t Size, const struct GeosLauncher *Launcher, const char *Name) +FLASHMEM static void GeosCopyName(char *CopyName, size_t Size, const char *Name) { int Base = strlen(Name) - GEOS_EXT_LENGTH; - snprintf(Path, Size, "%s/%.*s" GEOS_COPY_TAG "%s", Launcher->Folder, Base, Name, Name + Base); + snprintf(CopyName, Size, "%.*s" GEOS_COPY_TAG "%s", Base, Name, Name + Base); } // The originals in the folder, listed before anything is written to it @@ -149,7 +149,8 @@ FLASHMEM static bool GeosPatchFile(const struct GeosLauncher *Launcher, const ch } // Patches a temporary copy and renames it into place, so a failure never leaves a half-patched copy behind -FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const char *Source, const char *Copy) +FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const char *Name, const char *Source, + const char *Copy) { char Temp[MaxNamePathLength], Error[GEOS_ERROR_SIZE] = "the SD card could not be written"; @@ -158,7 +159,7 @@ FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const ch (SD.exists(Copy) && !SD.remove(Copy)) || !SD.rename(Temp, Copy)) { SD.remove(Temp); - SendMsgPrintfln("Could not add the mouse driver:\r\n%s", Error); + SendMsgPrintfln("Could not add the mouse driver to\r\na copy of %s:\r\n%s", Name, Error); return false; } return true; @@ -173,12 +174,13 @@ FLASHMEM static bool GeosReady(const struct GeosLauncher *Launcher, const char * // Whether the image is a boot disk, set up or not: only with none does the user need telling where to copy one FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const char *Name) { - char Source[MaxNamePathLength], Copy[MaxNamePathLength], Error[GEOS_ERROR_SIZE]; + char Source[MaxNamePathLength], CopyName[MaxItemNameLength + sizeof GEOS_COPY_TAG], Copy[MaxNamePathLength]; + char Error[GEOS_ERROR_SIZE]; struct GeosBootDisk Found; - const char *CopyName = Copy + strlen(Launcher->Folder) + 1; GeosPath(Source, sizeof Source, Launcher, Name); - GeosCopyPath(Copy, sizeof Copy, Launcher, Name); + GeosCopyName(CopyName, sizeof CopyName, Name); + GeosPath(Copy, sizeof Copy, Launcher, CopyName); if (!GeosInspectFile(Launcher, Source, &Found, Error, sizeof Error)) { SendMsgPrintfln("%s:\r\n%s", Name, Error); @@ -197,7 +199,7 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c } if (Found.HasDriver) return GeosReady(Launcher, CopyName); } - if (!GeosPatchCopy(Launcher, Source, Copy)) return true; + if (!GeosPatchCopy(Launcher, Name, Source, Copy)) return true; SendMsgPrintfln("Added the TR USB mouse driver to\r\na copy of %s", Name); return GeosReady(Launcher, CopyName); } From b7d1933b8b36fe318ff8a424f2bc96894f5bc12f Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 20 Oct 2026 12:42:02 -0500 Subject: [PATCH 36/51] firmware: GEOS 128 boot disk setup moves from the root menu to TeensyROM Specific Preparing the boot disk is a TeensyROM utility, not a program to launch. --- Source/Teensy/MainMenuItems.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Teensy/MainMenuItems.h b/Source/Teensy/MainMenuItems.h index f638ee5f..9f63c511 100644 --- a/Source/Teensy/MainMenuItems.h +++ b/Source/Teensy/MainMenuItems.h @@ -296,6 +296,7 @@ StructMenuItem dirTeensyROMSpecific[] = rtFilePrg , IOH_TR_BASIC , (char*)"BASIC TR Command IO access only" , (uint8_t*)empty_prg , sizeof(empty_prg) , rtBin8kLo , IOH_TeensyROM , (char*)"(This) TeensyROM Menu Cart" , (uint8_t*)TeensyROMC64_bin , sizeof(TeensyROMC64_bin) , rtFilePrg , IOH_TeensyROM , (char*)"TeensyROM External Ports Test" , (uint8_t*)TRExtPortCheck_prg , sizeof(TRExtPortCheck_prg) , + rtGEOS , IOH_None , (char*)"GEOS 128 Boot Disk Setup" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , #ifdef Fab04_REU rtFilePrg , IOH_TeensyROM , (char*)"TR+ C64 Expansion Port Test" , (uint8_t*)ExpansionPortTest_prg , sizeof(ExpansionPortTest_prg) , #endif @@ -324,6 +325,5 @@ StructMenuItem TeensyROMMenu[] = /**7**/ rtDirectory, IOH_None , (char*)"/Utilities" , (uint8_t*)dirUtilities , sizeof(dirUtilities) , /* 8 */ rtDirectory, IOH_None , (char*)"/Text + PETSCII + Docs" , (uint8_t*)dirTEXT_PETSCII , sizeof(dirTEXT_PETSCII) , /**9**/ rtDirectory, IOH_None , (char*)"/TeensyROM Specific" , (uint8_t*)dirTeensyROMSpecific , sizeof(dirTeensyROMSpecific) , - /* 10*/ rtGEOS , IOH_None , (char*)"GEOS 128" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , }; From ce6ffef9fd36fb6701b0e828cb3440d7863146d3 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:09:16 -0500 Subject: [PATCH 37/51] firmware: the USB mouse registers live only beside GeoRAM, and plain REU mode leaves IO1 alone again Serving them at $DE00 beside the REU made every read of $DE00/$DE01 consume mouse movement, and IO1 probes for SwiftLink, RR-Net or MIDI saw changing values. The GEOS 128 driver now probes only $DF03, so it stops reading $DE03-$DE05 every frame. --- .../MinimalBoot/Common/IO_Handlers/IOH_REU.c | 9 +------ .../MinimalBoot/Common/IO_Handlers/USBMouse.c | 4 +-- tools/geos/trmouse128.asm | 26 +++++++------------ 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c index 9a76752c..b602bf91 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c @@ -51,7 +51,6 @@ __attribute__((always_inline)) inline void Printf_dbg_reu(...) {}; #endif -void IO1Hndlr_REU(uint8_t Address, bool R_Wn); void IO2Hndlr_REU(uint8_t Address, bool R_Wn); void PollingHndlr_REU(); void InitHndlr_REU(); @@ -60,7 +59,7 @@ stcIOHandlers IOHndlr_REU = { "512KB REU", //Name of handler &InitHndlr_REU, //Called once at handler startup - &IO1Hndlr_REU, //IO1 R/W handler + NULL, //IO1 R/W handler &IO2Hndlr_REU, //IO2 R/W handler NULL, //ROML Read handler, in addition to any ROM data sent NULL, //ROMH Read handler, in addition to any ROM data sent @@ -470,7 +469,6 @@ FLASHMEM void InitHndlr_REU() //set reg defaults: uint8_t REURegsInit[REUReg_NumRegs]={0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0xff, 0x1f, 0x3f}; memcpy(REURegs, REURegsInit, REUReg_NumRegs); - InitUSBMouse(); #ifdef Direct_REU Printf_dbg_reu("Direct REU mode\n"); @@ -552,11 +550,6 @@ FLASHMEM void InitHndlr_REU() } //InitHndlr_REU -void IO1Hndlr_REU(uint8_t Address, bool R_Wn) -{ - if (R_Wn) USBMouseIORead(Address); -} - void IO2Hndlr_REU(uint8_t Address, bool R_Wn) { #ifdef DbgIOTraceLog diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index adbe925e..07095e6d 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -// A USB mouse on the TR's USB host port, read through registers that a RAM expansion handler -// serves from the IO page it leaves free: $DF00 beside GeoRAM, $DE00 beside the REU. +// A USB mouse on the TR's USB host port, read through registers the GeoRAM handler serves +// at $DF00, in the part of the IO2 page that GeoRAM leaves free. // It cannot be a 1351: the SID's POT lines are not on the cartridge port, so only software // with a TR driver sees it (tools/geos). // diff --git a/tools/geos/trmouse128.asm b/tools/geos/trmouse128.asm index d18bcd93..cc6f1d3e 100644 --- a/tools/geos/trmouse128.asm +++ b/tools/geos/trmouse128.asm @@ -17,6 +17,7 @@ BUTTON_UP = $80 NO_DIRECTION = $ff OFF_TOP = 228 ; the kernal reads a mouseYPos this high as above the top +regs = $df00 regX = 0 regY = 1 regButtons = 2 @@ -44,28 +45,19 @@ SlowMouse SetMouse rts -; The registers are at $DF00 beside GeoRAM and $DE00 beside the REU; the ID tells which. +; The ID tells whether the registers are there, beside GeoRAM. ; $DF03-$DF05 are REU address registers, which have no side effects when read. -; Carry set: neither page has the ID, so what reads there is not a mouse. +; Carry set: what reads there is not a mouse. FindRegs - ldy #$df -nextPage - sty readID+2 ldx #2 -readID lda $df00+regID,x +- lda regs+regID,x cmp trID,x bne + dex - bpl readID - sty readX+2 - sty readY+2 - sty readButtons+2 + bpl - clc rts -+ dey - cpy #$dd - bne nextPage - sec ++ sec rts trID !text "TRM" @@ -73,16 +65,16 @@ trID !text "TRM" UpdateMouse jsr FindRegs bcs noMouse -readX lda $df00+regX + lda regs+regX sta deltaX -readY lda $df00+regY + lda regs+regY sta deltaY bit mouseOn bmi readButtons noMouse rts ; movement while the pointer is off is dropped readButtons - lda $df00+regButtons + lda regs+regButtons and #LEFT_BUTTON beq + lda #0 ; pressed From 54a7f9acd2bbd44b56ec4694c7f7ebfe07daffd6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:09:41 -0500 Subject: [PATCH 38/51] firmware: GeoRAM polls the USB mouse from its handler instead of every main loop pass Other handlers no longer pay for the poll. While GeoRAM is not running the library's report accumulates unread, so starting GeoRAM clears it rather than delivering it to the C64 as one jump. --- Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c | 8 +++++++- Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c | 3 ++- Source/Teensy/Teensy.ino | 1 - 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index afaeeb31..98b745c1 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -8,6 +8,7 @@ void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn); void IO2Hndlr_GeoRAM(uint8_t Address, bool R_Wn); void InitHndlr_GeoRAM(); +void PollingHndlr_GeoRAM(); extern void (*fSpecialBtnChange)(bool Up_nDn); stcIOHandlers IOHndlr_GeoRAM = @@ -18,7 +19,7 @@ stcIOHandlers IOHndlr_GeoRAM = &IO2Hndlr_GeoRAM, //IO2 R/W handler NULL, //ROML Read handler, in addition to any ROM data sent NULL, //ROMH Read handler, in addition to any ROM data sent - NULL, //Polled in main routine + &PollingHndlr_GeoRAM, //Polled in main routine NULL, //called at the end of EVERY c64 cycle }; @@ -80,6 +81,11 @@ FLASHMEM void InitHndlr_GeoRAM() LoadRAMBanks(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); } +FLASHMEM void PollingHndlr_GeoRAM() +{ + PollUSBMouse(); +} + void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn) { if (NumGeoRAM_Banks == 0) return; // disabled: $DE00 stays open bus, so software sees no geoRAM diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index 07095e6d..98d113c9 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -25,7 +25,7 @@ static int32_t USBMouse_Steps(uint8_t Axis) return USBMouse_Counts[Axis] >> USBMouse_StepShift; // floors, so no wider dead zone at 0 } -void PollUSBMouse() +FLASHMEM void PollUSBMouse() { // MouseController is also a BTHIDInput, so its own bool conversion is ambiguous if (!static_cast(usbHostMouse)) @@ -55,6 +55,7 @@ void PollUSBMouse() // Movement from before the handler started (at the TR menu) is not the C64's to see FLASHMEM void InitUSBMouse() { + usbHostMouse.mouseDataClear(); for (uint8_t Axis = 0; Axis < 2; Axis++) USBMouse_StepsRead[Axis] = USBMouse_Steps(Axis); } diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index ffe7b2df..0dd28899 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -370,7 +370,6 @@ void loop() if (Serial.available()) ServiceSerial(&Serial); myusbHost.Task(); - PollUSBMouse(); if (nfcState == nfcStateEnabled) nfcCheck(); From bb4e2e168de280e70b0f762c517e09d335123818 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:12:16 -0500 Subject: [PATCH 39/51] firmware: the USB mouse's X and Y reads drive a byte kept ready, and do the arithmetic after A read that reaches DataPortWriteWait late is latched wrong on some C64s, so the step and clamp now run after the byte is on the bus. The main loop's poll updates the ready byte with a compare-exchange that fails if a read landed meanwhile, so a step is never driven twice. The bytes a read sequence returns are unchanged. --- .../MinimalBoot/Common/IO_Handlers/USBMouse.c | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index 98d113c9..99494423 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -14,17 +14,41 @@ #define USBMouse_RegID 3 #define USBMouse_StepShift 2 // USB mice count ~1000 per inch; GEOS's screen is 320 wide #define USBMouse_MaxStep 127 +#define USBMouse_ReadyByte 0xff +#define USBMouse_ReadyNext 0x100 static const char USBMouse_ID[] = "TRM"; static volatile int32_t USBMouse_Counts[2]; // USB counts, written only by the main loop -static int32_t USBMouse_StepsRead[2]; // written only by the IO ISR, after InitUSBMouse +static volatile int32_t USBMouse_StepsRead[2]; // written only by the IO ISR, after InitUSBMouse static volatile uint8_t USBMouse_Buttons; +// The next read's byte, kept ready so a read drives the bus before any arithmetic. The bits +// above it count reads, so the main loop can tell that a read slipped in while it computed. +static volatile uint32_t USBMouse_Ready[2]; static int32_t USBMouse_Steps(uint8_t Axis) { return USBMouse_Counts[Axis] >> USBMouse_StepShift; // floors, so no wider dead zone at 0 } +static uint8_t USBMouse_NextStep(uint8_t Axis) +{ + int32_t Step = USBMouse_Steps(Axis) - USBMouse_StepsRead[Axis]; + if (Step > USBMouse_MaxStep) Step = USBMouse_MaxStep; + else if (Step < -USBMouse_MaxStep) Step = -USBMouse_MaxStep; + return (uint8_t)Step; +} + +static void USBMouse_UpdateReady() +{ + for (uint8_t Axis = 0; Axis < 2; Axis++) + { + uint32_t Ready = __atomic_load_n(&USBMouse_Ready[Axis], __ATOMIC_RELAXED); + uint32_t Next; + do Next = (Ready & ~USBMouse_ReadyByte) | USBMouse_NextStep(Axis); + while (!__atomic_compare_exchange_n(&USBMouse_Ready[Axis], &Ready, Next, true, __ATOMIC_RELAXED, __ATOMIC_RELAXED)); + } +} + FLASHMEM void PollUSBMouse() { // MouseController is also a BTHIDInput, so its own bool conversion is ambiguous @@ -50,24 +74,28 @@ FLASHMEM void PollUSBMouse() USBMouse_Counts[0] += X; USBMouse_Counts[1] += Y; USBMouse_Buttons = Buttons; + USBMouse_UpdateReady(); } // Movement from before the handler started (at the TR menu) is not the C64's to see FLASHMEM void InitUSBMouse() { usbHostMouse.mouseDataClear(); - for (uint8_t Axis = 0; Axis < 2; Axis++) USBMouse_StepsRead[Axis] = USBMouse_Steps(Axis); + for (uint8_t Axis = 0; Axis < 2; Axis++) + { + USBMouse_StepsRead[Axis] = USBMouse_Steps(Axis); + USBMouse_Ready[Axis] = 0; + } } void USBMouseIORead(uint8_t Address) { if (Address < 2) { - int32_t Step = USBMouse_Steps(Address) - USBMouse_StepsRead[Address]; - if (Step > USBMouse_MaxStep) Step = USBMouse_MaxStep; - else if (Step < -USBMouse_MaxStep) Step = -USBMouse_MaxStep; - USBMouse_StepsRead[Address] += Step; - DataPortWriteWaitLog((uint8_t)Step); + uint32_t Ready = USBMouse_Ready[Address]; + DataPortWriteWaitLog((uint8_t)Ready); + USBMouse_StepsRead[Address] += (int8_t)Ready; + USBMouse_Ready[Address] = (Ready & ~USBMouse_ReadyByte) + USBMouse_ReadyNext + USBMouse_NextStep(Address); } else if (Address == USBMouse_RegButtons) DataPortWriteWaitLog(USBMouse_Buttons); else if (Address < USBMouse_RegID + sizeof(USBMouse_ID) - 1) From 12d176d2b60ab753506afad8cc134e2129e963aa Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:17:49 -0500 Subject: [PATCH 40/51] firmware: GeoRAM drops its 256-byte no-RAM page GeoRAM_NoRAM was never read: the IO handlers return while there are no banks. Init now points the window at the first page before it publishes the bank count, so that stays true without it; the two are volatile so the stores keep that order. --- .../Common/IO_Handlers/IOH_GeoRAM.c | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 787b86b3..19f60399 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -32,11 +32,12 @@ stcIOHandlers IOHndlr_GeoRAM = #define GeoRAM_RegsBase 0x80 #define GeoRAM_RegBlockBit 0x01 -static uint8_t NumGeoRAM_Banks = 0; +// volatile: the IO ISRs read the page only while there are banks, so the two stores +// must reach memory in the order written +static volatile uint8_t NumGeoRAM_Banks = 0; static uint8_t *GeoRAM_Bank[MAX_GEORAM_BANKS]; static uint8_t GeoRAM_Block, GeoRAM_PageNum; -static uint8_t GeoRAM_NoRAM[256]; // the window until a page of allocated RAM is selected -static uint8_t *GeoRAM_Page = GeoRAM_NoRAM; +static uint8_t * volatile GeoRAM_Page; static void GeoRAM_SelectPage() { @@ -53,7 +54,6 @@ FLASHMEM void FreeGeoRAM() // touching the banks before the first one is freed uint8_t NumBanks = NumGeoRAM_Banks; NumGeoRAM_Banks = 0; - GeoRAM_Page = GeoRAM_NoRAM; FreeRAMBanks(GeoRAM_Bank, &NumBanks); } @@ -67,16 +67,17 @@ FLASHMEM void InitHndlr_GeoRAM() { FreeGeoRAM(); GeoRAM_Block = GeoRAM_PageNum = 0; - NumGeoRAM_Banks = AllocRAMBanks(GeoRAM_Bank, MAX_GEORAM_BANKS, MIN_GEORAM_BANKS); - if (NumGeoRAM_Banks == 0) + uint8_t NumBanks = AllocRAMBanks(GeoRAM_Bank, MAX_GEORAM_BANKS, MIN_GEORAM_BANKS); + if (NumBanks == 0) { Serial.printf("GeoRAM is disabled\n"); return; } - GeoRAM_SelectPage(); + GeoRAM_Page = GeoRAM_Bank[0]; + NumGeoRAM_Banks = NumBanks; fSpecialBtnChange = &SpecialBtn_GeoRAM; - Serial.printf("GeoRAM Size: %dK bytes\n", NumGeoRAM_Banks * RAM_Bank_Size / 1024); - LoadRAMBanks(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); + Serial.printf("GeoRAM Size: %dK bytes\n", NumBanks * RAM_Bank_Size / 1024); + LoadRAMBanks(GeoRAM_Bank, NumBanks, GeoRAM_Filename); } void IO1Hndlr_GeoRAM(uint8_t Address, bool R_Wn) From ce7cf6c0edcc16dc137b773a9df9a2aa12e6786a Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:19:11 -0500 Subject: [PATCH 41/51] firmware: keep the GEOS disk patcher out of ITCM The patcher's helpers had no FLASHMEM, so the ones GCC kept out of line (GeosReadDirectory, GeosReadTrackBam, GeosTake and five more, 1,770 B) were placed in RAM1 code. They run only from the GEOS menu item, never from an interrupt, so flash is fast enough. The host harness gets an empty FLASHMEM, as TRMouse128.h does for PROGMEM. --- Source/Teensy/GEOS/GeosDiskPatch.h | 69 ++++++++++++++++-------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 181dff72..3220738e 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -6,6 +6,11 @@ #include #include #include +#ifdef ARDUINO +#include +#else +#define FLASHMEM +#endif #define GEOS_SECTOR_SIZE 256 #define GEOS_DIR_ENTRY_SIZE 32 @@ -78,13 +83,13 @@ static const struct GeosFormat GeosFormats[] = { "D81", 80, 40, 3, 39, { 40, 0 }, 819200, true, false }, }; -static bool GeosFail(struct GeosDisk *Disk, const char *Message) +FLASHMEM static bool GeosFail(struct GeosDisk *Disk, const char *Message) { snprintf(Disk->Error, sizeof Disk->Error, "%s", Message); return false; } -static uint8_t GeosSectorsOnTrack(const struct GeosFormat *Format, uint8_t Track) +FLASHMEM static uint8_t GeosSectorsOnTrack(const struct GeosFormat *Format, uint8_t Track) { uint8_t ZoneTrack = (Track - 1) % 35 + 1; @@ -92,7 +97,7 @@ static uint8_t GeosSectorsOnTrack(const struct GeosFormat *Format, uint8_t Track return ZoneTrack <= 17 ? 21 : ZoneTrack <= 24 ? 19 : ZoneTrack <= 30 ? 18 : 17; } -static uint32_t GeosOffset(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) +FLASHMEM static uint32_t GeosOffset(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) { uint32_t Blocks = Sector; @@ -100,30 +105,30 @@ static uint32_t GeosOffset(const struct GeosFormat *Format, uint8_t Track, uint8 return Blocks * GEOS_SECTOR_SIZE; } -static bool GeosReadTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, uint8_t *Block) +FLASHMEM static bool GeosReadTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, uint8_t *Block) { if (Disk->Read(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; return GeosFail(Disk, "the image could not be read"); } -static bool GeosWriteTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, const uint8_t *Block) +FLASHMEM static bool GeosWriteTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, const uint8_t *Block) { if (Disk->Write(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; return GeosFail(Disk, "the image could not be written"); } -static bool GeosIsReserved(const struct GeosFormat *Format, uint8_t Track) +FLASHMEM static bool GeosIsReserved(const struct GeosFormat *Format, uint8_t Track) { return Track == Format->Reserved[0] || Track == Format->Reserved[1]; } -static bool GeosIsDirSector(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) +FLASHMEM static bool GeosIsDirSector(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) { return Track == Format->DirTrack && Sector >= Format->FirstDirSector && Sector <= Format->LastDirSector; } // Where a track's free count and free-sector bitmap live -static void GeosBamPlaces(const struct GeosFormat *Format, uint8_t Track, struct GeosPlace *Count, struct GeosPlace *Bitmap) +FLASHMEM static void GeosBamPlaces(const struct GeosFormat *Format, uint8_t Track, struct GeosPlace *Count, struct GeosPlace *Bitmap) { if (Format->FixedSectors) { @@ -143,12 +148,12 @@ static void GeosBamPlaces(const struct GeosFormat *Format, uint8_t Track, struct } } -static bool GeosSectorIsFree(const uint8_t *Bitmap, uint8_t Sector) +FLASHMEM static bool GeosSectorIsFree(const uint8_t *Bitmap, uint8_t Sector) { return Bitmap[Sector / 8] & (1 << (Sector % 8)); } -static uint8_t GeosCountFree(const struct GeosFormat *Format, uint8_t Track, const uint8_t *Bitmap, int *FirstFree) +FLASHMEM static uint8_t GeosCountFree(const struct GeosFormat *Format, uint8_t Track, const uint8_t *Bitmap, int *FirstFree) { uint8_t Free = 0; @@ -172,7 +177,7 @@ struct GeosTrackBam int FirstFree; }; -static bool GeosReadTrackBam(struct GeosDisk *Disk, uint8_t Track, struct GeosTrackBam *Bam) +FLASHMEM static bool GeosReadTrackBam(struct GeosDisk *Disk, uint8_t Track, struct GeosTrackBam *Bam) { GeosBamPlaces(Disk->Format, Track, &Bam->CountAt, &Bam->BitmapAt); Bam->Shared = Bam->CountAt.Track == Bam->BitmapAt.Track && Bam->CountAt.Sector == Bam->BitmapAt.Sector; @@ -185,7 +190,7 @@ static bool GeosReadTrackBam(struct GeosDisk *Disk, uint8_t Track, struct GeosTr return true; } -static bool GeosCheckCount(struct GeosDisk *Disk, uint8_t Track, const struct GeosTrackBam *Bam) +FLASHMEM static bool GeosCheckCount(struct GeosDisk *Disk, uint8_t Track, const struct GeosTrackBam *Bam) { if (*Bam->Count >= Bam->Free) return true; snprintf(Disk->Error, sizeof Disk->Error, "the BAM count for track %u disagrees with its bitmap", Track); @@ -193,7 +198,7 @@ static bool GeosCheckCount(struct GeosDisk *Disk, uint8_t Track, const struct Ge } // Marks the first free sector of a track in use; *Sector is -1 when the track has none. -static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) +FLASHMEM static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) { struct GeosTrackBam Bam; @@ -209,7 +214,7 @@ static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) // Refuses, before anything is written, a disk that GeosAllocate would run out on partway. // Reads the tracks GeosAllocate would visit, in its order, so the refusals match trmouse.py's. -static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) +FLASHMEM static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) { struct GeosTrackBam Bam; unsigned Found = 0; @@ -224,7 +229,7 @@ static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) return GeosFail(Disk, "the disk is full"); } -static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) +FLASHMEM static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) { for (uint8_t Track = 1; Track <= Disk->Format->Tracks; Track++) { @@ -238,7 +243,7 @@ static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) return GeosFail(Disk, "the disk is full"); } -static bool GeosChainHas(const struct GeosTS *Chain, int Length, uint8_t Track, uint8_t Sector) +FLASHMEM static bool GeosChainHas(const struct GeosTS *Chain, int Length, uint8_t Track, uint8_t Sector) { for (int i = 0; i < Length; i++) { @@ -247,7 +252,7 @@ static bool GeosChainHas(const struct GeosTS *Chain, int Length, uint8_t Track, return false; } -static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +FLASHMEM static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) { uint8_t Block[GEOS_SECTOR_SIZE]; uint8_t Track, Sector; @@ -275,7 +280,7 @@ static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, int *Lengt } // Refuses, before anything is written, a directory that GeosGrowDirectory could not grow. -static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length) +FLASHMEM static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length) { const struct GeosFormat *Format = Disk->Format; struct GeosTrackBam Bam; @@ -293,7 +298,7 @@ static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *C } // Chains one more directory sector, as DOS does when a directory fills. -static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) +FLASHMEM static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length) { const struct GeosFormat *Format = Disk->Format; uint8_t Block[GEOS_SECTOR_SIZE]; @@ -314,18 +319,18 @@ static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * return GeosWriteTS(Disk, Format->DirTrack, New, Block); } -static uint16_t GeosDriverDataBlocks(const struct GeosTarget *Target) +FLASHMEM static uint16_t GeosDriverDataBlocks(const struct GeosTarget *Target) { return (Target->DriverSize + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK; } -static uint16_t GeosDriverBlocks(const struct GeosTarget *Target) +FLASHMEM static uint16_t GeosDriverBlocks(const struct GeosTarget *Target) { return GeosDriverDataBlocks(Target) + 1; } // Writes the driver's data chain and info block; returns the first data sector, the info sector and the block count. -static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) +FLASHMEM static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct GeosTS *Info, uint16_t *Blocks) { const struct GeosTarget *Target = Disk->Target; const uint16_t DataBlocks = GeosDriverDataBlocks(Target); @@ -354,7 +359,7 @@ static bool GeosWriteDriver(struct GeosDisk *Disk, struct GeosTS *First, struct return GeosWriteTS(Disk, Info->Track, Info->Sector, Target->DriverInfo); } -static void GeosBuildEntry(const struct GeosTarget *Target, uint8_t *Slot, struct GeosTS First, struct GeosTS Info, uint16_t Blocks, const struct GeosDate *Date) +FLASHMEM static void GeosBuildEntry(const struct GeosTarget *Target, uint8_t *Slot, struct GeosTS First, struct GeosTS Info, uint16_t Blocks, const struct GeosDate *Date) { memset(Slot + SlotType, 0, GEOS_DIR_ENTRY_SIZE - SlotType); Slot[SlotType] = CBM_USR_CLOSED; @@ -375,7 +380,7 @@ static void GeosBuildEntry(const struct GeosTarget *Target, uint8_t *Slot, struc Slot[SlotBlocks + 1] = Blocks >> 8; } -static bool GeosSlotNamed(const uint8_t *Slot, const char *Name) +FLASHMEM static bool GeosSlotNamed(const uint8_t *Slot, const char *Name) { size_t Length = strlen(Name); @@ -396,7 +401,7 @@ struct GeosDirScan bool HasFree, HasInputDriver, HasDeskTop, HasOurs; }; -static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length, struct GeosDirScan *Scan) +FLASHMEM static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length, struct GeosDirScan *Scan) { const struct GeosTarget *Target = Disk->Target; uint8_t Block[GEOS_SECTOR_SIZE]; @@ -421,12 +426,12 @@ static bool GeosScanDirectory(struct GeosDisk *Disk, const struct GeosTS *Chain, return true; } -static bool GeosSlotBefore(struct GeosSlotRef a, struct GeosSlotRef b) +FLASHMEM static bool GeosSlotBefore(struct GeosSlotRef a, struct GeosSlotRef b) { return a.Sector < b.Sector || (a.Sector == b.Sector && a.Slot < b.Slot); } -static bool GeosSwapEntries(struct GeosDisk *Disk, const struct GeosTS *Chain, struct GeosSlotRef a, struct GeosSlotRef b) +FLASHMEM static bool GeosSwapEntries(struct GeosDisk *Disk, const struct GeosTS *Chain, struct GeosSlotRef a, struct GeosSlotRef b) { uint8_t BlockA[GEOS_SECTOR_SIZE], BlockB[GEOS_SECTOR_SIZE], Saved[GEOS_DIR_ENTRY_SIZE]; bool Shared = a.Sector == b.Sector; @@ -443,7 +448,7 @@ static bool GeosSwapEntries(struct GeosDisk *Disk, const struct GeosTS *Chain, s return GeosWriteTS(Disk, Chain[a.Sector].Track, Chain[a.Sector].Sector, BlockA); } -static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, const struct GeosDate *Date) +FLASHMEM static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, const struct GeosDate *Date) { struct GeosTS First, Info; uint16_t Blocks; @@ -455,7 +460,7 @@ static bool GeosWriteEntry(struct GeosDisk *Disk, struct GeosTS Where, int Slot, return GeosWriteTS(Disk, Where.Track, Where.Sector, Block); } -static inline const struct GeosFormat *GeosFormatForSize(uint32_t Size) +FLASHMEM static inline const struct GeosFormat *GeosFormatForSize(uint32_t Size) { for (size_t i = 0; i < sizeof GeosFormats / sizeof GeosFormats[0]; i++) { @@ -464,7 +469,7 @@ static inline const struct GeosFormat *GeosFormatForSize(uint32_t Size) return NULL; } -static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length, struct GeosDirScan *Scan) +FLASHMEM static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length, struct GeosDirScan *Scan) { uint8_t Header[GEOS_SECTOR_SIZE]; @@ -485,7 +490,7 @@ static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int * } // Checks the image and says whether it is the target's boot disk and already holds the TR driver. -static inline bool GeosInspect(struct GeosDisk *Disk, struct GeosBootDisk *Found) +FLASHMEM static inline bool GeosInspect(struct GeosDisk *Disk, struct GeosBootDisk *Found) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; struct GeosDirScan Scan; @@ -499,7 +504,7 @@ static inline bool GeosInspect(struct GeosDisk *Disk, struct GeosBootDisk *Found // Writes the driver ahead of every other input driver of its type: GEOS 128 boots with the first one in the directory. // Every refusal but a failed block read or write comes before the first write, leaving the image as it was. -static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struct GeosDate *Date) +FLASHMEM static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struct GeosDate *Date) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; struct GeosDirScan Scan; From 9d1c7b98468ace632b43b2b0ebd8a4396e841e14 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:28:16 -0500 Subject: [PATCH 42/51] firmware: GeoRAM saves back to georam.ram, keeping the last one as georam.bak The button saved to the REU's numbered names (georam000.ram, ...), but preload reads only georam.ram, so a saved GeoRAM never came back. The new image is written to georam.tmp first and renamed into place only after the write succeeds. The REU keeps its numbered saves. --- .../Common/IO_Handlers/IOH_GeoRAM.c | 2 +- .../MinimalBoot/Common/IO_Handlers/RAMBanks.c | 93 ++++++++++++++----- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c index 19f60399..49997e10 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -60,7 +60,7 @@ FLASHMEM void FreeGeoRAM() FLASHMEM void SpecialBtn_GeoRAM(bool Up_nDn) { // on button release save GeoRAM contents - if(Up_nDn) SaveRAMBanks(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); + if(Up_nDn) SaveRAMBanksReplacing(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); } FLASHMEM void InitHndlr_GeoRAM() diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c index e15d37e2..3c4caa79 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -121,6 +121,45 @@ FLASHMEM void LoadRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source LoadFile.close(); } +FLASHMEM static bool WriteRAMBanksFile(FS *sourceFS, const char *FileName, uint8_t *Bank[], uint8_t NumBanks) +{ + Serial.printf("Saving: %s\n", FileName); + sourceFS->remove(FileName); // FILE_WRITE appends to an existing file + File SaveFile = sourceFS->open(FileName, FILE_WRITE); + if (!SaveFile) + { + Serial.println("Unable to open!"); + return false; + } + + for (uint8_t BankNum = 0; BankNum < NumBanks; BankNum++) + { + if (SaveFile.write(Bank[BankNum], RAM_Bank_Size) != RAM_Bank_Size) + { + Serial.printf("Write failed after %dKBytes!\n", BankNum * RAM_Bank_Size / 1024); + SaveFile.close(); + return false; + } + } + Serial.printf("Saved %dKBytes\n", NumBanks * RAM_Bank_Size / 1024); + SaveFile.close(); + return true; +} + +FLASHMEM static void FlashSavedLED() +{ + uint32_t LastmS = millis(); + bool LEDState = true; + + for (uint32_t FlashCount=0; FlashCount<20; FlashCount++) + { + while(millis()-LastmS < 75); + LastmS = millis(); + if ((LEDState = !LEDState)) SetLEDOn; + else SetLEDOff; + } +} + // Saves to the first free numbered name: reu.reu becomes reu000.reu, reu000.reu becomes reu001.reu, etc FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *SourcePath) { @@ -157,34 +196,38 @@ FLASHMEM void SaveRAMBanks(uint8_t *Bank[], uint8_t NumBanks, const char *Source do sprintf(NewFilename, "%s%03d%s", ptrFileName, NewNum++, Extension); while (sourceFS->exists(NewFilename)); - Serial.printf("Saving: %s\n", NewFilename); - File SaveFile = sourceFS->open(NewFilename, FILE_WRITE); - if (!SaveFile) - { - Serial.println("Unable to open!"); - return; - } + if (WriteRAMBanksFile(sourceFS, NewFilename, Bank, NumBanks)) FlashSavedLED(); +} - for (uint8_t BankNum = 0; BankNum < NumBanks; BankNum++) - { - if (SaveFile.write(Bank[BankNum], RAM_Bank_Size) != RAM_Bank_Size) - { - Serial.printf("Write failed after %dKBytes!\n", BankNum * RAM_Bank_Size / 1024); - SaveFile.close(); - return; - } - } - Serial.printf("Saved %dKBytes\n", NumBanks * RAM_Bank_Size / 1024); - SaveFile.close(); +FLASHMEM static void WithExtension(char *Dest, const char *FileName, const char *Extension) +{ + strlcpy(Dest, FileName, MaxPathLength); + char *pDot = strrchr(Dest, '.'); + if (pDot != NULL) *pDot = 0; + strlcat(Dest, Extension, MaxPathLength); +} - uint32_t LastmS = millis(); - bool LEDState = true; +// Overwrites the file, keeping the one it replaces as .bak: georam.ram becomes georam.bak +FLASHMEM void SaveRAMBanksReplacing(uint8_t *Bank[], uint8_t NumBanks, const char *SourcePath) +{ + if (NumBanks == 0) return; // nothing to save: probably due to failure to allocate - for (uint32_t FlashCount=0; FlashCount<20; FlashCount++) + char Filename[MaxPathLength]; + strlcpy(Filename, SourcePath, MaxPathLength); + char *ptrFileName; + FS *sourceFS = RAMBanksFS(Filename, &ptrFileName); + + char TempFilename[MaxPathLength], BackupFilename[MaxPathLength]; + WithExtension(TempFilename, ptrFileName, ".tmp"); + WithExtension(BackupFilename, ptrFileName, ".bak"); + + if (!WriteRAMBanksFile(sourceFS, TempFilename, Bank, NumBanks)) return; + sourceFS->remove(BackupFilename); + sourceFS->rename(ptrFileName, BackupFilename); // fails harmlessly when there is no previous save + if (!sourceFS->rename(TempFilename, ptrFileName)) { - while(millis()-LastmS < 75); - LastmS = millis(); - if ((LEDState = !LEDState)) SetLEDOn; - else SetLEDOff; + Serial.printf("Unable to rename %s to %s!\n", TempFilename, ptrFileName); + return; } + FlashSavedLED(); } From 2c864364f5fc20d784b4033d8a818ca22df130c5 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:36:46 -0500 Subject: [PATCH 43/51] firmware: keep the GEOS patcher's messages in flash On Teensy 4 string literals land in .rodata, which the linker places in DTCM. PSTR() puts each one in flash instead, where snprintf and SendMsgPrintfln read it directly. The host harness gets PSTR(s) as (s). --- Source/Teensy/GEOS/GeosDiskPatch.h | 31 +++++++++++++++--------------- Source/Teensy/GeosLauncher.ino | 27 +++++++++++++------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 3220738e..13375a03 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -10,6 +10,7 @@ #include #else #define FLASHMEM +#define PSTR(s) (s) #endif #define GEOS_SECTOR_SIZE 256 @@ -85,7 +86,7 @@ static const struct GeosFormat GeosFormats[] = FLASHMEM static bool GeosFail(struct GeosDisk *Disk, const char *Message) { - snprintf(Disk->Error, sizeof Disk->Error, "%s", Message); + snprintf(Disk->Error, sizeof Disk->Error, PSTR("%s"), Message); return false; } @@ -108,13 +109,13 @@ FLASHMEM static uint32_t GeosOffset(const struct GeosFormat *Format, uint8_t Tra FLASHMEM static bool GeosReadTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, uint8_t *Block) { if (Disk->Read(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; - return GeosFail(Disk, "the image could not be read"); + return GeosFail(Disk, PSTR("the image could not be read")); } FLASHMEM static bool GeosWriteTS(struct GeosDisk *Disk, uint8_t Track, uint8_t Sector, const uint8_t *Block) { if (Disk->Write(Disk->Context, GeosOffset(Disk->Format, Track, Sector), Block)) return true; - return GeosFail(Disk, "the image could not be written"); + return GeosFail(Disk, PSTR("the image could not be written")); } FLASHMEM static bool GeosIsReserved(const struct GeosFormat *Format, uint8_t Track) @@ -193,7 +194,7 @@ FLASHMEM static bool GeosReadTrackBam(struct GeosDisk *Disk, uint8_t Track, stru FLASHMEM static bool GeosCheckCount(struct GeosDisk *Disk, uint8_t Track, const struct GeosTrackBam *Bam) { if (*Bam->Count >= Bam->Free) return true; - snprintf(Disk->Error, sizeof Disk->Error, "the BAM count for track %u disagrees with its bitmap", Track); + snprintf(Disk->Error, sizeof Disk->Error, PSTR("the BAM count for track %u disagrees with its bitmap"), Track); return false; } @@ -226,7 +227,7 @@ FLASHMEM static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) Found += Bam.Free; if (Found >= Needed) return true; } - return GeosFail(Disk, "the disk is full"); + return GeosFail(Disk, PSTR("the disk is full")); } FLASHMEM static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) @@ -240,7 +241,7 @@ FLASHMEM static bool GeosAllocate(struct GeosDisk *Disk, struct GeosTS *Where) *Where = (struct GeosTS){ Track, (uint8_t)Sector }; return true; } - return GeosFail(Disk, "the disk is full"); + return GeosFail(Disk, PSTR("the disk is full")); } FLASHMEM static bool GeosChainHas(const struct GeosTS *Chain, int Length, uint8_t Track, uint8_t Sector) @@ -261,14 +262,14 @@ FLASHMEM static bool GeosDirChain(struct GeosDisk *Disk, struct GeosTS *Chain, i if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Block)) return false; Track = Block[0]; Sector = Block[1]; - if (!Track) return GeosFail(Disk, "the disk header links to no directory"); + if (!Track) return GeosFail(Disk, PSTR("the disk header links to no directory")); while (Track) { if (GeosChainHas(Chain, *Length, Track, Sector) || !GeosIsDirSector(Disk->Format, Track, Sector) || *Length == GEOS_MAX_DIR_SECTORS) { - snprintf(Disk->Error, sizeof Disk->Error, "the directory chain is broken at %u/%u", Track, Sector); + snprintf(Disk->Error, sizeof Disk->Error, PSTR("the directory chain is broken at %u/%u"), Track, Sector); return false; } Chain[(*Length)++] = (struct GeosTS){ Track, Sector }; @@ -289,12 +290,12 @@ FLASHMEM static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct for (int i = 0; i < Length; i++) { if (!GeosSectorIsFree(Bam.Bits, Chain[i].Sector)) continue; - snprintf(Disk->Error, sizeof Disk->Error, "the BAM shows directory sector %u/%u free", Chain[i].Track, Chain[i].Sector); + snprintf(Disk->Error, sizeof Disk->Error, PSTR("the BAM shows directory sector %u/%u free"), Chain[i].Track, Chain[i].Sector); return false; } if (!GeosCheckCount(Disk, Format->DirTrack, &Bam)) return false; if (Bam.FirstFree >= 0 && GeosIsDirSector(Format, Format->DirTrack, Bam.FirstFree)) return true; - return GeosFail(Disk, "the directory is full"); + return GeosFail(Disk, PSTR("the directory is full")); } // Chains one more directory sector, as DOS does when a directory fills. @@ -304,7 +305,7 @@ FLASHMEM static bool GeosGrowDirectory(struct GeosDisk *Disk, struct GeosTS *Cha uint8_t Block[GEOS_SECTOR_SIZE]; int New; - if (*Length == GEOS_MAX_DIR_SECTORS) return GeosFail(Disk, "the directory is full"); + if (*Length == GEOS_MAX_DIR_SECTORS) return GeosFail(Disk, PSTR("the directory is full")); if (!GeosCheckDirectoryRoom(Disk, Chain, *Length) || !GeosTake(Disk, Format->DirTrack, &New)) return false; struct GeosTS Last = Chain[*Length - 1]; @@ -477,13 +478,13 @@ FLASHMEM static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Cha if (strlen(Target->DeskTopName) > GEOS_NAME_SIZE || strlen(Target->DriverName) > GEOS_NAME_SIZE || !Target->DriverSize) { - return GeosFail(Disk, "the GEOS target's names or driver do not fit a directory entry"); + return GeosFail(Disk, PSTR("the GEOS target's names or driver do not fit a directory entry")); } - if (!Disk->Format) return GeosFail(Disk, "not a D64, D71 or D81 without error bytes"); + if (!Disk->Format) return GeosFail(Disk, PSTR("not a D64, D71 or D81 without error bytes")); if (!GeosReadTS(Disk, Disk->Format->DirTrack, 0, Header)) return false; if (Disk->Format->TwoSided && !(Header[3] & D71_DOUBLE_SIDED)) { - return GeosFail(Disk, "this D71 is formatted single-sided"); + return GeosFail(Disk, PSTR("this D71 is formatted single-sided")); } if (!GeosDirChain(Disk, Chain, Length)) return false; return GeosScanDirectory(Disk, Chain, *Length, Scan); @@ -513,7 +514,7 @@ FLASHMEM static inline bool GeosInstallDriver(struct GeosDisk *Disk, const struc if (!GeosReadDirectory(Disk, Chain, &Length, &Scan)) return false; if (Scan.HasOurs) { - snprintf(Disk->Error, sizeof Disk->Error, "%s is already on this disk", Disk->Target->DriverName); + snprintf(Disk->Error, sizeof Disk->Error, PSTR("%s is already on this disk"), Disk->Target->DriverName); return false; } if (!Scan.HasFree && !GeosCheckDirectoryRoom(Disk, Chain, Length)) return false; diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index 9ca7726e..f9de7ae1 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -55,13 +55,13 @@ FLASHMEM static bool GeosIsPatchedCopy(const char *Name) FLASHMEM static void GeosPath(char *Path, size_t Size, const struct GeosLauncher *Launcher, const char *Name) { - snprintf(Path, Size, "%s/%s", Launcher->Folder, Name); + snprintf(Path, Size, PSTR("%s/%s"), Launcher->Folder, Name); } FLASHMEM static void GeosCopyName(char *CopyName, size_t Size, const char *Name) { int Base = strlen(Name) - GEOS_EXT_LENGTH; - snprintf(CopyName, Size, "%.*s" GEOS_COPY_TAG "%s", Base, Name, Name + Base); + snprintf(CopyName, Size, PSTR("%.*s" GEOS_COPY_TAG "%s"), Base, Name, Name + Base); } // The originals in the folder, listed before anything is written to it @@ -91,12 +91,12 @@ FLASHMEM static bool GeosInspectFile(const struct GeosLauncher *Launcher, const if (!Image) { - if (Error) snprintf(Error, ErrorSize, "the image could not be opened"); + if (Error) snprintf(Error, ErrorSize, PSTR("the image could not be opened")); return false; } Disk = GeosDiskOn(Launcher, &Image); Inspected = GeosInspect(&Disk, Found); - if (!Inspected && Error) snprintf(Error, ErrorSize, "%s", Disk.Error); + if (!Inspected && Error) snprintf(Error, ErrorSize, PSTR("%s"), Disk.Error); Image.close(); return Inspected; } @@ -143,7 +143,7 @@ FLASHMEM static bool GeosPatchFile(const struct GeosLauncher *Launcher, const ch if (!Image) return false; Disk = GeosDiskOn(Launcher, &Image); Patched = GeosInstallDriver(&Disk, &Today); - if (!Patched) snprintf(Error, ErrorSize, "%s", Disk.Error); + if (!Patched) snprintf(Error, ErrorSize, PSTR("%s"), Disk.Error); Image.close(); return Patched; } @@ -152,14 +152,15 @@ FLASHMEM static bool GeosPatchFile(const struct GeosLauncher *Launcher, const ch FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const char *Name, const char *Source, const char *Copy) { - char Temp[MaxNamePathLength], Error[GEOS_ERROR_SIZE] = "the SD card could not be written"; + char Temp[MaxNamePathLength], Error[GEOS_ERROR_SIZE]; + strcpy(Error, PSTR("the SD card could not be written")); GeosPath(Temp, sizeof Temp, Launcher, GEOS_TEMP_NAME); if (!GeosCopyFile(Source, Temp) || !GeosPatchFile(Launcher, Temp, Error, sizeof Error) || (SD.exists(Copy) && !SD.remove(Copy)) || !SD.rename(Temp, Copy)) { SD.remove(Temp); - SendMsgPrintfln("Could not add the mouse driver to\r\na copy of %s:\r\n%s", Name, Error); + SendMsgPrintfln(PSTR("Could not add the mouse driver to\r\na copy of %s:\r\n%s"), Name, Error); return false; } return true; @@ -167,7 +168,7 @@ FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const ch FLASHMEM static bool GeosReady(const struct GeosLauncher *Launcher, const char *Name) { - SendMsgPrintfln("%s boot disk:\r\n%s", Launcher->Title, Name); + SendMsgPrintfln(PSTR("%s boot disk:\r\n%s"), Launcher->Title, Name); return true; } @@ -183,7 +184,7 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c GeosPath(Copy, sizeof Copy, Launcher, CopyName); if (!GeosInspectFile(Launcher, Source, &Found, Error, sizeof Error)) { - SendMsgPrintfln("%s:\r\n%s", Name, Error); + SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Error); return false; } if (!Found.HasDeskTop) return false; @@ -194,13 +195,13 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c // The copy is the disk GEOS saves the user's work to: one that cannot be read is left alone, not replaced if (!GeosInspectFile(Launcher, Copy, &Found, Error, sizeof Error)) { - SendMsgPrintfln("%s:\r\n%s", CopyName, Error); + SendMsgPrintfln(PSTR("%s:\r\n%s"), CopyName, Error); return true; } if (Found.HasDriver) return GeosReady(Launcher, CopyName); } if (!GeosPatchCopy(Launcher, Name, Source, Copy)) return true; - SendMsgPrintfln("Added the TR USB mouse driver to\r\na copy of %s", Name); + SendMsgPrintfln(PSTR("Added the TR USB mouse driver to\r\na copy of %s"), Name); return GeosReady(Launcher, CopyName); } @@ -212,12 +213,12 @@ FLASHMEM void LaunchGeos(const struct GeosLauncher *Launcher) if (!SDFullInit()) { - SendMsgPrintfln("%s starts from your boot disk\r\nimage on the SD card. Insert the card.", Launcher->Title); + SendMsgPrintfln(PSTR("%s starts from your boot disk\r\nimage on the SD card. Insert the card."), Launcher->Title); return; } Count = GeosListImages(Launcher, Names); for (uint8_t i = 0; i < Count; i++) BootDiskFound |= GeosSetUpImage(Launcher, Names[i]); if (BootDiskFound) return; - SendMsgPrintfln("Copy your %s boot disk image\r\n(.D64, .D71 or .D81) to %s\r\non the SD card, then select this again.", + SendMsgPrintfln(PSTR("Copy your %s boot disk image\r\n(.D64, .D71 or .D81) to %s\r\non the SD card, then select this again."), Launcher->Title, Launcher->Folder); } From dbc1ba8c833004e25de57847156968a28ddc11c0 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:33:07 -0500 Subject: [PATCH 44/51] firmware: the USB mouse register map says the ID marks the registers, not where they are --- Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c index 99494423..c3612801 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -8,7 +8,7 @@ // +0 X movement since the last read of +0, signed, right is positive // +1 Y movement since the last read of +1, signed, down is positive // +2 buttons, 1 = pressed: bit 0 left, bit 1 right, bit 2 middle -// +3..5 "TRM", for a driver to find the base it is at +// +3..5 "TRM", for a driver to tell that the registers are there #define USBMouse_RegButtons 2 #define USBMouse_RegID 3 From e7f06e25d263bd9294bdf3ab13d8d764d33dd846 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 19:50:47 -0500 Subject: [PATCH 45/51] firmware: regenerate the GEOS 128 mouse driver header for the driver's single $DF00 probe --- Source/Teensy/GEOS/TRMouse128.h | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Source/Teensy/GEOS/TRMouse128.h b/Source/Teensy/GEOS/TRMouse128.h index 1658658c..52c27ba1 100644 --- a/Source/Teensy/GEOS/TRMouse128.h +++ b/Source/Teensy/GEOS/TRMouse128.h @@ -9,29 +9,27 @@ #endif static const char TRMouse128Name[] PROGMEM = "TR USB MOUSE"; -static const uint8_t TRMouse128Code[226] PROGMEM = { - 0x4c, 0x10, 0xfd, 0x4c, 0x1a, 0xfd, 0x4c, 0x42, 0xfd, 0x4c, 0x1a, 0xfd, 0x80, 0x00, 0x00, 0x00, - 0xa9, 0x00, 0x85, 0x3b, 0xa9, 0x08, 0x85, 0x3a, 0x85, 0x3c, 0x60, 0xa0, 0xdf, 0x8c, 0x24, 0xfd, - 0xa2, 0x02, 0xbd, 0x03, 0xdf, 0xdd, 0x3f, 0xfd, 0xd0, 0x0e, 0xca, 0x10, 0xf5, 0x8c, 0x49, 0xfd, - 0x8c, 0x4f, 0xfd, 0x8c, 0x5a, 0xfd, 0x18, 0x60, 0x88, 0xc0, 0xdd, 0xd0, 0xe0, 0x38, 0x60, 0x54, - 0x52, 0x4d, 0x20, 0x1b, 0xfd, 0xb0, 0x10, 0xad, 0x00, 0xdf, 0x8d, 0x0d, 0xfd, 0xad, 0x01, 0xdf, - 0x8d, 0x0f, 0xfd, 0x24, 0x30, 0x30, 0x01, 0x60, 0xad, 0x02, 0xdf, 0x29, 0x01, 0xf0, 0x04, 0xa9, - 0x00, 0xf0, 0x02, 0xa9, 0x80, 0xcd, 0x0c, 0xfd, 0xf0, 0x0c, 0x8d, 0x0c, 0xfd, 0x8d, 0x05, 0x85, - 0xa5, 0x39, 0x09, 0x20, 0x85, 0x39, 0xa2, 0x00, 0xad, 0x0d, 0xfd, 0x10, 0x01, 0xca, 0x8e, 0x0e, - 0xfd, 0x24, 0x3f, 0x10, 0x06, 0x0e, 0x0d, 0xfd, 0x2e, 0x0e, 0xfd, 0x18, 0xa5, 0x3a, 0x6d, 0x0d, - 0xfd, 0x85, 0x3a, 0xa5, 0x3b, 0x6d, 0x0e, 0xfd, 0x85, 0x3b, 0xad, 0x0f, 0xfd, 0x18, 0x65, 0x3c, - 0x2c, 0x0f, 0xfd, 0x30, 0x0a, 0xb0, 0x04, 0xc9, 0xe4, 0x90, 0x08, 0xa9, 0xe3, 0xd0, 0x04, 0xb0, - 0x02, 0xa9, 0xff, 0x85, 0x3c, 0xa2, 0x01, 0xad, 0x0e, 0xfd, 0x30, 0x08, 0x0d, 0x0d, 0xfd, 0xf0, - 0x04, 0xe8, 0xd0, 0x01, 0xca, 0xad, 0x0f, 0xfd, 0xf0, 0x05, 0x30, 0x06, 0xe8, 0xe8, 0xe8, 0xe8, - 0xe8, 0xe8, 0xbd, 0xd9, 0xfd, 0x8d, 0x06, 0x85, 0x60, 0x03, 0x02, 0x01, 0x04, 0xff, 0x00, 0x05, - 0x06, 0x07 +static const uint8_t TRMouse128Code[207] PROGMEM = { + 0x4c, 0x10, 0xfd, 0x4c, 0x1a, 0xfd, 0x4c, 0x2f, 0xfd, 0x4c, 0x1a, 0xfd, 0x80, 0x00, 0x00, 0x00, + 0xa9, 0x00, 0x85, 0x3b, 0xa9, 0x08, 0x85, 0x3a, 0x85, 0x3c, 0x60, 0xa2, 0x02, 0xbd, 0x03, 0xdf, + 0xdd, 0x2c, 0xfd, 0xd0, 0x05, 0xca, 0x10, 0xf5, 0x18, 0x60, 0x38, 0x60, 0x54, 0x52, 0x4d, 0x20, + 0x1b, 0xfd, 0xb0, 0x10, 0xad, 0x00, 0xdf, 0x8d, 0x0d, 0xfd, 0xad, 0x01, 0xdf, 0x8d, 0x0f, 0xfd, + 0x24, 0x30, 0x30, 0x01, 0x60, 0xad, 0x02, 0xdf, 0x29, 0x01, 0xf0, 0x04, 0xa9, 0x00, 0xf0, 0x02, + 0xa9, 0x80, 0xcd, 0x0c, 0xfd, 0xf0, 0x0c, 0x8d, 0x0c, 0xfd, 0x8d, 0x05, 0x85, 0xa5, 0x39, 0x09, + 0x20, 0x85, 0x39, 0xa2, 0x00, 0xad, 0x0d, 0xfd, 0x10, 0x01, 0xca, 0x8e, 0x0e, 0xfd, 0x24, 0x3f, + 0x10, 0x06, 0x0e, 0x0d, 0xfd, 0x2e, 0x0e, 0xfd, 0x18, 0xa5, 0x3a, 0x6d, 0x0d, 0xfd, 0x85, 0x3a, + 0xa5, 0x3b, 0x6d, 0x0e, 0xfd, 0x85, 0x3b, 0xad, 0x0f, 0xfd, 0x18, 0x65, 0x3c, 0x2c, 0x0f, 0xfd, + 0x30, 0x0a, 0xb0, 0x04, 0xc9, 0xe4, 0x90, 0x08, 0xa9, 0xe3, 0xd0, 0x04, 0xb0, 0x02, 0xa9, 0xff, + 0x85, 0x3c, 0xa2, 0x01, 0xad, 0x0e, 0xfd, 0x30, 0x08, 0x0d, 0x0d, 0xfd, 0xf0, 0x04, 0xe8, 0xd0, + 0x01, 0xca, 0xad, 0x0f, 0xfd, 0xf0, 0x05, 0x30, 0x06, 0xe8, 0xe8, 0xe8, 0xe8, 0xe8, 0xe8, 0xbd, + 0xc6, 0xfd, 0x8d, 0x06, 0x85, 0x60, 0x03, 0x02, 0x01, 0x04, 0xff, 0x00, 0x05, 0x06, 0x07 }; static const uint8_t TRMouse128Info[256] PROGMEM = { 0x00, 0xff, 0x03, 0x15, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x7e, 0x00, 0x01, 0x91, 0x80, 0x02, 0x10, 0x40, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x04, 0x10, 0x20, 0x07, 0xff, 0xe0, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x04, 0x00, 0x20, 0x02, 0x00, 0x40, 0x01, 0x81, 0x80, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x00, 0x1e, 0xf7, 0x80, 0x04, 0x94, - 0x80, 0x04, 0xe7, 0x80, 0x83, 0x0f, 0x00, 0x00, 0xfd, 0xe2, 0xfd, 0x00, 0x00, 0x54, 0x52, 0x20, + 0x80, 0x04, 0xe7, 0x80, 0x83, 0x0f, 0x00, 0x00, 0xfd, 0xcf, 0xfd, 0x00, 0x00, 0x54, 0x52, 0x20, 0x55, 0x53, 0x42, 0x20, 0x4d, 0x6f, 0x75, 0x73, 0x65, 0x56, 0x31, 0x2e, 0x30, 0x00, 0x00, 0x00, 0x00, 0x54, 0x65, 0x65, 0x6e, 0x73, 0x79, 0x52, 0x4f, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, From 9d413205c2e131d8bf9d1f68231311f6f5af06a4 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 20:36:06 -0500 Subject: [PATCH 46/51] firmware: GeoRAM rotates georam.ram to .bak only when there is one, and 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. --- Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c index 3c4caa79..e7d75094 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -222,11 +222,16 @@ FLASHMEM void SaveRAMBanksReplacing(uint8_t *Bank[], uint8_t NumBanks, const cha WithExtension(BackupFilename, ptrFileName, ".bak"); if (!WriteRAMBanksFile(sourceFS, TempFilename, Bank, NumBanks)) return; - sourceFS->remove(BackupFilename); - sourceFS->rename(ptrFileName, BackupFilename); // fails harmlessly when there is no previous save + bool HadSave = sourceFS->exists(ptrFileName); + if (HadSave) + { // rename refuses an existing target + sourceFS->remove(BackupFilename); + sourceFS->rename(ptrFileName, BackupFilename); + } if (!sourceFS->rename(TempFilename, ptrFileName)) { Serial.printf("Unable to rename %s to %s!\n", TempFilename, ptrFileName); + if (HadSave) sourceFS->rename(BackupFilename, ptrFileName); return; } FlashSavedLED(); From 76c13e52cd688b46616a69e91725ae80ae8579b8 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 20:36:07 -0500 Subject: [PATCH 47/51] firmware: a remote launch finds its TR menu target before freeing the 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. --- Source/Teensy/RemoteControl.ino | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/Source/Teensy/RemoteControl.ino b/Source/Teensy/RemoteControl.ino index cc3dfd46..9ae54f1f 100644 --- a/Source/Teensy/RemoteControl.ino +++ b/Source/Teensy/RemoteControl.ino @@ -226,22 +226,15 @@ void RemoteLaunch(RegMenuTypes MenuSourceID, const char *FileNamePath, bool DoCa } //DriveDirPath's buffer can't hold both "/" and the filename starting at index 1 } - //free mem for DriveDirMenu in case current (non-tr) handler is using it all - FreeCrtChips(); -#ifdef Fab04_REU - FreeREU(); -#endif - FreeGeoRAM(); - FreeSwiftlinkBuffs(); - InitDriveDirMenu(); + int16_t MenuNum = -1; + StructMenuItem* DefTRMenu = TeensyROMMenu; //default to root menu + uint16_t NumMenuItems = sizeof(TeensyROMMenu)/sizeof(StructMenuItem); + // A launch can arrive while a handler is running the C64: one that finds no + // target must return before anything that handler uses is freed. if (MenuSourceID == rmtTeensy) { //point MenuSource to directory - int16_t MenuNum = -1; - StructMenuItem* DefTRMenu = TeensyROMMenu; //default to root menu - uint16_t NumMenuItems = sizeof(TeensyROMMenu)/sizeof(StructMenuItem); - if(strcmp(DriveDirPath, "/") !=0 ) {//find dir menu MenuNum = FindTRMenuItem(DefTRMenu, NumMenuItems, DriveDirPath); @@ -265,7 +258,19 @@ void RemoteLaunch(RegMenuTypes MenuSourceID, const char *FileNamePath, bool DoCa return; } Printf_dbg("TR File Num = %d\n", MenuNum); + } + //free mem for DriveDirMenu in case current (non-tr) handler is using it all + FreeCrtChips(); +#ifdef Fab04_REU + FreeREU(); +#endif + FreeGeoRAM(); + FreeSwiftlinkBuffs(); + InitDriveDirMenu(); + + if (MenuSourceID == rmtTeensy) + { //point to item # matching filename MenuSource = DefTRMenu; SetNumItems(NumMenuItems); From 9f0c8f8be3f5c5b0081773bdce37720f524acf2e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 20:41:49 -0500 Subject: [PATCH 48/51] tests: drive the USB mouse registers on the host 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(""). --- Source/Teensy/tests/usb-mouse.test.js | 92 +++++++++++++++ Source/Teensy/tests/usb_mouse_harness.cpp | 132 ++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 Source/Teensy/tests/usb-mouse.test.js create mode 100644 Source/Teensy/tests/usb_mouse_harness.cpp diff --git a/Source/Teensy/tests/usb-mouse.test.js b/Source/Teensy/tests/usb-mouse.test.js new file mode 100644 index 00000000..c84f17bc --- /dev/null +++ b/Source/Teensy/tests/usb-mouse.test.js @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// The USB mouse registers GeoRAM serves at $DF00, held to the register map in USBMouse.c. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { commandExists } from '../../../tools/lib/toolchain.mjs'; + +const HARNESS_SOURCE = path.join(import.meta.dirname, 'usb_mouse_harness.cpp'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const skip = !commandExists('c++') && !inCi && 'c++ is not on PATH'; + +const X = 0, Y = 1, BUTTONS = 2; +const COUNTS_PER_STEP = 4; +const MAX_STEP = 127; + +let harnessPath; +function harness() { + if (harnessPath) return harnessPath; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'usb-mouse-')); + harnessPath = path.join(dir, 'harness'); + const built = spawnSync('c++', ['-std=gnu++17', '-Wall', '-Wextra', '-Werror', '-o', harnessPath, HARNESS_SOURCE], { encoding: 'utf8' }); + assert.equal(built.status, 0, built.stderr); + return harnessPath; +} + +function run(lines) { + const ran = spawnSync(harness(), { input: lines.join('\n') + '\n', encoding: 'utf8' }); + assert.equal(ran.status, 0, ran.stderr); + return ran.stdout.split('\n').filter(Boolean); +} + +const report = (dx, dy, buttons = 0) => [`report ${dx} ${dy} ${buttons.toString(16)}`, 'poll']; +const read = (offset) => `r ${offset}`; +const signed = (byte) => (parseInt(byte, 16) << 24) >> 24; + +test('the id is TRM at +3, and nothing answers after it', { skip }, () => { + assert.deepEqual(run([3, 4, 5, 6, 7].map(read)), ['54', '52', '4d', 'open', 'open']); +}); + +test('a large movement arrives over several reads, 127 at most each, then the remainder', { skip }, () => { + const steps = 300; + const got = run([...report(steps * COUNTS_PER_STEP, 0), ...Array(5).fill(read(X))]).map(signed); + assert.deepEqual(got, [MAX_STEP, MAX_STEP, steps - 2 * MAX_STEP, 0, 0]); +}); + +test('X and Y keep their own movement, whatever order they are read in', { skip }, () => { + const got = run([...report(200 * COUNTS_PER_STEP, -150 * COUNTS_PER_STEP), ...[X, Y, Y, X, Y, X, X].map(read)]).map(signed); + assert.deepEqual(got, [127, -127, -23, 73, 0, 0, 0]); +}); + +test('counts below a step carry over to the next report, rounding toward negative', { skip }, () => { + assert.deepEqual(run([...report(3, -1), read(X), read(Y), ...report(1, 0), read(X), read(Y)]).map(signed), [0, -1, 1, 0]); +}); + +// A driver reads +0 and +1 from its interrupt, so any read can land while PollUSBMouse +// is part way through updating the byte the next read will drive. +for (const axis of [X, Y]) { + test(`axis ${axis}: a read landing during the poll's update neither repeats nor loses movement`, { skip }, () => { + const unread = 100, more = 20; + const move = (steps) => report(axis === X ? steps * COUNTS_PER_STEP : 0, axis === Y ? steps * COUNTS_PER_STEP : 0); + const lines = run([...move(unread), `race ${axis}`, ...move(more), ...Array(5).fill(read(axis))]); + assert.equal(lines[0], `race ${unread.toString(16)}`); + assert.deepEqual(lines.slice(1).map(signed), [more, 0, 0, 0, 0]); + }); +} + +test('a weak compare-exchange failing for no reason still leaves the new movement ready', { skip }, () => { + assert.deepEqual(run(['spurious 0', ...report(20 * COUNTS_PER_STEP, 0), read(X)]).map(signed), [20]); +}); + +test('the handler starting discards movement from before it, both polled and still in the controller', { skip }, () => { + const polled = report(40 * COUNTS_PER_STEP, 40 * COUNTS_PER_STEP); + const pending = `report ${30 * COUNTS_PER_STEP} ${30 * COUNTS_PER_STEP} 0`; + const got = run([...polled, pending, 'init', 'poll', read(X), read(Y), ...report(COUNTS_PER_STEP, -COUNTS_PER_STEP), read(X), read(Y)]).map(signed); + assert.deepEqual(got, [0, 0, 1, -1]); +}); + +test('the buttons register follows the latest report, and clears when the mouse is unplugged', { skip }, () => { + const got = run([...report(0, 0, 0b101), read(BUTTONS), ...report(0, 0, 0b010), read(BUTTONS), 'unplug', 'poll', read(BUTTONS)]); + assert.deepEqual(got, ['05', '02', '00']); +}); + +test('a report the controller held when the mouse was unplugged is not delivered on replug', { skip }, () => { + const got = run(['unplug', `report ${10 * COUNTS_PER_STEP} 0 1`, 'poll', 'plug', 'poll', read(X), read(BUTTONS)]); + assert.deepEqual(got, ['00', '00']); +}); diff --git a/Source/Teensy/tests/usb_mouse_harness.cpp b/Source/Teensy/tests/usb_mouse_harness.cpp new file mode 100644 index 00000000..16e00c4f --- /dev/null +++ b/Source/Teensy/tests/usb_mouse_harness.cpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +// usb_mouse_harness: the USB mouse registers, driven from stdin one command a line, +// as the USB host, the main loop and the IO ISR would drive them: +// report X Y B the USB host delivers a report into the mouse controller +// unplug, plug the mouse leaves or returns +// poll the main loop's PollUSBMouse +// init InitUSBMouse, as the handler starts +// r OFFSET an IO read, printing the byte driven or "open" +// race AXIS at the next poll's atomic write of AXIS's ready word, an IO read of +// that axis lands first and prints "race BYTE" +// spurious AXIS the next compare-exchange on AXIS's ready word fails without a reason, +// as a weak one may +#include +#include +#include + +#define FLASHMEM +#define NVIC_DISABLE_IRQ(Irq) +#define NVIC_ENABLE_IRQ(Irq) +#define IRQ_USBHS 0 +// Cortex-M barriers do not assemble for the host: asm volatile(...) becomes asm("") +#define volatile(...) ("") + +struct USBHIDInput +{ + bool Plugged = true; + explicit operator bool() { return Plugged; } +}; + +static struct MouseController : USBHIDInput +{ + int X = 0, Y = 0; + uint8_t Buttons = 0; + bool Available = false; + bool available() { return Available; } + int getMouseX() { return X; } + int getMouseY() { return Y; } + uint8_t getButtons() { return Buttons; } + void mouseDataClear() { X = Y = 0; Buttons = 0; Available = false; } +} usbHostMouse; + +static int Driven; +static void DataPortWriteWaitLog(uint8_t Data) { Driven = Data; } + +static int RaceAxis = -1, SpuriousAxis = -1; +static void LandRace(volatile uint32_t *Word); +static bool FailSpuriously(volatile uint32_t *Word, uint32_t *Expected); + +template +static bool RacedCompareExchange(volatile uint32_t *Word, uint32_t *Expected, Rest... rest) +{ + LandRace(Word); + if (FailSpuriously(Word, Expected)) return false; + return __atomic_compare_exchange_n(Word, Expected, rest...); +} + +template +static void RacedStore(volatile uint32_t *Word, Rest... rest) +{ + LandRace(Word); + __atomic_store_n(Word, rest...); +} + +#define __atomic_compare_exchange_n RacedCompareExchange +#define __atomic_store_n RacedStore +#include "../MinimalBoot/Common/IO_Handlers/USBMouse.c" +#undef __atomic_compare_exchange_n +#undef __atomic_store_n +#undef volatile + +static int Read(unsigned Offset) +{ + Driven = -1; + USBMouseIORead(Offset); + return Driven; +} + +static void LandRace(volatile uint32_t *Word) +{ + if (RaceAxis < 0 || Word != &USBMouse_Ready[RaceAxis]) return; + unsigned Axis = RaceAxis; + RaceAxis = -1; + printf("race %02x\n", Read(Axis)); +} + +static bool FailSpuriously(volatile uint32_t *Word, uint32_t *Expected) +{ + if (SpuriousAxis < 0 || Word != &USBMouse_Ready[SpuriousAxis]) return false; + SpuriousAxis = -1; + *Expected = *Word; + return true; +} + +int main() +{ + char Line[64]; + while (fgets(Line, sizeof Line, stdin)) + { + int X, Y; + unsigned Value; + if (sscanf(Line, "report %d %d %x", &X, &Y, &Value) == 3) + { + usbHostMouse.X = X; + usbHostMouse.Y = Y; + usbHostMouse.Buttons = Value; + usbHostMouse.Available = true; + } + else if (!strncmp(Line, "unplug", 6)) usbHostMouse.Plugged = false; + else if (!strncmp(Line, "plug", 4)) usbHostMouse.Plugged = true; + else if (!strncmp(Line, "poll", 4)) PollUSBMouse(); + else if (!strncmp(Line, "init", 4)) InitUSBMouse(); + else if (sscanf(Line, "race %u", &Value) == 1) RaceAxis = Value; + else if (sscanf(Line, "spurious %u", &Value) == 1) SpuriousAxis = Value; + else if (sscanf(Line, "r %x", &Value) == 1) + { + int Byte = Read(Value); + if (Byte < 0) printf("open\n"); + else printf("%02x\n", Byte); + } + else + { + fprintf(stderr, "unknown command: %s", Line); + return 2; + } + } + if (RaceAxis >= 0 || SpuriousAxis >= 0) + { + fprintf(stderr, "an armed race or spurious failure never happened\n"); + return 1; + } + return 0; +} From e7d08660adc8ff375a66465b7c82cafd4a604162 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 22 Oct 2026 20:44:14 -0500 Subject: [PATCH 49/51] fix: review findings for 9f0c8f8 --- Source/Teensy/tests/usb-mouse.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Source/Teensy/tests/usb-mouse.test.js b/Source/Teensy/tests/usb-mouse.test.js index c84f17bc..cd791b71 100644 --- a/Source/Teensy/tests/usb-mouse.test.js +++ b/Source/Teensy/tests/usb-mouse.test.js @@ -68,6 +68,14 @@ for (const axis of [X, Y]) { assert.equal(lines[0], `race ${unread.toString(16)}`); assert.deepEqual(lines.slice(1).map(signed), [more, 0, 0, 0, 0]); }); + + test(`axis ${axis}: a read landing during the poll's update that leaves the same byte ready does not repeat movement`, { skip }, () => { + const steps = 10; + const move = (n) => report(axis === X ? n * COUNTS_PER_STEP : 0, axis === Y ? n * COUNTS_PER_STEP : 0); + const lines = run([...move(steps), `race ${axis}`, ...move(steps), ...Array(3).fill(read(axis))]); + assert.equal(lines[0], `race ${steps.toString(16).padStart(2, '0')}`); + assert.deepEqual(lines.slice(1).map(signed), [steps, 0, 0]); + }); } test('a weak compare-exchange failing for no reason still leaves the new movement ready', { skip }, () => { From 4210842589247c3f2361adcb942a20778818f5e1 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 25 Oct 2026 19:32:07 -0500 Subject: [PATCH 50/51] fix: review findings for 5ec90dd --- Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c | 1 - 1 file changed, 1 deletion(-) diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c index e7d75094..684290d0 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -30,7 +30,6 @@ extern bool USBFileSystemWait(); extern FS *FSfromSourceID(RegMenuTypes SourceID); extern void FreeDriveDirMenu(); extern uint8_t RAM_Image[]; -extern uint8_t NumCrtChips; extern uint8_t *ptrRAM_ImageEnd; // points to end of CRT-used RAM1 buffer FLASHMEM void FreeRAMBanks(uint8_t *Bank[], uint8_t *NumBanks) From 156f4a4c5e854191f7308ffcb400bf9f3dcf901f Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 25 Oct 2026 19:36:57 -0500 Subject: [PATCH 51/51] test: a PSTR("...") message format counts as a literal --- Source/Teensy/tests/c64-message-buffer.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Teensy/tests/c64-message-buffer.test.js b/Source/Teensy/tests/c64-message-buffer.test.js index 65b0767e..c86bbccc 100644 --- a/Source/Teensy/tests/c64-message-buffer.test.js +++ b/Source/Teensy/tests/c64-message-buffer.test.js @@ -9,7 +9,7 @@ const FIRMWARE_ROOT = path.resolve(import.meta.dirname, '..'); const SOURCE_EXTENSIONS = new Set(['.ino', '.c', '.cpp', '.h']); const SKIPPED_DIRECTORIES = new Set(['tests', 'TRMenuFiles']); -const MESSAGE_FORMATTERS = /\b(SendMsgPrintfln|SendMsgPrintf|SendStrPrintfln)\s*\(\s*(\S)/g; +const MESSAGE_FORMATTERS = /\b(SendMsgPrintfln|SendMsgPrintf|SendStrPrintfln)\s*\(\s*(?:PSTR\s*\(\s*)?(\S)/g; const FORMATTER_DECLARATION = /^\s*(?:FLASHMEM\s+|extern\s+)*(?:void|bool)\s+Send(?:Msg|Str)/; function firmwareSources(dir = FIRMWARE_ROOT) { @@ -45,6 +45,9 @@ test('the scan still sees a call wrapped after its open paren', () => { ['x.ino:1 SendMsgPrintfln']); assert.deepEqual(nonLiteralFormats('SendMsgPrintfln(\n "%s", MenuSelCpy.Name);\n', 'x.ino'), []); assert.deepEqual(nonLiteralFormats('/* SendMsgPrintfln(Name); */\n', 'x.ino'), []); + assert.deepEqual(nonLiteralFormats('SendMsgPrintfln(PSTR("%s"), Name);\n', 'x.ino'), []); + assert.deepEqual(nonLiteralFormats('SendMsgPrintfln(PSTR(Name));\n', 'x.ino'), + ['x.ino:1 SendMsgPrintfln']); }); test('no caller throws away whether the current path fit', () => {