diff --git a/.gitattributes b/.gitattributes index ebeba5c5..84883f63 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/TRMouse128.h text eol=lf 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/BlockDevice/TRBlock.h b/Source/Teensy/BlockDevice/TRBlock.h new file mode 100644 index 00000000..345c3021 --- /dev/null +++ b/Source/Teensy/BlockDevice/TRBlock.h @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// The register side of the TR block device, free of Arduino so a host test can drive it. +// The contract is docs/TR_Block_Device.md; offsets here are from the base that doc names. +#pragma once +#include +#include +#include + +#define TRBLOCK_SIZE 256 +#define TRBLOCK_WINDOW_SIZE 64 +#define TRBLOCK_INFO_SIZE 5 + +enum TRBlockOffset +{ + TRBlockCmd = 0x08, + TRBlockAddr0 = 0x09, + TRBlockAddr1 = 0x0a, + TRBlockPage = 0x0b, + TRBlockToken = 0x0c, + TRBlockStatus = 0x0d, + TRBlockEcho = 0x0e, + TRBlockId = 0x10, + TRBlockWindow = 0x40, +}; + +enum TRBlockCommand +{ + TRBlockRead = 1, + TRBlockWrite = 2, + TRBlockFlush = 3, + TRBlockInfo = 4, +}; + +enum TRBlockStatusValue +{ + TRBlockNeverRun = 0x00, + TRBlockBusy = 0x80, + TRBlockDone = 0x5a, + TRBlockNoImage = 0xe1, + TRBlockBadAddress = 0xe2, + TRBlockReadFailed = 0xe3, + TRBlockWriteFailed = 0xe4, + TRBlockReadOnly = 0xe5, + TRBlockBadCommand = 0xe6, +}; + +static const char TRBlockIdText[] = "TRB"; +#define TRBLOCK_ID_SIZE (sizeof TRBlockIdText - 1) + +// Where the ISR and the main loop meet: the ISR only fills registers and the window and starts a +// job, the main loop does the card's work. +struct TRBlock +{ + uint8_t Buffer[TRBLOCK_SIZE]; + uint8_t *Window; // the page of Buffer the window shows, kept by page writes so a read does no arithmetic + uint8_t Addr0, Addr1, Token; + uint8_t JobCmd, JobAddr0, JobAddr1, JobToken; + volatile uint8_t Status, Echo; + volatile bool Pending; +}; + +struct TRBlockBackend +{ + void *Context; + uint8_t (*Transfer)(void *Context, bool Write, uint8_t Addr0, uint8_t Addr1, uint8_t *Block); + uint8_t (*Info)(void *Context, uint8_t *Out); // TRBLOCK_INFO_SIZE bytes + uint8_t (*Flush)(void *Context); +}; + +// ISR and main loop run on one core, so only the compiler's ordering matters +#define TRBLOCK_ORDER() __atomic_signal_fence(__ATOMIC_SEQ_CST) + +static inline void TRBlockReset(struct TRBlock *Dev) +{ + memset((void *)Dev, 0, sizeof *Dev); + Dev->Window = Dev->Buffer; +} + +#define TRBLOCK_UNSERVED (-1) + +// The IO ISR's side: a read answers from the window or a register, else TRBLOCK_UNSERVED +static inline int TRBlockReadAt(const struct TRBlock *Dev, uint8_t Offset) +{ + if (Offset >= TRBlockWindow) + { + if (Offset >= TRBlockWindow + TRBLOCK_WINDOW_SIZE) return TRBLOCK_UNSERVED; + return Dev->Window[Offset - TRBlockWindow]; + } + if (Offset == TRBlockStatus) return Dev->Status; + if (Offset == TRBlockEcho) return Dev->Echo; + if ((uint8_t)(Offset - TRBlockId) < TRBLOCK_ID_SIZE) return (uint8_t)TRBlockIdText[Offset - TRBlockId]; + return TRBLOCK_UNSERVED; +} + +// ...and writes go to registers or the window. The caller reads the data byte only when this says +// the offset takes one. +static inline bool TRBlockTakes(uint8_t Offset) +{ + return (Offset >= TRBlockCmd && Offset <= TRBlockToken) || + (Offset >= TRBlockWindow && Offset < TRBlockWindow + TRBLOCK_WINDOW_SIZE); +} + +static inline void TRBlockWriteAt(struct TRBlock *Dev, uint8_t Offset, uint8_t Data) +{ + bool Busy = Dev->Status == TRBlockBusy; + + if (Offset >= TRBlockWindow) + { + if (Offset < TRBlockWindow + TRBLOCK_WINDOW_SIZE && !Busy) Dev->Window[Offset - TRBlockWindow] = Data; + return; + } + if (Offset == TRBlockAddr0) Dev->Addr0 = Data; + else if (Offset == TRBlockAddr1) Dev->Addr1 = Data; + else if (Offset == TRBlockPage) Dev->Window = Dev->Buffer + (Data & 3) * TRBLOCK_WINDOW_SIZE; + else if (Offset == TRBlockToken) Dev->Token = Data; + else if (Offset == TRBlockCmd && !Busy) + { + Dev->JobCmd = Data; + Dev->JobAddr0 = Dev->Addr0; + Dev->JobAddr1 = Dev->Addr1; + Dev->JobToken = Dev->Token; + TRBLOCK_ORDER(); + Dev->Status = TRBlockBusy; + TRBLOCK_ORDER(); + Dev->Pending = true; + } +} + +static uint8_t TRBlockRun(struct TRBlock *Dev, const struct TRBlockBackend *Backend) +{ + if (!Backend || !Backend->Context) return TRBlockNoImage; + switch (Dev->JobCmd) + { + case TRBlockRead: + return Backend->Transfer(Backend->Context, false, Dev->JobAddr0, Dev->JobAddr1, Dev->Buffer); + case TRBlockWrite: + return Backend->Transfer(Backend->Context, true, Dev->JobAddr0, Dev->JobAddr1, Dev->Buffer); + case TRBlockFlush: + return Backend->Flush(Backend->Context); + case TRBlockInfo: + return Backend->Info(Backend->Context, Dev->Buffer); + } + return TRBlockBadCommand; +} + +// The main loop's side: runs a started command, true if there was one. The echo is published +// before the status, so a status that is not busy always comes with its token. +static inline bool TRBlockService(struct TRBlock *Dev, const struct TRBlockBackend *Backend) +{ + uint8_t Result; + + if (!Dev->Pending) return false; + TRBLOCK_ORDER(); + Result = TRBlockRun(Dev, Backend); + Dev->Pending = false; + TRBLOCK_ORDER(); + Dev->Echo = Dev->JobToken; + TRBLOCK_ORDER(); + Dev->Status = Result; + return true; +} diff --git a/Source/Teensy/BlockDevice/TRBlockImage.h b/Source/Teensy/BlockDevice/TRBlockImage.h new file mode 100644 index 00000000..b9c788d7 --- /dev/null +++ b/Source/Teensy/BlockDevice/TRBlockImage.h @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// A CBM disk image (D64, D71, D81) as the block device's backend: the track and sector the driver +// sends become an offset in the image file. +#pragma once +#include "../GEOS/GeosDiskPatch.h" +#include "TRBlock.h" + +#define TRBLOCK_IMAGE_WRITABLE 0x01 + +struct TRBlockImage +{ + const struct GeosFormat *Format; + bool Writable; + void *Context; + bool (*Read)(void *Context, uint32_t Offset, uint8_t *Block); + bool (*Write)(void *Context, uint32_t Offset, const uint8_t *Block); + bool (*Flush)(void *Context); +}; + +static inline bool TRBlockImageHasSector(const struct GeosFormat *Format, uint8_t Track, uint8_t Sector) +{ + return Track >= 1 && Track <= Format->Tracks && Sector < GeosSectorsOnTrack(Format, Track); +} + +FLASHMEM static uint8_t TRBlockImageTransfer(void *Context, bool Write, uint8_t Track, uint8_t Sector, uint8_t *Block) +{ + struct TRBlockImage *Image = (struct TRBlockImage *)Context; + uint32_t Offset; + + if (!Image->Format) return TRBlockNoImage; + if (!TRBlockImageHasSector(Image->Format, Track, Sector)) return TRBlockBadAddress; + Offset = GeosOffset(Image->Format, Track, Sector); + if (!Write) return Image->Read(Image->Context, Offset, Block) ? TRBlockDone : TRBlockReadFailed; + if (!Image->Writable) return TRBlockReadOnly; + return Image->Write(Image->Context, Offset, Block) ? TRBlockDone : TRBlockWriteFailed; +} + +FLASHMEM static uint8_t TRBlockImageInfo(void *Context, uint8_t *Out) +{ + struct TRBlockImage *Image = (struct TRBlockImage *)Context; + uint32_t Blocks; + + if (!Image->Format) return TRBlockNoImage; + Blocks = Image->Format->Size / GEOS_SECTOR_SIZE; + + Out[0] = (uint8_t)(Image->Format - GeosFormats + 1); + Out[1] = Image->Writable ? TRBLOCK_IMAGE_WRITABLE : 0; + Out[2] = (uint8_t)Blocks; + Out[3] = (uint8_t)(Blocks >> 8); + Out[4] = (uint8_t)(Blocks >> 16); + return TRBlockDone; +} + +FLASHMEM static uint8_t TRBlockImageFlush(void *Context) +{ + struct TRBlockImage *Image = (struct TRBlockImage *)Context; + + if (!Image->Format) return TRBlockNoImage; + return Image->Flush(Image->Context) ? TRBlockDone : TRBlockWriteFailed; +} + +static inline struct TRBlockBackend TRBlockImageBackend(struct TRBlockImage *Image) +{ + return (struct TRBlockBackend){ Image, TRBlockImageTransfer, TRBlockImageInfo, TRBlockImageFlush }; +} diff --git a/Source/Teensy/DriveDirLoad.ino b/Source/Teensy/DriveDirLoad.ino index d3e7be7e..2f202dcd 100644 --- a/Source/Teensy/DriveDirLoad.ino +++ b/Source/Teensy/DriveDirLoad.ino @@ -270,6 +270,11 @@ FLASHMEM void HandleExecution() return; } + if (MenuSelCpy.ItemType == rtGEOS) + { + if (!LaunchGeos((const struct GeosLauncher*)MenuSelCpy.Code_Image, &MenuSelCpy)) return; + } + SendMsgPrintfln("%s", MenuSelCpy.Name); if (MenuSelCpy.ItemType == rtFileCrt) { //load the CRT into RAM @@ -322,7 +327,7 @@ FLASHMEM void HandleExecution() else { //non-CRT: copy the whole thing from flash into the RAM1 buffer - memcpy(RAM_Image, MenuSelCpy.Code_Image, MenuSelCpy.Size); + if (MenuSelCpy.Code_Image != RAM_Image) memcpy(RAM_Image, MenuSelCpy.Code_Image, MenuSelCpy.Size); MenuSelCpy.Code_Image = RAM_Image; } SendMsgPrintfln("Copied to RAM"); diff --git a/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h new file mode 100644 index 00000000..9d596509 --- /dev/null +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +// What launching a GEOS boot disk from the TR needs of its image: the boot file with the disk driver swapped for +// the TR's, and the disk's own auto-configuration kept from probing a drive that is not there. +#pragma once +#include "GeosDiskPatch.h" +#include "TRDriver.h" + +#define GEOS_DRIVER_AREA 0x9000 +#define GEOS_DRIVER_SIZE 0xd80 +#define GEOS_VECTOR_WORDS 24 +#define GEOS_JUMP_SLOTS 0x30 +#define GEOS_JUMP_COUNT 10 +#define GEOS_TYPE_BYTE 0x4e +#define GEOS_JUMP_OPCODE 0x4c +#define GEOS_AUTO_EXEC 14 +#define GEOS_SYSTEM_FILE 4 +#define GEOS_CONFIG_PREFIX "CONFIG" +#define GEOS_BASIC_START 0x0801 +#define GEOS_MEMORY_END 0x10000 +#define GEOS_MAX_FILE_BLOCKS 3200 +#define GEOS_DEVICE_8 8 + +enum GeosDriverVector { VecInitForIO, VecDoneWithIO, VecExitTurbo, VecPurgeTurbo, VecEnterTurbo, VecChangeDiskDevice, + VecNewDisk, VecReadBlock, VecWriteBlock, VecVerWriteBlock, VecCount = 10 }; +enum GeosDriverJump { JumpSendTSBytes = 6, JumpGetDOSError = 7, JumpReadLink = 9 }; +enum TRDriverEntry { EntrySendTSBytes = 10, EntryGetDOSError, EntryReadLink }; + +static uint16_t GeosWord(const uint8_t *At) { return At[0] | At[1] << 8; } + +static void GeosPutWord(uint8_t *At, uint16_t Value) +{ + At[0] = Value & 0xff; + At[1] = Value >> 8; +} + +static bool GeosInDriverArea(uint16_t Address) { return Address >= GEOS_DRIVER_AREA && Address < GEOS_DRIVER_AREA + GEOS_DRIVER_SIZE; } + +// Whether a stock disk driver's tables start here: the vectors and the jump slots all point into the driver, and +// the type byte names a 1541, 1571 or 1581 +static bool GeosIsDriverAt(const uint8_t *At) +{ + uint8_t Type = At[GEOS_TYPE_BYTE]; + + for (int i = 0; i < GEOS_VECTOR_WORDS; i++) + { + if (!GeosInDriverArea(GeosWord(At + 2 * i))) return false; + } + for (int i = 0; i < GEOS_JUMP_COUNT; i++) + { + const uint8_t *Slot = At + GEOS_JUMP_SLOTS + 3 * i; + if (Slot[0] != GEOS_JUMP_OPCODE || !GeosInDriverArea(GeosWord(Slot + 1))) return false; + } + return Type >= 1 && Type <= 3; +} + +// Offset of the stock driver's image in a boot file, or -1 +static int32_t GeosFindDriver(const uint8_t *Data, uint32_t Size) +{ + for (uint32_t At = 0; At + GEOS_DRIVER_SIZE <= Size; At++) + { + if (GeosIsDriverAt(Data + At)) return At; + } + return -1; +} + +// The stock driver's turbo routines occupy one run of its code, from InitForIO up to ReadBlock; the TR's +// driver replaces them and points every vector it implements at its own routines +static bool GeosReplaceTransport(uint8_t *Driver) +{ + uint16_t Start = GeosWord(Driver + 2 * VecInitForIO), End = GeosWord(Driver + 2 * VecReadBlock); + uint8_t Code[sizeof TRDriverCode]; + + if (End <= Start || (size_t)(End - Start) < sizeof Code) return false; + for (int i = VecDoneWithIO; i <= VecNewDisk; i++) + { + uint16_t Routine = GeosWord(Driver + 2 * i); + if (Routine < Start || Routine >= End) return false; + } + for (int i = VecCount; i < GEOS_VECTOR_WORDS; i++) + { + uint16_t Other = GeosWord(Driver + 2 * i); + if (Other >= Start && Other < End) return false; + } + for (int i = 0; i < GEOS_JUMP_COUNT; i++) + { + uint16_t Target = GeosWord(Driver + GEOS_JUMP_SLOTS + 3 * i + 1); + bool Repointed = i == JumpSendTSBytes || i == JumpGetDOSError || i == JumpReadLink; + if (!Repointed && Target >= Start && Target < End) return false; + } + memcpy(Code, TRDriverCode, sizeof Code); + for (size_t i = 0; i < sizeof TRDriverRelocations / sizeof TRDriverRelocations[0]; i++) + { + uint8_t *Word = Code + TRDriverRelocations[i]; + GeosPutWord(Word, GeosWord(Word) + Start - TR_DRIVER_BASE); + } + memcpy(Driver + (Start - GEOS_DRIVER_AREA), Code, sizeof Code); + for (int i = 0; i < VecCount; i++) GeosPutWord(Driver + 2 * i, Start + TRDriverEntries[i]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpSendTSBytes + 1, Start + TRDriverEntries[EntrySendTSBytes]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpGetDOSError + 1, Start + TRDriverEntries[EntryGetDOSError]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpReadLink + 1, Start + TRDriverEntries[EntryReadLink]); + return true; +} + +// A file's bytes, found by name in the directory and followed along its sector chain +static bool GeosReadFile(struct GeosDisk *Disk, const char *Name, uint8_t *Out, uint32_t Capacity, uint32_t *Length) +{ + struct GeosTS Chain[GEOS_MAX_DIR_SECTORS], At = { 0, 0 }; + uint8_t Block[GEOS_SECTOR_SIZE]; + struct GeosDirScan Scan; + int Count; + + if (!GeosReadDirectory(Disk, Chain, &Count, &Scan)) return false; + for (int i = 0; i < Count && !At.Track; 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; + if (!(Slot[SlotType] & CBM_TYPE_MASK) || !GeosSlotNamed(Slot, Name)) continue; + At = (struct GeosTS){ Slot[SlotFirst], Slot[SlotFirst + 1] }; + break; + } + } + if (!At.Track) return GeosFail(Disk, PSTR("the boot file is not on the disk")); + + *Length = 0; + for (int Blocks = 0; At.Track; Blocks++) + { + uint32_t Used; + if (Blocks > GEOS_MAX_FILE_BLOCKS || At.Track > Disk->Format->Tracks) return GeosFail(Disk, PSTR("the boot file's chain is broken")); + if (!GeosReadTS(Disk, At.Track, At.Sector, Block)) return false; + Used = Block[0] ? GEOS_DATA_PER_BLOCK : (Block[1] > 1 ? Block[1] - 1 : 0); + if (*Length + Used > Capacity) return GeosFail(Disk, PSTR("the boot file is too large")); + memcpy(Out + *Length, Block + 2, Used); + *Length += Used; + At = (struct GeosTS){ Block[0], Block[1] }; + } + return true; +} + +// Gives AUTO_EXEC configuration files the type of a file GEOS leaves alone, the way the stock disk's own +// configuration probes the IEC bus and empties drive A; returns whether anything was retyped +static bool GeosDisableConfigure(struct GeosDisk *Disk, bool *Changed) +{ + struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; + uint8_t Block[GEOS_SECTOR_SIZE]; + struct GeosDirScan Scan; + int Count; + + *Changed = false; + if (!GeosReadDirectory(Disk, Chain, &Count, &Scan)) return false; + for (int i = 0; i < Count; i++) + { + bool Retyped = false; + if (!GeosReadTS(Disk, Chain[i].Track, Chain[i].Sector, Block)) return false; + for (int e = 0; e < GEOS_ENTRIES_PER_DIR; e++) + { + uint8_t *Slot = Block + e * GEOS_DIR_ENTRY_SIZE; + if (!(Slot[SlotType] & CBM_TYPE_MASK) || Slot[SlotGeosType] != GEOS_AUTO_EXEC) continue; + if (strncmp((char *)Slot + SlotName, GEOS_CONFIG_PREFIX, strlen(GEOS_CONFIG_PREFIX))) continue; + Slot[SlotGeosType] = GEOS_SYSTEM_FILE; + Retyped = true; + } + if (!Retyped) continue; + if (!GeosWriteTS(Disk, Chain[i].Track, Chain[i].Sector, Block)) return false; + *Changed = true; + } + return true; +} + +// BASIC "10 SYS 2061" and the stub it runs: the screen colors and drive 8 the stock GEOS start-up file sets, then +// the boot file's entry. The stock file would LOAD the boot file from the drive; here it arrives in the same PRG. +static const uint8_t GeosBasicLine[] = { 0x0b, 0x08, 0x0a, 0x00, 0x9e, '2', '0', '6', '1', 0x00, 0x00, 0x00 }; +static const uint8_t GeosBootStub[] = +{ + 0xa9, 0x0e, 0x8d, 0x20, 0xd0, // lda #$0e : sta $d020 + 0xa0, 0x06, 0x8c, 0x21, 0xd0, // ldy #$06 : sty $d021 + 0xa9, GEOS_DEVICE_8, 0x85, 0xba, // lda #8 : sta $ba + 0x4c, 0x00, 0x00, // jmp entry +}; + +// The kernal's start-up code sets the date with three immediate stores and the clock in CIA 1 with another run; +// both are found in the boot file by their bytes, since the stock file fixes the date and time they set. The +// clock's run follows a loop that leaves X zero, which the patched run uses for the seconds and tenths. +static const uint8_t GeosStartDate[] = { 0xa9, 0x17, 0x8d, 0x18, 0x85, 0xa9, 0x03, 0x8d, 0x17, 0x85, 0xa9, 0x5c, 0x8d, 0x16, 0x85 }; +static const uint8_t GeosStartClock[] = +{ + 0xca, 0xd0, 0xe4, // dex : bne, the loop before it + 0xad, 0x0f, 0xdc, 0x29, 0x7f, 0x8d, 0x0f, 0xdc, // clear the alarm-set bit + 0xa9, 0x81, 0x8d, 0x0b, 0xdc, // hour 1 PM + 0xa9, 0x00, 0x8d, 0x0a, 0xdc, // minute 0 + 0x8d, 0x09, 0xdc, 0x8d, 0x08, 0xdc, // second and tenths 0; the tenths start the clock +}; +#define GEOS_CLOCK_TIME 11 +#define GEOS_PM_BIT 0x80 + +static uint8_t GeosBcd(uint8_t Value) +{ + return (Value / 10) << 4 | Value % 10; +} + +// The hour byte that leaves CIA 1's TOD holding Hour: a 6526 flips AM/PM when hour 12 is written to it +static uint8_t GeosTodHour(uint8_t Hour) +{ + uint8_t Hour12 = Hour % 12 ? Hour % 12 : 12; + bool Pm = (Hour >= 12) != (Hour12 == 12); + + return GeosBcd(Hour12) | (Pm ? GEOS_PM_BIT : 0); +} + +// Finds Pattern once in Prg; NULL when it is absent or ambiguous +static uint8_t *GeosFindOnce(uint8_t *Prg, uint32_t Length, const uint8_t *Pattern, uint32_t Size) +{ + uint8_t *Found = NULL; + + for (uint32_t i = 0; i + Size <= Length; i++) + { + if (memcmp(Prg + i, Pattern, Size)) continue; + if (Found) return NULL; + Found = Prg + i; + } + return Found; +} + +// Sets the date and time GEOS starts with. Leaves the PRG as it is, and returns false, for a boot file whose +// start-up code is not the one this knows +static bool GeosSetClock(uint8_t *Prg, uint32_t Length, const struct GeosDate *Now) +{ + uint8_t *Date = GeosFindOnce(Prg, Length, GeosStartDate, sizeof GeosStartDate); + uint8_t *Clock = GeosFindOnce(Prg, Length, GeosStartClock, sizeof GeosStartClock); + uint8_t Time[] = + { + 0xa9, GeosTodHour(Now->Hour), 0x8d, 0x0b, 0xdc, + 0xa9, GeosBcd(Now->Minute), 0x8d, 0x0a, 0xdc, + 0x8e, 0x09, 0xdc, 0x8e, 0x08, 0xdc, + }; + + if (!Date || !Clock) return false; + Date[1] = Now->Day; + Date[6] = Now->Month; + Date[11] = Now->Year; + memcpy(Clock + GEOS_CLOCK_TIME, Time, sizeof Time); + return true; +} + +// One PRG that starts GEOS: the boot file with the disk driver's transport replaced, placed where its load +// address puts it, behind a BASIC line that jumps to the boot entry +static bool GeosBuildBootPrg(struct GeosDisk *Disk, uint8_t *Out, uint32_t Capacity, uint32_t *Length) +{ + const uint32_t Prologue = 2 + sizeof GeosBasicLine + sizeof GeosBootStub; + uint32_t FileSize, BodySize, Load, Place; + int32_t Driver; + uint8_t *Body = Out + 2; + + if (!Disk->Target->BootFile) return GeosFail(Disk, PSTR("this version has no boot file to start from")); + if (!GeosReadFile(Disk, Disk->Target->BootFile, Out, Capacity, &FileSize)) return false; + if (FileSize < 2 + GEOS_DRIVER_SIZE) return GeosFail(Disk, PSTR("the boot file is too short")); + BodySize = FileSize - 2; + Load = GeosWord(Out); + Driver = GeosFindDriver(Body, BodySize); + if (Driver < 0 || !GeosReplaceTransport(Body + Driver)) return GeosFail(Disk, PSTR("the boot file's disk driver is not one the TR can replace")); + Place = 2 + Load - GEOS_BASIC_START; + if (Load < GEOS_BASIC_START + Prologue - 2 || Place + BodySize > Capacity || Load + BodySize > GEOS_MEMORY_END) return GeosFail(Disk, PSTR("the boot file does not fit in memory")); + + memmove(Out + Place, Body, BodySize); + memset(Out, 0, Place); + GeosPutWord(Out, GEOS_BASIC_START); + memcpy(Out + 2, GeosBasicLine, sizeof GeosBasicLine); + memcpy(Out + 2 + sizeof GeosBasicLine, GeosBootStub, sizeof GeosBootStub); + GeosPutWord(Out + Prologue - 2, Disk->Target->BootEntry); + *Length = Place + BodySize; + return true; +} diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h new file mode 100644 index 00000000..35926627 --- /dev/null +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: MIT +// 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 +#include +#include +#include +#ifdef ARDUINO +#include +#else +#define FLASHMEM +#define PSTR(s) (s) +#endif + +#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 GEOS_DATA_PER_BLOCK (GEOS_SECTOR_SIZE - 2) + +#define CBM_NAME_PAD 0xa0 +#define CBM_TYPE_MASK 0x07 +#define CBM_USR_CLOSED 0x83 +#define GEOS_SEQUENTIAL 0 +#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 + bool TwoSided; +}; + +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; + const char *BootFile; // what the TR starts the disk from + uint16_t BootEntry; +}; + +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); + char Error[GEOS_ERROR_SIZE]; +}; + +static const struct GeosFormat GeosFormats[] = +{ + { "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 }, +}; + +FLASHMEM static bool GeosFail(struct GeosDisk *Disk, const char *Message) +{ + snprintf(Disk->Error, sizeof Disk->Error, PSTR("%s"), Message); + return false; +} + +FLASHMEM 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; +} + +FLASHMEM 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; +} + +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, 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, PSTR("the image could not be written")); +} + +FLASHMEM static bool GeosIsReserved(const struct GeosFormat *Format, uint8_t Track) +{ + return Track == Format->Reserved[0] || Track == Format->Reserved[1]; +} + +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 +FLASHMEM 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) }; + } +} + +FLASHMEM static bool GeosSectorIsFree(const uint8_t *Bitmap, uint8_t Sector) +{ + return Bitmap[Sector / 8] & (1 << (Sector % 8)); +} + +FLASHMEM 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; +} + +// 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; +}; + +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; + 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; +} + +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, PSTR("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. +FLASHMEM static bool GeosTake(struct GeosDisk *Disk, uint8_t Track, int *Sector) +{ + struct GeosTrackBam Bam; + + if (!GeosReadTrackBam(Disk, Track, &Bam) || !GeosCheckCount(Disk, Track, &Bam)) return false; + *Sector = Bam.FirstFree; + if (*Sector < 0) return true; + + 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. +FLASHMEM static bool GeosCheckRoom(struct GeosDisk *Disk, unsigned Needed) +{ + struct GeosTrackBam Bam; + unsigned Found = 0; + + for (uint8_t Track = 1; Track <= Disk->Format->Tracks; Track++) + { + 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; + } + return GeosFail(Disk, PSTR("the disk is full")); +} + +FLASHMEM 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, PSTR("the disk is full")); +} + +FLASHMEM 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; +} + +FLASHMEM 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, 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, PSTR("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; +} + +// Refuses, before anything is written, a directory that GeosGrowDirectory could not grow. +FLASHMEM static bool GeosCheckDirectoryRoom(struct GeosDisk *Disk, const struct GeosTS *Chain, int Length) +{ + const struct GeosFormat *Format = Disk->Format; + struct GeosTrackBam Bam; + + if (!GeosReadTrackBam(Disk, Format->DirTrack, &Bam)) return false; + for (int i = 0; i < Length; i++) + { + if (!GeosSectorIsFree(Bam.Bits, Chain[i].Sector)) continue; + 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, PSTR("the directory is full")); +} + +// Chains one more directory sector, as DOS does when a directory fills. +FLASHMEM 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 (*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]; + 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); +} + +FLASHMEM static uint16_t GeosDriverDataBlocks(const struct GeosTarget *Target) +{ + return (Target->DriverSize + GEOS_DATA_PER_BLOCK - 1) / GEOS_DATA_PER_BLOCK; +} + +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. +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); + 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++) + { + 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 == 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, Target->DriverCode + Start, Length); + if (!GeosWriteTS(Disk, Here.Track, Here.Sector, Block)) return false; + Here = Next; + } + + if (!GeosAllocate(Disk, Info)) return false; + *Blocks = GeosDriverBlocks(Target); + return GeosWriteTS(Disk, Info->Track, Info->Sector, Target->DriverInfo); +} + +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; + Slot[SlotFirst] = First.Track; + Slot[SlotFirst + 1] = First.Sector; + memset(Slot + SlotName, CBM_NAME_PAD, GEOS_NAME_SIZE); + memcpy(Slot + SlotName, Target->DriverName, strlen(Target->DriverName)); + Slot[SlotInfo] = Info.Track; + Slot[SlotInfo + 1] = Info.Sector; + Slot[SlotStructure] = GEOS_SEQUENTIAL; + Slot[SlotGeosType] = Target->InputType; + 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; +} + +FLASHMEM 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, FirstInputDriver; + bool HasFree, HasInputDriver, HasDeskTop, HasOurs; +}; + +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]; + + 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, Target->DriverName)) Scan->HasOurs = true; + if (GeosSlotNamed(Slot, Target->DeskTopName)) Scan->HasDeskTop = true; + if (Slot[SlotGeosType] == Target->InputType && !Scan->HasInputDriver) { Scan->FirstInputDriver = Here; Scan->HasInputDriver = true; } + } + } + return true; +} + +FLASHMEM static bool GeosSlotBefore(struct GeosSlotRef a, struct GeosSlotRef b) +{ + return a.Sector < b.Sector || (a.Sector == b.Sector && a.Slot < b.Slot); +} + +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; + 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); +} + +FLASHMEM 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(Disk->Target, Block + Slot * GEOS_DIR_ENTRY_SIZE, First, Info, Blocks, Date); + return GeosWriteTS(Disk, Where.Track, Where.Sector, Block); +} + +FLASHMEM static inline 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; +} + +FLASHMEM static bool GeosReadDirectory(struct GeosDisk *Disk, struct GeosTS *Chain, int *Length, struct GeosDirScan *Scan) +{ + 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, PSTR("the GEOS target's names or driver do not fit a directory entry")); + } + 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, PSTR("this D71 is formatted single-sided")); + } + if (!GeosDirChain(Disk, Chain, Length)) return false; + return GeosScanDirectory(Disk, Chain, *Length, Scan); +} + +// Checks the image and says whether it is the target's boot disk and already holds the TR driver. +FLASHMEM 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; + Found->HasDeskTop = Scan.HasDeskTop; + Found->HasDriver = Scan.HasOurs; + return true; +} + +// 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. +FLASHMEM 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.HasOurs) + { + 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; + if (!GeosCheckRoom(Disk, GeosDriverBlocks(Disk->Target))) 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.HasInputDriver || !GeosSlotBefore(Scan.FirstInputDriver, Scan.Free)) return true; + return GeosSwapEntries(Disk, Chain, Scan.FirstInputDriver, Scan.Free); +} diff --git a/Source/Teensy/GEOS/GeosTargets.h b/Source/Teensy/GEOS/GeosTargets.h new file mode 100644 index 00000000..c96b9f46 --- /dev/null +++ b/Source/Teensy/GEOS/GeosTargets.h @@ -0,0 +1,22 @@ +// 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 "TRMouse64.h" + +#define GEOS_INPUT_64 10 +#define GEOS64_BOOT_FILE "GEOBOOT" +#define GEOS64_BOOT_ENTRY 0x2f3c + +struct GeosLauncher +{ + const char *Title, *Folder; + struct GeosTarget Target; +}; + +static const struct GeosLauncher Geos64Launcher = +{ + "GEOS 64", "/GEOS64", + { "DESK TOP", TRMouse64Name, TRMouse64Code, TRMouse64Info, sizeof TRMouse64Code, GEOS_INPUT_64, + GEOS64_BOOT_FILE, GEOS64_BOOT_ENTRY }, +}; diff --git a/Source/Teensy/GEOS/TRDriver.h b/Source/Teensy/GEOS/TRDriver.h new file mode 100644 index 00000000..ce4e66b3 --- /dev/null +++ b/Source/Teensy/GEOS/TRDriver.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// Generated by tools/geos/trdrive.py from tools/geos/trdrive.asm; do not edit. +#pragma once +#include +#ifdef ARDUINO +#include +#else +#define PROGMEM +#endif + +#define TR_DRIVER_BASE 0x9000 +static const uint8_t TRDriverCode[501] PROGMEM = { + 0x08, 0x68, 0x8d, 0xeb, 0x91, 0x78, 0xa5, 0x01, 0x8d, 0xec, 0x91, 0xa9, 0x36, 0x85, 0x01, 0xad, + 0x1a, 0xd0, 0x8d, 0xf4, 0x91, 0xa9, 0x00, 0x8d, 0x1a, 0xd0, 0xa9, 0x7f, 0x8d, 0x19, 0xd0, 0x8d, + 0x0d, 0xdc, 0x8d, 0x0d, 0xdd, 0x60, 0x78, 0xad, 0x0d, 0xdc, 0xad, 0x0d, 0xdd, 0xad, 0xf4, 0x91, + 0x8d, 0x1a, 0xd0, 0xad, 0xec, 0x91, 0x85, 0x01, 0xad, 0xeb, 0x91, 0x48, 0x28, 0x60, 0xa2, 0x00, + 0x8a, 0x60, 0xa2, 0x00, 0xa0, 0x00, 0x60, 0x8d, 0x89, 0x84, 0x85, 0xba, 0xa2, 0x00, 0x60, 0x20, + 0x9c, 0x90, 0xe0, 0x00, 0xd0, 0x03, 0x20, 0x88, 0x91, 0xa0, 0x00, 0x60, 0x20, 0x88, 0x91, 0x20, + 0xd8, 0x90, 0x8a, 0x48, 0x20, 0x88, 0x91, 0x68, 0xaa, 0xa0, 0x00, 0x60, 0x20, 0xbb, 0x91, 0xb0, + 0x26, 0xa9, 0x01, 0x20, 0x15, 0x91, 0xe0, 0x00, 0xd0, 0xdf, 0xa9, 0x00, 0xa2, 0x0b, 0x20, 0xe4, + 0x91, 0xa2, 0x40, 0x20, 0xd5, 0x91, 0xa0, 0x00, 0x91, 0x0a, 0xa2, 0x41, 0x20, 0xd5, 0x91, 0xc8, + 0x91, 0x0a, 0xa2, 0x00, 0xa0, 0x00, 0x60, 0xa2, 0x0d, 0xa0, 0x00, 0x60, 0x20, 0xbb, 0x91, 0xb0, + 0xf6, 0xa9, 0x01, 0x20, 0x15, 0x91, 0xe0, 0x00, 0xd0, 0x2d, 0xa0, 0x00, 0xa9, 0x00, 0x8d, 0xf0, + 0x91, 0xad, 0xf0, 0x91, 0xa2, 0x0b, 0x20, 0xe4, 0x91, 0xa2, 0x00, 0xbd, 0x40, 0xdf, 0xdd, 0x40, + 0xdf, 0xd0, 0xf8, 0x91, 0x0a, 0xc8, 0xe8, 0xe0, 0x40, 0xd0, 0xf0, 0xee, 0xf0, 0x91, 0xad, 0xf0, + 0x91, 0xc9, 0x04, 0xd0, 0xdc, 0xa2, 0x00, 0x60, 0x20, 0xbb, 0x91, 0xb0, 0xba, 0xa0, 0x00, 0xa9, + 0x00, 0x8d, 0xf0, 0x91, 0xad, 0xf0, 0x91, 0xa2, 0x0b, 0x20, 0xe4, 0x91, 0xa2, 0x00, 0xb1, 0x0a, + 0x9d, 0x40, 0xdf, 0xdd, 0x40, 0xdf, 0xf0, 0x05, 0xdd, 0x40, 0xdf, 0xd0, 0x15, 0xc8, 0xe8, 0xe0, + 0x40, 0xd0, 0xeb, 0xee, 0xf0, 0x91, 0xad, 0xf0, 0x91, 0xc9, 0x04, 0xd0, 0xd7, 0xa9, 0x02, 0x4c, + 0x15, 0x91, 0xa2, 0x0d, 0x60, 0x8d, 0xee, 0x91, 0xee, 0xf1, 0x91, 0xd0, 0x03, 0xee, 0xf1, 0x91, + 0xad, 0xf1, 0x91, 0xa2, 0x0c, 0x20, 0xe4, 0x91, 0xa5, 0x04, 0xa2, 0x09, 0x20, 0xe4, 0x91, 0xa5, + 0x05, 0xa2, 0x0a, 0x20, 0xe4, 0x91, 0xad, 0xee, 0x91, 0xa2, 0x08, 0x20, 0xe4, 0x91, 0xa9, 0x00, + 0x8d, 0xf2, 0x91, 0x8d, 0xf3, 0x91, 0xa2, 0x0d, 0x20, 0xd1, 0x91, 0x8d, 0xef, 0x91, 0x20, 0xd1, + 0x91, 0xcd, 0xef, 0x91, 0xd0, 0x04, 0xc9, 0x80, 0xd0, 0x0d, 0xee, 0xf2, 0x91, 0xd0, 0xe7, 0xee, + 0xf3, 0x91, 0xd0, 0xe2, 0xa2, 0x0d, 0x60, 0x48, 0xa2, 0x0e, 0x20, 0xd1, 0x91, 0xcd, 0xf1, 0x91, + 0xf0, 0x04, 0x68, 0x4c, 0x5a, 0x91, 0x68, 0xc9, 0x5a, 0xf0, 0x07, 0xc9, 0xe2, 0xf0, 0x06, 0xa2, + 0x0d, 0x60, 0xa2, 0x00, 0x60, 0xa2, 0x02, 0x60, 0xad, 0x4e, 0x90, 0xc9, 0x03, 0xd0, 0x2b, 0xa5, + 0x04, 0xc9, 0x28, 0xd0, 0x25, 0xa5, 0x05, 0xd0, 0x21, 0xa0, 0x04, 0xb1, 0x0a, 0x8d, 0xed, 0x91, + 0x98, 0x18, 0x69, 0x8c, 0xa8, 0xb1, 0x0a, 0x48, 0xad, 0xed, 0x91, 0x91, 0x0a, 0x98, 0x38, 0xe9, + 0x8c, 0xa8, 0x68, 0x91, 0x0a, 0xc8, 0xc0, 0x1d, 0xd0, 0xe1, 0x60, 0xa0, 0x02, 0xb9, 0x10, 0xdf, + 0xd9, 0x10, 0xdf, 0xd0, 0xf8, 0xd9, 0xe8, 0x91, 0xd0, 0x05, 0x88, 0x10, 0xf0, 0x18, 0x60, 0x38, + 0x60, 0xbd, 0x00, 0xdf, 0x60, 0x20, 0xd1, 0x91, 0x8d, 0xef, 0x91, 0x20, 0xd1, 0x91, 0xcd, 0xef, + 0x91, 0xd0, 0xf2, 0x60, 0x9d, 0x00, 0xdf, 0x60, 0x54, 0x52, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00 +}; +static const uint16_t TRDriverRelocations[57] PROGMEM = { + 0x0003, 0x0009, 0x0013, 0x002e, 0x0034, 0x0039, 0x0050, 0x0057, 0x005d, 0x0060, 0x0065, 0x006d, 0x0074, 0x007f, 0x0084, 0x008d, + 0x009d, 0x00a4, 0x00af, 0x00b2, 0x00b7, 0x00cc, 0x00cf, 0x00d9, 0x00e2, 0x00e5, 0x00ea, 0x0104, 0x0107, 0x0110, 0x0116, 0x0119, + 0x011e, 0x0121, 0x0126, 0x012d, 0x0134, 0x0137, 0x013c, 0x0141, 0x0144, 0x0149, 0x014c, 0x014f, 0x0152, 0x015b, 0x0160, 0x016b, + 0x016e, 0x0174, 0x019e, 0x01a9, 0x01c6, 0x01d6, 0x01d9, 0x01dc, 0x01df +}; +static const uint16_t TRDriverEntries[13] PROGMEM = { + 0x0000, 0x0026, 0x0041, 0x0041, 0x003e, 0x0047, 0x0042, 0x004f, 0x005c, 0x0042, 0x0042, 0x0042, 0x006c +}; diff --git a/Source/Teensy/GEOS/TRMouse128.h b/Source/Teensy/GEOS/TRMouse128.h new file mode 100644 index 00000000..52c27ba1 --- /dev/null +++ b/Source/Teensy/GEOS/TRMouse128.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// 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[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, 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, + 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/Source/Teensy/GEOS/TRMouse64.h b/Source/Teensy/GEOS/TRMouse64.h new file mode 100644 index 00000000..b68cc617 --- /dev/null +++ b/Source/Teensy/GEOS/TRMouse64.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// 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 TRMouse64Name[] PROGMEM = "TR USB MOUSE"; +static const uint8_t TRMouse64Code[197] PROGMEM = { + 0x4c, 0x90, 0xfe, 0x4c, 0x9a, 0xfe, 0x4c, 0xaf, 0xfe, 0x4c, 0x9a, 0xfe, 0x80, 0x00, 0x00, 0x00, + 0xa9, 0x00, 0x85, 0x3b, 0xa9, 0x08, 0x85, 0x3a, 0x85, 0x3c, 0x60, 0xa2, 0x02, 0xbd, 0x03, 0xdf, + 0xdd, 0xac, 0xfe, 0xd0, 0x05, 0xca, 0x10, 0xf5, 0x18, 0x60, 0x38, 0x60, 0x54, 0x52, 0x4d, 0x20, + 0x9b, 0xfe, 0xb0, 0x10, 0xad, 0x00, 0xdf, 0x8d, 0x8d, 0xfe, 0xad, 0x01, 0xdf, 0x8d, 0x8f, 0xfe, + 0x24, 0x30, 0x30, 0x01, 0x60, 0xad, 0x02, 0xdf, 0x29, 0x01, 0xf0, 0x04, 0xa9, 0x00, 0xf0, 0x02, + 0xa9, 0x80, 0xcd, 0x8c, 0xfe, 0xf0, 0x0c, 0x8d, 0x8c, 0xfe, 0x8d, 0x05, 0x85, 0xa5, 0x39, 0x09, + 0x20, 0x85, 0x39, 0xa2, 0x00, 0xad, 0x8d, 0xfe, 0x10, 0x01, 0xca, 0x8e, 0x8e, 0xfe, 0x18, 0xa5, + 0x3a, 0x6d, 0x8d, 0xfe, 0x85, 0x3a, 0xa5, 0x3b, 0x6d, 0x8e, 0xfe, 0x85, 0x3b, 0xad, 0x8f, 0xfe, + 0x18, 0x65, 0x3c, 0x2c, 0x8f, 0xfe, 0x30, 0x0a, 0xb0, 0x04, 0xc9, 0xe4, 0x90, 0x08, 0xa9, 0xe3, + 0xd0, 0x04, 0xb0, 0x02, 0xa9, 0xff, 0x85, 0x3c, 0xa2, 0x01, 0xad, 0x8e, 0xfe, 0x30, 0x08, 0x0d, + 0x8d, 0xfe, 0xf0, 0x04, 0xe8, 0xd0, 0x01, 0xca, 0xad, 0x8f, 0xfe, 0xf0, 0x05, 0x30, 0x06, 0xe8, + 0xe8, 0xe8, 0xe8, 0xe8, 0xe8, 0xbd, 0x3c, 0xff, 0x8d, 0x06, 0x85, 0x60, 0x03, 0x02, 0x01, 0x04, + 0xff, 0x00, 0x05, 0x06, 0x07 +}; +static const uint8_t TRMouse64Info[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, 0x0a, 0x00, 0x80, 0xfe, 0x45, 0xff, 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/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino new file mode 100644 index 00000000..b2278fb7 --- /dev/null +++ b/Source/Teensy/GeosLauncher.ino @@ -0,0 +1,313 @@ +// 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/GeosBoot.h" +#include "GEOS/GeosTargets.h" + +#define GEOS_TEMP_NAME "PATCHING.TMP" +#define GEOS_BACKUP_TAG ".BAK" +#define GEOS_COPY_TAG "-TR" +#define GEOS_EXT_LENGTH 4 +#define GEOS_MAX_IMAGES 8 + +// Only GEOS 64's D81 image, with the deskTop and the applications on one disk, is supported +#define GEOS_IMAGE_EXTENSION ".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); + + return Length > GEOS_EXT_LENGTH && !strcasecmp(Name + Length - GEOS_EXT_LENGTH, GEOS_IMAGE_EXTENSION); +} + +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, 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, PSTR("%.*s" GEOS_COPY_TAG "%s"), 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() || IsDotfile(Name) || !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) + { + 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, PSTR("%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]; + uint64_t Total = 0; + 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; + 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() +{ + 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) +{ + File Image = SD.open(Path, FILE_WRITE_BEGIN); + struct GeosDisk Disk; + struct GeosDate Today = GeosToday(); + bool Patched; + + if (!Image) + { + snprintf(Error, ErrorSize, PSTR("the image could not be opened")); + return false; + } + Disk = GeosDiskOn(Launcher, &Image); + Patched = GeosInstallDriver(&Disk, &Today); + if (!Patched) snprintf(Error, ErrorSize, PSTR("%s"), Disk.Error); + Image.close(); + return Patched; +} + +FLASHMEM static void GeosBackupPath(char *Backup, size_t Size, const char *Copy) +{ + snprintf(Backup, Size, PSTR("%s" GEOS_BACKUP_TAG), Copy); +} + +// A copy renamed aside and never put back means power dropped between the two renames of GeosPatchCopy +FLASHMEM static bool GeosRestoreCopy(const char *Copy) +{ + char Backup[MaxNamePathLength]; + + GeosBackupPath(Backup, sizeof Backup, Copy); + if (SD.exists(Copy)) + { + SD.remove(Backup); + return true; + } + return !SD.exists(Backup) || SD.rename(Backup, Copy); +} + +// Patches a temporary copy and renames it into place, keeping the copy it replaces until the new one is in, so a +// failure never leaves a half-patched copy behind or loses the one GEOS saved to +FLASHMEM static bool GeosPatchCopy(const struct GeosLauncher *Launcher, const char *Name, const char *Source, + const char *Copy, bool Patch) +{ + char Temp[MaxNamePathLength], Backup[MaxNamePathLength], Error[MaxNamePathLength + GEOS_ERROR_SIZE]; + bool Replacing = SD.exists(Copy); + + strcpy(Error, PSTR("the SD card could not be written")); + GeosPath(Temp, sizeof Temp, Launcher, GEOS_TEMP_NAME); + GeosBackupPath(Backup, sizeof Backup, Copy); + if (Replacing) SD.remove(Backup); + if (GeosCopyFile(Source, Temp) && (!Patch || GeosPatchFile(Launcher, Temp, Error, sizeof Error)) && + (!Replacing || SD.rename(Copy, Backup))) + { + if (SD.rename(Temp, Copy)) + { + if (Replacing) SD.remove(Backup); + return true; + } + if (Replacing && !SD.rename(Backup, Copy)) snprintf(Error, sizeof Error, PSTR("your disk is now %s"), Backup); + } + SD.remove(Temp); + if (Patch) SendMsgPrintfln(PSTR("Could not add the mouse driver to\r\na copy of %s:\r\n%s"), Name, Error); + else SendMsgPrintfln(PSTR("Could not make a copy of\r\n%s:\r\n%s"), Name, Error); + return false; +} + +FLASHMEM static bool GeosReady(const struct GeosLauncher *Launcher, const char *Name) +{ + SendMsgPrintfln(PSTR("%s boot disk:\r\n%s"), Launcher->Title, Name); + return true; +} + +FLASHMEM static bool GeosUse(const struct GeosLauncher *Launcher, const char *Name, char *BootName) +{ + strcpy(BootName, Name); + return GeosReady(Launcher, Name); +} + +// Whether the image is a boot disk, set up or not: only with none does the user need telling where to copy one. +// BootName gets the disk to start from: always a copy, since GEOS saves to the disk it starts from +FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const char *Name, char *BootName) +{ + char Source[MaxNamePathLength], CopyName[MaxItemNameLength + sizeof GEOS_COPY_TAG], Copy[MaxNamePathLength]; + char Error[GEOS_ERROR_SIZE]; + struct GeosBootDisk Found; + bool NeedsDriver; + + GeosPath(Source, sizeof Source, Launcher, Name); + GeosCopyName(CopyName, sizeof CopyName, Name); + GeosPath(Copy, sizeof Copy, Launcher, CopyName); + if (!GeosInspectFile(Launcher, Source, &Found, Error, sizeof Error)) + { + SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Error); + return false; + } + if (!Found.HasDeskTop) return false; + NeedsDriver = !Found.HasDriver; + + if (!GeosRestoreCopy(Copy)) + { + SendMsgPrintfln(PSTR("%s\r\nwas set aside and could not be put back"), CopyName); + 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(PSTR("%s:\r\n%s"), CopyName, Error); + return true; + } + if (Found.HasDriver) return GeosUse(Launcher, CopyName, BootName); + if (!Found.HasDeskTop) + { + SendMsgPrintfln(PSTR("%s\r\nhas no desktop; remove it to start over"), CopyName); + return true; + } + if (!GeosPatchCopy(Launcher, CopyName, Copy, Copy, true)) return true; + return GeosUse(Launcher, CopyName, BootName); + } + if (!GeosPatchCopy(Launcher, Name, Source, Copy, NeedsDriver)) return true; + if (NeedsDriver) SendMsgPrintfln(PSTR("Added the TR USB mouse driver to\r\na copy of %s"), Name); + else SendMsgPrintfln(PSTR("Made a copy of %s\r\nto start from"), Name); + return GeosUse(Launcher, CopyName, BootName); +} + +FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char *Name, StructMenuItem *Item) +{ + char Path[MaxNamePathLength]; + struct GeosDisk Disk; + uint32_t Length = 0; + struct GeosDate Now = GeosToday(); + bool Changed, Built; + File Image; + + GeosPath(Path, sizeof Path, Launcher, Name); + Image = SD.open(Path, FILE_WRITE_BEGIN); + if (!Image) + { + SendMsgPrintfln(PSTR("%s\r\ncould not be opened"), Name); + return false; + } + Disk = GeosDiskOn(Launcher, &Image); + Built = GeosBuildBootPrg(&Disk, RAM_Image, RAM_ImageSize, &Length) && GeosDisableConfigure(&Disk, &Changed); + if (Built) GeosSetClock(RAM_Image, Length, &Now); + if (!Built) SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Disk.Error); + Image.close(); + if (!Built) return false; + if (!TRBlockMount(Path)) + { + SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); + return false; + } + Item->ItemType = rtFilePrg; + Item->Code_Image = RAM_Image; + Item->Size = Length; + return true; +} + +FLASHMEM bool LaunchGeos(const struct GeosLauncher *Launcher, StructMenuItem *Item) +{ + char Names[GEOS_MAX_IMAGES][MaxItemNameLength], BootName[MaxItemNameLength + sizeof GEOS_COPY_TAG] = ""; + uint8_t Count; + bool BootDiskFound = false; + + if (!SDFullInit()) + { + SendMsgPrintfln(PSTR("%s starts from your boot disk\r\nimage on the SD card. Insert the card."), Launcher->Title); + return false; + } + Count = GeosListImages(Launcher, Names); + for (uint8_t i = 0; i < Count && !*BootName; i++) BootDiskFound |= GeosSetUpImage(Launcher, Names[i], BootName); + if (!BootDiskFound) + { + SendMsgPrintfln(PSTR("Copy your %s boot disk image\r\n(.D81) to %s\r\non the SD card, then select this again."), + Launcher->Title, Launcher->Folder); + return false; + } + return *BootName && GeosStart(Launcher, BootName, Item); +} diff --git a/Source/Teensy/MainMenuItems.h b/Source/Teensy/MainMenuItems.h index e048374e..e52bfdd0 100644 --- a/Source/Teensy/MainMenuItems.h +++ b/Source/Teensy/MainMenuItems.h @@ -299,6 +299,7 @@ StructMenuItem dirTeensyROMSpecific[] = #ifdef Fab04_REU rtFilePrg , IOH_TeensyROM , (char*)"TR+ C64 Expansion Port Test" , (uint8_t*)ExpansionPortTest_prg , sizeof(ExpansionPortTest_prg) , #endif + rtGEOS , IOH_GeoRAM , (char*)"GEOS 64" , (uint8_t*)&Geos64Launcher , sizeof(Geos64Launcher) , //could repeat these here... //rtFilePrg , IOH_ASID , (char*)"TeensyROM ASID Player +TR ASID", (uint8_t*)ASIDPlayer_prg , sizeof(ASIDPlayer_prg) , //rtFilePrg , IOH_TeensyROM , (char*)"MIDI2SID +TeensyROM MIDI", (uint8_t*)MIDI2SID_prg , sizeof(MIDI2SID_prg) , diff --git a/Source/Teensy/MinimalBoot/Common/IOHandlers.h b/Source/Teensy/MinimalBoot/Common/IOHandlers.h index 88bc75b7..ae1d60b8 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...) @@ -109,6 +110,10 @@ struct stcIOHandlers #ifndef MinimumBuild #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/TRBlockIO.c" + #include "IO_Handlers/IOH_GeoRAM.c" #ifdef Fab04_REU #include "IO_Handlers/IOH_REU.c" #endif @@ -158,6 +163,7 @@ stcIOHandlers* IOHandler[] = //Synch order/qty with enum enumIOHandlers &IOHndlr_KernalReplace, //IOH_KernalReplace, #endif &IOHndlr_Debug, //IOH_Debug, + &IOHndlr_GeoRAM, //IOH_GeoRAM, //*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..2fa7bb93 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_GeoRAM.c @@ -0,0 +1,117 @@ +// 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(); +void PollingHndlr_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 + &PollingHndlr_GeoRAM, //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 the USB mouse's. +#define GeoRAM_RegsBase 0x80 +#define GeoRAM_RegBlockBit 0x01 + +// 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 * volatile GeoRAM_Page; + +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() +{ + // 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; + FreeRAMBanks(GeoRAM_Bank, &NumBanks); +} + +FLASHMEM void SpecialBtn_GeoRAM(bool Up_nDn) +{ + // on button release save GeoRAM contents + if(Up_nDn) SaveRAMBanksReplacing(GeoRAM_Bank, NumGeoRAM_Banks, GeoRAM_Filename); +} + +FLASHMEM void InitHndlr_GeoRAM() +{ + FreeGeoRAM(); + GeoRAM_Block = GeoRAM_PageNum = 0; + InitUSBMouse(); + InitTRBlock(); + uint8_t NumBanks = AllocRAMBanks(GeoRAM_Bank, MAX_GEORAM_BANKS, MIN_GEORAM_BANKS); + if (NumBanks == 0) + { + Serial.printf("GeoRAM is disabled\n"); + return; + } + GeoRAM_Page = GeoRAM_Bank[0]; + NumGeoRAM_Banks = NumBanks; + fSpecialBtnChange = &SpecialBtn_GeoRAM; + Serial.printf("GeoRAM Size: %dK bytes\n", NumBanks * RAM_Bank_Size / 1024); + LoadRAMBanks(GeoRAM_Bank, NumBanks, GeoRAM_Filename); +} + +FLASHMEM void PollingHndlr_GeoRAM() +{ + PollUSBMouse(); + PollTRBlock(); +} + +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 + { + uint8_t Data = DataPortWaitRead(); + GeoRAM_Page[Address] = Data; + TraceLogAddValidData(Data); + } +} + +void IO2Hndlr_GeoRAM(uint8_t Address, bool R_Wn) +{ + if (Address < GeoRAM_RegsBase) + { + TRPageIO(Address, R_Wn); + return; + } + if (R_Wn) 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/IO_Handlers/IOH_REU.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/IOH_REU.c index 269499dc..36e46597 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,17 +139,11 @@ 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[]; -// CrtChips.h declares the main image's static table and count. 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) @@ -455,83 +443,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; } @@ -622,37 +490,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..684290d0 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/RAMBanks.c @@ -0,0 +1,237 @@ +// 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 *ptrRAM_ImageEnd; // points to end of CRT-used RAM1 buffer + +FLASHMEM void FreeRAMBanks(uint8_t *Bank[], uint8_t *NumBanks) +{ + 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(); + + 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 + + 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++; + } + + while(NumBanks < MaxBanks && (NULL != (Bank[NumBanks] = (uint8_t*)malloc(RAM_Bank_Size)))) + NumBanks++; + + 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; + 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++) + { + int BytesRead = LoadFile.read(Bank[BankNum], RAM_Bank_Size); + if (BytesRead <= 0) break; + CharNum += BytesRead; + } + Serial.printf("Read %lu Bytes\n", CharNum); + 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) +{ + 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, '.'); + if (pDot == NULL) pDot = ptrFileName + strlen(ptrFileName); //if no extension, set to end of filename + strlcpy(Extension, pDot, sizeof(Extension)); + *pDot = 0; + + 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 + } + + char NewFilename[MaxPathLength]; + + do sprintf(NewFilename, "%s%03d%s", ptrFileName, NewNum++, Extension); + while (sourceFS->exists(NewFilename)); + + if (WriteRAMBanksFile(sourceFS, NewFilename, Bank, NumBanks)) FlashSavedLED(); +} + +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); +} + +// 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 + + 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; + 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(); +} diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c new file mode 100644 index 00000000..5f4903e3 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT + +// The TR block device (docs/TR_Block_Device.md) in IO2, the page GeoRAM leaves free, beside the USB +// mouse's registers. The ISR side only fills registers and the window; the SD card is touched +// only from the main loop: PollTRBlock from GeoRAM's polling handler, and SDFullInit. + +#include "../../../BlockDevice/TRBlockImage.h" + +#define TRBlock_FlushIdlemS 250 + +static struct TRBlock TRBlockDev; +static struct TRBlockImage TRBlockImg; +static struct TRBlockBackend TRBlockBack; +// An FsFile, not an SD.h File: File::flush() discards whether the sync reached the card +static FsFile TRBlockFile; +static bool TRBlockDirty; +static uint32_t TRBlockLastWritemS; +static char *TRBlockPath; +static bool TRBlockFlushFailureLogged; +static struct { uint32_t FirstSector, CardSerial; } TRBlockMounted; + +FLASHMEM static bool TRBlockFileRead(void *Context, uint32_t Offset, uint8_t *Block) +{ + return TRBlockFile.seekSet(Offset) && TRBlockFile.read(Block, GEOS_SECTOR_SIZE) == GEOS_SECTOR_SIZE; +} + +FLASHMEM static bool TRBlockFileWrite(void *Context, uint32_t Offset, const uint8_t *Block) +{ + if (!TRBlockFile.seekSet(Offset) || TRBlockFile.write(Block, GEOS_SECTOR_SIZE) != GEOS_SECTOR_SIZE) return false; + TRBlockDirty = true; + TRBlockLastWritemS = millis(); + return true; +} + +// GEOS never sends FLUSH, so a failed sync stays dirty for the idle flush to retry +FLASHMEM static bool TRBlockFileFlush(void *Context) +{ + if (TRBlockFile.sync()) + { + TRBlockDirty = TRBlockFlushFailureLogged = false; + return true; + } + TRBlockDirty = true; + TRBlockLastWritemS = millis(); + if (!TRBlockFlushFailureLogged) Serial.printf("TR block image: writes are not reaching the card; retrying\n"); + TRBlockFlushFailureLogged = true; + return false; +} + +FLASHMEM static bool TRBlockOpen() +{ + uint64_t Size; + + if (!TRBlockFile.open(&SD.sdfs, TRBlockPath, O_RDWR)) return false; + Size = TRBlockFile.size(); + TRBlockImg = (struct TRBlockImage){ Size <= UINT32_MAX ? GeosFormatForSize(Size) : NULL, true, NULL, + TRBlockFileRead, TRBlockFileWrite, TRBlockFileFlush }; + if (!TRBlockImg.Format) + { + TRBlockFile.close(); + return false; + } + TRBlockBack = TRBlockImageBackend(&TRBlockImg); + return true; +} + +// Close and reopen around an SD.begin that can run while GeoRAM has an image mounted, as SDFullInit does. +// SD.begin rebuilds the volume in place under any open file and drops its cached, unwritten sectors. +FLASHMEM void TRBlockClose() +{ + TRBlockBack.Context = NULL; + if (TRBlockFile.isOpen() && !TRBlockFile.close()) Serial.printf("TR block image: last writes did not reach the card\n"); + TRBlockDirty = TRBlockFlushFailureLogged = false; +} + +FLASHMEM static uint32_t TRBlockCardSerial() +{ + cid_t CID; + + return SD.sdfs.card()->readCID(&CID) ? CID.psn : 0; +} + +FLASHMEM static bool TRBlockIsMountedFile() +{ + return TRBlockFile.firstSector() == TRBlockMounted.FirstSector && TRBlockCardSerial() == TRBlockMounted.CardSerial; +} + +// Every D64 is the same size, so a swapped card's image at the same path would take GEOS's cached BAM and directory +FLASHMEM void TRBlockReopen() +{ + if (!TRBlockPath || !TRBlockOpen() || TRBlockIsMountedFile()) return; + TRBlockClose(); + Serial.printf("TR block image: %s on the card is not the one mounted; left unmounted\n", TRBlockPath); +} + +FLASHMEM void TRBlockUnmount() +{ + TRBlockClose(); + free(TRBlockPath); + TRBlockPath = NULL; +} + +// The image stays mounted across handler restarts, so a launcher can mount it and then start the handler +FLASHMEM bool TRBlockMount(const char *Path) +{ + TRBlockUnmount(); + TRBlockPath = strdup(Path); + if (TRBlockPath && TRBlockOpen()) + { + TRBlockMounted = { TRBlockFile.firstSector(), TRBlockCardSerial() }; + return true; + } + TRBlockUnmount(); + return false; +} + +FLASHMEM void InitTRBlock() +{ + TRBlockReset(&TRBlockDev); +} + +FLASHMEM void PollTRBlock() +{ + TRBlockService(&TRBlockDev, &TRBlockBack); + if (TRBlockDirty && millis() - TRBlockLastWritemS > TRBlock_FlushIdlemS) TRBlockFileFlush(NULL); +} + +// One IO access to the page the mouse registers start: the mouse owns the offsets below the block device's. +// A read that reaches DataPortWriteWait late is latched wrong on some C64s, so the window is answered first. +__attribute__((always_inline)) inline void TRPageIO(uint8_t Address, bool R_Wn) +{ + uint8_t WindowOffset = Address - TRBlockWindow; + + if (R_Wn) + { + int Data; + + if (WindowOffset < TRBLOCK_WINDOW_SIZE) + { + DataPortWriteWaitLog(TRBlockDev.Window[WindowOffset]); + return; + } + if (Address < TRBlockCmd) + { + USBMouseIORead(Address); + return; + } + Data = TRBlockReadAt(&TRBlockDev, Address); + if (Data != TRBLOCK_UNSERVED) DataPortWriteWaitLog(Data); + } + else if (TRBlockTakes(Address)) + { + uint8_t Data = DataPortWaitRead(); + TRBlockWriteAt(&TRBlockDev, Address, Data); + TraceLogAddValidData(Data); + } +} 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..c3612801 --- /dev/null +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/USBMouse.c @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +// 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). +// +// +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 tell that the registers are there + +#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 +#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 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 + if (!static_cast(usbHostMouse)) + { + // 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 + 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(); + asm volatile("" ::: "memory"); + NVIC_ENABLE_IRQ(IRQ_USBHS); + 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); + USBMouse_Ready[Axis] = 0; + } +} + +void USBMouseIORead(uint8_t Address) +{ + if (Address < 2) + { + 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) + DataPortWriteWaitLog(USBMouse_ID[Address - USBMouse_RegID]); +} diff --git a/Source/Teensy/MinimalBoot/Common/Menu_Regs.h b/Source/Teensy/MinimalBoot/Common/Menu_Regs.h index 166ecfe0..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 @@ -437,7 +438,8 @@ enum enumIOHandlers //Synch order/qty with IOHandler[] (IOHandlers.h) #ifdef Fab04_KernalReplace IOH_KernalReplace, #endif - IOH_Debug, //last manually selectable, see LastSelectableIOH + IOH_Debug, + IOH_GeoRAM, //last manually selectable, see LastSelectableIOH //*not* manually selectable, see LastSelectableIOH @@ -471,4 +473,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 diff --git a/Source/Teensy/RemoteControl.ino b/Source/Teensy/RemoteControl.ino index cfa10e5e..3733b66c 100644 --- a/Source/Teensy/RemoteControl.ino +++ b/Source/Teensy/RemoteControl.ino @@ -273,6 +273,8 @@ void RemoteLaunch(RegMenuTypes MenuSourceID, const char *FileNamePath, bool DoCa #ifdef Fab04_REU FreeREU(); #endif + FreeGeoRAM(); + TRBlockUnmount(); FreeSwiftlinkBuffs(); if (MenuSourceID == rmtTeensy) diff --git a/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h b/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h index e48e852f..56db8d2f 100644 --- a/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h +++ b/Source/Teensy/TRMenuFiles/ROMs/TeensyROMC64.h @@ -389,7 +389,8 @@ static const unsigned char TeensyROMC64_bin[] = { 0x30, 0x00, 0xd3, 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 + 0x38, 0x00, 0xd2, 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 ff171ca6..1e114f16 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. @@ -420,7 +421,9 @@ FLASHMEM void SetUpMainMenuROM() FreeCrtChips(); #ifdef Fab04_REU FreeREU(); -#endif +#endif + FreeGeoRAM(); + TRBlockUnmount(); FreeSwiftlinkBuffs(); RedirectEmptyDriveDirMenu(); free((void*)MIDIRxBuf); MIDIRxBuf=NULL; @@ -559,7 +562,7 @@ bool SDBegin() return SD.begin(BUILTIN_SDCARD); } -bool SDFullInit() +bool SDBeginRetrying() { // begin() takes 3 seconds for fail, 20-200mS for pass, 2 seconds for unpopulated @@ -590,6 +593,16 @@ bool SDFullInit() return true; } +bool SDFullInit() +{ + bool Began; + + TRBlockClose(); + Began = SDBeginRetrying(); + TRBlockReopen(); + return Began; +} + bool USBFileSystemWait() { //wait for USB file system to init in case it's needed by startup SID or auto-launched diff --git a/Source/Teensy/nfcScan.ino b/Source/Teensy/nfcScan.ino index b8d4bbb9..48d2608b 100644 --- a/Source/Teensy/nfcScan.ino +++ b/Source/Teensy/nfcScan.ino @@ -173,7 +173,7 @@ FS *FSfromSourceID(RegMenuTypes SourceID) { if(SourceID == rmtSD) { - SDBegin(); // refresh, takes 3 seconds for fail/unpopulated, 20-200mS populated + SDFullInit(); // SD.begin(BUILTIN_SDCARD); with retry if presence detected return &SD; } else if(SourceID == rmtUSBDrive) return &firstPartition; 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', () => { diff --git a/Source/Teensy/tests/geos-boot.test.js b/Source/Teensy/tests/geos-boot.test.js new file mode 100644 index 00000000..f919f1ea --- /dev/null +++ b/Source/Teensy/tests/geos-boot.test.js @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MIT +// The firmware's GEOS 64 boot PRG and CONFIGURE handling, run on synthetic D81 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 HARNESS_SOURCE = path.join(FIRMWARE_ROOT, 'tests/geos_boot_harness.c'); +const DRIVER_HEADER = path.join(FIRMWARE_ROOT, 'GEOS/TRDriver.h'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const missingTool = ['cc', 'c++'].find((tool) => !commandExists(tool)); +const skip = missingTool && !inCi && `${missingTool} is not on PATH`; + +const SECTOR = 256; +const SECTORS_PER_TRACK = 40; +const TRACKS = 80; +const DIR_TRACK = 40; +const FIRST_DIR_SECTOR = 3; +const FIRST_FILE_TRACK = 1; +const DIR_SLOT = 32; +const SLOT_TYPE = 2; +const SLOT_FIRST = 3; +const SLOT_NAME = 5; +const SLOT_GEOS_TYPE = 24; +const PAD = 0xa0; +const USR_CLOSED = 0x83; +const AUTO_EXEC = 14; +const SYSTEM_FILE = 4; + +const DRIVER_AREA = 0x9000; +const DRIVER_SIZE = 0xd80; +const TURBO_START = 0x9100; +const READ_BLOCK = 0x9400; +const TYPE_BYTE = 0x4e; +const JUMP_SLOTS = 0x30; +const JUMP_COUNT = 10; +const LOAD_ADDRESS = 0x2c98; +const DRIVER_AT_IN_BODY = 0x4400; +const BODY_SIZE = 0x6000; +const BASIC_START = 0x0801; + +const header = fs.readFileSync(DRIVER_HEADER, 'utf8'); +const arrayOf = (name) => [...header.match(new RegExp(`${name}\\[\\d+\\] PROGMEM = \\{([^}]*)\\}`))[1].matchAll(/0x[0-9a-f]+/g)].map((m) => parseInt(m[0], 16)); +const CODE = arrayOf('TRDriverCode'); +const RELOCATIONS = arrayOf('TRDriverRelocations'); +const ENTRIES = arrayOf('TRDriverEntries'); + +const offsetOf = (track, sector) => ((track - 1) * SECTORS_PER_TRACK + sector) * SECTOR; + +function stockDriver() { + const driver = Buffer.alloc(DRIVER_SIZE, 0xea); + const vectors = [TURBO_START, 0x9140, 0x9180, 0x91c0, 0x9200, 0x9240, 0x9280, READ_BLOCK, 0x9500, 0x9540]; + for (let i = 0; i < 24; i++) driver.writeUInt16LE(vectors[i] ?? 0x9600 + i, 2 * i); + for (let i = 0; i < JUMP_COUNT; i++) { + driver[JUMP_SLOTS + 3 * i] = 0x4c; + driver.writeUInt16LE(0x9700 + i, JUMP_SLOTS + 3 * i + 1); + } + driver[TYPE_BYTE] = 3; + return driver; +} + +const START_AT_IN_BODY = 0x1000; +const START_DATE = [0xa9, 0x17, 0x8d, 0x18, 0x85, 0xa9, 0x03, 0x8d, 0x17, 0x85, 0xa9, 0x5c, 0x8d, 0x16, 0x85]; +const LOOP_END = [0xca, 0xd0, 0xe4]; +const START_CLOCK = [0xad, 0x0f, 0xdc, 0x29, 0x7f, 0x8d, 0x0f, 0xdc, 0xa9, 0x81, 0x8d, 0x0b, 0xdc, 0xa9, 0x00, 0x8d, 0x0a, 0xdc, 0x8d, 0x09, 0xdc, 0x8d, 0x08, 0xdc]; +const START_UP = [...START_DATE, ...LOOP_END, ...START_CLOCK]; +const CLOCK_AT = START_DATE.length + LOOP_END.length; + +const CRB = 0xdc0f; +const ALARM_SELECT = 0x80; +const TOD_TENTHS = 0xdc08; +const TOD_HOURS = 0xdc0b; +const PM = 0x80; + +// Runs the start-up clock code the way a 6526 takes it: X is zero from the loop before it, writes go to the alarm +// while CRB selects it, and writing hour 12 flips AM/PM. Returns the TOD registers it sets, tenths first. +function runClock(code) { + let a = 0; + let x = 0; + let crb = ALARM_SELECT; + const tod = []; + const write = (address, value) => { + if (address === CRB) crb = value; + else if (crb & ALARM_SELECT) assert.fail(`$${address.toString(16)} written while CRB selects the alarm`); + else tod[address - TOD_TENTHS] = address === TOD_HOURS && (value & 0x1f) === 0x12 ? value ^ PM : value; + }; + for (let pc = 0; pc < code.length; ) { + const op = code[pc]; + const word = code[pc + 1] | (code[pc + 2] << 8); + if (op === 0xa9) (a = code[pc + 1]), (pc += 2); + else if (op === 0xa2) (x = code[pc + 1]), (pc += 2); + else if (op === 0x29) (a &= code[pc + 1]), (pc += 2); + else if (op === 0xad && word === CRB) (a = crb), (pc += 3); + else if (op === 0x8d) write(word, a), (pc += 3); + else if (op === 0x8e) write(word, x), (pc += 3); + else assert.fail(`unexpected opcode $${op.toString(16)}`); + } + return tod; +} + +function bootFile(driver = stockDriver(), startUp = []) { + const body = Buffer.alloc(BODY_SIZE); + for (let i = 0; i < BODY_SIZE; i++) body[i] = (i * 31 + 7) & 0xff; + driver.copy(body, DRIVER_AT_IN_BODY); + Buffer.from(startUp).copy(body, START_AT_IN_BODY); + const file = Buffer.alloc(2 + BODY_SIZE); + file.writeUInt16LE(LOAD_ADDRESS, 0); + body.copy(file, 2); + return { file, body }; +} + +class Disk { + constructor() { + this.image = Buffer.alloc(TRACKS * SECTORS_PER_TRACK * SECTOR); + this.nextSector = 0; + this.slots = []; + const header = offsetOf(DIR_TRACK, 0); + this.image[header] = DIR_TRACK; + this.image[header + 1] = FIRST_DIR_SECTOR; + this.image[offsetOf(DIR_TRACK, FIRST_DIR_SECTOR) + 1] = 0xff; + } + + addFile(name, data, geosType = 0) { + const sectors = []; + for (let at = 0; at < data.length || !sectors.length; at += SECTOR - 2) sectors.push(this.nextSector++); + sectors.forEach((n, i) => { + const track = FIRST_FILE_TRACK + Math.floor(n / SECTORS_PER_TRACK); + const o = offsetOf(track, n % SECTORS_PER_TRACK); + const chunk = data.subarray(i * (SECTOR - 2), (i + 1) * (SECTOR - 2)); + const last = i === sectors.length - 1; + const next = sectors[i + 1]; + this.image[o] = last ? 0 : FIRST_FILE_TRACK + Math.floor(next / SECTORS_PER_TRACK); + this.image[o + 1] = last ? chunk.length + 1 : next % SECTORS_PER_TRACK; + chunk.copy(this.image, o + 2); + }); + const slot = offsetOf(DIR_TRACK, FIRST_DIR_SECTOR) + this.slots.length * DIR_SLOT; + this.image[slot + SLOT_TYPE] = USR_CLOSED; + this.image[slot + SLOT_FIRST] = FIRST_FILE_TRACK + Math.floor(sectors[0] / SECTORS_PER_TRACK); + this.image[slot + SLOT_FIRST + 1] = sectors[0] % SECTORS_PER_TRACK; + this.image.fill(PAD, slot + SLOT_NAME, slot + SLOT_NAME + 16); + this.image.write(name, slot + SLOT_NAME, 'latin1'); + this.image[slot + SLOT_GEOS_TYPE] = geosType; + this.slots.push(slot); + return slot; + } +} + +function run(args) { + const r = spawnSync(args[0], args.slice(1), { encoding: 'utf8' }); + return { status: r.status, out: r.stdout, err: r.stderr }; +} + +function withHarness(body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geos-boot-')); + try { + const built = run(['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', '-o', path.join(dir, 'harness'), HARNESS_SOURCE]); + assert.equal(built.status, 0, built.err); + const asCpp = run(['c++', '-x', 'c++', '-std=gnu++17', '-Wall', '-Werror', '-fsyntax-only', HARNESS_SOURCE]); + assert.equal(asCpp.status, 0, asCpp.err); + const launch = (disk, time = []) => { + fs.writeFileSync(path.join(dir, 'in.d81'), disk.image); + const r = run([path.join(dir, 'harness'), path.join(dir, 'in.d81'), path.join(dir, 'out.prg'), path.join(dir, 'out.d81'), ...time.map(String)]); + return { ...r, prg: r.status === 0 ? fs.readFileSync(path.join(dir, 'out.prg')) : null, image: r.status === 0 ? fs.readFileSync(path.join(dir, 'out.d81')) : null }; + }; + body(launch); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function standardDisk(driver, startUp = []) { + const disk = new Disk(); + disk.addFile('GEOS', Buffer.from([1, 8, 0])); + disk.addFile('GEOBOOT', bootFile(driver, startUp).file); + return disk; +} + +test('the boot PRG carries the boot file, with the TR disk driver in place of the stock transport', { skip }, () => { + withHarness((launch) => { + const r = launch(standardDisk()); + assert.equal(r.status, 0, r.err); + const { body } = bootFile(); + const place = 2 + LOAD_ADDRESS - BASIC_START; + + assert.equal(r.prg.readUInt16LE(0), BASIC_START); + assert.equal(r.prg.length, place + BODY_SIZE); + assert.equal(r.prg.subarray(2, 14).toString('hex'), '0b080a009e323036310000' + '00'); + assert.equal(r.prg.subarray(31, place).every((b) => b === 0), true, 'zero padding up to the load address'); + assert.deepEqual([...r.prg.subarray(2 + 12 + 14, 2 + 12 + 17)], [0x4c, 0x3c, 0x2f], 'jumps to the boot entry'); + + const patched = r.prg.subarray(place + DRIVER_AT_IN_BODY, place + DRIVER_AT_IN_BODY + DRIVER_SIZE); + const expected = Buffer.from(CODE); + for (const at of RELOCATIONS) expected.writeUInt16LE(expected.readUInt16LE(at) + TURBO_START - DRIVER_AREA, at); + const start = TURBO_START - DRIVER_AREA; + assert.equal(patched.subarray(start, start + CODE.length).equals(expected), true, 'relocated driver at InitForIO'); + assert.equal(patched.subarray(start + CODE.length).equals(stockDriver().subarray(start + CODE.length)), true, 'the rest of the stock driver stays'); + for (let i = 0; i < 10; i++) assert.equal(patched.readUInt16LE(2 * i), TURBO_START + ENTRIES[i], `vector ${i}`); + for (const [slot, entry] of [[6, 10], [7, 11], [9, 12]]) assert.equal(patched.readUInt16LE(JUMP_SLOTS + 3 * slot + 1), TURBO_START + ENTRIES[entry], `jump slot ${slot}`); + assert.equal(patched[TYPE_BYTE], 3); + assert.equal(r.prg.subarray(place, place + DRIVER_AT_IN_BODY).equals(body.subarray(0, DRIVER_AT_IN_BODY)), true, 'bytes before the driver'); + assert.equal(r.prg.subarray(place + DRIVER_AT_IN_BODY + DRIVER_SIZE).equals(body.subarray(DRIVER_AT_IN_BODY + DRIVER_SIZE)), true, 'bytes after the driver'); + }); +}); + +test('the boot PRG starts GEOS with the given date and time', { skip }, () => { + withHarness((launch) => { + const r = launch(standardDisk(undefined, START_UP)); + const place = 2 + LOAD_ADDRESS - BASIC_START + START_AT_IN_BODY; + const at = r.prg.subarray(place, place + START_UP.length); + + assert.match(r.out, /clock=1/); + assert.deepEqual([at[1], at[6], at[11]], [21, 10, 26], 'day, month, year'); + assert.deepEqual([...at.subarray(START_DATE.length, CLOCK_AT)], LOOP_END); + assert.deepEqual(runClock(at.subarray(CLOCK_AT)), [0x00, 0x00, 0x07, PM | 0x03], '3:07 PM'); + }); +}); + +test('the TOD holds noon and midnight as 12 PM and 12 AM', { skip }, () => { + withHarness((launch) => { + const place = 2 + LOAD_ADDRESS - BASIC_START + START_AT_IN_BODY + CLOCK_AT; + for (const [hour, held] of [[12, PM | 0x12], [0, 0x12], [11, 0x11], [23, PM | 0x11]]) { + const r = launch(standardDisk(undefined, START_UP), [hour, 30]); + assert.equal(r.status, 0, r.err); + assert.deepEqual(runClock(r.prg.subarray(place, place + START_CLOCK.length)), [0x00, 0x00, 0x30, held], `${hour}:30`); + } + }); +}); + +test('a boot file without the known start-up code keeps the date and time it has', { skip }, () => { + withHarness((launch) => { + const place = 2 + LOAD_ADDRESS - BASIC_START; + for (const startUp of [[], [...START_DATE, ...START_CLOCK]]) { + const r = launch(standardDisk(undefined, startUp)); + assert.match(r.out, /clock=0/); + assert.equal(r.prg.subarray(place, place + DRIVER_AT_IN_BODY).equals(bootFile(undefined, startUp).body.subarray(0, DRIVER_AT_IN_BODY)), true); + } + }); +}); + +test('a boot file whose driver is not recognized is refused', { skip }, () => { + withHarness((launch) => { + const odd = stockDriver(); + odd[TYPE_BYTE] = 9; + const r = launch(standardDisk(odd)); + assert.equal(r.status, 1); + assert.match(r.err, /disk driver is not one the TR can replace/); + + const crowded = stockDriver(); + crowded.writeUInt16LE(0x9110, 2 * 7); + assert.equal(launch(standardDisk(crowded)).status, 1, 'no room for the TR driver before ReadBlock'); + + const shared = stockDriver(); + shared.writeUInt16LE(TURBO_START + 0x20, 2 * 12); + assert.equal(launch(standardDisk(shared)).status, 1, 'a vector the TR driver does not replace points into the replaced run'); + + const jumped = stockDriver(); + jumped.writeUInt16LE(TURBO_START + 0x20, JUMP_SLOTS + 3 * 2 + 1); + assert.equal(launch(standardDisk(jumped)).status, 1, 'a jump slot the TR driver does not replace points into the replaced run'); + }); +}); + +test('a disk with no boot file is refused', { skip }, () => { + withHarness((launch) => { + const disk = new Disk(); + disk.addFile('GEOS', Buffer.from([1, 8, 0])); + const r = launch(disk); + assert.equal(r.status, 1); + assert.match(r.err, /boot file is not on the disk/); + }); +}); + +test('CONFIGURE files stop being auto-executed, and nothing else changes', { skip }, () => { + withHarness((launch) => { + const disk = standardDisk(); + const configure = disk.addFile('CONFIGURE', Buffer.from([1, 2, 3]), AUTO_EXEC); + const second = disk.addFile('CONFIG 2.0r', Buffer.from([1, 2, 3]), AUTO_EXEC); + const other = disk.addFile('pad color mgr', Buffer.from([1, 2, 3]), AUTO_EXEC); + const plain = disk.addFile('CONFIGURE.DOC', Buffer.from([1, 2, 3]), 0); + + const first = launch(disk); + assert.equal(first.status, 0, first.err); + assert.match(first.out, /changed=1/); + assert.equal(first.image[configure + SLOT_GEOS_TYPE], SYSTEM_FILE); + assert.equal(first.image[second + SLOT_GEOS_TYPE], SYSTEM_FILE); + assert.equal(first.image[other + SLOT_GEOS_TYPE], AUTO_EXEC, 'other auto-exec files stay'); + assert.equal(first.image[plain + SLOT_GEOS_TYPE], 0, 'a CONFIG file that is not auto-exec stays'); + + const again = launch({ image: first.image }); + assert.match(again.out, /changed=0/); + assert.equal(again.image.equals(first.image), true); + }); +}); 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..e2a90ab5 --- /dev/null +++ b/Source/Teensy/tests/geos-disk-patch.test.js @@ -0,0 +1,253 @@ +// 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 REFUSED_AFTER_WRITING = 3; + +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; +} + +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, + '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 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); + 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.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()); + }); + } + } + + 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(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'); + 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; + 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_boot_harness.c b/Source/Teensy/tests/geos_boot_harness.c new file mode 100644 index 00000000..24b75d57 --- /dev/null +++ b/Source/Teensy/tests/geos_boot_harness.c @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// geos_boot_harness IN.d81 OUT.prg OUT.d81 [HOUR MINUTE]: builds the GEOS 64 boot PRG from the disk, retypes its +// CONFIGURE files, sets the clock to 2026-10-21 at HOUR:MINUTE (15:07 if not given), and writes both. Prints "changed=<0|1> clock=<0|1>"; exits 1 with the refusal on stderr. +#include + +#include "../GEOS/GeosBoot.h" +#include "../GEOS/GeosTargets.h" + +#define MAX_IMAGE 819200 +#define MAX_PRG (128 * 1024) + +static uint8_t Image[MAX_IMAGE + 1], Prg[MAX_PRG]; + +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) +{ + struct GeosDate Now = { 26, 10, 21, 15, 7 }; + struct GeosDisk Disk = { 0 }; + uint32_t Length; + bool Changed; + FILE *File; + size_t Size; + + if ((argc != 4 && argc != 6) || !(File = fopen(argv[1], "rb"))) return 2; + if (argc == 6) + { + Now.Hour = atoi(argv[4]); + Now.Minute = atoi(argv[5]); + } + Size = fread(Image, 1, sizeof Image, File); + fclose(File); + + Disk.Format = GeosFormatForSize(Size); + Disk.Target = &Geos64Launcher.Target; + Disk.Context = Image; + Disk.Read = ReadBlock; + Disk.Write = WriteBlock; + if (!GeosBuildBootPrg(&Disk, Prg, sizeof Prg, &Length) || !GeosDisableConfigure(&Disk, &Changed)) + { + fprintf(stderr, "%s\n", Disk.Error); + return 1; + } + printf("changed=%d clock=%d\n", Changed, GeosSetClock(Prg, Length, &Now)); + File = fopen(argv[2], "wb"); + fwrite(Prg, 1, Length, File); + fclose(File); + File = fopen(argv[3], "wb"); + fwrite(Image, 1, Size, File); + fclose(File); + return 0; +} 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..88b5f30e --- /dev/null +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -0,0 +1,77 @@ +// 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/GeosTargets.h" +#include "../GEOS/TRMouse128.h" + +#define GEOS_INPUT_128 15 + +#define MAX_IMAGE 819200 + +// 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) +{ + 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; +} + +static const struct GeosTarget Geos128Target = + { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, NULL, 0 }; + +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; + 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 = &Geos128Target; + Disk.Context = Image; + Disk.Read = ReadBlock; + Disk.Write = WriteBlock; + if (Inspecting) return Inspect(&Disk); + if (!GeosInstallDriver(&Disk, &Date)) + { + fprintf(stderr, "%s\n", Disk.Error); + return memcmp(Original, Image, Size) ? 3 : 1; + } + + Out = fopen(argv[2], "wb"); + if (!Out || fwrite(Image, 1, Size, Out) != Size) return 2; + fclose(Out); + return 0; +} diff --git a/Source/Teensy/tests/trblock.test.js b/Source/Teensy/tests/trblock.test.js new file mode 100644 index 00000000..e73e331b --- /dev/null +++ b/Source/Teensy/tests/trblock.test.js @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// The TR block device's registers and disk image backend, held to docs/TR_Block_Device.md. +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 HARNESS_SOURCE = path.join(FIRMWARE_ROOT, 'tests/trblock_harness.c'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const skip = !commandExists('cc') && !inCi && 'cc is not on PATH'; + +const SECTOR = 256; +const WINDOW = 64; +const PAGES = SECTOR / WINDOW; +const CMD = 0x08, ADDR0 = 0x09, ADDR1 = 0x0a, PAGE = 0x0b, TOKEN = 0x0c, STATUS = 0x0d, ECHO = 0x0e, ID = 0x10; +const WINDOW_AT = 0x40; +const READ = 1, WRITE = 2, FLUSH = 3, INFO = 4; +const BUSY = 0x80, DONE = 0x5a, NO_IMAGE = 0xe1, BAD_ADDRESS = 0xe2, WRITE_FAILED = 0xe4, READ_ONLY = 0xe5, BAD_COMMAND = 0xe6; + +const zoneSectors = (track) => { + const zone = (track - 1) % 35 + 1; + return zone <= 17 ? 21 : zone <= 24 ? 19 : zone <= 30 ? 18 : 17; +}; + +const FORMATS = { + d64: { kind: 1, tracks: 35, sectors: zoneSectors }, + d71: { kind: 2, tracks: 70, sectors: zoneSectors }, + d81: { kind: 3, tracks: 80, sectors: () => 40 }, +}; + +const blockNumber = (format, track, sector) => { + let blocks = sector; + for (let t = 1; t < track; t++) blocks += format.sectors(t); + return blocks; +}; + +const totalBlocks = (format) => blockNumber(format, format.tracks + 1, 0); + +// Every byte says which block it is in: the block number's two bytes, then a counter +const sectorBytes = (number) => Buffer.from(Array.from({ length: SECTOR }, (_, i) => + i === 0 ? number & 0xff : i === 1 ? (number >> 8) & 0xff : (i + number) & 0xff)); + +function makeImage(format) { + return Buffer.concat(Array.from({ length: totalBlocks(format) }, (_, n) => sectorBytes(n))); +} + +const hex = (n) => n.toString(16); +const write = (offset, value) => `w ${hex(offset)} ${hex(value)}\n`; +const read = (offset) => `r ${hex(offset)}\n`; + +// A command as the driver sends it: operands, token, then CMD +const command = (cmd, { addr0 = 0, addr1 = 0, page = 0, token = 1 } = {}) => + write(ADDR0, addr0) + write(ADDR1, addr1) + write(PAGE, page) + write(TOKEN, token) + write(CMD, cmd); + +const readBlock = (addr0, addr1, token = 1) => + command(READ, { addr0, addr1, token }) + 'svc\n' + + Array.from({ length: PAGES }, (_, p) => write(PAGE, p) + Array.from({ length: WINDOW }, (_, i) => read(WINDOW_AT + i)).join('')).join(''); + +const fillWindow = (bytes) => Array.from({ length: PAGES }, (_, p) => + write(PAGE, p) + Array.from({ length: WINDOW }, (_, i) => write(WINDOW_AT + i, bytes[p * WINDOW + i])).join('')).join(''); + +let harnessPath; +function harness() { + if (harnessPath) return harnessPath; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trblock-')); + harnessPath = path.join(dir, 'harness'); + const built = spawnSync('cc', ['-std=c99', '-Wall', '-Wextra', '-Werror', '-o', harnessPath, HARNESS_SOURCE], { encoding: 'utf8' }); + assert.equal(built.status, 0, built.stderr); + return harnessPath; +} + +function run(kind, script, mode) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trblock-run-')); + const image = path.join(dir, 'in.img'), out = path.join(dir, 'out.img'); + fs.writeFileSync(image, makeImage(FORMATS[kind])); + const ran = spawnSync(harness(), [image, out, ...(mode ? [mode] : [])], { input: script, encoding: 'utf8' }); + assert.equal(ran.status, 0, ran.stderr); + const lines = ran.stdout.split('\n').filter(Boolean); + return { lines, image: fs.readFileSync(out), original: fs.readFileSync(image) }; +} + +const bytesOf = (lines) => Buffer.from(lines.map((l) => parseInt(l, 16))); +const statusOf = (script, kind = 'd81', mode) => run(kind, script + read(STATUS), mode).lines.at(-1); + +test('the id is TRB at 0x10, and nothing else answers outside the registers and window', { skip }, () => { + const { lines } = run('d81', [0x10, 0x11, 0x12, 0x13, 0x0f, 0x08, 0x09, 0x0b, 0x0c, 0x3f, 0x80].map(read).join('')); + assert.deepEqual(lines, ['54', '52', '42', 'open', 'open', 'open', 'open', 'open', 'open', 'open', 'open']); +}); + +test('status reads 00 before any command', { skip }, () => { + assert.equal(run('d81', read(STATUS) + read(ECHO)).lines.join(), '00,00'); +}); + +test('status is busy from the CMD write until the main loop services it, then done with the token', { skip }, () => { + const { lines } = run('d81', command(READ, { addr0: 40, addr1: 0, token: 0x37 }) + read(STATUS) + read(ECHO) + 'svc\n' + read(STATUS) + read(ECHO) + 'svc\n'); + assert.deepEqual(lines, ['80', '00', '1', '5a', '37', '0']); +}); + +for (const [kind, format] of Object.entries(FORMATS)) { + test(`${kind}: every track's first and last sector read back as that block, in four pages`, { skip }, () => { + for (let track = 1; track <= format.tracks; track++) { + for (const sector of [0, format.sectors(track) - 1]) { + const { lines } = run(kind, readBlock(track, sector)); + const got = bytesOf(lines.slice(1)); + assert.deepEqual(got, sectorBytes(blockNumber(format, track, sector)), `${kind} ${track}/${sector}`); + } + } + }); + + test(`${kind}: the sector after a track's last, track 0 and the track past the end fail with bad address`, { skip }, () => { + const cases = [[0, 0], [format.tracks + 1, 0], [1, format.sectors(1)], [format.tracks, format.sectors(format.tracks)], [255, 255]]; + for (const [track, sector] of cases) { + assert.equal(statusOf(command(READ, { addr0: track, addr1: sector }) + 'svc\n', kind), hex(BAD_ADDRESS), `${track}/${sector}`); + } + }); + + test(`${kind}: info says the kind, that it is writable and the block count`, { skip }, () => { + const blocks = totalBlocks(format); + const { lines } = run(kind, command(INFO) + 'svc\n' + Array.from({ length: 5 }, (_, i) => read(WINDOW_AT + i)).join('')); + assert.deepEqual(bytesOf(lines.slice(1)), Buffer.from([format.kind, 1, blocks & 0xff, (blocks >> 8) & 0xff, blocks >> 16])); + }); +} + +test('a write reaches exactly its block in the image and leaves every other byte alone', { skip }, () => { + const format = FORMATS.d81; + const payload = Buffer.from(Array.from({ length: SECTOR }, (_, i) => (i * 7 + 3) & 0xff)); + const { image, original } = run('d81', fillWindow(payload) + command(WRITE, { addr0: 79, addr1: 5 }) + 'svc\n'); + const at = blockNumber(format, 79, 5) * SECTOR; + assert.deepEqual(image.subarray(at, at + SECTOR), payload); + assert.deepEqual(Buffer.concat([image.subarray(0, at), image.subarray(at + SECTOR)]), + Buffer.concat([original.subarray(0, at), original.subarray(at + SECTOR)])); +}); + +test('a write then a read of the same block returns what was written, whatever came before', { skip }, () => { + const payload = Buffer.from(Array.from({ length: SECTOR }, (_, i) => 255 - i)); + const { lines } = run('d64', fillWindow(payload) + command(WRITE, { addr0: 18, addr1: 1 }) + 'svc\n' + readBlock(18, 1, 2)); + assert.deepEqual(bytesOf(lines.slice(2)), payload); +}); + +test('a failed write changes nothing in the image', { skip }, () => { + const payload = Buffer.alloc(SECTOR, 0xee); + const bad = run('d64', fillWindow(payload) + command(WRITE, { addr0: 36, addr1: 0 }) + 'svc\n'); + assert.deepEqual(bad.image, bad.original); + const readOnly = run('d81', fillWindow(payload) + command(WRITE, { addr0: 1, addr1: 0 }) + 'svc\n' + read(STATUS), 'ro'); + assert.equal(readOnly.lines.at(-1), hex(READ_ONLY)); + assert.deepEqual(readOnly.image, readOnly.original); +}); + +test('a read-only image still reads, and info clears the writable flag', { skip }, () => { + const { lines } = run('d81', command(INFO) + 'svc\n' + read(WINDOW_AT + 1) + readBlock(1, 0, 2).split('\n').slice(0, 2).join('\n') + '\n' + read(STATUS), 'ro'); + assert.equal(lines[1], '00'); + assert.equal(lines.at(-1), '5a'); +}); + +test('with no image mounted every command fails with no image, and status never claims done', { skip }, () => { + for (const cmd of [READ, WRITE, FLUSH, INFO]) { + assert.equal(statusOf(command(cmd, { addr0: 1 }) + 'svc\n', 'd81', 'nobackend'), hex(NO_IMAGE), `cmd ${cmd}`); + } +}); + +test('an image that is no known size mounts as no image rather than being guessed at', { skip }, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trblock-odd-')); + const image = path.join(dir, 'in.img'), out = path.join(dir, 'out.img'); + try { + fs.writeFileSync(image, makeImage(FORMATS.d64).subarray(0, SECTOR * 100)); + for (const cmd of [READ, WRITE, FLUSH, INFO]) { + const ran = spawnSync(harness(), [image, out], { input: command(cmd, { addr0: 1 }) + 'svc\n' + read(STATUS), encoding: 'utf8' }); + assert.equal(ran.stdout.trim().split('\n').at(-1), hex(NO_IMAGE), `cmd ${cmd}`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a command that is not one fails with bad command, and so does zero', { skip }, () => { + for (const cmd of [0, 5, 0x80, 0xff]) assert.equal(statusOf(command(cmd) + 'svc\n'), hex(BAD_COMMAND), `cmd ${cmd}`); +}); + +test('flush reaches the backend once per command and reports done', { skip }, () => { + const { lines } = run('d81', command(FLUSH) + 'svc\n' + read(STATUS) + 'flushes\n' + command(FLUSH, { token: 2 }) + 'svc\nflushes\n'); + assert.deepEqual(lines, ['1', '5a', '1', '1', '2']); +}); + +test('a flush the card fails reports write failed, not done', { skip }, () => { + assert.equal(statusOf(command(FLUSH) + 'svc\n', 'd81', 'flushfails'), hex(WRITE_FAILED)); +}); + +test('operands are latched when CMD is written: changing them while the command waits changes nothing', { skip }, () => { + const start = command(READ, { addr0: 40, addr1: 3, token: 9 }); + const { lines } = run('d81', start + write(ADDR0, 1) + write(ADDR1, 1) + write(TOKEN, 0x44) + 'svc\n' + read(ECHO) + read(WINDOW_AT)); + assert.equal(lines[1], '09'); + assert.equal(parseInt(lines[2], 16), blockNumber(FORMATS.d81, 40, 3) & 0xff); +}); + +test('a second CMD while one is busy is ignored, and window writes while busy are ignored', { skip }, () => { + const { lines } = run('d81', command(READ, { addr0: 40, addr1: 3, token: 5 }) + command(READ, { addr0: 1, addr1: 0, token: 6 }) + + write(WINDOW_AT, 0x99) + 'svc\nsvc\n' + read(ECHO) + read(WINDOW_AT)); + assert.deepEqual(lines.slice(0, 2), ['1', '0']); + assert.equal(lines[2], '05'); + assert.equal(parseInt(lines[3], 16), blockNumber(FORMATS.d81, 40, 3) & 0xff); +}); + +test('a window write while a write command is busy does not change what that command writes', { skip }, () => { + const payload = Buffer.alloc(SECTOR, 0x11); + const { image } = run('d81', fillWindow(payload) + command(WRITE, { addr0: 2, addr1: 0 }) + write(PAGE, 0) + write(WINDOW_AT, 0x99) + 'svc\n'); + const at = blockNumber(FORMATS.d81, 2, 0) * SECTOR; + assert.equal(image[at], 0x11); +}); + +test('the page register uses only its low two bits', { skip }, () => { + const { lines } = run('d81', command(READ, { addr0: 1, addr1: 0 }) + 'svc\n' + write(PAGE, 0) + read(WINDOW_AT) + write(PAGE, 4) + read(WINDOW_AT) + write(PAGE, 5) + read(WINDOW_AT)); + assert.equal(lines[1], lines[2]); + assert.notEqual(lines[1], lines[3]); +}); diff --git a/Source/Teensy/tests/trblock_harness.c b/Source/Teensy/tests/trblock_harness.c new file mode 100644 index 00000000..b65c88c4 --- /dev/null +++ b/Source/Teensy/tests/trblock_harness.c @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// trblock_harness IMAGE OUT [ro|nobackend|flushfails]: the block device's register side and disk image backend, +// driven from stdin one command a line, as the IO ISR and the main loop would drive them: +// w OFFSET HEX an IO write r OFFSET an IO read, printing the byte or "open" +// svc the main loop's service, printing 1 if a command ran +// flushes how many times the backend was asked to flush +// OUT receives the image when stdin ends. +#include +#include + +#include "../BlockDevice/TRBlockImage.h" + +#define MAX_IMAGE 819200 + +static uint8_t Bytes[MAX_IMAGE]; +static uint32_t Size, Flushes; +static bool FlushFails; + +static bool ReadAt(void *Context, uint32_t Offset, uint8_t *Block) +{ + (void)Context; + if (Offset + GEOS_SECTOR_SIZE > Size) return false; + memcpy(Block, Bytes + Offset, GEOS_SECTOR_SIZE); + return true; +} + +static bool WriteAt(void *Context, uint32_t Offset, const uint8_t *Block) +{ + (void)Context; + if (Offset + GEOS_SECTOR_SIZE > Size) return false; + memcpy(Bytes + Offset, Block, GEOS_SECTOR_SIZE); + return true; +} + +static bool FlushAll(void *Context) +{ + (void)Context; + Flushes++; + return !FlushFails; +} + +int main(int argc, char **argv) +{ + FILE *In = argc >= 3 ? fopen(argv[1], "rb") : NULL; + struct TRBlock Dev; + struct TRBlockImage Image = { NULL, argc < 4 || strcmp(argv[3], "ro"), NULL, ReadAt, WriteAt, FlushAll }; + struct TRBlockBackend Backend; + char Line[64]; + + if (!In) + { + fprintf(stderr, "usage: trblock_harness IMAGE OUT [ro|nobackend|flushfails]\n"); + return 2; + } + Size = fread(Bytes, 1, MAX_IMAGE, In); + fclose(In); + Image.Format = GeosFormatForSize(Size); + FlushFails = argc >= 4 && !strcmp(argv[3], "flushfails"); + Backend = TRBlockImageBackend(&Image); + TRBlockReset(&Dev); + const struct TRBlockBackend *Use = argc >= 4 && !strcmp(argv[3], "nobackend") ? NULL : &Backend; + + while (fgets(Line, sizeof Line, stdin)) + { + unsigned Offset, Data; + + if (sscanf(Line, "w %x %x", &Offset, &Data) == 2) + { + if (TRBlockTakes(Offset)) TRBlockWriteAt(&Dev, Offset, Data); + } + else if (sscanf(Line, "r %x", &Offset) == 1) + { + int Read = TRBlockReadAt(&Dev, Offset); + if (Read != TRBLOCK_UNSERVED) printf("%02x\n", Read); + else printf("open\n"); + } + else if (!strncmp(Line, "svc", 3)) printf("%d\n", TRBlockService(&Dev, Use)); + else if (!strncmp(Line, "flushes", 7)) printf("%u\n", Flushes); + } + FILE *Out = fopen(argv[2], "wb"); + fwrite(Bytes, 1, Size, Out); + fclose(Out); + return 0; +} diff --git a/Source/Teensy/tests/usb-mouse.test.js b/Source/Teensy/tests/usb-mouse.test.js new file mode 100644 index 00000000..cd791b71 --- /dev/null +++ b/Source/Teensy/tests/usb-mouse.test.js @@ -0,0 +1,100 @@ +// 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(`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 }, () => { + 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; +} diff --git a/docs/TR_Block_Device.md b/docs/TR_Block_Device.md new file mode 100644 index 00000000..e5f1de6f --- /dev/null +++ b/docs/TR_Block_Device.md @@ -0,0 +1,120 @@ +# TeensyROM block device + +A small register interface that lets software on the C64 or C128 read and write 256-byte blocks of a disk image file on the TeensyROM's SD card, with no drive on the IEC bus. The first user is the GEOS disk driver in [`tools/geos`](../tools/geos), but nothing here is specific to GEOS: any program that finds the registers can use them. + +This document is the whole contract. The firmware side is [`TRBlock.h`](../Source/Teensy/BlockDevice/TRBlock.h); the registers sit beside the USB mouse's, served from [`TRBlockIO.c`](../Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c). + +## Table of contents + * [What it is and is not](#what-it-is-and-is-not) + * [Where the registers are](#where-the-registers-are) + * [Register map](#register-map) + * [Running a command](#running-a-command) + * [Commands](#commands) + * [Status and errors](#status-and-errors) + * [Disk addresses](#disk-addresses) + * [Writes reach the card later](#writes-reach-the-card-later) + * [What the firmware does not promise](#what-the-firmware-does-not-promise) + * [A reader's checklist](#a-readers-checklist) + +## What it is and is not +* One image at a time, chosen by the firmware (for GEOS, when the menu launches it). The C64 cannot choose or replace the image. +* Blocks are 256 bytes. A block is addressed by two bytes whose meaning depends on the image kind ([Disk addresses](#disk-addresses)). +* It is available while the GeoRAM handler is active, which is also where the USB mouse registers are. It is not available under other handlers, the REU included, and not under a Host Extension. +* It is not an IEC drive. Software that talks to the IEC bus cannot see it. + +## Where the registers are +The registers share IO2, the page GeoRAM leaves free, with the USB mouse registers, which start the page and identify it. The base `B` is `$DF00`. + +The block device is present when `TRB` is at `B+$10..B+$12`. A handler that does not carry it leaves those bytes unanswered, which reads as whatever the bus last held, so check all three bytes. + +Everything below is an offset from `B`. + +## Register map + +| Offset | Dir | Name | Meaning | +|-------:|:---:|------|---------| +| `$08` | write | CMD | command; writing it starts the command | +| `$09` | write | ADDR0 | block address, first byte | +| `$0A` | write | ADDR1 | block address, second byte | +| `$0B` | write | PAGE | which 64-byte quarter of the block the window shows, 0 to 3 (higher bits ignored) | +| `$0C` | write | TOKEN | a non-zero byte the driver chooses for the next command | +| `$0D` | read | STATUS | `$80` busy, `$5A` done, `$E0` and up failed, `$00` no command run yet | +| `$0E` | read | ECHO | the token of the command STATUS reports on | +| `$10..$12` | read | ID | `TRB` | +| `$40..$7F` | read/write | WINDOW | the quarter of the block PAGE selects | + +Reading a register marked write is not answered. Offsets not listed are not answered. + +## Running a command +1. Write ADDR0, ADDR1 and PAGE as the command needs, and TOKEN, a non-zero byte that differs from the last command's. +2. For a write, fill the window, one PAGE at a time (see [`WRITE`](#commands)). +3. Write CMD. STATUS reads `$80` from the moment this write completes, so a read of STATUS after it never sees the previous command's result. +4. Read STATUS until it is not `$80`, then check ECHO equals your TOKEN. A result with another token is not yours: wait, or run the command again. +5. `$5A` is success. `$E0` and up is a failure ([Status and errors](#status-and-errors)). + +Two rules that Wes Biggs' [teensyrom-vdrive](https://github.com/wesbiggs/teensyrom-vdrive) learned on hardware with a similar register interface, and which hold here too: +* **Read STATUS until two consecutive reads agree** before believing a value other than `$80`. A read can land while the firmware is updating it. +* **Read back what you write into the window** when the data matters, and compare, before sending CMD. + +On an NTSC C64 with a Kawari VIC-II, a TR+ read now and then comes back with the previous read's bits ORed in, so **read every byte until two consecutive reads agree**, the window and ID included, and compare a read-back again before calling it a fault. + +Do not write ADDR0, ADDR1, PAGE or TOKEN, or the window, while STATUS is `$80`. The firmware latches the command's operands when CMD is written, so a stray register write cannot change a command already running, but writes to the window during a command are ignored. + +## Commands + +| CMD | Name | What it does | +|----:|------|--------------| +| `$01` | `READ` | reads the block at ADDR0, ADDR1 into the firmware's 256-byte buffer. Afterwards the window shows the PAGE quarter of it, and each write to PAGE shows another | +| `$02` | `WRITE` | writes the 256-byte buffer to the block at ADDR0, ADDR1. The buffer is what the window wrote, one PAGE at a time, since the last command | +| `$03` | `FLUSH` | forces the card to hold every write accepted so far | +| `$04` | `INFO` | describes the image: five bytes at the start of the window (see below) | + +`READ` and `INFO` fill the buffer; a `WRITE` after either writes what they left unless the window changed it. A `WRITE` for a block the driver has not filled is the driver's mistake, not the firmware's to guard. + +`INFO` answers, in window bytes 0 to 4: + +| Byte | Meaning | +|-----:|---------| +| 0 | image kind: `1` D64, `2` D71, `3` D81 | +| 1 | flags; bit 0 set when the image can be written | +| 2..4 | blocks in the image, 24 bits, low byte first | + +Any other CMD fails with `$E6`. + +## Status and errors + +| STATUS | Meaning | +|-------:|---------| +| `$00` | no command has been run since the handler started | +| `$80` | a command is running | +| `$5A` | the command with ECHO's token succeeded | +| `$E1` | no image is mounted | +| `$E2` | the address is outside the image | +| `$E3` | the card could not be read | +| `$E4` | the card could not be written | +| `$E5` | the image is read-only | +| `$E6` | not a command | + +A failed `READ` leaves the buffer unspecified. A failed `WRITE` that was rejected before the card was touched (`$E1`, `$E2`, `$E5`) changes nothing; one the card failed (`$E4`) may have changed part of the block. + +## Disk addresses +For the disk image kinds (D64, D71, D81) ADDR0 is the track and ADDR1 the sector, as the 1541, 1571 and 1581 number them: tracks start at 1, sectors at 0. The 1541 and 1571 use the usual four speed zones (21, 19, 18 and 17 sectors a track, the 1571's second side repeating them); the 1581 has 40 sectors on every track. An address off the end of the image or past a track's last sector fails with `$E2`. + +The firmware does this arithmetic so that a 6502 driver does not have to. A driver that wants raw sector numbers instead is a different image kind, which this version does not define. + +## Writes reach the card later +`WRITE` returns `$5A` when the block is accepted, not when the card holds it. The firmware writes the block to the file at once and flushes the file a quarter of a second after the last `WRITE`, or on `FLUSH`. A `FLUSH` the card fails returns `$E4`. A flush the card fails is tried again every quarter second until the card takes it. A program that must know a write survived power loss sends `FLUSH` and waits for `$5A`. GEOS does not, so a disk saved and then powered off within that quarter second can be torn. + +## What the firmware does not promise +* **Speed.** A command takes as long as the card takes. Poll; do not time it. +* **C128 2 MHz.** The registers have not been exercised at 2 MHz. A driver on the C128 should run at 1 MHz around a transfer until that is measured. +* **Concurrent users.** One command at a time, one driver at a time. Two programs polling the same STATUS will read each other's results; the token tells them apart but does not queue them. +* **The image.** It must not be changed on the card while it is mounted. A card swapped while it is mounted is noticed when the firmware next starts the card, as a GeoRAM save does; commands then fail with `$E1` rather than write to the other card. + +## A reader's checklist +A driver that follows this document: +* checks `TRB` at `$DF10`; +* writes TOKEN, the address, PAGE, then CMD; +* reads STATUS until two reads agree and the value is not `$80`, then checks ECHO; +* moves a block as four PAGEs of the window; +* sends `FLUSH` where a write must survive power loss. diff --git a/tools/geos/trdrive.asm b/tools/geos/trdrive.asm new file mode 100644 index 00000000..b08cc7d2 --- /dev/null +++ b/tools/geos/trdrive.asm @@ -0,0 +1,354 @@ +; SPDX-License-Identifier: MIT +; The transport of a GEOS disk driver served by the TR block device (docs/TR_Block_Device.md). The firmware +; puts this over the stock driver's turbo code and points the driver's vectors here; build with acme. + +!ifndef ORG { ORG = $9000 } + +r1L = $04 +r1H = $05 +r4 = $0a +curDevice = $ba +curDrive = $8489 +driveType = $904e + +TYPE_1581 = 3 +DIR_1581_TRACK = 40 +HEADER_SWAP = $8c +HEADER_FIRST = 4 +HEADER_END = $1d + +; offsets from BASE_GEOS_RAM's page +REG_CMD = $08 +REG_ADDR0 = $09 +REG_ADDR1 = $0a +REG_PAGE = $0b +REG_TOKEN = $0c +REG_STATUS = $0d +REG_ECHO = $0e +WINDOW = $40 +WINDOW_PAGES = 4 +WINDOW_SIZE = 64 + +CMD_READ = 1 +CMD_WRITE = 2 +STATUS_BUSY = $80 +STATUS_DONE = $5a +STATUS_BAD_ADDRESS = $e2 + +ERR_INV_TRACK = 2 +ERR_DEV_NOT_FOUND = 13 + +VIC_IRQ_FLAGS = $d019 +VIC_IRQ_ENABLE = $d01a +CIA1_ICR = $dc0d +CIA2_ICR = $dd0d +MASK_ALL = $7f + +BASE_GEOS_RAM = $df + +* = ORG + +InitForIO + php + pla + sta savedP + sei + lda $01 + sta saved01 + lda #$36 + sta $01 + lda VIC_IRQ_ENABLE + sta savedIrqEnable + lda #0 + sta VIC_IRQ_ENABLE + lda #MASK_ALL + sta VIC_IRQ_FLAGS + sta CIA1_ICR + sta CIA2_ICR + rts + +DoneWithIO + sei + lda CIA1_ICR + lda CIA2_ICR + lda savedIrqEnable + sta VIC_IRQ_ENABLE + lda saved01 + sta $01 + lda savedP + pha + plp + rts + +EnterTurbo + ldx #0 + txa +ExitTurbo +PurgeTurbo + rts + +NewDisk +VerWriteBlock +SendTSBytes +GetDOSError + ldx #0 + ldy #0 + rts + +ChangeDiskDevice + sta curDrive + sta curDevice + ldx #0 + rts + +ReadBlock + jsr readSector + cpx #0 + bne finished + jsr swapHeader +finished + ldy #0 + rts + +WriteBlock + jsr swapHeader + jsr writeSector + txa + pha + jsr swapHeader + pla + tax + ldy #0 + rts + +ReadLink + jsr findDevice + bcs missing + lda #CMD_READ + jsr runCommand + cpx #0 + bne finished + lda #0 + ldx #REG_PAGE + jsr putReg + ldx #WINDOW + jsr getStable + ldy #0 + sta (r4),y + ldx #WINDOW + 1 + jsr getStable + iny + sta (r4),y + ldx #0 + ldy #0 + rts + +missing + ldx #ERR_DEV_NOT_FOUND + ldy #0 + rts + +readSector + jsr findDevice + bcs missing + lda #CMD_READ + jsr runCommand + cpx #0 + bne readDone + ldy #0 + lda #0 + sta page +readPage + lda page + ldx #REG_PAGE + jsr putReg + ldx #0 +windowRead + lda BASE_GEOS_RAM * 256 + WINDOW,x + cmp BASE_GEOS_RAM * 256 + WINDOW,x + bne windowRead + sta (r4),y + iny + inx + cpx #WINDOW_SIZE + bne windowRead + inc page + lda page + cmp #WINDOW_PAGES + bne readPage + ldx #0 +readDone + rts + +writeSector + jsr findDevice + bcs missing + ldy #0 + lda #0 + sta page +writePage + lda page + ldx #REG_PAGE + jsr putReg + ldx #0 +windowWrite + lda (r4),y + sta BASE_GEOS_RAM * 256 + WINDOW,x + cmp BASE_GEOS_RAM * 256 + WINDOW,x + beq windowStored + cmp BASE_GEOS_RAM * 256 + WINDOW,x + bne windowFault +windowStored + iny + inx + cpx #WINDOW_SIZE + bne windowWrite + inc page + lda page + cmp #WINDOW_PAGES + bne writePage + lda #CMD_WRITE + jmp runCommand +windowFault + ldx #ERR_DEV_NOT_FOUND + rts + +; A = command; returns the GEOS error in X +runCommand + sta command + inc token + bne tokenReady + inc token +tokenReady + lda token + ldx #REG_TOKEN + jsr putReg + lda r1L + ldx #REG_ADDR0 + jsr putReg + lda r1H + ldx #REG_ADDR1 + jsr putReg + lda command + ldx #REG_CMD + jsr putReg + lda #0 + sta patience + sta patience + 1 +poll + ldx #REG_STATUS + jsr getReg + sta seen + jsr getReg + cmp seen + bne again + cmp #STATUS_BUSY + bne decided +again + inc patience + bne poll + inc patience + 1 + bne poll + ldx #ERR_DEV_NOT_FOUND + rts +decided + pha + ldx #REG_ECHO + jsr getReg + cmp token + beq ours + pla + jmp again +ours + pla + cmp #STATUS_DONE + beq succeeded + cmp #STATUS_BAD_ADDRESS + beq badTrack + ldx #ERR_DEV_NOT_FOUND + rts +succeeded + ldx #0 + rts +badTrack + ldx #ERR_INV_TRACK + rts + +; the 1581 header sector keeps its fields where the 1541's are +swapHeader + lda driveType + cmp #TYPE_1581 + bne swapped + lda r1L + cmp #DIR_1581_TRACK + bne swapped + lda r1H + bne swapped + ldy #HEADER_FIRST +swapField + lda (r4),y + sta scratch + tya + clc + adc #HEADER_SWAP + tay + lda (r4),y + pha + lda scratch + sta (r4),y + tya + sec + sbc #HEADER_SWAP + tay + pla + sta (r4),y + iny + cpy #HEADER_END + bne swapField +swapped + rts + +; carry clear when the TRB id answers +findDevice + ldy #2 +probe + lda BASE_GEOS_RAM * 256 + $10,y + cmp BASE_GEOS_RAM * 256 + $10,y + bne probe + cmp trbId,y + bne absent + dey + bpl probe + clc + rts +absent + sec + rts + +getReg + lda BASE_GEOS_RAM * 256,x + rts + +; a read that comes back the same twice; on some machines the TR's byte arrives with the last one's bits ORed in +getStable + jsr getReg + sta seen + jsr getReg + cmp seen + bne getStable + rts + +putReg + sta BASE_GEOS_RAM * 256,x + rts + +trbId !byte $54, $52, $42 +savedP !byte 0 +saved01 !byte 0 +scratch !byte 0 +command !byte 0 +seen !byte 0 +page !byte 0 +token !byte 0 +patience !word 0 +savedIrqEnable !byte 0 diff --git a/tools/geos/trdrive.py b/tools/geos/trdrive.py new file mode 100644 index 00000000..b0552d94 --- /dev/null +++ b/tools/geos/trdrive.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Emit the TR block device driver for the firmware. + + python3 tools/geos/trdrive.py out.h + +The driver is assembled twice, a page and a byte apart, and every 16-bit word that moved is an +address of its own, so the firmware can place the code wherever the stock driver has room. Needs acme. +""" + +import pathlib +import re +import subprocess +import sys +import tempfile + +SOURCE = pathlib.Path(__file__).with_name('trdrive.asm') +BASE = 0x9000 +SHIFTED = 0x0101 +ENTRIES = ('InitForIO', 'DoneWithIO', 'ExitTurbo', 'PurgeTurbo', 'EnterTurbo', 'ChangeDiskDevice', 'NewDisk', + 'ReadBlock', 'WriteBlock', 'VerWriteBlock', 'SendTSBytes', 'GetDOSError', 'ReadLink') + + +def assemble(org, tmp): + out, labels = tmp / f'{org:x}.bin', tmp / f'{org:x}.lbl' + subprocess.run(['acme', f'-DORG={org}', '-f', 'plain', '-o', str(out), '-l', str(labels), str(SOURCE)], + check=True) + return out.read_bytes(), labels.read_text() + + +def words(code): + return [code[i] | code[i + 1] << 8 for i in range(len(code) - 1)] + + +def relocations(low, high): + moved = [i for i, (a, b) in enumerate(zip(words(low), words(high))) if (b - a) & 0xffff == SHIFTED] + return [i for n, i in enumerate(moved) if n == 0 or i != moved[n - 1] + 1] + + +def entry_offsets(labels): + found = {m.group(1): int(m.group(2), 16) - BASE for m in re.finditer(r'^\s*(\w+)\s*=\s*\$([0-9a-f]+)', labels, re.M)} + return [found[name] for name in ENTRIES] + + +def c_array(name, values, width): + rows = [values[i:i + 16] for i in range(0, len(values), 16)] + body = ',\n'.join(' ' + ', '.join(f'0x{v:0{width}x}' for v in row) for row in rows) + return f'static const uint{width * 4}_t {name}[{len(values)}] PROGMEM = {{\n{body}\n}};\n' + + +def header(): + with tempfile.TemporaryDirectory() as tmp: + low, labels = assemble(BASE, pathlib.Path(tmp)) + high, _ = assemble(BASE + SHIFTED, pathlib.Path(tmp)) + relocs = relocations(low, high) + return ( + '// SPDX-License-Identifier: MIT\n' + '// Generated by tools/geos/trdrive.py from tools/geos/trdrive.asm; do not edit.\n' + '#pragma once\n' + '#include \n' + '#ifdef ARDUINO\n' + '#include \n' + '#else\n' + '#define PROGMEM\n' + '#endif\n\n' + f'#define TR_DRIVER_BASE 0x{BASE:04x}\n' + + c_array('TRDriverCode', list(low), 2) + + c_array('TRDriverRelocations', relocs, 4) + + c_array('TRDriverEntries', entry_offsets(labels), 4)) + + +if __name__ == '__main__': + if len(sys.argv) != 2: + sys.exit(__doc__) + pathlib.Path(sys.argv[1]).write_bytes(header().encode()) diff --git a/tools/geos/trdrive.test.mjs b/tools/geos/trdrive.test.mjs new file mode 100644 index 00000000..88ec892c --- /dev/null +++ b/tools/geos/trdrive.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 trdrive = path.join(root, 'tools/geos/trdrive.py'); +const committedHeader = path.join(root, 'Source/Teensy/GEOS/TRDriver.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 disk 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(), 'trdrive-test-')); + try { + const header = path.join(out, 'TRDriver.h'); + const r = spawnSync('python3', [trdrive, 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/trdrive.py'); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } +}); diff --git a/tools/geos/trmouse.py b/tools/geos/trmouse.py new file mode 100644 index 00000000..7293ad4d --- /dev/null +++ b/tools/geos/trmouse.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Put the TR USB mouse driver on a GEOS 128 (or, with --target 64, GEOS 64) 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) + +Add --target 64 to either form for GEOS 64. + +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' +CLASS_NAME = b'TR USB Mouse' +CLASS_NAME_SIZE = 12 # GEOS reads the version right after it +TARGETS = {'128': (0xfd00, 15), '64': (0xfe80, 10)} # where GEOS loads an input driver, and its type +AREA_END = {'128': 0xfe80, '64': 0xfffa} # the 64's area stops at the hardware vectors +LOAD, INPUT_TYPE = TARGETS['128'] +TARGET = '128' + +SECTOR_SIZE = 256 +DIR_ENTRY_SIZE = 32 + +USR_CLOSED = 0x83 +GEOS_SEQUENTIAL = 0 +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'-DMOUSE_BASE={LOAD}', *(['-DGEOS64=1'] if TARGET == '64' else []), '-f', 'plain', '-o', str(out), str(SOURCE)], check=True) + code = out.read_bytes() + room = AREA_END[TARGET] - LOAD + if len(code) > room: + sys.exit(f'the driver is {len(code)} bytes; GEOS {TARGET} has room for {room}') + 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)) + + +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): + 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 (self.format.starts[track - 1] + sector) * SECTOR_SIZE + + 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)) + 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): + if track not in self.format.reserved: + found = self.take(track) + if found: + return found + sys.exit('the disk is full') + + 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 + 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) + 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(): + 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.""" + 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') + 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 self.slots(*new) + + 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 + SECTOR_SIZE] = padded(link + chunk, SECTOR_SIZE) + info_ts = self.allocate() + o = self.offset(*info_ts) + self.image[o:o + SECTOR_SIZE] = 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_TYPE, GEOS_SEQUENTIAL)) + info[0x47:0x49] = LOAD.to_bytes(2, 'little') + info[0x49:0x4b] = (LOAD + size).to_bytes(2, 'little') + 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) + + +def install(image, code): + 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: + 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() + 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_TYPE)) + 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_TYPE] + 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 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)}] PROGMEM = {{\n{body}\n}};\n' + + +def c_header(code, target): + 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' + '#ifdef ARDUINO\n' + '#include \n' + '#else\n' + '#define PROGMEM\n' + '#endif\n\n' + f'static const char TRMouse{target}Name[] PROGMEM = "{NAME.decode()}";\n' + + c_array(f'TRMouse{target}Code', code) + + c_array(f'TRMouse{target}Info', info_block(len(code)))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + 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') + parser.add_argument('--target', choices=TARGETS, default='128') + args = parser.parse_args() + global LOAD, INPUT_TYPE, TARGET + TARGET = args.target + LOAD, INPUT_TYPE = TARGETS[args.target] + if args.c_header: + if args.disk: + parser.error('--c-header takes no disk images') + args.c_header.write_bytes(c_header(assemble(), args.target).encode()) + 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())) + + +if __name__ == '__main__': + main() diff --git a/tools/geos/trmouse.test.mjs b/tools/geos/trmouse.test.mjs new file mode 100644 index 00000000..ec8206c9 --- /dev/null +++ b/tools/geos/trmouse.test.mjs @@ -0,0 +1,35 @@ +// 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 committedHeaders = { 128: 'Source/Teensy/GEOS/TRMouse128.h', 64: 'Source/Teensy/GEOS/TRMouse64.h' }; + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const missingTool = ['acme', 'python3'].find((tool) => !commandExists(tool)); + +for (const [target, committedPath] of Object.entries(committedHeaders)) { + test(`the committed GEOS ${target} 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, 'TRMouse.h'); + const r = spawnSync('python3', [trmouse, '--target', target, '--c-header', header], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + const committed = fs.readFileSync(path.join(root, committedPath), 'utf8').replaceAll('\r\n', '\n'); + const first = committed.match(/Code\[\d+\] PROGMEM = \{\s*0x4c, 0x([0-9a-f]{2}), 0x([0-9a-f]{2})/); + assert.equal(parseInt(first[2] + first[1], 16) >> 8, target === '64' ? 0xfe : 0xfd, 'the first jump lands in the target\'s own driver area'); + assert.equal(fs.readFileSync(header, 'utf8'), committed, `rerun tools/geos/trmouse.py --target ${target} --c-header`); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } + }); +} diff --git a/tools/geos/trmouse128.asm b/tools/geos/trmouse128.asm new file mode 100644 index 00000000..04e3a0e1 --- /dev/null +++ b/tools/geos/trmouse128.asm @@ -0,0 +1,152 @@ +; SPDX-License-Identifier: MIT +; +; GEOS input driver (128, or 64 assembled at its own address) 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 + +regs = $df00 +regX = 0 +regY = 1 +regButtons = 2 +regID = 3 +LEFT_BUTTON = $01 + +!ifndef MOUSE_BASE { MOUSE_BASE = $fd00 } + * = MOUSE_BASE ; where GEOS 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 ID tells whether the registers are there, beside GeoRAM. +; $DF03-$DF05 are REU address registers, which have no side effects when read. +; Carry set: what reads there is not a mouse. +FindRegs + ldx #2 +- lda regs+regID,x + cmp trID,x + bne + + dex + bpl - + clc + rts ++ sec + rts + +trID !text "TRM" + +UpdateMouse + jsr FindRegs + bcs noMouse + lda regs+regX + sta deltaX + lda regs+regY + sta deltaY + bit mouseOn + bmi readButtons +noMouse rts ; movement while the pointer is off is dropped + +readButtons + lda regs+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 +++ + + ldx #0 + lda deltaX + bpl + + dex ++ stx deltaX+1 +!ifndef GEOS64 { + 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 + + lda 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