From 634424845aab002008df20e83225aeae9f08b018 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:27:42 -0500 Subject: [PATCH 01/17] firmware: the GEOS boot image is built per target, ready for GEOS 128 Each GEOS target now names its own TR disk driver, the start-up runs that set its date and time, and where its boot file keeps the C128 reset key. GeosBuildBootImage reads the boot file, swaps the transport, breaks the reset key and sets the clock. GeosBuildBootPrg wraps that image in GEOS 64's BASIC stub, as before. GEOS 128 sets the clock and the date in one 44-byte run, behind a store of zero that the patched run takes over to load X. Its 1581 driver keeps NewDisk after ReadBlock. The transport swap no longer requires the routines it replaces to lie inside the run it overwrites, since one left outside is only unused. The checks that nothing it leaves alone points into the run stay. CONFIGURE is turned off for any auto-exec file whose name contains CONFIG, which catches GEOS 128's "128 CONFIGURE", "128 CONFIG 2.0r" and "64 CONFIG 2.0r". GEOS 64's boot PRG and patched disk are byte-identical to before on the stock GEOS 64 D81. --- Source/Teensy/GEOS/GeosBoot.h | 249 +++++++++++++----- Source/Teensy/GEOS/GeosDiskPatch.h | 4 + Source/Teensy/GEOS/GeosTargets.h | 15 +- Source/Teensy/GeosLauncher.ino | 5 +- Source/Teensy/tests/geos-boot.test.js | 121 ++++++++- Source/Teensy/tests/geos_boot_harness.c | 39 ++- Source/Teensy/tests/geos_disk_patch_harness.c | 2 +- 7 files changed, 339 insertions(+), 96 deletions(-) diff --git a/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h index 9d596509a..fcc207bfb 100644 --- a/Source/Teensy/GEOS/GeosBoot.h +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -3,7 +3,6 @@ // 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 @@ -14,7 +13,7 @@ #define GEOS_JUMP_OPCODE 0x4c #define GEOS_AUTO_EXEC 14 #define GEOS_SYSTEM_FILE 4 -#define GEOS_CONFIG_PREFIX "CONFIG" +#define GEOS_CONFIG_NAME "CONFIG" #define GEOS_BASIC_START 0x0801 #define GEOS_MEMORY_END 0x10000 #define GEOS_MAX_FILE_BLOCKS 3200 @@ -25,6 +24,17 @@ enum GeosDriverVector { VecInitForIO, VecDoneWithIO, VecExitTurbo, VecPurgeTurbo enum GeosDriverJump { JumpSendTSBytes = 6, JumpGetDOSError = 7, JumpReadLink = 9 }; enum TRDriverEntry { EntrySendTSBytes = 10, EntryGetDOSError, EntryReadLink }; +// A TR disk driver as tools/geos/trdrive.py emits it: code assembled at Base, the offsets of the words that hold +// addresses inside it, and the offsets of its routines in TRDriverEntry order +struct GeosDiskDriver +{ + const uint8_t *Code; + uint16_t Size, Base; + const uint16_t *Relocations; + uint16_t RelocationCount; + const uint16_t *Entries; +}; + static uint16_t GeosWord(const uint8_t *At) { return At[0] | At[1] << 8; } static void GeosPutWord(uint8_t *At, uint16_t Value) @@ -63,19 +73,15 @@ static int32_t GeosFindDriver(const uint8_t *Data, uint32_t Size) 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) +// The TR's driver goes over the stock driver's code from InitForIO up to ReadBlock, provided no vector or jump slot +// it leaves alone points there, and every vector it implements is pointed at its own routines. Replaced routines +// outside that run stay where they are, unused: GEOS 128 keeps NewDisk after ReadBlock. +static bool GeosReplaceTransport(uint8_t *Driver, const struct GeosDiskDriver *With) { uint16_t Start = GeosWord(Driver + 2 * VecInitForIO), End = GeosWord(Driver + 2 * VecReadBlock); - uint8_t Code[sizeof TRDriverCode]; + uint8_t *Code = Driver + (Start - GEOS_DRIVER_AREA); - 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; - } + if (End <= Start || (size_t)(End - Start) < With->Size) return false; for (int i = VecCount; i < GEOS_VECTOR_WORDS; i++) { uint16_t Other = GeosWord(Driver + 2 * i); @@ -87,17 +93,16 @@ static bool GeosReplaceTransport(uint8_t *Driver) 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++) + memcpy(Code, With->Code, With->Size); + for (uint16_t i = 0; i < With->RelocationCount; i++) { - uint8_t *Word = Code + TRDriverRelocations[i]; - GeosPutWord(Word, GeosWord(Word) + Start - TR_DRIVER_BASE); + uint8_t *Word = Code + With->Relocations[i]; + GeosPutWord(Word, GeosWord(Word) + Start - With->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]); + for (int i = 0; i < VecCount; i++) GeosPutWord(Driver + 2 * i, Start + With->Entries[i]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpSendTSBytes + 1, Start + With->Entries[EntrySendTSBytes]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpGetDOSError + 1, Start + With->Entries[EntryGetDOSError]); + GeosPutWord(Driver + GEOS_JUMP_SLOTS + 3 * JumpReadLink + 1, Start + With->Entries[EntryReadLink]); return true; } @@ -138,9 +143,20 @@ static bool GeosReadFile(struct GeosDisk *Disk, const char *Name, uint8_t *Out, return true; } +static bool GeosNameContains(const uint8_t *Name, const char *Part) +{ + size_t Length = strlen(Part); + + for (size_t i = 0; i + Length <= GEOS_NAME_SIZE; i++) + { + if (!memcmp(Name + i, Part, Length)) return true; + } + return false; +} + // 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) +static inline bool GeosDisableConfigure(struct GeosDisk *Disk, bool *Changed) { struct GeosTS Chain[GEOS_MAX_DIR_SECTORS]; uint8_t Block[GEOS_SECTOR_SIZE]; @@ -157,7 +173,7 @@ static bool GeosDisableConfigure(struct GeosDisk *Disk, bool *Changed) { 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; + if (!GeosNameContains(Slot + SlotName, GEOS_CONFIG_NAME)) continue; Slot[SlotGeosType] = GEOS_SYSTEM_FILE; Retyped = true; } @@ -179,19 +195,6 @@ static const uint8_t GeosBootStub[] = 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) @@ -208,61 +211,171 @@ static uint8_t GeosTodHour(uint8_t Hour) 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) +// One run of a boot file's start-up code that sets the date or the time. It is found by its bytes, since the stock +// file fixes the values it sets, and Set makes it set Now instead without changing its length. +struct GeosClockRun +{ + const uint8_t *Pattern; + uint8_t Size; + void (*Set)(uint8_t *Run, const struct GeosDate *Now); +}; + +// GEOS 64 sets the date with three immediate stores and the clock in CIA 1 with another run. 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 Geos64StartDate[] = { 0xa9, 0x17, 0x8d, 0x18, 0x85, 0xa9, 0x03, 0x8d, 0x17, 0x85, 0xa9, 0x5c, 0x8d, 0x16, 0x85 }; +static const uint8_t Geos64StartClock[] = +{ + 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 GEOS64_CLOCK_TIME 11 + +static void Geos64SetDate(uint8_t *Run, const struct GeosDate *Now) +{ + Run[1] = Now->Day; + Run[6] = Now->Month; + Run[11] = Now->Year; +} + +static void Geos64SetTime(uint8_t *Run, const struct GeosDate *Now) +{ + const uint8_t Time[] = + { + 0xa9, GeosTodHour(Now->Hour), 0x8d, 0x0b, 0xdc, + 0xa9, GeosBcd(Now->Minute), 0x8d, 0x0a, 0xdc, + 0x8e, 0x09, 0xdc, 0x8e, 0x08, 0xdc, + }; + + memcpy(Run + GEOS64_CLOCK_TIME, Time, sizeof Time); +} + +static const struct GeosClockRun Geos64ClockRuns[] = +{ + { Geos64StartDate, sizeof Geos64StartDate, Geos64SetDate }, + { Geos64StartClock, sizeof Geos64StartClock, Geos64SetTime }, +}; + +// GEOS 128 sets the clock and then the date in one run, behind a store of zero. No register is known to be zero +// there and the run has no byte to spare for clearing one, so the patched run makes that store load X instead. +static const uint8_t Geos128StartUp[] = +{ + 0xa9, 0x00, 0x8d, 0xc5, 0x88, // lda #0 : sta $88c5 + 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 + 0xa9, 0x5c, 0x8d, 0x16, 0x85, // year 92 + 0xa9, 0x03, 0x8d, 0x17, 0x85, // month 3 + 0xa9, 0x17, 0x8d, 0x18, 0x85, // day 23 +}; + +static void Geos128SetClock(uint8_t *Run, const struct GeosDate *Now) +{ + const uint8_t StartUp[sizeof Geos128StartUp] = + { + 0xa2, 0x00, 0x8e, 0xc5, 0x88, + 0xad, 0x0f, 0xdc, 0x29, 0x7f, 0x8d, 0x0f, 0xdc, + 0xa9, GeosTodHour(Now->Hour), 0x8d, 0x0b, 0xdc, + 0xa9, GeosBcd(Now->Minute), 0x8d, 0x0a, 0xdc, + 0x8e, 0x09, 0xdc, 0x8e, 0x08, 0xdc, + 0xa9, Now->Year, 0x8d, 0x16, 0x85, + 0xa9, Now->Month, 0x8d, 0x17, 0x85, + 0xa9, Now->Day, 0x8d, 0x18, 0x85, + }; + + memcpy(Run, StartUp, sizeof StartUp); +} + +static const struct GeosClockRun Geos128ClockRuns[] = { { Geos128StartUp, sizeof Geos128StartUp, Geos128SetClock } }; + +// Finds Pattern once in Body; NULL when it is absent or ambiguous +static uint8_t *GeosFindOnce(uint8_t *Body, 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 (memcmp(Body + i, Pattern, Size)) continue; if (Found) return NULL; - Found = Prg + i; + Found = Body + 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) +// Sets the date and time GEOS starts with. Leaves the body as it is, and returns false, for a boot file whose +// start-up code is not the one the target knows +static bool GeosSetClock(uint8_t *Body, uint32_t Length, const struct GeosTarget *Target, 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[] = + for (uint8_t i = 0; i < Target->ClockRunCount; i++) { - 0xa9, GeosTodHour(Now->Hour), 0x8d, 0x0b, 0xdc, - 0xa9, GeosBcd(Now->Minute), 0x8d, 0x0a, 0xdc, - 0x8e, 0x09, 0xdc, 0x8e, 0x08, 0xdc, - }; + const struct GeosClockRun *Run = &Target->ClockRuns[i]; + if (!GeosFindOnce(Body, Length, Run->Pattern, Run->Size)) return false; + } + for (uint8_t i = 0; i < Target->ClockRunCount; i++) + { + const struct GeosClockRun *Run = &Target->ClockRuns[i]; + Run->Set(GeosFindOnce(Body, Length, Run->Pattern, Run->Size), Now); + } + return true; +} + +// GEOS 128 leaves "CBM" and $03E4 in bank 1 at $FFF5, and the C128 kernal resumes there on every reset until the +// power goes off. The TR menu runs in C64 mode and overwrites $03E4, so the next C128 program would crash. +static const uint8_t GeosResetKey[] = { 'C', 'B', 'M', 0xe4, 0x03 }; - 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); +static bool GeosBreakResetKey(struct GeosDisk *Disk, uint8_t *Image, uint32_t Length) +{ + uint16_t Key = Disk->Target->ResetKey, Load = GeosWord(Image); + uint32_t At = 2 + (uint32_t)(Key - Load); + + if (!Key) return true; + if (Key < Load || At + sizeof GeosResetKey > Length || memcmp(Image + At, GeosResetKey, sizeof GeosResetKey)) + { + return GeosFail(Disk, PSTR("the boot file's reset key is not where this version keeps it")); + } + Image[At] = 0; 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) +// The boot file as GEOS starts from it, load address first: the disk driver's transport replaced, any reset key +// broken, and the clock set to Now where the start-up code is one the target knows +static inline bool GeosBuildBootImage(struct GeosDisk *Disk, const struct GeosDate *Now, uint8_t *Out, uint32_t Capacity, + uint32_t *Length, bool *ClockSet) { - const uint32_t Prologue = 2 + sizeof GeosBasicLine + sizeof GeosBootStub; - uint32_t FileSize, BodySize, Load, Place; + const struct GeosTarget *Target = Disk->Target; 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; + if (!Target->BootFile) return GeosFail(Disk, PSTR("this version has no boot file to start from")); + if (!GeosReadFile(Disk, Target->BootFile, Out, Capacity, Length)) return false; + if (*Length < 2 + GEOS_DRIVER_SIZE) return GeosFail(Disk, PSTR("the boot file is too short")); + Driver = GeosFindDriver(Out + 2, *Length - 2); + if (Driver < 0 || !GeosReplaceTransport(Out + 2 + Driver, Target->DiskDriver)) + { + return GeosFail(Disk, PSTR("the boot file's disk driver is not one the TR can replace")); + } + if (!GeosBreakResetKey(Disk, Out, *Length)) return false; + *ClockSet = GeosSetClock(Out + 2, *Length - 2, Target, Now); + return true; +} + +// One PRG that starts GEOS 64: the boot image placed where its load address puts it, behind a BASIC line that +// jumps to the boot entry +static inline bool GeosBuildBootPrg(struct GeosDisk *Disk, const struct GeosDate *Now, uint8_t *Out, uint32_t Capacity, + uint32_t *Length, bool *ClockSet) +{ + const uint32_t Prologue = 2 + sizeof GeosBasicLine + sizeof GeosBootStub; + uint32_t ImageSize, BodySize, Load, Place; + + if (!GeosBuildBootImage(Disk, Now, Out, Capacity, &ImageSize, ClockSet)) return false; + BodySize = ImageSize - 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); + memmove(Out + Place, Out + 2, BodySize); memset(Out, 0, Place); GeosPutWord(Out, GEOS_BASIC_START); memcpy(Out + 2, GeosBasicLine, sizeof GeosBasicLine); diff --git a/Source/Teensy/GEOS/GeosDiskPatch.h b/Source/Teensy/GEOS/GeosDiskPatch.h index 359266274..ad8271382 100644 --- a/Source/Teensy/GEOS/GeosDiskPatch.h +++ b/Source/Teensy/GEOS/GeosDiskPatch.h @@ -64,6 +64,10 @@ struct GeosTarget uint8_t InputType; const char *BootFile; // what the TR starts the disk from uint16_t BootEntry; + const struct GeosDiskDriver *DiskDriver; + const struct GeosClockRun *ClockRuns; + uint8_t ClockRunCount; + uint16_t ResetKey; // where the boot file holds the C128's reset key, or 0 }; struct GeosBootDisk { bool HasDeskTop, HasDriver; }; diff --git a/Source/Teensy/GEOS/GeosTargets.h b/Source/Teensy/GEOS/GeosTargets.h index c96b9f462..7bc7e8a3c 100644 --- a/Source/Teensy/GEOS/GeosTargets.h +++ b/Source/Teensy/GEOS/GeosTargets.h @@ -1,22 +1,35 @@ // 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 "GeosBoot.h" #include "GeosDiskPatch.h" +#include "TRDriver.h" #include "TRMouse64.h" #define GEOS_INPUT_64 10 #define GEOS64_BOOT_FILE "GEOBOOT" #define GEOS64_BOOT_ENTRY 0x2f3c +#define GEOS128_BOOT_FILE "GEOBOOT128" +#define GEOS128_BOOT_ENTRY 0x2f3c +#define GEOS128_RESET_KEY 0x6ff5 + struct GeosLauncher { const char *Title, *Folder; struct GeosTarget Target; }; +static const struct GeosDiskDriver TRDiskDriver64 = +{ + TRDriverCode, sizeof TRDriverCode, TR_DRIVER_BASE, + TRDriverRelocations, sizeof TRDriverRelocations / sizeof TRDriverRelocations[0], TRDriverEntries, +}; + static const struct GeosLauncher Geos64Launcher = { "GEOS 64", "/GEOS64", { "DESK TOP", TRMouse64Name, TRMouse64Code, TRMouse64Info, sizeof TRMouse64Code, GEOS_INPUT_64, - GEOS64_BOOT_FILE, GEOS64_BOOT_ENTRY }, + GEOS64_BOOT_FILE, GEOS64_BOOT_ENTRY, &TRDiskDriver64, + Geos64ClockRuns, sizeof Geos64ClockRuns / sizeof Geos64ClockRuns[0], 0 }, }; diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index b2278fb7e..d4021e2d8 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -263,7 +263,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * struct GeosDisk Disk; uint32_t Length = 0; struct GeosDate Now = GeosToday(); - bool Changed, Built; + bool Changed, ClockSet, Built; File Image; GeosPath(Path, sizeof Path, Launcher, Name); @@ -274,8 +274,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * return false; } Disk = GeosDiskOn(Launcher, &Image); - Built = GeosBuildBootPrg(&Disk, RAM_Image, RAM_ImageSize, &Length) && GeosDisableConfigure(&Disk, &Changed); - if (Built) GeosSetClock(RAM_Image, Length, &Now); + Built = GeosBuildBootPrg(&Disk, &Now, RAM_Image, RAM_ImageSize, &Length, &ClockSet) && GeosDisableConfigure(&Disk, &Changed); if (!Built) SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Disk.Error); Image.close(); if (!Built) return false; diff --git a/Source/Teensy/tests/geos-boot.test.js b/Source/Teensy/tests/geos-boot.test.js index f919f1eaa..7001a0226 100644 --- a/Source/Teensy/tests/geos-boot.test.js +++ b/Source/Teensy/tests/geos-boot.test.js @@ -66,6 +66,14 @@ function stockDriver() { return driver; } +// GEOS 128's 1581 driver keeps NewDisk after ReadBlock, outside the run the TR driver takes +const NEW_DISK_128 = 0x9480; +function stockDriver128() { + const driver = stockDriver(); + driver.writeUInt16LE(NEW_DISK_128, 2 * 6); + 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]; @@ -79,15 +87,16 @@ 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) { +// Runs the start-up clock code the way a 6526 takes it: X is zero from the loop before it unless given, writes go +// to the alarm while CRB selects it, and writing hour 12 flips AM/PM. Returns the TOD registers it sets, tenths +// first; other stores land in memory. +function runClock(code, { x = 0, memory = {} } = {}) { let a = 0; - let x = 0; let crb = ALARM_SELECT; const tod = []; const write = (address, value) => { if (address === CRB) crb = value; + else if (address < TOD_TENTHS || address > TOD_HOURS) memory[address] = 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; }; @@ -105,13 +114,16 @@ function runClock(code) { return tod; } -function bootFile(driver = stockDriver(), startUp = []) { +const GEOS64 = { load: LOAD_ADDRESS, driverAt: DRIVER_AT_IN_BODY, startUpAt: START_AT_IN_BODY }; + +function bootFile(driver = stockDriver(), startUp = [], layout = GEOS64) { 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); + driver.copy(body, layout.driverAt); + Buffer.from(startUp).copy(body, layout.startUpAt); + if (layout.resetKey) Buffer.from(layout.resetKey.bytes).copy(body, layout.resetKey.at); const file = Buffer.alloc(2 + BODY_SIZE); - file.writeUInt16LE(LOAD_ADDRESS, 0); + file.writeUInt16LE(layout.load, 0); body.copy(file, 2); return { file, body }; } @@ -164,9 +176,9 @@ function withHarness(body) { 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 = []) => { + const launch = (disk, time = [], version = '64') => { 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)]); + const r = run([path.join(dir, 'harness'), version, 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); @@ -182,6 +194,29 @@ function standardDisk(driver, startUp = []) { return disk; } +// GEOBOOT128 as the stock GEOS 128 2.0 disk has it: loaded at $2B19, the 1581 driver staged at $7000 with the +// reset key just below it, and the clock and date set in one run at $2C24 +const GEOS128_LOAD = 0x2b19; +const RESET_KEY = [0x43, 0x42, 0x4d, 0xe4, 0x03]; +const START_UP_128 = [ + 0xa9, 0x00, 0x8d, 0xc5, 0x88, ...START_CLOCK, + 0xa9, 0x5c, 0x8d, 0x16, 0x85, 0xa9, 0x03, 0x8d, 0x17, 0x85, 0xa9, 0x17, 0x8d, 0x18, 0x85, +]; +const GEOS128 = { + load: GEOS128_LOAD, + driverAt: 0x7000 - GEOS128_LOAD, + startUpAt: 0x2c24 - GEOS128_LOAD, + resetKey: { at: 0x6ff5 - GEOS128_LOAD, bytes: RESET_KEY }, +}; +const DATE = { 0x8516: 26, 0x8517: 10, 0x8518: 21 }; + +function geos128Disk(startUp = START_UP_128, layout = GEOS128) { + const disk = new Disk(); + disk.addFile('GEOS128', Buffer.from([1, 8, 0])); + disk.addFile('GEOBOOT128', bootFile(stockDriver128(), startUp, layout).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()); @@ -281,6 +316,7 @@ test('CONFIGURE files stop being auto-executed, and nothing else changes', { ski 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 geos128 = ['128 CONFIGURE', '128 CONFIG 2.0r', '64 CONFIG 2.0r'].map((name) => disk.addFile(name, 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); @@ -289,6 +325,7 @@ test('CONFIGURE files stop being auto-executed, and nothing else changes', { ski 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); + for (const slot of geos128) assert.equal(first.image[slot + SLOT_GEOS_TYPE], SYSTEM_FILE, 'GEOS 128 names CONFIG after a prefix'); 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'); @@ -297,3 +334,67 @@ test('CONFIGURE files stop being auto-executed, and nothing else changes', { ski assert.equal(again.image.equals(first.image), true); }); }); + +test('the GEOS 128 boot image is the boot file with the TR disk driver in place and the reset key broken', { skip }, () => { + withHarness((launch) => { + const r = launch(geos128Disk(), [], '128'); + assert.equal(r.status, 0, r.err); + const { file } = bootFile(stockDriver128(), START_UP_128, GEOS128); + const driverAt = 2 + GEOS128.driverAt; + const keyAt = 2 + GEOS128.resetKey.at; + const startAt = 2 + GEOS128.startUpAt; + + assert.equal(r.prg.length, file.length); + assert.equal(r.prg.readUInt16LE(0), GEOS128_LOAD); + const patched = r.prg.subarray(driverAt, driverAt + DRIVER_SIZE); + for (let i = 0; i < 10; i++) assert.equal(patched.readUInt16LE(2 * i), TURBO_START + ENTRIES[i], `vector ${i}`); + const oldNewDisk = NEW_DISK_128 - DRIVER_AREA; + assert.equal(patched.subarray(oldNewDisk, READ_BLOCK - DRIVER_AREA + 0x100).equals(stockDriver128().subarray(oldNewDisk, READ_BLOCK - DRIVER_AREA + 0x100)), true, 'the stock NewDisk stays'); + assert.deepEqual([...r.prg.subarray(keyAt, keyAt + RESET_KEY.length)], [0, ...RESET_KEY.slice(1)], 'the C of CBM is gone'); + + const unchanged = (from, to) => assert.equal(r.prg.subarray(from, to).equals(file.subarray(from, to)), true, `bytes ${from}-${to}`); + unchanged(0, startAt); + unchanged(startAt + START_UP_128.length, keyAt); + unchanged(keyAt + 1, driverAt); + unchanged(driverAt + DRIVER_SIZE, file.length); + }); +}); + +test('GEOS 128 starts with the given date and time, whatever X holds', { skip }, () => { + withHarness((launch) => { + const startAt = 2 + GEOS128.startUpAt; + for (const [hour, minute, held] of [[0, 5, 0x12], [12, 30, PM | 0x12], [23, 59, PM | 0x11], [15, 7, PM | 0x03]]) { + const r = launch(geos128Disk(), [hour, minute], '128'); + assert.match(r.out, /clock=1/); + const memory = {}; + const tod = runClock(r.prg.subarray(startAt, startAt + START_UP_128.length), { x: 0x5a, memory }); + assert.deepEqual(tod, [0x00, 0x00, parseInt(String(minute), 16), held], `${hour}:${minute}`); + assert.deepEqual(memory, { 0x88c5: 0, ...DATE }, 'the store before the run, then year, month, day'); + } + }); +}); + +test('a GEOS 128 boot file without its start-up code keeps the date and time it has', { skip }, () => { + withHarness((launch) => { + const startAt = 2 + GEOS128.startUpAt; + for (const startUp of [START_UP_128.slice(5), START_UP]) { + const r = launch(geos128Disk(startUp), [], '128'); + assert.equal(r.status, 0, r.err); + assert.match(r.out, /clock=0/, 'GEOS 64\'s runs are not GEOS 128\'s'); + assert.deepEqual([...r.prg.subarray(startAt, startAt + startUp.length)], startUp); + } + }); +}); + +test('a GEOS 128 boot file whose reset key is not where GEOS 128 keeps it is refused', { skip }, () => { + withHarness((launch) => { + for (const bytes of [[0x43, 0x42, 0x4d, 0xe5, 0x03], [0, 0, 0, 0, 0]]) { + const r = launch(geos128Disk(START_UP_128, { ...GEOS128, resetKey: { at: GEOS128.resetKey.at, bytes } }), [], '128'); + assert.equal(r.status, 1); + assert.match(r.err, /reset key is not where this version keeps it/); + } + const early = launch(geos128Disk(START_UP_128, { ...GEOS128, load: 0x7000 }), [], '128'); + assert.equal(early.status, 1, 'a boot file loaded above the key'); + assert.match(early.err, /reset key/); + }); +}); diff --git a/Source/Teensy/tests/geos_boot_harness.c b/Source/Teensy/tests/geos_boot_harness.c index 24b75d574..0d05248ff 100644 --- a/Source/Teensy/tests/geos_boot_harness.c +++ b/Source/Teensy/tests/geos_boot_harness.c @@ -1,13 +1,23 @@ // 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. +// geos_boot_harness 64|128 IN.d81 OUT OUT.d81 [HOUR MINUTE]: builds the GEOS 64 boot PRG, or the GEOS 128 boot +// image, 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" +#include "../GEOS/TRMouse128.h" #define MAX_IMAGE 819200 #define MAX_PRG (128 * 1024) +#define GEOS_INPUT_128 15 + +// GEOS 128's boot facts, on GEOS 64's disk driver until there is a 128 one: the transport swap is the same +static const struct GeosTarget Geos128Boot = +{ + "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, + GEOS128_BOOT_FILE, GEOS128_BOOT_ENTRY, &TRDiskDriver64, + Geos128ClockRuns, sizeof Geos128ClockRuns / sizeof Geos128ClockRuns[0], GEOS128_RESET_KEY, +}; static uint8_t Image[MAX_IMAGE + 1], Prg[MAX_PRG]; @@ -28,34 +38,37 @@ int main(int argc, char **argv) struct GeosDate Now = { 26, 10, 21, 15, 7 }; struct GeosDisk Disk = { 0 }; uint32_t Length; - bool Changed; + bool Is128, Changed, ClockSet, Built; FILE *File; size_t Size; - if ((argc != 4 && argc != 6) || !(File = fopen(argv[1], "rb"))) return 2; - if (argc == 6) + if ((argc != 5 && argc != 7) || !(File = fopen(argv[2], "rb"))) return 2; + Is128 = !strcmp(argv[1], "128"); + if (argc == 7) { - Now.Hour = atoi(argv[4]); - Now.Minute = atoi(argv[5]); + Now.Hour = atoi(argv[5]); + Now.Minute = atoi(argv[6]); } Size = fread(Image, 1, sizeof Image, File); fclose(File); Disk.Format = GeosFormatForSize(Size); - Disk.Target = &Geos64Launcher.Target; + Disk.Target = Is128 ? &Geos128Boot : &Geos64Launcher.Target; Disk.Context = Image; Disk.Read = ReadBlock; Disk.Write = WriteBlock; - if (!GeosBuildBootPrg(&Disk, Prg, sizeof Prg, &Length) || !GeosDisableConfigure(&Disk, &Changed)) + Built = Is128 ? GeosBuildBootImage(&Disk, &Now, Prg, sizeof Prg, &Length, &ClockSet) + : GeosBuildBootPrg(&Disk, &Now, Prg, sizeof Prg, &Length, &ClockSet); + if (!Built || !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"); + printf("changed=%d clock=%d\n", Changed, ClockSet); + File = fopen(argv[3], "wb"); fwrite(Prg, 1, Length, File); fclose(File); - File = fopen(argv[3], "wb"); + File = fopen(argv[4], "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 index 88b5f30e6..3a3d22131 100644 --- a/Source/Teensy/tests/geos_disk_patch_harness.c +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -27,7 +27,7 @@ static bool WriteBlock(void *Context, uint32_t Offset, const uint8_t *Block) } static const struct GeosTarget Geos128Target = - { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, NULL, 0 }; + { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, NULL, 0, NULL, NULL, 0, 0 }; static int Inspect(struct GeosDisk *Disk) { From 5e1035170305c828f379552bd8d569b9dc3a2b97 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:34:18 -0500 Subject: [PATCH 02/17] fix: review findings for 6344248 --- Source/Teensy/tests/geos-boot.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/Teensy/tests/geos-boot.test.js b/Source/Teensy/tests/geos-boot.test.js index 7001a0226..6174b16c5 100644 --- a/Source/Teensy/tests/geos-boot.test.js +++ b/Source/Teensy/tests/geos-boot.test.js @@ -396,5 +396,8 @@ test('a GEOS 128 boot file whose reset key is not where GEOS 128 keeps it is ref const early = launch(geos128Disk(START_UP_128, { ...GEOS128, load: 0x7000 }), [], '128'); assert.equal(early.status, 1, 'a boot file loaded above the key'); assert.match(early.err, /reset key/); + const short = launch(geos128Disk(START_UP_128, { ...GEOS128, load: 0x0f00 }), [], '128'); + assert.equal(short.status, 1, 'a boot file that ends before the key'); + assert.match(short.err, /reset key/); }); }); From f224c24d81047f6fe5726e79214979ea93910ff6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 29 Oct 2026 13:03:42 -0500 Subject: [PATCH 03/17] Apply suggestions from code review Travis's review of #75: the GEOS 64 clock helpers keep FLASHMEM now that they are reached through function pointers. --- Source/Teensy/GEOS/GeosBoot.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h index fcc207bfb..d54af67cb 100644 --- a/Source/Teensy/GEOS/GeosBoot.h +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -233,14 +233,14 @@ static const uint8_t Geos64StartClock[] = }; #define GEOS64_CLOCK_TIME 11 -static void Geos64SetDate(uint8_t *Run, const struct GeosDate *Now) +FLASHMEM static void Geos64SetDate(uint8_t *Run, const struct GeosDate *Now) { Run[1] = Now->Day; Run[6] = Now->Month; Run[11] = Now->Year; } -static void Geos64SetTime(uint8_t *Run, const struct GeosDate *Now) +FLASHMEM static void Geos64SetTime(uint8_t *Run, const struct GeosDate *Now) { const uint8_t Time[] = { From 80c96445635f13585bdc91dfd43c108d9fc2ca7f Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:31:36 -0500 Subject: [PATCH 04/17] tools: the TR disk driver has a C128 build for GEOS 128 trdrive.asm assembled with C128 defined brings I/O in through the MMU ($FF00=$7E) instead of $01, and holds the CPU at 1 MHz ($D030) while it talks to the block window. DoneWithIO puts both back. trdrive.py --c128 emits it as TRDriver128.h. The GEOS 64 build and TRDriver.h are unchanged. --- Source/Teensy/GEOS/TRDriver128.h | 55 ++++++++++++++++++++++++++++++++ tools/geos/trdrive.asm | 42 ++++++++++++++++++++---- tools/geos/trdrive.py | 30 +++++++++-------- tools/geos/trdrive.test.mjs | 36 +++++++++++++-------- 4 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 Source/Teensy/GEOS/TRDriver128.h diff --git a/Source/Teensy/GEOS/TRDriver128.h b/Source/Teensy/GEOS/TRDriver128.h new file mode 100644 index 000000000..1d90f9563 --- /dev/null +++ b/Source/Teensy/GEOS/TRDriver128.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Generated by tools/geos/trdrive.py --c128 from tools/geos/trdrive.asm; do not edit. +#pragma once +#include +#ifdef ARDUINO +#include +#else +#define PROGMEM +#endif + +#define TR_DRIVER128_BASE 0x9000 +static const uint8_t TRDriver128Code[522] PROGMEM = { + 0x08, 0x68, 0x8d, 0xff, 0x91, 0x78, 0xad, 0x00, 0xff, 0x8d, 0x00, 0x92, 0xa9, 0x7e, 0x8d, 0x00, + 0xff, 0xad, 0x30, 0xd0, 0x8d, 0x01, 0x92, 0xa9, 0x00, 0x8d, 0x30, 0xd0, 0xad, 0x1a, 0xd0, 0x8d, + 0x09, 0x92, 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, 0x09, 0x92, 0x8d, 0x1a, 0xd0, + 0xad, 0x01, 0x92, 0x8d, 0x30, 0xd0, 0xad, 0x00, 0x92, 0x8d, 0x00, 0xff, 0xad, 0xff, 0x91, 0x48, + 0x28, 0x60, 0xa2, 0x00, 0x8a, 0x60, 0xa2, 0x00, 0xa0, 0x00, 0x60, 0x8d, 0x89, 0x84, 0x85, 0xba, + 0xa2, 0x00, 0x60, 0x20, 0xb0, 0x90, 0xe0, 0x00, 0xd0, 0x03, 0x20, 0x9c, 0x91, 0xa0, 0x00, 0x60, + 0x20, 0x9c, 0x91, 0x20, 0xec, 0x90, 0x8a, 0x48, 0x20, 0x9c, 0x91, 0x68, 0xaa, 0xa0, 0x00, 0x60, + 0x20, 0xcf, 0x91, 0xb0, 0x26, 0xa9, 0x01, 0x20, 0x29, 0x91, 0xe0, 0x00, 0xd0, 0xdf, 0xa9, 0x00, + 0xa2, 0x0b, 0x20, 0xf8, 0x91, 0xa2, 0x40, 0x20, 0xe9, 0x91, 0xa0, 0x00, 0x91, 0x0a, 0xa2, 0x41, + 0x20, 0xe9, 0x91, 0xc8, 0x91, 0x0a, 0xa2, 0x00, 0xa0, 0x00, 0x60, 0xa2, 0x0d, 0xa0, 0x00, 0x60, + 0x20, 0xcf, 0x91, 0xb0, 0xf6, 0xa9, 0x01, 0x20, 0x29, 0x91, 0xe0, 0x00, 0xd0, 0x2d, 0xa0, 0x00, + 0xa9, 0x00, 0x8d, 0x05, 0x92, 0xad, 0x05, 0x92, 0xa2, 0x0b, 0x20, 0xf8, 0x91, 0xa2, 0x00, 0xbd, + 0x40, 0xdf, 0xdd, 0x40, 0xdf, 0xd0, 0xf8, 0x91, 0x0a, 0xc8, 0xe8, 0xe0, 0x40, 0xd0, 0xf0, 0xee, + 0x05, 0x92, 0xad, 0x05, 0x92, 0xc9, 0x04, 0xd0, 0xdc, 0xa2, 0x00, 0x60, 0x20, 0xcf, 0x91, 0xb0, + 0xba, 0xa0, 0x00, 0xa9, 0x00, 0x8d, 0x05, 0x92, 0xad, 0x05, 0x92, 0xa2, 0x0b, 0x20, 0xf8, 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, 0x05, 0x92, 0xad, 0x05, 0x92, 0xc9, 0x04, 0xd0, + 0xd7, 0xa9, 0x02, 0x4c, 0x29, 0x91, 0xa2, 0x0d, 0x60, 0x8d, 0x03, 0x92, 0xee, 0x06, 0x92, 0xd0, + 0x03, 0xee, 0x06, 0x92, 0xad, 0x06, 0x92, 0xa2, 0x0c, 0x20, 0xf8, 0x91, 0xa5, 0x04, 0xa2, 0x09, + 0x20, 0xf8, 0x91, 0xa5, 0x05, 0xa2, 0x0a, 0x20, 0xf8, 0x91, 0xad, 0x03, 0x92, 0xa2, 0x08, 0x20, + 0xf8, 0x91, 0xa9, 0x00, 0x8d, 0x07, 0x92, 0x8d, 0x08, 0x92, 0xa2, 0x0d, 0x20, 0xe5, 0x91, 0x8d, + 0x04, 0x92, 0x20, 0xe5, 0x91, 0xcd, 0x04, 0x92, 0xd0, 0x04, 0xc9, 0x80, 0xd0, 0x0d, 0xee, 0x07, + 0x92, 0xd0, 0xe7, 0xee, 0x08, 0x92, 0xd0, 0xe2, 0xa2, 0x0d, 0x60, 0x48, 0xa2, 0x0e, 0x20, 0xe5, + 0x91, 0xcd, 0x06, 0x92, 0xf0, 0x04, 0x68, 0x4c, 0x6e, 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, 0x02, 0x92, 0x98, 0x18, 0x69, 0x8c, 0xa8, 0xb1, 0x0a, 0x48, 0xad, 0x02, 0x92, 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, 0xfc, 0x91, 0xd0, 0x05, 0x88, 0x10, + 0xf0, 0x18, 0x60, 0x38, 0x60, 0xbd, 0x00, 0xdf, 0x60, 0x20, 0xe5, 0x91, 0x8d, 0x04, 0x92, 0x20, + 0xe5, 0x91, 0xcd, 0x04, 0x92, 0xd0, 0xf2, 0x60, 0x9d, 0x00, 0xdf, 0x60, 0x54, 0x52, 0x42, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 +}; +static const uint16_t TRDriver128Relocations[59] PROGMEM = { + 0x0003, 0x000a, 0x0015, 0x0020, 0x003b, 0x0041, 0x0047, 0x004d, 0x0064, 0x006b, 0x0071, 0x0074, 0x0079, 0x0081, 0x0088, 0x0093, + 0x0098, 0x00a1, 0x00b1, 0x00b8, 0x00c3, 0x00c6, 0x00cb, 0x00e0, 0x00e3, 0x00ed, 0x00f6, 0x00f9, 0x00fe, 0x0118, 0x011b, 0x0124, + 0x012a, 0x012d, 0x0132, 0x0135, 0x013a, 0x0141, 0x0148, 0x014b, 0x0150, 0x0155, 0x0158, 0x015d, 0x0160, 0x0163, 0x0166, 0x016f, + 0x0174, 0x017f, 0x0182, 0x0188, 0x01b2, 0x01bd, 0x01da, 0x01ea, 0x01ed, 0x01f0, 0x01f3 +}; +static const uint16_t TRDriver128Entries[13] PROGMEM = { + 0x0000, 0x0033, 0x0055, 0x0055, 0x0052, 0x005b, 0x0056, 0x0063, 0x0070, 0x0056, 0x0056, 0x0056, 0x0080 +}; diff --git a/tools/geos/trdrive.asm b/tools/geos/trdrive.asm index b08cc7d2b..a2d74e03d 100644 --- a/tools/geos/trdrive.asm +++ b/tools/geos/trdrive.asm @@ -44,6 +44,13 @@ CIA1_ICR = $dc0d CIA2_ICR = $dd0d MASK_ALL = $7f +CPU_PORT = $01 +IO_IN_64 = $36 +MMU_CR = $ff00 +IO_IN_BANK_1 = $7e +VIC_CLOCK = $d030 +CLOCK_1MHZ = 0 + BASE_GEOS_RAM = $df * = ORG @@ -53,10 +60,21 @@ InitForIO pla sta savedP sei - lda $01 - sta saved01 - lda #$36 - sta $01 +!ifdef C128 { + lda MMU_CR + sta savedMemory + lda #IO_IN_BANK_1 + sta MMU_CR + lda VIC_CLOCK + sta savedClock + lda #CLOCK_1MHZ + sta VIC_CLOCK +} else { + lda CPU_PORT + sta savedMemory + lda #IO_IN_64 + sta CPU_PORT +} lda VIC_IRQ_ENABLE sta savedIrqEnable lda #0 @@ -73,8 +91,15 @@ DoneWithIO lda CIA2_ICR lda savedIrqEnable sta VIC_IRQ_ENABLE - lda saved01 - sta $01 +!ifdef C128 { + lda savedClock + sta VIC_CLOCK + lda savedMemory + sta MMU_CR +} else { + lda savedMemory + sta CPU_PORT +} lda savedP pha plp @@ -344,7 +369,10 @@ putReg trbId !byte $54, $52, $42 savedP !byte 0 -saved01 !byte 0 +savedMemory !byte 0 +!ifdef C128 { +savedClock !byte 0 +} scratch !byte 0 command !byte 0 seen !byte 0 diff --git a/tools/geos/trdrive.py b/tools/geos/trdrive.py index b0552d940..1b8aa8578 100644 --- a/tools/geos/trdrive.py +++ b/tools/geos/trdrive.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: MIT """Emit the TR block device driver for the firmware. - python3 tools/geos/trdrive.py out.h + python3 tools/geos/trdrive.py out.h GEOS 64's driver, TRDriver.h + python3 tools/geos/trdrive.py --c128 out.h GEOS 128's, TRDriver128.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. @@ -21,9 +22,9 @@ 'ReadBlock', 'WriteBlock', 'VerWriteBlock', 'SendTSBytes', 'GetDOSError', 'ReadLink') -def assemble(org, tmp): +def assemble(org, tmp, defines): 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)], + subprocess.run(['acme', f'-DORG={org}', *defines, '-f', 'plain', '-o', str(out), '-l', str(labels), str(SOURCE)], check=True) return out.read_bytes(), labels.read_text() @@ -48,14 +49,15 @@ def c_array(name, values, width): return f'static const uint{width * 4}_t {name}[{len(values)}] PROGMEM = {{\n{body}\n}};\n' -def header(): +def header(c128=False): + defines, name, macro, flag = (['-DC128=1'], 'TRDriver128', 'TR_DRIVER128', ' --c128') if c128 else ([], 'TRDriver', 'TR_DRIVER', '') with tempfile.TemporaryDirectory() as tmp: - low, labels = assemble(BASE, pathlib.Path(tmp)) - high, _ = assemble(BASE + SHIFTED, pathlib.Path(tmp)) + low, labels = assemble(BASE, pathlib.Path(tmp), defines) + high, _ = assemble(BASE + SHIFTED, pathlib.Path(tmp), defines) 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' + f'// Generated by tools/geos/trdrive.py{flag} from tools/geos/trdrive.asm; do not edit.\n' '#pragma once\n' '#include \n' '#ifdef ARDUINO\n' @@ -63,13 +65,15 @@ def header(): '#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)) + f'#define {macro}_BASE 0x{BASE:04x}\n' + + c_array(f'{name}Code', list(low), 2) + + c_array(f'{name}Relocations', relocs, 4) + + c_array(f'{name}Entries', entry_offsets(labels), 4)) if __name__ == '__main__': - if len(sys.argv) != 2: + args = sys.argv[1:] + c128 = args[:1] == ['--c128'] + if len(args) != 1 + c128: sys.exit(__doc__) - pathlib.Path(sys.argv[1]).write_bytes(header().encode()) + pathlib.Path(args[-1]).write_bytes(header(c128).encode()) diff --git a/tools/geos/trdrive.test.mjs b/tools/geos/trdrive.test.mjs index 88ec892c6..ea5e4b5c5 100644 --- a/tools/geos/trdrive.test.mjs +++ b/tools/geos/trdrive.test.mjs @@ -10,22 +10,30 @@ 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 GEOS_DIR = path.join(root, 'Source/Teensy/GEOS'); 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 }); - } -}); +const variants = [ + { name: 'TRDriver.h', args: [] }, + { name: 'TRDriver128.h', args: ['--c128'] }, +]; + +for (const { name, args } of variants) { + test(`the committed disk driver header ${name} 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, name); + const r = spawnSync('python3', [trdrive, ...args, header], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.equal(r.stderr, '', 'acme warns about nothing'); + const committed = fs.readFileSync(path.join(GEOS_DIR, name), 'utf8').replaceAll('\r\n', '\n'); + assert.equal(fs.readFileSync(header, 'utf8'), committed, `rerun tools/geos/trdrive.py ${args.join(' ')}`); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } + }); +} From 4ade36b10f3b53b6f25896ed0d11b71f48015c9d Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:36:07 -0500 Subject: [PATCH 05/17] firmware: the TR block device's BOOT command reads the launch's boot image BOOT ($05) reads page ADDR1:ADDR0 of a boot image into the window as READ does, padding a short last page with zeros. Past the end it fails with $E2, and with no boot image with $E1. TRBlockMount takes the boot image with the disk image, and it goes with that image. GEOS 64 starts from a PRG and mounts none. --- Source/Teensy/BlockDevice/TRBlock.h | 17 +++++++++ Source/Teensy/BlockDevice/TRBlockImage.h | 2 +- Source/Teensy/GeosLauncher.ino | 2 +- .../Common/IO_Handlers/TRBlockIO.c | 13 +++++-- Source/Teensy/tests/trblock.test.js | 35 +++++++++++++++++-- Source/Teensy/tests/trblock_harness.c | 15 ++++++-- docs/TR_Block_Device.md | 11 +++--- 7 files changed, 81 insertions(+), 14 deletions(-) diff --git a/Source/Teensy/BlockDevice/TRBlock.h b/Source/Teensy/BlockDevice/TRBlock.h index 345c3021e..43c89477b 100644 --- a/Source/Teensy/BlockDevice/TRBlock.h +++ b/Source/Teensy/BlockDevice/TRBlock.h @@ -29,6 +29,7 @@ enum TRBlockCommand TRBlockWrite = 2, TRBlockFlush = 3, TRBlockInfo = 4, + TRBlockBoot = 5, }; enum TRBlockStatusValue @@ -65,6 +66,8 @@ struct TRBlockBackend 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); + const uint8_t *Boot; // what BOOT reads, page by page; NULL when the launch left none + uint32_t BootSize; }; // ISR and main loop run on one core, so only the compiler's ordering matters @@ -126,6 +129,18 @@ static inline void TRBlockWriteAt(struct TRBlock *Dev, uint8_t Offset, uint8_t D } } +static uint8_t TRBlockBootPage(const struct TRBlockBackend *Backend, uint8_t Addr0, uint8_t Addr1, uint8_t *Block) +{ + uint32_t Offset = (uint32_t)(Addr1 << 8 | Addr0) * TRBLOCK_SIZE, Length; + + if (!Backend->Boot) return TRBlockNoImage; + if (Offset >= Backend->BootSize) return TRBlockBadAddress; + Length = Backend->BootSize - Offset < TRBLOCK_SIZE ? Backend->BootSize - Offset : TRBLOCK_SIZE; + memcpy(Block, Backend->Boot + Offset, Length); + memset(Block + Length, 0, TRBLOCK_SIZE - Length); + return TRBlockDone; +} + static uint8_t TRBlockRun(struct TRBlock *Dev, const struct TRBlockBackend *Backend) { if (!Backend || !Backend->Context) return TRBlockNoImage; @@ -139,6 +154,8 @@ static uint8_t TRBlockRun(struct TRBlock *Dev, const struct TRBlockBackend *Back return Backend->Flush(Backend->Context); case TRBlockInfo: return Backend->Info(Backend->Context, Dev->Buffer); + case TRBlockBoot: + return TRBlockBootPage(Backend, Dev->JobAddr0, Dev->JobAddr1, Dev->Buffer); } return TRBlockBadCommand; } diff --git a/Source/Teensy/BlockDevice/TRBlockImage.h b/Source/Teensy/BlockDevice/TRBlockImage.h index b9c788d73..bda1b87dd 100644 --- a/Source/Teensy/BlockDevice/TRBlockImage.h +++ b/Source/Teensy/BlockDevice/TRBlockImage.h @@ -61,5 +61,5 @@ FLASHMEM static uint8_t TRBlockImageFlush(void *Context) static inline struct TRBlockBackend TRBlockImageBackend(struct TRBlockImage *Image) { - return (struct TRBlockBackend){ Image, TRBlockImageTransfer, TRBlockImageInfo, TRBlockImageFlush }; + return (struct TRBlockBackend){ Image, TRBlockImageTransfer, TRBlockImageInfo, TRBlockImageFlush, NULL, 0 }; } diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index d4021e2d8..672660387 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -278,7 +278,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * if (!Built) SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Disk.Error); Image.close(); if (!Built) return false; - if (!TRBlockMount(Path)) + if (!TRBlockMount(Path, NULL, 0)) { SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); return false; diff --git a/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c b/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c index 5f4903e3f..c9dfb6d6e 100644 --- a/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c +++ b/Source/Teensy/MinimalBoot/Common/IO_Handlers/TRBlockIO.c @@ -18,6 +18,8 @@ static uint32_t TRBlockLastWritemS; static char *TRBlockPath; static bool TRBlockFlushFailureLogged; static struct { uint32_t FirstSector, CardSerial; } TRBlockMounted; +static const uint8_t *TRBlockBootImage; +static uint32_t TRBlockBootSize; FLASHMEM static bool TRBlockFileRead(void *Context, uint32_t Offset, uint8_t *Block) { @@ -61,6 +63,8 @@ FLASHMEM static bool TRBlockOpen() return false; } TRBlockBack = TRBlockImageBackend(&TRBlockImg); + TRBlockBack.Boot = TRBlockBootImage; + TRBlockBack.BootSize = TRBlockBootSize; return true; } @@ -98,12 +102,17 @@ FLASHMEM void TRBlockUnmount() TRBlockClose(); free(TRBlockPath); TRBlockPath = NULL; + TRBlockBootImage = NULL; + TRBlockBootSize = 0; } -// The image stays mounted across handler restarts, so a launcher can mount it and then start the handler -FLASHMEM bool TRBlockMount(const char *Path) +// The image stays mounted across handler restarts, so a launcher can mount it and then start the handler. +// Boot, which BOOT reads, must stay in place for as long as the image is mounted; NULL for none. +FLASHMEM bool TRBlockMount(const char *Path, const uint8_t *Boot, uint32_t BootSize) { TRBlockUnmount(); + TRBlockBootImage = Boot; + TRBlockBootSize = BootSize; TRBlockPath = strdup(Path); if (TRBlockPath && TRBlockOpen()) { diff --git a/Source/Teensy/tests/trblock.test.js b/Source/Teensy/tests/trblock.test.js index e73e331b1..7c3a7f413 100644 --- a/Source/Teensy/tests/trblock.test.js +++ b/Source/Teensy/tests/trblock.test.js @@ -21,7 +21,7 @@ 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 READ = 1, WRITE = 2, FLUSH = 3, INFO = 4, BOOT = 5; const BUSY = 0x80, DONE = 0x5a, NO_IMAGE = 0xe1, BAD_ADDRESS = 0xe2, WRITE_FAILED = 0xe4, READ_ONLY = 0xe5, BAD_COMMAND = 0xe6; const zoneSectors = (track) => { @@ -76,10 +76,14 @@ function harness() { return harnessPath; } -function run(kind, script, mode) { +function run(kind, script, mode, boot) { 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])); + if (boot) { + fs.writeFileSync(path.join(dir, 'boot.bin'), boot); + mode = `boot=${path.join(dir, 'boot.bin')}`; + } 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); @@ -180,7 +184,7 @@ test('an image that is no known size mounts as no image rather than being guesse }); 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}`); + for (const cmd of [0, 6, 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 }, () => { @@ -219,3 +223,28 @@ test('the page register uses only its low two bits', { skip }, () => { assert.equal(lines[1], lines[2]); assert.notEqual(lines[1], lines[3]); }); + +// A boot image with its page number in every byte's high bits, longer than one page and ending partway into one +const BOOT_IMAGE = Buffer.from(Array.from({ length: 2 * SECTOR + 100 }, (_, i) => ((i >> 8) * 0x40 + i) & 0xff)); +const bootPage = (page, token = 1) => readBlock(page & 0xff, page >> 8, token).replace(`w ${hex(CMD)} ${hex(READ)}`, `w ${hex(CMD)} ${hex(BOOT)}`); + +test('boot reads the launch\'s boot image a page at a time, the last page padded with zeros', { skip }, () => { + for (const page of [0, 1, 2]) { + const { lines } = run('d81', bootPage(page), undefined, BOOT_IMAGE); + const expected = Buffer.alloc(SECTOR); + BOOT_IMAGE.copy(expected, 0, page * SECTOR, (page + 1) * SECTOR); + assert.equal(lines[0], '1', 'the main loop ran it'); + assert.deepEqual(bytesOf(lines.slice(1)), expected, `page ${page}`); + } +}); + +test('boot past the end of the boot image fails with bad address', { skip }, () => { + for (const [addr0, addr1] of [[3, 0], [0, 1], [0xff, 0xff]]) { + assert.equal(run('d81', command(BOOT, { addr0, addr1 }) + 'svc\n' + read(STATUS), undefined, BOOT_IMAGE).lines.at(-1), hex(BAD_ADDRESS), `page ${addr1}:${addr0}`); + } +}); + +test('boot with no boot image fails with no image, while the disk image still reads', { skip }, () => { + assert.equal(statusOf(command(BOOT) + 'svc\n'), hex(NO_IMAGE)); + assert.equal(statusOf(command(READ, { addr0: 1 }) + 'svc\n'), hex(DONE)); +}); diff --git a/Source/Teensy/tests/trblock_harness.c b/Source/Teensy/tests/trblock_harness.c index b65c88c4c..7d0ef8ce1 100644 --- a/Source/Teensy/tests/trblock_harness.c +++ b/Source/Teensy/tests/trblock_harness.c @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -// trblock_harness IMAGE OUT [ro|nobackend|flushfails]: the block device's register side and disk image backend, +// trblock_harness IMAGE OUT [ro|nobackend|flushfails|boot=FILE]: the block device's register side and disk image +// backend, with FILE as the launch's boot image when given, // 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 @@ -12,7 +13,7 @@ #define MAX_IMAGE 819200 -static uint8_t Bytes[MAX_IMAGE]; +static uint8_t Bytes[MAX_IMAGE], Boot[MAX_IMAGE]; static uint32_t Size, Flushes; static bool FlushFails; @@ -49,7 +50,7 @@ int main(int argc, char **argv) if (!In) { - fprintf(stderr, "usage: trblock_harness IMAGE OUT [ro|nobackend|flushfails]\n"); + fprintf(stderr, "usage: trblock_harness IMAGE OUT [ro|nobackend|flushfails|boot=FILE]\n"); return 2; } Size = fread(Bytes, 1, MAX_IMAGE, In); @@ -57,6 +58,14 @@ int main(int argc, char **argv) Image.Format = GeosFormatForSize(Size); FlushFails = argc >= 4 && !strcmp(argv[3], "flushfails"); Backend = TRBlockImageBackend(&Image); + if (argc >= 4 && !strncmp(argv[3], "boot=", 5)) + { + FILE *BootFile = fopen(argv[3] + 5, "rb"); + if (!BootFile) return 2; + Backend.Boot = Boot; + Backend.BootSize = fread(Boot, 1, sizeof Boot, BootFile); + fclose(BootFile); + } TRBlockReset(&Dev); const struct TRBlockBackend *Use = argc >= 4 && !strcmp(argv[3], "nobackend") ? NULL : &Backend; diff --git a/docs/TR_Block_Device.md b/docs/TR_Block_Device.md index e5f1de6f8..9f88596cf 100644 --- a/docs/TR_Block_Device.md +++ b/docs/TR_Block_Device.md @@ -68,8 +68,9 @@ Do not write ADDR0, ADDR1, PAGE or TOKEN, or the window, while STATUS is `$80`. | `$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) | +| `$05` | `BOOT` | reads page ADDR1:ADDR0 (ADDR0 the low byte) of the launch's boot image into the buffer, as `READ` does; a last page shorter than 256 bytes is padded with zeros | -`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. +`READ`, `INFO` and `BOOT` 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: @@ -79,6 +80,8 @@ Do not write ADDR0, ADDR1, PAGE or TOKEN, or the window, while STATUS is `$80`. | 1 | flags; bit 0 set when the image can be written | | 2..4 | blocks in the image, 24 bits, low byte first | +The boot image is what a launch that cannot start from a PRG hands its boot code: GEOS 128's C128 boot ROM pulls `GEOBOOT128`, with the TR driver already in it, through `BOOT`. The firmware sets it when it mounts the disk image, and it goes with that image. A launch that mounts an image without one leaves `BOOT` failing with `$E1`, and `BOOT` past the boot image's end fails with `$E2`. + Any other CMD fails with `$E6`. ## Status and errors @@ -88,14 +91,14 @@ Any other CMD fails with `$E6`. | `$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 | +| `$E1` | no image is mounted, or for `BOOT`, no boot image | +| `$E2` | the address is outside the image, or for `BOOT`, past the boot image's end | | `$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. +A failed `READ` or `BOOT` 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`. From 55630867360d7bb2957767023ead692027f7942d Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:37:30 -0500 Subject: [PATCH 06/17] tools: a C128 function ROM that starts GEOS 128 from the TR block device geos128boot.asm is #63's ROM stub without the unpacker. The cold start takes SEI first and copies the loader into the RAM both banks share. The loader pulls the boot image page by page through BOOT into bank 1 at 1 MHz, with the screen off, then enters it as GEOS's own ROM loader does. If the image cannot be read, the border flashes. The top of ROMH holds the CPU vectors: IRQ restarts the cold start and NMI returns. geos128boot.mjs emits Geos128Boot.h with the offsets where the firmware writes the load address, page count and entry. --- Source/Teensy/GEOS/Geos128Boot.h | 34 ++++++ tools/geos/geos128boot.asm | 181 +++++++++++++++++++++++++++++++ tools/geos/geos128boot.mjs | 67 ++++++++++++ tools/geos/geos128boot.test.mjs | 30 +++++ 4 files changed, 312 insertions(+) create mode 100644 Source/Teensy/GEOS/Geos128Boot.h create mode 100644 tools/geos/geos128boot.asm create mode 100644 tools/geos/geos128boot.mjs create mode 100644 tools/geos/geos128boot.test.mjs diff --git a/Source/Teensy/GEOS/Geos128Boot.h b/Source/Teensy/GEOS/Geos128Boot.h new file mode 100644 index 000000000..7c87ac4c2 --- /dev/null +++ b/Source/Teensy/GEOS/Geos128Boot.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Generated by tools/geos/geos128boot.mjs from tools/geos/geos128boot.asm; do not edit. +#pragma once +#include +#ifdef ARDUINO +#include +#else +#define PROGMEM +#endif + +#define GEOS128_BOOT_CHIP_SIZE 0x2000 +#define GEOS128_BOOT_PARAMS 0x0021 // load address, page count, entry: words in ROML +#define GEOS128_BOOT_VECTORS 0x1ff9 // in ROMH +static const uint8_t Geos128BootRoml[245] PROGMEM = { + 0xea, 0xea, 0xea, 0x4c, 0x0a, 0x80, 0xff, 0x43, 0x42, 0x4d, 0x78, 0xad, 0x00, 0xff, 0x29, 0x3f, + 0x8d, 0x00, 0xff, 0xa2, 0x00, 0xbd, 0x21, 0x80, 0x9d, 0x00, 0x13, 0xe8, 0xd0, 0xf7, 0x4c, 0x06, + 0x13, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xa9, 0x3e, 0x8d, 0x00, 0xff, 0xa9, 0x06, 0x8d, + 0x06, 0xd5, 0xa9, 0x00, 0x8d, 0x11, 0xd0, 0xa9, 0x00, 0x8d, 0x30, 0xd0, 0xa9, 0x7e, 0x8d, 0x00, + 0xff, 0xad, 0x00, 0x13, 0x85, 0x02, 0xad, 0x01, 0x13, 0x85, 0x03, 0xa9, 0x00, 0x85, 0x04, 0x85, + 0x05, 0x85, 0x06, 0xa5, 0x04, 0xcd, 0x02, 0x13, 0xd0, 0x13, 0xa5, 0x05, 0xcd, 0x03, 0x13, 0xd0, + 0x0c, 0xa9, 0x1b, 0x8d, 0x11, 0xd0, 0xa9, 0x08, 0x85, 0xba, 0x6c, 0x04, 0x13, 0xe6, 0x06, 0xd0, + 0x02, 0xe6, 0x06, 0xa5, 0x06, 0x8d, 0x0c, 0xdf, 0xa5, 0x04, 0x8d, 0x09, 0xdf, 0xa5, 0x05, 0x8d, + 0x0a, 0xdf, 0xa9, 0x00, 0x85, 0x07, 0x85, 0x08, 0x85, 0x09, 0xa9, 0x05, 0x8d, 0x08, 0xdf, 0xe6, + 0x07, 0xd0, 0x0c, 0xe6, 0x08, 0xd0, 0x08, 0xe6, 0x09, 0xa5, 0x09, 0xc9, 0x08, 0xf0, 0x4b, 0xad, + 0x0d, 0xdf, 0xcd, 0x0d, 0xdf, 0xd0, 0xe8, 0xc9, 0x80, 0xf0, 0xe4, 0xc9, 0x5a, 0xd0, 0x3b, 0xad, + 0x0e, 0xdf, 0xcd, 0x0e, 0xdf, 0xd0, 0xd8, 0xc5, 0x06, 0xd0, 0xd4, 0xa2, 0x00, 0x8e, 0x0b, 0xdf, + 0xa0, 0x00, 0xb9, 0x40, 0xdf, 0xd9, 0x40, 0xdf, 0xd0, 0xf8, 0x91, 0x02, 0xc8, 0xc0, 0x40, 0xd0, + 0xf1, 0x18, 0xa5, 0x02, 0x69, 0x40, 0x85, 0x02, 0x90, 0x02, 0xe6, 0x03, 0xe8, 0xe0, 0x04, 0xd0, + 0xdc, 0xe6, 0x04, 0xd0, 0x02, 0xe6, 0x05, 0x4c, 0x32, 0x13, 0xa9, 0x1b, 0x8d, 0x11, 0xd0, 0xee, + 0x20, 0xd0, 0x4c, 0xce, 0x13 +}; +static const uint8_t Geos128BootVectors[7] PROGMEM = { + 0x40, 0xf9, 0xff, 0xf9, 0xff, 0x0a, 0x80 +}; diff --git a/tools/geos/geos128boot.asm b/tools/geos/geos128boot.asm new file mode 100644 index 000000000..4aa43a5e2 --- /dev/null +++ b/tools/geos/geos128boot.asm @@ -0,0 +1,181 @@ +; SPDX-License-Identifier: MIT +; A C128 function ROM that starts GEOS 128 with no drive: it pulls the boot image the firmware built through the +; TR block device's BOOT command (docs/TR_Block_Device.md) into RAM bank 1, then enters it as GEOS's own ROM +; loader does. geos128boot.mjs turns it into Source/Teensy/GEOS/Geos128Boot.h; build with acme. +; The firmware fills in bootLoad, bootPages and bootEntry. + +MMU_CR = $ff00 +MMU_RAM_SHARE = $d506 +SHARE_BOTTOM_8K = $06 +KEEP_BANK_0 = $3f +BANK_0_IO = $3e +BANK_1_IO = $7e +VIC_CONTROL = $d011 +SCREEN_OFF = $00 +SCREEN_ON = $1b +VIC_CLOCK = $d030 +CLOCK_1MHZ = 0 +VIC_BORDER = $d020 +curDevice = $ba +DEVICE_8 = 8 + +; in the common RAM the loader shares between the banks, where #63's loader ran +LOADER = $1300 + +BLOCK = $df00 +REG_CMD = BLOCK + $08 +REG_ADDR0 = BLOCK + $09 +REG_ADDR1 = BLOCK + $0a +REG_PAGE = BLOCK + $0b +REG_TOKEN = BLOCK + $0c +REG_STATUS = BLOCK + $0d +REG_ECHO = BLOCK + $0e +WINDOW = BLOCK + $40 +WINDOW_SIZE = 64 +WINDOW_PAGES = 4 +CMD_BOOT = 5 +STATUS_BUSY = $80 +STATUS_DONE = $5a +; about ten seconds of polling at 1 MHz, for the card to answer the first page +PATIENCE_HIGH = 8 + +dst = $02 +page = $04 +token = $06 +patience = $07 + +* = $8000 + nop ; the kernal's cold start call lands here + nop + nop + jmp cold ; warm start + !byte $ff ; start at power-up and reset + !byte $43, $42, $4d ; "CBM": a function ROM + +; IRQs are on when the kernal calls in, and this ROM's top page holds the CPU vectors +cold sei + lda MMU_CR + and #KEEP_BANK_0 + sta MMU_CR + ldx #0 +- lda loaderRom,x + sta LOADER,x + inx + bne - + jmp start + +loaderRom +!pseudopc LOADER { +bootLoad !word 0 +bootPages !word 0 +bootEntry !word 0 + +start sei + lda #BANK_0_IO + sta MMU_CR + lda #SHARE_BOTTOM_8K + sta MMU_RAM_SHARE + lda #SCREEN_OFF + sta VIC_CONTROL + lda #CLOCK_1MHZ + sta VIC_CLOCK + lda #BANK_1_IO + sta MMU_CR + lda bootLoad + sta dst + lda bootLoad + 1 + sta dst + 1 + lda #0 + sta page + sta page + 1 + sta token + +nextPage + lda page + cmp bootPages + bne fetch + lda page + 1 + cmp bootPages + 1 + bne fetch + lda #SCREEN_ON + sta VIC_CONTROL + lda #DEVICE_8 + sta curDevice + jmp (bootEntry) + +fetch inc token + bne + + inc token ++ lda token + sta REG_TOKEN + lda page + sta REG_ADDR0 + lda page + 1 + sta REG_ADDR1 + lda #0 + sta patience + sta patience + 1 + sta patience + 2 + lda #CMD_BOOT + sta REG_CMD +wait inc patience + bne + + inc patience + 1 + bne + + inc patience + 2 + lda patience + 2 + cmp #PATIENCE_HIGH + beq failed ++ lda REG_STATUS + cmp REG_STATUS + bne wait + cmp #STATUS_BUSY + beq wait + cmp #STATUS_DONE + bne failed + lda REG_ECHO + cmp REG_ECHO + bne wait + cmp token + bne wait + + ldx #0 +quarter stx REG_PAGE + ldy #0 +- lda WINDOW,y + cmp WINDOW,y + bne - + sta (dst),y + iny + cpy #WINDOW_SIZE + bne - + clc + lda dst + adc #WINDOW_SIZE + sta dst + bcc + + inc dst + 1 ++ inx + cpx #WINDOW_PAGES + bne quarter + inc page + bne + + inc page + 1 ++ jmp nextPage + +; the boot image could not be read: a flashing border says so +failed lda #SCREEN_ON + sta VIC_CONTROL +- inc VIC_BORDER + jmp - +} +loaderEnd +!if loaderEnd - loaderRom > 256 { !error "the cold start copies the loader as one page" } + +; the top of ROMH, which the CPU vectors read while this ROM is mapped in: an IRQ restarts the cold start, an NMI +; returns +* = $fff9 +return rti + !word return ; NMI + !word return ; reset + !word cold ; IRQ diff --git a/tools/geos/geos128boot.mjs b/tools/geos/geos128boot.mjs new file mode 100644 index 000000000..eb00e7d88 --- /dev/null +++ b/tools/geos/geos128boot.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// node tools/geos/geos128boot.mjs [OUT.h]: assembles the GEOS 128 boot ROM (geos128boot.asm) and emits it for the +// firmware, by default to Source/Teensy/GEOS/Geos128Boot.h. Needs ACME (found as the C64 build finds it). +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 { resolveAcme } from '../lib/c64-toolchain.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const SOURCE = path.join(ROOT, 'tools/geos/geos128boot.asm'); +const DEFAULT_OUT = path.join(ROOT, 'Source/Teensy/GEOS/Geos128Boot.h'); +const ROML = 0x8000; +const ROMH = 0xc000; +const CHIP_MASK = 0x1fff; +const BYTES_PER_LINE = 16; + +function assemble() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'geos128boot-')); + try { + const out = path.join(dir, 'rom.bin'), labels = path.join(dir, 'rom.lbl'); + const r = spawnSync(resolveAcme(path.join(ROOT, 'tools/.cache')), ['-f', 'plain', '-o', out, '-l', labels, SOURCE], { encoding: 'utf8' }); + if (r.status !== 0 || r.stderr) throw new Error(`acme: ${r.stdout}${r.stderr}`); + const found = Object.fromEntries([...fs.readFileSync(labels, 'utf8').matchAll(/^\s*(\w+)\s*=\s*\$([0-9a-f]+)/gm)] + .map((m) => [m[1], parseInt(m[2], 16)])); + return { rom: fs.readFileSync(out), labels: found }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function cArray(name, bytes) { + const rows = []; + for (let i = 0; i < bytes.length; i += BYTES_PER_LINE) { + rows.push(' ' + [...bytes.subarray(i, i + BYTES_PER_LINE)].map((b) => `0x${b.toString(16).padStart(2, '0')}`).join(', ')); + } + return `static const uint8_t ${name}[${bytes.length}] PROGMEM = {\n${rows.join(',\n')}\n};\n`; +} + +const hex4 = (n) => `0x${n.toString(16).padStart(4, '0')}`; + +export function header() { + const { rom, labels } = assemble(); + const code = rom.subarray(0, labels.loaderEnd - ROML); + const vectors = rom.subarray(labels.return - ROML); + return '// SPDX-License-Identifier: MIT\n' + + '// Generated by tools/geos/geos128boot.mjs from tools/geos/geos128boot.asm; do not edit.\n' + + '#pragma once\n' + + '#include \n' + + '#ifdef ARDUINO\n' + + '#include \n' + + '#else\n' + + '#define PROGMEM\n' + + '#endif\n\n' + + `#define GEOS128_BOOT_CHIP_SIZE ${hex4(CHIP_MASK + 1)}\n` + + `#define GEOS128_BOOT_PARAMS ${hex4(labels.loaderRom - ROML)} // load address, page count, entry: words in ROML\n` + + `#define GEOS128_BOOT_VECTORS ${hex4((labels.return - ROMH) & CHIP_MASK)} // in ROMH\n` + + cArray('Geos128BootRoml', code) + + cArray('Geos128BootVectors', vectors); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + fs.writeFileSync(process.argv[2] ?? DEFAULT_OUT, header()); +} diff --git a/tools/geos/geos128boot.test.mjs b/tools/geos/geos128boot.test.mjs new file mode 100644 index 000000000..e6d1456a4 --- /dev/null +++ b/tools/geos/geos128boot.test.mjs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { commandExists } from '../lib/toolchain.mjs'; +import { header } from './geos128boot.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const committedHeader = path.join(root, 'Source/Teensy/GEOS/Geos128Boot.h'); + +const NOT_CI = ['', '0', 'false']; +const inCi = !NOT_CI.includes(process.env.CI ?? ''); +const skip = !commandExists('acme') && !process.env.ACME && !inCi && 'acme is not on PATH'; + +test('the committed GEOS 128 boot ROM header matches its source', { skip }, () => { + const committed = fs.readFileSync(committedHeader, 'utf8').replaceAll('\r\n', '\n'); + assert.equal(header(), committed, 'rerun node tools/geos/geos128boot.mjs'); +}); + +test('the boot ROM is a C128 function ROM whose vectors stay in this ROM', { skip }, () => { + const bytes = (name) => [...header().match(new RegExp(`${name}\\[\\d+\\] PROGMEM = \\{([^}]*)\\}`))[1].matchAll(/0x[0-9a-f]+/g)].map((m) => parseInt(m[0], 16)); + const roml = bytes('Geos128BootRoml'); + const [rti, nmi, nmiHigh, , , irq, irqHigh] = bytes('Geos128BootVectors'); + assert.deepEqual(roml.slice(7, 10), [0x43, 0x42, 0x4d], '"CBM" at $8007'); + assert.equal(rti, 0x40); + assert.equal(nmi | (nmiHigh << 8), 0xfff9, 'NMI returns at once'); + assert.equal(roml[(irq | (irqHigh << 8)) - 0x8000], 0x78, 'IRQ restarts the cold start, at its SEI'); +}); From f0af5358b29599e432bd6f67e58e6def20d15857 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:39:56 -0500 Subject: [PATCH 07/17] firmware: GEOS 128 starts from the Utilities menu on a C128 The GEOS 128 row follows GEOS 64. It is shown only once the menu has reported a C128, and selecting it on a C64 is refused, because the boot ROM cannot run there. The launch builds the boot ROM's two chips in RAM_Image with the boot image after them. It tells the ROM where that image loads, how many pages it has and where to enter it, and mounts the copy with the image as BOOT's to serve. Then it starts as a C128 cart. A boot file that would reach the shared RAM below $2000 or I/O at $D000 is refused. The GEOS 128 target names its deskTop, mouse driver, disk driver, clock run and reset key. --- Source/Teensy/DriveDirLoad.ino | 2 +- Source/Teensy/FileTransfer.ino | 2 +- Source/Teensy/GEOS/GeosBoot.h | 30 ++++++++ Source/Teensy/GEOS/GeosTargets.h | 23 +++++- Source/Teensy/GeosLauncher.ino | 22 +++++- Source/Teensy/MainMenuItems.h | 1 + Source/Teensy/MinimalBoot/Common/C128Crt.h | 9 +++ Source/Teensy/Teensy.ino | 14 ++++ Source/Teensy/tests/geos-boot.test.js | 76 +++++++++++++++---- Source/Teensy/tests/geos_boot_harness.c | 32 ++++---- Source/Teensy/tests/geos_disk_patch_harness.c | 6 +- 11 files changed, 176 insertions(+), 41 deletions(-) diff --git a/Source/Teensy/DriveDirLoad.ino b/Source/Teensy/DriveDirLoad.ino index 670750f3e..2be4ba34b 100644 --- a/Source/Teensy/DriveDirLoad.ino +++ b/Source/Teensy/DriveDirLoad.ino @@ -252,7 +252,7 @@ FLASHMEM void HandleExecution() if(strcmp(MenuSelCpy.Name, UpDirString)==0) MenuChange(); //only 1 level, returning to root else { - SetMenu((StructMenuItem*)MenuSelCpy.Code_Image, MenuSelCpy.Size/sizeof(StructMenuItem)); + SetMenu((StructMenuItem*)MenuSelCpy.Code_Image, TeensyDirItemCount(&MenuSelCpy)); strcat(DriveDirPath, MenuSelCpy.Name); //append selected dir name } return; diff --git a/Source/Teensy/FileTransfer.ino b/Source/Teensy/FileTransfer.ino index f775f955a..83d75d85f 100644 --- a/Source/Teensy/FileTransfer.ino +++ b/Source/Teensy/FileTransfer.ino @@ -275,7 +275,7 @@ FLASHMEM void SendTRDirectory(const char *dirName, StructMenuItem *TROMMenu, uin if(strcmp(TROMMenu[itemNum].Name, UpDirString)!=0) // Skip Up Dir entries { SendDirInfo(TROMMenu[itemNum].Name, dirName); - SendTRDirectory(TROMMenu[itemNum].Name, (StructMenuItem*)TROMMenu[itemNum].Code_Image, TROMMenu[itemNum].Size/sizeof(StructMenuItem)); + SendTRDirectory(TROMMenu[itemNum].Name, (StructMenuItem*)TROMMenu[itemNum].Code_Image, TeensyDirItemCount(&TROMMenu[itemNum])); } } else diff --git a/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h index d54af67cb..49a709787 100644 --- a/Source/Teensy/GEOS/GeosBoot.h +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -2,6 +2,7 @@ // 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 "Geos128Boot.h" #include "GeosDiskPatch.h" #define GEOS_DRIVER_AREA 0x9000 @@ -18,6 +19,8 @@ #define GEOS_MEMORY_END 0x10000 #define GEOS_MAX_FILE_BLOCKS 3200 #define GEOS_DEVICE_8 8 +#define GEOS128_SHARED_RAM_END 0x2000 // the C128 boot ROM's loader runs in the RAM below, which both banks share +#define GEOS128_IO_START 0xd000 // and writes bank 1 with I/O mapped in from here up enum GeosDriverVector { VecInitForIO, VecDoneWithIO, VecExitTurbo, VecPurgeTurbo, VecEnterTurbo, VecChangeDiskDevice, VecNewDisk, VecReadBlock, VecWriteBlock, VecVerWriteBlock, VecCount = 10 }; @@ -384,3 +387,30 @@ static inline bool GeosBuildBootPrg(struct GeosDisk *Disk, const struct GeosDate *Length = Place + BodySize; return true; } + +// The C128 boot ROM, its ROML chip then its ROMH chip, followed by the boot image it pulls through BOOT a page at a +// time. Image and ImageSize say where that image starts, without its load address, for TRBlockMount. +static inline bool GeosBuildC128Boot(struct GeosDisk *Disk, const struct GeosDate *Now, uint8_t *Out, uint32_t Capacity, + const uint8_t **Image, uint32_t *ImageSize, bool *ClockSet) +{ + uint8_t *Roml = Out, *Romh = Out + GEOS128_BOOT_CHIP_SIZE, *File = Out + 2 * GEOS128_BOOT_CHIP_SIZE; + uint32_t Length, Pages, Load; + + if (!GeosBuildBootImage(Disk, Now, File, Capacity - 2 * GEOS128_BOOT_CHIP_SIZE, &Length, ClockSet)) return false; + Load = GeosWord(File); + Pages = (Length - 2 + GEOS_SECTOR_SIZE - 1) / GEOS_SECTOR_SIZE; + if (Load < GEOS128_SHARED_RAM_END || Load + Pages * GEOS_SECTOR_SIZE > GEOS128_IO_START) + { + return GeosFail(Disk, PSTR("the boot file does not fit in memory")); + } + + memset(Out, 0, 2 * GEOS128_BOOT_CHIP_SIZE); + memcpy(Roml, Geos128BootRoml, sizeof Geos128BootRoml); + memcpy(Romh + GEOS128_BOOT_VECTORS, Geos128BootVectors, sizeof Geos128BootVectors); + GeosPutWord(Roml + GEOS128_BOOT_PARAMS, Load); + GeosPutWord(Roml + GEOS128_BOOT_PARAMS + 2, Pages); + GeosPutWord(Roml + GEOS128_BOOT_PARAMS + 4, Disk->Target->BootEntry); + *Image = File + 2; + *ImageSize = Length - 2; + return true; +} diff --git a/Source/Teensy/GEOS/GeosTargets.h b/Source/Teensy/GEOS/GeosTargets.h index 7bc7e8a3c..953a32101 100644 --- a/Source/Teensy/GEOS/GeosTargets.h +++ b/Source/Teensy/GEOS/GeosTargets.h @@ -4,19 +4,26 @@ #include "GeosBoot.h" #include "GeosDiskPatch.h" #include "TRDriver.h" +#include "TRDriver128.h" #include "TRMouse64.h" +#include "TRMouse128.h" #define GEOS_INPUT_64 10 #define GEOS64_BOOT_FILE "GEOBOOT" #define GEOS64_BOOT_ENTRY 0x2f3c +#define GEOS_INPUT_128 15 #define GEOS128_BOOT_FILE "GEOBOOT128" #define GEOS128_BOOT_ENTRY 0x2f3c #define GEOS128_RESET_KEY 0x6ff5 +// What the TR starts the boot image on: a PRG on the C64, or the C128 boot ROM, which a C64 cannot run +enum GeosMachine { GeosC64, GeosC128 }; + struct GeosLauncher { const char *Title, *Folder; + enum GeosMachine Machine; struct GeosTarget Target; }; @@ -26,10 +33,24 @@ static const struct GeosDiskDriver TRDiskDriver64 = TRDriverRelocations, sizeof TRDriverRelocations / sizeof TRDriverRelocations[0], TRDriverEntries, }; +static const struct GeosDiskDriver TRDiskDriver128 = +{ + TRDriver128Code, sizeof TRDriver128Code, TR_DRIVER128_BASE, + TRDriver128Relocations, sizeof TRDriver128Relocations / sizeof TRDriver128Relocations[0], TRDriver128Entries, +}; + static const struct GeosLauncher Geos64Launcher = { - "GEOS 64", "/GEOS64", + "GEOS 64", "/GEOS64", GeosC64, { "DESK TOP", TRMouse64Name, TRMouse64Code, TRMouse64Info, sizeof TRMouse64Code, GEOS_INPUT_64, GEOS64_BOOT_FILE, GEOS64_BOOT_ENTRY, &TRDiskDriver64, Geos64ClockRuns, sizeof Geos64ClockRuns / sizeof Geos64ClockRuns[0], 0 }, }; + +static const struct GeosLauncher Geos128Launcher = +{ + "GEOS 128", "/GEOS128", GeosC128, + { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, + GEOS128_BOOT_FILE, GEOS128_BOOT_ENTRY, &TRDiskDriver128, + Geos128ClockRuns, sizeof Geos128ClockRuns / sizeof Geos128ClockRuns[0], GEOS128_RESET_KEY }, +}; diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index 672660387..efaa8ae3c 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -10,7 +10,7 @@ #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 +// Only the 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) @@ -262,6 +262,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * char Path[MaxNamePathLength]; struct GeosDisk Disk; uint32_t Length = 0; + const uint8_t *Boot = NULL; struct GeosDate Now = GeosToday(); bool Changed, ClockSet, Built; File Image; @@ -274,27 +275,42 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * return false; } Disk = GeosDiskOn(Launcher, &Image); - Built = GeosBuildBootPrg(&Disk, &Now, RAM_Image, RAM_ImageSize, &Length, &ClockSet) && GeosDisableConfigure(&Disk, &Changed); + Built = Launcher->Machine == GeosC128 + ? GeosBuildC128Boot(&Disk, &Now, RAM_Image, RAM_ImageSize, &Boot, &Length, &ClockSet) + : GeosBuildBootPrg(&Disk, &Now, RAM_Image, RAM_ImageSize, &Length, &ClockSet); + Built = Built && GeosDisableConfigure(&Disk, &Changed); if (!Built) SendMsgPrintfln(PSTR("%s:\r\n%s"), Name, Disk.Error); Image.close(); if (!Built) return false; - if (!TRBlockMount(Path, NULL, 0)) + if (!TRBlockMount(Path, Boot, Boot ? Length : 0)) { SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); return false; } + if (Launcher->Machine == GeosC128) + { + SetC128Image(RAM_Image, RAM_Image + GEOS128_BOOT_CHIP_SIZE, GEOS128_BOOT_CHIP_SIZE); + Item->ItemType = rtBinC128; + return true; + } 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 (Launcher->Machine == GeosC128 && !HostIsC128()) + { + SendMsgPrintfln(PSTR("%s needs a C128"), Launcher->Title); + return false; + } if (!SDFullInit()) { SendMsgPrintfln(PSTR("%s starts from your boot disk\r\nimage on the SD card. Insert the card."), Launcher->Title); diff --git a/Source/Teensy/MainMenuItems.h b/Source/Teensy/MainMenuItems.h index 962e8d07a..33154639a 100644 --- a/Source/Teensy/MainMenuItems.h +++ b/Source/Teensy/MainMenuItems.h @@ -187,6 +187,7 @@ StructMenuItem dirUtilities[] = rtFilePrg , IOH_Swiftlink , (char*)"Simple SwiftLink Term (dev ref)" , (uint8_t*)SST_prg , sizeof(SST_prg) , rtFilePrg , IOH_TR_BASIC , (char*)"Segment+Dot Matrix Digital Clock" , (uint8_t*)shclock_tr_prg , sizeof(shclock_tr_prg) , rtGEOS , IOH_GeoRAM , (char*)"GEOS 64" , (uint8_t*)&Geos64Launcher , sizeof(Geos64Launcher) , + rtGEOS , IOH_GeoRAM , (char*)"GEOS 128" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , //last: hidden unless on a C128 // rtFilePrg , IOH_Swiftlink , (char*)"V-1541.19k.sl +SwiftLink ", (uint8_t*)v1541wrap_prg , sizeof(v1541wrap_prg) , }; diff --git a/Source/Teensy/MinimalBoot/Common/C128Crt.h b/Source/Teensy/MinimalBoot/Common/C128Crt.h index cfb0373a5..36bbdd4a7 100644 --- a/Source/Teensy/MinimalBoot/Common/C128Crt.h +++ b/Source/Teensy/MinimalBoot/Common/C128Crt.h @@ -49,3 +49,12 @@ static void LaunchC128Cart() LOROM_Mask = C128Map.ROMLMask; HIROM_Mask = C128Map.ROMHMask; } + +// A C128 cart the TR built in memory rather than read from a crt, launched as LaunchC128Cart launches one +static inline void SetC128Image(const uint8_t *ROML, const uint8_t *ROMH, uint16_t ChipSize) +{ + C128Map.ROML = ROML; + C128Map.ROMH = ROMH; + C128Map.ROMLMask = ChipSize - 1; + C128Map.ROMHMask = ChipSize - 1; +} diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index 0b07c07bd..174fb9f21 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -550,6 +550,20 @@ void SetMenu(StructMenuItem* Source, uint16_t NumItems) (NumItems==0 ? 1 : 0); } +// Known only once the menu has reported the machine, which it does before the user can select anything +FLASHMEM bool HostIsC128() +{ + return VidTODClksKnown & IO1[wRegVid_TOD_Clks] & rvtcC128; +} + +// The rows of a TeensyROM menu directory this machine can run: the last Utilities row starts GEOS 128 +FLASHMEM uint16_t TeensyDirItemCount(const StructMenuItem *DirEntry) +{ + uint16_t NumItems = DirEntry->Size/sizeof(StructMenuItem); + if (DirEntry->Code_Image == (uint8_t*)dirUtilities && !HostIsC128()) NumItems--; + return NumItems; +} + // Whether a card is in the slot, from card detect on DAT3. Takes the pin back from the // SD controller first, so it reads true whoever had it last, a failed SD.begin included. // The pin is left a pulldown input, as the SD library itself leaves it when a card is diff --git a/Source/Teensy/tests/geos-boot.test.js b/Source/Teensy/tests/geos-boot.test.js index 6174b16c5..4ba1005df 100644 --- a/Source/Teensy/tests/geos-boot.test.js +++ b/Source/Teensy/tests/geos-boot.test.js @@ -12,6 +12,8 @@ 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 DRIVER128_HEADER = path.join(FIRMWARE_ROOT, 'GEOS/TRDriver128.h'); +const BOOT_ROM_HEADER = path.join(FIRMWARE_ROOT, 'GEOS/Geos128Boot.h'); const NOT_CI = ['', '0', 'false']; const inCi = !NOT_CI.includes(process.env.CI ?? ''); @@ -46,11 +48,16 @@ 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 arrayIn = (file) => (name) => [...fs.readFileSync(file, 'utf8').match(new RegExp(`${name}\\[\\d+\\] PROGMEM = \\{([^}]*)\\}`))[1].matchAll(/0x[0-9a-f]+/g)].map((m) => parseInt(m[0], 16)); +const arrayOf = arrayIn(DRIVER_HEADER); const CODE = arrayOf('TRDriverCode'); const RELOCATIONS = arrayOf('TRDriverRelocations'); const ENTRIES = arrayOf('TRDriverEntries'); +const ENTRIES_128 = arrayIn(DRIVER128_HEADER)('TRDriver128Entries'); +const BOOT_ROML = arrayIn(BOOT_ROM_HEADER)('Geos128BootRoml'); +const BOOT_VECTORS = arrayIn(BOOT_ROM_HEADER)('Geos128BootVectors'); +const BOOT_CHIP = 0x2000; +const BOOT_PARAMS = 0x21; const offsetOf = (track, sector) => ((track - 1) * SECTORS_PER_TRACK + sector) * SECTOR; @@ -117,12 +124,13 @@ function runClock(code, { x = 0, memory = {} } = {}) { const GEOS64 = { load: LOAD_ADDRESS, driverAt: DRIVER_AT_IN_BODY, startUpAt: START_AT_IN_BODY }; function bootFile(driver = stockDriver(), startUp = [], layout = GEOS64) { - const body = Buffer.alloc(BODY_SIZE); - for (let i = 0; i < BODY_SIZE; i++) body[i] = (i * 31 + 7) & 0xff; + const size = layout.size ?? BODY_SIZE; + const body = Buffer.alloc(size); + for (let i = 0; i < size; i++) body[i] = (i * 31 + 7) & 0xff; driver.copy(body, layout.driverAt); Buffer.from(startUp).copy(body, layout.startUpAt); if (layout.resetKey) Buffer.from(layout.resetKey.bytes).copy(body, layout.resetKey.at); - const file = Buffer.alloc(2 + BODY_SIZE); + const file = Buffer.alloc(2 + size); file.writeUInt16LE(layout.load, 0); body.copy(file, 2); return { file, body }; @@ -179,7 +187,14 @@ function withHarness(body) { const launch = (disk, time = [], version = '64') => { fs.writeFileSync(path.join(dir, 'in.d81'), disk.image); const r = run([path.join(dir, 'harness'), version, 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 }; + const out = r.status === 0 ? fs.readFileSync(path.join(dir, 'out.prg')) : null; + const image = r.status === 0 ? fs.readFileSync(path.join(dir, 'out.d81')) : null; + const boot = r.out.match(/ boot=(\d+),(\d+)/); + if (!boot) return { ...r, prg: out, image }; + const [at, size] = [Number(boot[1]), Number(boot[2])]; + assert.equal(at + size, out.length, 'the boot image ends the output'); + // the boot file as the disk holds it, load address first, and the ROM in front of it + return { ...r, prg: out.subarray(at - 2), rom: out.subarray(0, at - 2), boot: out.subarray(at), image }; }; body(launch); } finally { @@ -202,12 +217,14 @@ const START_UP_128 = [ 0xa9, 0x00, 0x8d, 0xc5, 0x88, ...START_CLOCK, 0xa9, 0x5c, 0x8d, 0x16, 0x85, 0xa9, 0x03, 0x8d, 0x17, 0x85, 0xa9, 0x17, 0x8d, 0x18, 0x85, ]; -const GEOS128 = { - load: GEOS128_LOAD, - driverAt: 0x7000 - GEOS128_LOAD, - startUpAt: 0x2c24 - GEOS128_LOAD, - resetKey: { at: 0x6ff5 - GEOS128_LOAD, bytes: RESET_KEY }, -}; +const geos128Layout = (load = GEOS128_LOAD, size = BODY_SIZE) => ({ + load, + size, + driverAt: 0x7000 - load, + startUpAt: 0x2c24 - load, + resetKey: { at: 0x6ff5 - load, bytes: RESET_KEY }, +}); +const GEOS128 = geos128Layout(); const DATE = { 0x8516: 26, 0x8517: 10, 0x8518: 21 }; function geos128Disk(startUp = START_UP_128, layout = GEOS128) { @@ -347,7 +364,7 @@ test('the GEOS 128 boot image is the boot file with the TR disk driver in place assert.equal(r.prg.length, file.length); assert.equal(r.prg.readUInt16LE(0), GEOS128_LOAD); const patched = r.prg.subarray(driverAt, driverAt + DRIVER_SIZE); - for (let i = 0; i < 10; i++) assert.equal(patched.readUInt16LE(2 * i), TURBO_START + ENTRIES[i], `vector ${i}`); + for (let i = 0; i < 10; i++) assert.equal(patched.readUInt16LE(2 * i), TURBO_START + ENTRIES_128[i], `vector ${i}`); const oldNewDisk = NEW_DISK_128 - DRIVER_AREA; assert.equal(patched.subarray(oldNewDisk, READ_BLOCK - DRIVER_AREA + 0x100).equals(stockDriver128().subarray(oldNewDisk, READ_BLOCK - DRIVER_AREA + 0x100)), true, 'the stock NewDisk stays'); assert.deepEqual([...r.prg.subarray(keyAt, keyAt + RESET_KEY.length)], [0, ...RESET_KEY.slice(1)], 'the C of CBM is gone'); @@ -401,3 +418,36 @@ test('a GEOS 128 boot file whose reset key is not where GEOS 128 keeps it is ref assert.match(short.err, /reset key/); }); }); + +test('the GEOS 128 boot ROM is told where the boot image goes, how many pages it has, and where to enter it', { skip }, () => { + withHarness((launch) => { + for (const size of [BODY_SIZE, BODY_SIZE + 1]) { + const r = launch(geos128Disk(START_UP_128, geos128Layout(GEOS128_LOAD, size)), [], '128'); + assert.equal(r.status, 0, r.err); + assert.equal(r.rom.length, 2 * BOOT_CHIP); + assert.equal(r.boot.length, size, 'BOOT serves the body, without its load address'); + const roml = r.rom.subarray(0, BOOT_CHIP), romh = r.rom.subarray(BOOT_CHIP); + assert.deepEqual([...roml.subarray(0, BOOT_PARAMS)], BOOT_ROML.slice(0, BOOT_PARAMS)); + assert.equal(roml.readUInt16LE(BOOT_PARAMS), GEOS128_LOAD); + assert.equal(roml.readUInt16LE(BOOT_PARAMS + 2), Math.ceil(size / SECTOR), 'a short last page counts'); + assert.equal(roml.readUInt16LE(BOOT_PARAMS + 4), 0x2f3c); + assert.deepEqual([...roml.subarray(BOOT_PARAMS + 6, BOOT_ROML.length)], BOOT_ROML.slice(BOOT_PARAMS + 6)); + assert.equal(roml.subarray(BOOT_ROML.length).every((b) => b === 0), true); + assert.deepEqual([...romh.subarray(BOOT_CHIP - BOOT_VECTORS.length)], BOOT_VECTORS, 'the CPU vectors end ROMH'); + assert.equal(romh.subarray(0, BOOT_CHIP - BOOT_VECTORS.length).every((b) => b === 0), true, 'ROMH is not a function ROM of its own'); + } + }); +}); + +test('a GEOS 128 boot file that would reach I/O or the shared RAM is refused', { skip }, () => { + withHarness((launch) => { + const fits = (load, size) => launch(geos128Disk(START_UP_128, geos128Layout(load, size)), [], '128'); + assert.equal(fits(GEOS128_LOAD, 0xa400).status, 0, 'its last page ends below $D000'); + for (const [load, size] of [[GEOS128_LOAD, 0xa401], [0x1fff, BODY_SIZE]]) { + const r = fits(load, size); + assert.equal(r.status, 1, `load ${load.toString(16)}, ${size} bytes`); + assert.match(r.err, /does not fit in memory/); + } + assert.equal(fits(0x2000, BODY_SIZE).status, 0, 'loaded right above the shared RAM'); + }); +}); diff --git a/Source/Teensy/tests/geos_boot_harness.c b/Source/Teensy/tests/geos_boot_harness.c index 0d05248ff..291232f1f 100644 --- a/Source/Teensy/tests/geos_boot_harness.c +++ b/Source/Teensy/tests/geos_boot_harness.c @@ -1,23 +1,14 @@ // SPDX-License-Identifier: MIT -// geos_boot_harness 64|128 IN.d81 OUT OUT.d81 [HOUR MINUTE]: builds the GEOS 64 boot PRG, or the GEOS 128 boot -// image, 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. +// geos_boot_harness 64|128 IN.d81 OUT OUT.d81 [HOUR MINUTE]: builds the GEOS 64 boot PRG, or the GEOS 128 boot ROM +// and the boot image behind it, 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>", and for GEOS 128 +// " boot=," where BOOT's image lies in OUT; exits 1 with the refusal on stderr. #include #include "../GEOS/GeosTargets.h" -#include "../GEOS/TRMouse128.h" #define MAX_IMAGE 819200 #define MAX_PRG (128 * 1024) -#define GEOS_INPUT_128 15 - -// GEOS 128's boot facts, on GEOS 64's disk driver until there is a 128 one: the transport swap is the same -static const struct GeosTarget Geos128Boot = -{ - "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, - GEOS128_BOOT_FILE, GEOS128_BOOT_ENTRY, &TRDiskDriver64, - Geos128ClockRuns, sizeof Geos128ClockRuns / sizeof Geos128ClockRuns[0], GEOS128_RESET_KEY, -}; static uint8_t Image[MAX_IMAGE + 1], Prg[MAX_PRG]; @@ -37,7 +28,8 @@ int main(int argc, char **argv) { struct GeosDate Now = { 26, 10, 21, 15, 7 }; struct GeosDisk Disk = { 0 }; - uint32_t Length; + uint32_t Length, BootSize = 0; + const uint8_t *Boot = NULL; bool Is128, Changed, ClockSet, Built; FILE *File; size_t Size; @@ -53,18 +45,24 @@ int main(int argc, char **argv) fclose(File); Disk.Format = GeosFormatForSize(Size); - Disk.Target = Is128 ? &Geos128Boot : &Geos64Launcher.Target; + Disk.Target = Is128 ? &Geos128Launcher.Target : &Geos64Launcher.Target; Disk.Context = Image; Disk.Read = ReadBlock; Disk.Write = WriteBlock; - Built = Is128 ? GeosBuildBootImage(&Disk, &Now, Prg, sizeof Prg, &Length, &ClockSet) + Built = Is128 ? GeosBuildC128Boot(&Disk, &Now, Prg, sizeof Prg, &Boot, &BootSize, &ClockSet) : GeosBuildBootPrg(&Disk, &Now, Prg, sizeof Prg, &Length, &ClockSet); if (!Built || !GeosDisableConfigure(&Disk, &Changed)) { fprintf(stderr, "%s\n", Disk.Error); return 1; } - printf("changed=%d clock=%d\n", Changed, ClockSet); + printf("changed=%d clock=%d", Changed, ClockSet); + if (Is128) + { + Length = (uint32_t)(Boot - Prg) + BootSize; + printf(" boot=%u,%u", (unsigned)(Boot - Prg), (unsigned)BootSize); + } + printf("\n"); File = fopen(argv[3], "wb"); fwrite(Prg, 1, Length, File); fclose(File); diff --git a/Source/Teensy/tests/geos_disk_patch_harness.c b/Source/Teensy/tests/geos_disk_patch_harness.c index 3a3d22131..7bff4d96a 100644 --- a/Source/Teensy/tests/geos_disk_patch_harness.c +++ b/Source/Teensy/tests/geos_disk_patch_harness.c @@ -5,9 +5,7 @@ #include #include "../GEOS/GeosTargets.h" -#include "../GEOS/TRMouse128.h" -#define GEOS_INPUT_128 15 #define MAX_IMAGE 819200 @@ -26,8 +24,6 @@ static bool WriteBlock(void *Context, uint32_t Offset, const uint8_t *Block) return true; } -static const struct GeosTarget Geos128Target = - { "128 DESKTOP", TRMouse128Name, TRMouse128Code, TRMouse128Info, sizeof TRMouse128Code, GEOS_INPUT_128, NULL, 0, NULL, NULL, 0, 0 }; static int Inspect(struct GeosDisk *Disk) { @@ -59,7 +55,7 @@ int main(int argc, char **argv) memcpy(Original, Image, Size); Disk.Format = GeosFormatForSize(Size); - Disk.Target = &Geos128Target; + Disk.Target = &Geos128Launcher.Target; Disk.Context = Image; Disk.Read = ReadBlock; Disk.Write = WriteBlock; From d6fe6bacde76a810a678b653e486afe5c99eb2f3 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 09:04:18 -0500 Subject: [PATCH 08/17] fix: review findings for cb68b7a --- Source/Teensy/tests/trblock.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Source/Teensy/tests/trblock.test.js b/Source/Teensy/tests/trblock.test.js index 7c3a7f413..40992fca4 100644 --- a/Source/Teensy/tests/trblock.test.js +++ b/Source/Teensy/tests/trblock.test.js @@ -229,12 +229,14 @@ const BOOT_IMAGE = Buffer.from(Array.from({ length: 2 * SECTOR + 100 }, (_, i) = const bootPage = (page, token = 1) => readBlock(page & 0xff, page >> 8, token).replace(`w ${hex(CMD)} ${hex(READ)}`, `w ${hex(CMD)} ${hex(BOOT)}`); test('boot reads the launch\'s boot image a page at a time, the last page padded with zeros', { skip }, () => { - for (const page of [0, 1, 2]) { - const { lines } = run('d81', bootPage(page), undefined, BOOT_IMAGE); + const pages = [0, 1, 2]; + const { lines } = run('d81', pages.map((page) => bootPage(page, page + 1)).join(''), undefined, BOOT_IMAGE); + for (const page of pages) { + const got = lines.slice(page * (SECTOR + 1), (page + 1) * (SECTOR + 1)); const expected = Buffer.alloc(SECTOR); BOOT_IMAGE.copy(expected, 0, page * SECTOR, (page + 1) * SECTOR); - assert.equal(lines[0], '1', 'the main loop ran it'); - assert.deepEqual(bytesOf(lines.slice(1)), expected, `page ${page}`); + assert.equal(got[0], '1', 'the main loop ran it'); + assert.deepEqual(bytesOf(got.slice(1)), expected, `page ${page}`); } }); @@ -242,6 +244,8 @@ test('boot past the end of the boot image fails with bad address', { skip }, () for (const [addr0, addr1] of [[3, 0], [0, 1], [0xff, 0xff]]) { assert.equal(run('d81', command(BOOT, { addr0, addr1 }) + 'svc\n' + read(STATUS), undefined, BOOT_IMAGE).lines.at(-1), hex(BAD_ADDRESS), `page ${addr1}:${addr0}`); } + const twoPages = BOOT_IMAGE.subarray(0, 2 * SECTOR); + assert.equal(run('d81', command(BOOT, { addr0: 2 }) + 'svc\n' + read(STATUS), undefined, twoPages).lines.at(-1), hex(BAD_ADDRESS), 'the page just past a whole-page end'); }); test('boot with no boot image fails with no image, while the disk image still reads', { skip }, () => { From 0a52a39420ff45d015110c1b58ae00fa170cbb36 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 10:34:18 -0500 Subject: [PATCH 09/17] fix: review findings for 4d3d3a0 --- Source/Teensy/tests/geos-boot.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/Teensy/tests/geos-boot.test.js b/Source/Teensy/tests/geos-boot.test.js index 4ba1005df..995c6b4b7 100644 --- a/Source/Teensy/tests/geos-boot.test.js +++ b/Source/Teensy/tests/geos-boot.test.js @@ -443,6 +443,7 @@ test('a GEOS 128 boot file that would reach I/O or the shared RAM is refused', { withHarness((launch) => { const fits = (load, size) => launch(geos128Disk(START_UP_128, geos128Layout(load, size)), [], '128'); assert.equal(fits(GEOS128_LOAD, 0xa400).status, 0, 'its last page ends below $D000'); + assert.equal(fits(0x2c00, 0xa400).status, 0, 'its last page ends at $D000'); for (const [load, size] of [[GEOS128_LOAD, 0xa401], [0x1fff, BODY_SIZE]]) { const r = fits(load, size); assert.equal(r.status, 1, `load ${load.toString(16)}, ${size} bytes`); From fea8fb8dbdee53456569e3fb2c7d0ea99eba0b73 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 09:51:33 -0500 Subject: [PATCH 10/17] tools: a 6502 emulator for host tests of C64 and C128 machine code It runs the documented NMOS opcodes, decimal mode included, against a bus the test supplies, so a test can mock a cartridge's registers and run the real ROM code against them in CI. It passes Klaus Dormann's 6502 functional test (not committed: GPL-3). --- tools/lib/mos6502.mjs | 408 +++++++++++++++++++++++++++++++++++++ tools/lib/mos6502.test.mjs | 217 ++++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 tools/lib/mos6502.mjs create mode 100644 tools/lib/mos6502.test.mjs diff --git a/tools/lib/mos6502.mjs b/tools/lib/mos6502.mjs new file mode 100644 index 000000000..71c42f580 --- /dev/null +++ b/tools/lib/mos6502.mjs @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: MIT +// An NMOS 6502 for host tests of C64 and C128 machine code. It runs the 151 documented +// opcodes and throws on any other. Each operand is read or written once: there are no dummy +// reads or writes, and no cycle counts, so a bus that has to see the real chip's extra +// accesses cannot be tested with it. + +export const FLAG = { C: 0x01, Z: 0x02, I: 0x04, D: 0x08, B: 0x10, U: 0x20, V: 0x40, N: 0x80 }; + +const VECTOR = { NMI: 0xfffa, RESET: 0xfffc, IRQ: 0xfffe }; +const STACK_PAGE = 0x100; + +const OPCODE_TABLE = ` +ADC 69 imm 65 zp 75 zpx 6d abs 7d absx 79 absy 61 indx 71 indy +AND 29 imm 25 zp 35 zpx 2d abs 3d absx 39 absy 21 indx 31 indy +ASL 0a acc 06 zp 16 zpx 0e abs 1e absx +BCC 90 rel +BCS b0 rel +BEQ f0 rel +BIT 24 zp 2c abs +BMI 30 rel +BNE d0 rel +BPL 10 rel +BRK 00 imp +BVC 50 rel +BVS 70 rel +CLC 18 imp +CLD d8 imp +CLI 58 imp +CLV b8 imp +CMP c9 imm c5 zp d5 zpx cd abs dd absx d9 absy c1 indx d1 indy +CPX e0 imm e4 zp ec abs +CPY c0 imm c4 zp cc abs +DEC c6 zp d6 zpx ce abs de absx +DEX ca imp +DEY 88 imp +EOR 49 imm 45 zp 55 zpx 4d abs 5d absx 59 absy 41 indx 51 indy +INC e6 zp f6 zpx ee abs fe absx +INX e8 imp +INY c8 imp +JMP 4c abs 6c ind +JSR 20 abs +LDA a9 imm a5 zp b5 zpx ad abs bd absx b9 absy a1 indx b1 indy +LDX a2 imm a6 zp b6 zpy ae abs be absy +LDY a0 imm a4 zp b4 zpx ac abs bc absx +LSR 4a acc 46 zp 56 zpx 4e abs 5e absx +NOP ea imp +ORA 09 imm 05 zp 15 zpx 0d abs 1d absx 19 absy 01 indx 11 indy +PHA 48 imp +PHP 08 imp +PLA 68 imp +PLP 28 imp +ROL 2a acc 26 zp 36 zpx 2e abs 3e absx +ROR 6a acc 66 zp 76 zpx 6e abs 7e absx +RTI 40 imp +RTS 60 imp +SBC e9 imm e5 zp f5 zpx ed abs fd absx f9 absy e1 indx f1 indy +SEC 38 imp +SED f8 imp +SEI 78 imp +STA 85 zp 95 zpx 8d abs 9d absx 99 absy 81 indx 91 indy +STX 86 zp 96 zpy 8e abs +STY 84 zp 94 zpx 8c abs +TAX aa imp +TAY a8 imp +TSX ba imp +TXA 8a imp +TXS 9a imp +TYA 98 imp +`; + +function decodeTable() { + const opcodes = new Map(); + for (const line of OPCODE_TABLE.trim().split('\n')) { + const [mnemonic, ...pairs] = line.split(' '); + for (let i = 0; i < pairs.length; i += 2) { + opcodes.set(parseInt(pairs[i], 16), { mnemonic, mode: pairs[i + 1] }); + } + } + return opcodes; +} + +export const OPCODES = decodeTable(); + +const hex = (value, digits) => '$' + value.toString(16).padStart(digits, '0'); + +export class Mos6502 { + a = 0; + x = 0; + y = 0; + s = 0xfd; + p = FLAG.U | FLAG.I; + pc = 0; + steps = 0; + + // bus: { read(address) -> byte, write(address, byte) } + constructor(bus) { + this.bus = bus; + } + + reset() { + this.s = 0xfd; + this.p |= FLAG.I; + this.pc = this.read16(VECTOR.RESET); + } + + irq() { + if (!(this.p & FLAG.I)) this.interrupt(VECTOR.IRQ, 0); + } + + nmi() { + this.interrupt(VECTOR.NMI, 0); + } + + // A run still going after limit steps throws, since in a test that is a hang. + run(done, limit = 1_000_000) { + for (let i = 0; i < limit; i++) { + if (done(this)) return; + this.step(); + } + throw new Error(`still running after ${limit} steps, at ${hex(this.pc, 4)}`); + } + + step() { + const at = this.pc; + const opcode = this.fetch(); + const decoded = OPCODES.get(opcode); + if (!decoded) throw new Error(`opcode ${hex(opcode, 2)} at ${hex(at, 4)} is not a documented 6502 opcode`); + this.steps++; + this[decoded.mnemonic](this.operand(decoded.mode)); + } + + read(address) { + return this.bus.read(address & 0xffff) & 0xff; + } + + write(address, value) { + this.bus.write(address & 0xffff, value & 0xff); + } + + read16(address) { + return this.read(address) | (this.read(address + 1) << 8); + } + + readZeroPage16(address) { + return this.read(address & 0xff) | (this.read((address + 1) & 0xff) << 8); + } + + fetch() { + const value = this.read(this.pc); + this.pc = (this.pc + 1) & 0xffff; + return value; + } + + fetch16() { + return this.fetch() | (this.fetch() << 8); + } + + operand(mode) { + switch (mode) { + case 'imp': + case 'acc': + return { mode }; + case 'imm': + return { mode, value: this.fetch() }; + case 'rel': { + const offset = this.fetch(); + return { mode, address: (this.pc + (offset < 0x80 ? offset : offset - 0x100)) & 0xffff }; + } + case 'zp': + return { mode, address: this.fetch() }; + case 'zpx': + return { mode, address: (this.fetch() + this.x) & 0xff }; + case 'zpy': + return { mode, address: (this.fetch() + this.y) & 0xff }; + case 'abs': + return { mode, address: this.fetch16() }; + case 'absx': + return { mode, address: (this.fetch16() + this.x) & 0xffff }; + case 'absy': + return { mode, address: (this.fetch16() + this.y) & 0xffff }; + case 'indx': + return { mode, address: this.readZeroPage16(this.fetch() + this.x) }; + case 'indy': + return { mode, address: (this.readZeroPage16(this.fetch()) + this.y) & 0xffff }; + case 'ind': { + // the NMOS part does not carry into the high byte: JMP ($10FF) reads $10FF and $1000 + const pointer = this.fetch16(); + const high = (pointer & 0xff00) | ((pointer + 1) & 0xff); + return { mode, address: this.read(pointer) | (this.read(high) << 8) }; + } + } + } + + load(operand) { + if (operand.mode === 'imm') return operand.value; + if (operand.mode === 'acc') return this.a; + return this.read(operand.address); + } + + store(operand, value) { + if (operand.mode === 'acc') this.a = value; + else this.write(operand.address, value); + } + + setFlag(flag, on) { + this.p = on ? this.p | flag : this.p & ~flag; + } + + setNZ(value) { + this.setFlag(FLAG.Z, (value & 0xff) === 0); + this.setFlag(FLAG.N, value & 0x80); + return value & 0xff; + } + + push(value) { + this.write(STACK_PAGE | this.s, value); + this.s = (this.s - 1) & 0xff; + } + + pull() { + this.s = (this.s + 1) & 0xff; + return this.read(STACK_PAGE | this.s); + } + + push16(value) { + this.push(value >> 8); + this.push(value); + } + + pull16() { + return this.pull() | (this.pull() << 8); + } + + interrupt(vector, breakFlag) { + this.push16(this.pc); + this.push(((this.p | FLAG.U) & ~FLAG.B) | breakFlag); + this.p |= FLAG.I; + this.pc = this.read16(vector); + } + + branch(operand, taken) { + if (taken) this.pc = operand.address; + } + + compare(register, operand) { + const value = this.load(operand); + this.setFlag(FLAG.C, register >= value); + this.setNZ(register - value); + } + + modify(operand, change) { + this.store(operand, this.setNZ(change(this.load(operand)))); + } + + // decimal mode follows https://www.6502.org/tutorials/decimal_mode.html appendix A, the NMOS flags + ADC(operand) { + const value = this.load(operand); + const carry = this.p & FLAG.C; + const binary = this.a + value + carry; + this.setFlag(FLAG.Z, (binary & 0xff) === 0); + if (!(this.p & FLAG.D)) { + this.setFlag(FLAG.C, binary > 0xff); + this.setFlag(FLAG.V, ~(this.a ^ value) & (this.a ^ binary) & 0x80); + this.setFlag(FLAG.N, binary & 0x80); + this.a = binary & 0xff; + return; + } + let low = (this.a & 0x0f) + (value & 0x0f) + carry; + if (low >= 0x0a) low = ((low + 0x06) & 0x0f) + 0x10; + let sum = (this.a & 0xf0) + (value & 0xf0) + low; + const signed = signedHigh(this.a) + signedHigh(value) + low; + this.setFlag(FLAG.N, sum & 0x80); + this.setFlag(FLAG.V, signed < -128 || signed > 127); + if (sum >= 0xa0) sum += 0x60; + this.setFlag(FLAG.C, sum >= 0x100); + this.a = sum & 0xff; + } + + SBC(operand) { + const value = this.load(operand); + const borrow = 1 - (this.p & FLAG.C); + const binary = this.a - value - borrow; + this.setFlag(FLAG.C, binary >= 0); + this.setFlag(FLAG.V, (this.a ^ value) & (this.a ^ binary) & 0x80); + this.setNZ(binary); + if (!(this.p & FLAG.D)) { + this.a = binary & 0xff; + return; + } + let low = (this.a & 0x0f) - (value & 0x0f) - borrow; + if (low < 0) low = ((low - 0x06) & 0x0f) - 0x10; + let difference = (this.a & 0xf0) - (value & 0xf0) + low; + if (difference < 0) difference -= 0x60; + this.a = difference & 0xff; + } + + AND(operand) { this.a = this.setNZ(this.a & this.load(operand)); } + ORA(operand) { this.a = this.setNZ(this.a | this.load(operand)); } + EOR(operand) { this.a = this.setNZ(this.a ^ this.load(operand)); } + + BIT(operand) { + const value = this.load(operand); + this.setFlag(FLAG.Z, (this.a & value) === 0); + this.setFlag(FLAG.N, value & FLAG.N); + this.setFlag(FLAG.V, value & FLAG.V); + } + + ASL(operand) { + this.modify(operand, (value) => { + this.setFlag(FLAG.C, value & 0x80); + return value << 1; + }); + } + + LSR(operand) { + this.modify(operand, (value) => { + this.setFlag(FLAG.C, value & 0x01); + return value >> 1; + }); + } + + ROL(operand) { + this.modify(operand, (value) => { + const carryIn = this.p & FLAG.C; + this.setFlag(FLAG.C, value & 0x80); + return (value << 1) | carryIn; + }); + } + + ROR(operand) { + this.modify(operand, (value) => { + const carryIn = this.p & FLAG.C; + this.setFlag(FLAG.C, value & 0x01); + return (value >> 1) | (carryIn << 7); + }); + } + + INC(operand) { this.modify(operand, (value) => value + 1); } + DEC(operand) { this.modify(operand, (value) => value - 1); } + INX() { this.x = this.setNZ(this.x + 1); } + INY() { this.y = this.setNZ(this.y + 1); } + DEX() { this.x = this.setNZ(this.x - 1); } + DEY() { this.y = this.setNZ(this.y - 1); } + + CMP(operand) { this.compare(this.a, operand); } + CPX(operand) { this.compare(this.x, operand); } + CPY(operand) { this.compare(this.y, operand); } + + BCC(operand) { this.branch(operand, !(this.p & FLAG.C)); } + BCS(operand) { this.branch(operand, this.p & FLAG.C); } + BNE(operand) { this.branch(operand, !(this.p & FLAG.Z)); } + BEQ(operand) { this.branch(operand, this.p & FLAG.Z); } + BPL(operand) { this.branch(operand, !(this.p & FLAG.N)); } + BMI(operand) { this.branch(operand, this.p & FLAG.N); } + BVC(operand) { this.branch(operand, !(this.p & FLAG.V)); } + BVS(operand) { this.branch(operand, this.p & FLAG.V); } + + JMP(operand) { this.pc = operand.address; } + + JSR(operand) { + this.push16((this.pc - 1) & 0xffff); + this.pc = operand.address; + } + + RTS() { this.pc = (this.pull16() + 1) & 0xffff; } + + BRK() { + this.pc = (this.pc + 1) & 0xffff; + this.interrupt(VECTOR.IRQ, FLAG.B); + } + + RTI() { + this.PLP(); + this.pc = this.pull16(); + } + + LDA(operand) { this.a = this.setNZ(this.load(operand)); } + LDX(operand) { this.x = this.setNZ(this.load(operand)); } + LDY(operand) { this.y = this.setNZ(this.load(operand)); } + STA(operand) { this.store(operand, this.a); } + STX(operand) { this.store(operand, this.x); } + STY(operand) { this.store(operand, this.y); } + + TAX() { this.x = this.setNZ(this.a); } + TAY() { this.y = this.setNZ(this.a); } + TXA() { this.a = this.setNZ(this.x); } + TYA() { this.a = this.setNZ(this.y); } + TSX() { this.x = this.setNZ(this.s); } + TXS() { this.s = this.x; } + + PHA() { this.push(this.a); } + PHP() { this.push(this.p | FLAG.B | FLAG.U); } + PLA() { this.a = this.setNZ(this.pull()); } + PLP() { this.p = (this.pull() & ~FLAG.B) | FLAG.U; } + + CLC() { this.p &= ~FLAG.C; } + SEC() { this.p |= FLAG.C; } + CLI() { this.p &= ~FLAG.I; } + SEI() { this.p |= FLAG.I; } + CLD() { this.p &= ~FLAG.D; } + SED() { this.p |= FLAG.D; } + CLV() { this.p &= ~FLAG.V; } + NOP() {} +} + +function signedHigh(byte) { + return (byte & 0xf0) - ((byte & 0x80) << 1); +} diff --git a/tools/lib/mos6502.test.mjs b/tools/lib/mos6502.test.mjs new file mode 100644 index 000000000..bed7ae92d --- /dev/null +++ b/tools/lib/mos6502.test.mjs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Mos6502, FLAG, OPCODES } from './mos6502.mjs'; + +const ORIGIN = 0x0200; +const STOP = 0xffff; + +// Runs code at $0200 until it reaches STOP, the address a final JMP $FFFF lands on. +function machine(code, setup = () => {}) { + const memory = new Uint8Array(0x10000); + memory.set(code, ORIGIN); + memory.set([0x4c, 0xff, 0xff], ORIGIN + code.length); + const reads = []; + const writes = []; + const cpu = new Mos6502({ + read: (address) => { + reads.push(address); + return memory[address]; + }, + write: (address, value) => { + writes.push([address, value]); + memory[address] = value; + }, + }); + cpu.pc = ORIGIN; + setup(cpu, memory); + cpu.run((c) => c.pc === STOP, 10_000); + return { cpu, memory, reads, writes }; +} + +const flags = (cpu, ...names) => names.map((name) => Boolean(cpu.p & FLAG[name])); + +test('the 151 documented opcodes decode and any other throws with its address', () => { + assert.equal(OPCODES.size, 151); + assert.throws(() => machine([0xea, 0x02]), /opcode \$02 at \$0201 is not a documented 6502 opcode/); +}); + +test('a copy loop through (zp),Y moves a block', () => { + const { memory } = machine( + [ + 0xa9, 0x00, 0x85, 0x10, // LDA #$00 / STA $10 + 0xa9, 0x40, 0x85, 0x11, // LDA #$40 / STA $11 + 0xa0, 0x00, // LDY #0 + 0xb9, 0x00, 0x30, // LDA $3000,Y + 0x91, 0x10, // STA ($10),Y + 0xc8, // INY + 0xc0, 0x05, // CPY #5 + 0xd0, 0xf6, // BNE -10 + ], + (cpu, mem) => mem.set([1, 2, 3, 4, 5], 0x3000), + ); + assert.deepEqual([...memory.subarray(0x4000, 0x4006)], [1, 2, 3, 4, 5, 0]); +}); + +test('binary ADC and SBC set carry and overflow', () => { + const add = (a, b, carry) => machine([carry ? 0x38 : 0x18, 0xa9, a, 0x69, b]).cpu; + const sub = (a, b, carry) => machine([carry ? 0x38 : 0x18, 0xa9, a, 0xe9, b]).cpu; + + let cpu = add(0x50, 0x50, 0); + assert.equal(cpu.a, 0xa0); + assert.deepEqual(flags(cpu, 'C', 'V', 'N', 'Z'), [false, true, true, false]); + cpu = add(0xd0, 0x90, 0); + assert.equal(cpu.a, 0x60); + assert.deepEqual(flags(cpu, 'C', 'V'), [true, true]); + cpu = add(0xff, 0x00, 1); + assert.equal(cpu.a, 0x00); + assert.deepEqual(flags(cpu, 'C', 'Z'), [true, true]); + + cpu = sub(0x50, 0xb0, 1); + assert.equal(cpu.a, 0xa0); + assert.deepEqual(flags(cpu, 'C', 'V', 'N'), [false, true, true]); + cpu = sub(0x50, 0x30, 0); + assert.equal(cpu.a, 0x1f); + assert.deepEqual(flags(cpu, 'C', 'V'), [true, false]); +}); + +test('decimal ADC and SBC give BCD results and the NMOS flags', () => { + const add = (a, b, carry) => machine([0xf8, carry ? 0x38 : 0x18, 0xa9, a, 0x69, b]).cpu; + const sub = (a, b, carry) => machine([0xf8, carry ? 0x38 : 0x18, 0xa9, a, 0xe9, b]).cpu; + + let cpu = add(0x58, 0x46, 1); + assert.equal(cpu.a, 0x05); + assert.equal(flags(cpu, 'C')[0], true); + cpu = add(0x12, 0x34, 0); + assert.equal(cpu.a, 0x46); + assert.equal(flags(cpu, 'C')[0], false); + // the NMOS part takes Z from the binary sum ($9A) and N from the sum before its high digit is adjusted ($A0) + cpu = add(0x99, 0x01, 0); + assert.equal(cpu.a, 0x00); + assert.deepEqual(flags(cpu, 'C', 'Z', 'N'), [true, false, true]); + cpu = add(0x79, 0x01, 0); + assert.equal(cpu.a, 0x80); + assert.deepEqual(flags(cpu, 'C', 'N', 'V'), [false, true, true]); + + cpu = sub(0x46, 0x12, 1); + assert.equal(cpu.a, 0x34); + assert.equal(flags(cpu, 'C')[0], true); + cpu = sub(0x40, 0x13, 1); + assert.equal(cpu.a, 0x27); + cpu = sub(0x21, 0x34, 1); + assert.equal(cpu.a, 0x87); + assert.equal(flags(cpu, 'C')[0], false); + cpu = sub(0x32, 0x02, 0); + assert.equal(cpu.a, 0x29); +}); + +test('shifts and rotates carry through C, in A and in memory', () => { + let { cpu, memory } = machine( + [0x38, 0xa9, 0x81, 0x6a, 0x26, 0x20], // SEC / LDA #$81 / ROR A / ROL $20 + (c, mem) => (mem[0x20] = 0x80), + ); + assert.equal(cpu.a, 0xc0); + assert.equal(memory[0x20], 0x01); + assert.equal(flags(cpu, 'C')[0], true); + + ({ cpu, memory } = machine([0xa9, 0x81, 0x0a, 0x4e, 0x00, 0x30], (c, mem) => (mem[0x3000] = 0x01))); + assert.equal(cpu.a, 0x02); + assert.equal(memory[0x3000], 0x00); + assert.deepEqual(flags(cpu, 'C', 'Z'), [true, true]); +}); + +test('JSR and RTS return past the call, and the stack wraps in page 1', () => { + const { cpu, memory } = machine( + [0x20, 0x08, 0x02, 0xa2, 0x07, 0x4c, 0x0b, 0x02, 0xa0, 0x09, 0x60], // JSR sub / LDX #7 / JMP end / sub: LDY #9 / RTS + (c) => (c.s = 0x00), + ); + assert.deepEqual([cpu.x, cpu.y, cpu.s], [7, 9, 0x00]); + assert.deepEqual([memory[0x100], memory[0x1ff]], [0x02, 0x02], 'return address $0202 pushed high byte first'); +}); + +test('BRK pushes B, and RTI returns two bytes past it with B cleared', () => { + const { cpu, memory } = machine([0x00, 0xe8, 0xa9, 0x42], (c, mem) => { + mem.set([0x00, 0x30], 0xfffe); + mem.set([0xe8, 0x40], 0x3000); // INX / RTI + }); + assert.deepEqual([cpu.a, cpu.x], [0x42, 1]); + assert.equal(memory[0x1fb] & FLAG.B, FLAG.B); + assert.equal(cpu.p & FLAG.B, 0); +}); + +test('an IRQ is held off while I is set and taken once it is clear', () => { + const memory = new Uint8Array(0x10000); + memory.set([0x00, 0x30], 0xfffe); + const cpu = new Mos6502({ read: (a) => memory[a], write: (a, v) => (memory[a] = v) }); + cpu.pc = 0x0200; + cpu.irq(); + assert.equal(cpu.pc, 0x0200); + cpu.p &= ~FLAG.I; + cpu.irq(); + assert.equal(cpu.pc, 0x3000); + assert.equal(memory[0x1fb] & FLAG.B, 0); +}); + +test('reset loads the PC from $FFFC', () => { + const memory = new Uint8Array(0x10000); + memory.set([0x34, 0x12], 0xfffc); + const cpu = new Mos6502({ read: (a) => memory[a], write: () => {} }); + cpu.reset(); + assert.equal(cpu.pc, 0x1234); +}); + +test('JMP indirect does not carry into the pointer high byte', () => { + const { cpu } = machine([0x6c, 0xff, 0x30], (c, mem) => { + mem[0x30ff] = 0x00; + mem[0x3000] = 0x40; + mem[0x3100] = 0x50; + mem.set([0xa9, 0x01, 0x4c, 0xff, 0xff], 0x4000); + mem.set([0xa9, 0x02, 0x4c, 0xff, 0xff], 0x5000); + }); + assert.equal(cpu.a, 0x01); +}); + +test('zero-page indexing and pointers wrap within page 0', () => { + let { cpu } = machine([0xa2, 0x02, 0xb5, 0xff], (c, mem) => (mem[0x01] = 0x11)); + assert.equal(cpu.a, 0x11, 'LDA $FF,X with X=2 reads $01'); + + const pointerAcrossTheWrap = (c, mem) => { + mem[0xff] = 0x00; + mem[0x00] = 0x30; + mem[0x100] = 0x40; + mem[0x3000] = 0x22; + mem[0x3001] = 0x33; + }; + ({ cpu } = machine([0xa2, 0x01, 0xa1, 0xfe], pointerAcrossTheWrap)); + assert.equal(cpu.a, 0x22, 'LDA ($FE,X) with X=1 takes its pointer from $FF and $00'); + ({ cpu } = machine([0xa0, 0x01, 0xb1, 0xff], pointerAcrossTheWrap)); + assert.equal(cpu.a, 0x33, 'LDA ($FF),Y takes its pointer from $FF and $00'); +}); + +test('a backward branch across a page boundary lands on the right address', () => { + const { cpu } = machine([0x4c, 0x02, 0x31], (c, mem) => { + mem.set([0xe8, 0x4c, 0xff, 0xff], 0x30f0); // INX / JMP end + mem.set([0xa2, 0x00, 0xf0, 0xea], 0x3102); // LDX #0 / BEQ $30F0 + }); + assert.equal(cpu.x, 1); +}); + +test('BIT copies bits 7 and 6 and sets Z from the AND', () => { + const { cpu } = machine([0xa9, 0x01, 0x2c, 0x00, 0x30], (c, mem) => (mem[0x3000] = 0xc0)); + assert.deepEqual(flags(cpu, 'N', 'V', 'Z'), [true, true, true]); +}); + +test('each operand is one bus access: a status register is read once per LDA', () => { + const { reads, writes } = machine([0xad, 0x0d, 0xdf, 0x8d, 0x0e, 0xdf]); + assert.equal(reads.filter((a) => a === 0xdf0d).length, 1); + assert.equal(reads.filter((a) => a === 0xdf0e).length, 0, 'STA does not read its target'); + assert.deepEqual(writes, [[0xdf0e, 0]]); +}); + +test('a run that never finishes throws instead of hanging the test', () => { + const memory = new Uint8Array(0x10000); + memory.set([0x4c, 0x00, 0x02], 0x0200); + const cpu = new Mos6502({ read: (a) => memory[a], write: () => {} }); + cpu.pc = 0x0200; + assert.throws(() => cpu.run(() => false, 100), /still running after 100 steps, at \$0200/); +}); From b920cda8eafa50805fccfedb4591708fc628a4f7 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 09:54:57 -0500 Subject: [PATCH 11/17] fix: review findings for 86bbb21 --- tools/lib/mos6502.mjs | 1 + tools/lib/mos6502.test.mjs | 57 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/tools/lib/mos6502.mjs b/tools/lib/mos6502.mjs index 71c42f580..50fe97eee 100644 --- a/tools/lib/mos6502.mjs +++ b/tools/lib/mos6502.mjs @@ -117,6 +117,7 @@ export class Mos6502 { if (done(this)) return; this.step(); } + if (done(this)) return; throw new Error(`still running after ${limit} steps, at ${hex(this.pc, 4)}`); } diff --git a/tools/lib/mos6502.test.mjs b/tools/lib/mos6502.test.mjs index bed7ae92d..1abae6d73 100644 --- a/tools/lib/mos6502.test.mjs +++ b/tools/lib/mos6502.test.mjs @@ -152,12 +152,57 @@ test('an IRQ is held off while I is set and taken once it is clear', () => { assert.equal(memory[0x1fb] & FLAG.B, 0); }); -test('reset loads the PC from $FFFC', () => { +test('an NMI is taken while I is set, through $FFFA, and sets I', () => { + const memory = new Uint8Array(0x10000); + memory.set([0x00, 0x40], 0xfffa); + memory.set([0x00, 0x30], 0xfffe); + const cpu = new Mos6502({ read: (a) => memory[a], write: (a, v) => (memory[a] = v) }); + cpu.pc = 0x0200; + cpu.nmi(); + assert.equal(cpu.pc, 0x4000); + cpu.p &= ~FLAG.I; + cpu.irq(); + assert.equal(cpu.pc, 0x3000); + assert.equal(cpu.p & FLAG.I, FLAG.I, 'the IRQ sets I'); +}); + +test('reset loads the PC from $FFFC, sets I and puts S at $FD', () => { const memory = new Uint8Array(0x10000); memory.set([0x34, 0x12], 0xfffc); const cpu = new Mos6502({ read: (a) => memory[a], write: () => {} }); + cpu.p = FLAG.U; + cpu.s = 0x12; cpu.reset(); - assert.equal(cpu.pc, 0x1234); + assert.deepEqual([cpu.pc, cpu.p & FLAG.I, cpu.s], [0x1234, FLAG.I, 0xfd]); +}); + +test('PHP pushes B and U, PLP keeps U set, TXS sets no flags', () => { + const { cpu, memory } = machine([ + 0x18, 0xb8, 0x08, // CLC / CLV / PHP + 0xa9, 0x00, 0x48, 0x28, // LDA #0 / PHA / PLP + 0xa2, 0x80, 0xa9, 0x01, // LDX #$80 / LDA #1 + 0x9a, // TXS + ], (c) => (c.p |= FLAG.V)); + assert.equal(memory[0x1fd], FLAG.B | FLAG.U | FLAG.I); + assert.equal(cpu.s, 0x80); + assert.deepEqual(flags(cpu, 'U', 'V', 'Z', 'N'), [true, false, false, false]); +}); + +test('PLA sets Z from the pulled byte', () => { + const { cpu } = machine([0xa9, 0x00, 0x48, 0xa9, 0x01, 0x68]); // LDA #0 / PHA / LDA #1 / PLA + assert.deepEqual(flags(cpu, 'Z'), [true]); +}); + +test('CMP of equal values sets C and Z', () => { + const { cpu } = machine([0xa9, 0x42, 0xc9, 0x42]); + assert.deepEqual(flags(cpu, 'C', 'Z', 'N'), [true, true, false]); +}); + +test('absolute,Y wraps past $FFFF and zero-page,Y indexes by Y', () => { + let { cpu } = machine([0xa0, 0x02, 0xb9, 0xff, 0xff], (c, mem) => (mem[0x0001] = 0x5a)); + assert.equal(cpu.a, 0x5a, 'LDA $FFFF,Y with Y=2 reads $0001'); + ({ cpu } = machine([0xa0, 0x03, 0xa2, 0x00, 0xb6, 0x10], (c, mem) => (mem[0x13] = 0x77))); + assert.equal(cpu.x, 0x77, 'LDX $10,Y with Y=3 reads $13'); }); test('JMP indirect does not carry into the pointer high byte', () => { @@ -215,3 +260,11 @@ test('a run that never finishes throws instead of hanging the test', () => { cpu.pc = 0x0200; assert.throws(() => cpu.run(() => false, 100), /still running after 100 steps, at \$0200/); }); + +test('a run that finishes on its last allowed step returns', () => { + const memory = new Uint8Array(0x10000).fill(0xea); + const cpu = new Mos6502({ read: (a) => memory[a], write: () => {} }); + cpu.pc = 0x0200; + cpu.run((c) => c.pc === 0x0203, 3); + assert.equal(cpu.steps, 3); +}); From 5ea4cd99c73c5972458c306aa174f3f7e50a1e46 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 10:35:06 -0500 Subject: [PATCH 12/17] test: the GEOS 128 boot ROM runs on an emulated C128 against the block device The committed ROM is run on the 6502 emulator, with the C128's MMU and shared RAM and a block device that follows docs/TR_Block_Device.md. The tests check that it loads every page into bank 1 and enters GEOS. They also cover a busy device, a stale result, glitched reads, a refused page and a device that never answers. --- tools/geos/geos128boot-run.test.mjs | 252 ++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 tools/geos/geos128boot-run.test.mjs diff --git a/tools/geos/geos128boot-run.test.mjs b/tools/geos/geos128boot-run.test.mjs new file mode 100644 index 000000000..ce68d4df3 --- /dev/null +++ b/tools/geos/geos128boot-run.test.mjs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Mos6502 } from '../lib/mos6502.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const committedHeader = path.join(root, 'Source/Teensy/GEOS/Geos128Boot.h'); + +const MMU_CR = 0xff00; +const MMU_RAM_SHARE = 0xd506; +const KERNAL_RESET_SHARE = 0x04; +const FUNCTION_ROM_AT_8000 = 0x08; +const COLD_START = 0x8000; +const BLOCK = 0xdf00; +const STATUS = { BUSY: 0x80, DONE: 0x5a, PAST_END: 0xe2 }; +const CMD_BOOT = 5; +const PAGE = 256; +const QUARTER = 64; + +function readHeader() { + const text = fs.readFileSync(committedHeader, 'utf8'); + const define = (name) => parseInt(text.match(new RegExp(`#define ${name}\\s+(0x[0-9a-f]+)`))[1], 16); + const bytes = (name) => Uint8Array.from([...text.match(new RegExp(`${name}\\[\\d+\\] PROGMEM = \\{([^}]*)\\}`))[1].matchAll(/0x[0-9a-f]+/g)].map((m) => parseInt(m[0], 16))); + return { + chipSize: define('GEOS128_BOOT_CHIP_SIZE'), + params: define('GEOS128_BOOT_PARAMS'), + vectors: define('GEOS128_BOOT_VECTORS'), + roml: bytes('Geos128BootRoml'), + romVectors: bytes('Geos128BootVectors'), + }; +} + +// The cartridge as GeosBuildC128Boot fills it: ROML with the load address, page count and entry patched in, ROMH +// holding only the vectors. +function cartridge({ load, pages, entry }) { + const h = readHeader(); + const roml = new Uint8Array(h.chipSize); + const romh = new Uint8Array(h.chipSize); + roml.set(h.roml); + romh.set(h.romVectors, h.vectors); + for (const [i, word] of [load, pages, entry].entries()) { + roml[h.params + 2 * i] = word & 0xff; + roml[h.params + 2 * i + 1] = word >> 8; + } + return { roml, romh }; +} + +// The block device's registers at $DF00 as docs/TR_Block_Device.md describes them, serving BOOT from image. +// busyReads: STATUS reads $80 this many times after each CMD. staleEcho: for this many ECHO reads after STATUS turns +// done, the device still shows the previous command's result, its token and its page. glitchEvery: every nth read +// comes back with bits ORed in, as the Kawari does. echoGlitch: ORed into the first stale ECHO read. +function blockDevice(image, { busyReads = 0, staleEcho = 0, glitchEvery = 0, echoGlitch = 0, neverDone = false } = {}) { + let buffer = new Uint8Array(PAGE), previousBuffer = buffer; + const regs = { addr0: 0, addr1: 0, page: 0, token: 0 }; + let status = 0, echo = 0, previousToken = 0, busy = 0, stale = 0, reads = 0; + const device = { + commands: 0, + write(offset, value) { + switch (offset) { + case 0x08: return device.command(value); + case 0x09: regs.addr0 = value; return; + case 0x0a: regs.addr1 = value; return; + case 0x0b: regs.page = value & 3; return; + case 0x0c: regs.token = value; return; + } + }, + command(cmd) { + device.commands++; + previousToken = echo; + previousBuffer = buffer; + buffer = new Uint8Array(PAGE); + echo = regs.token; + busy = neverDone ? Infinity : busyReads; + stale = staleEcho; + const start = ((regs.addr1 << 8) | regs.addr0) * PAGE; + if (cmd !== CMD_BOOT) throw new Error(`the loader sent command ${cmd}`); + if (start >= image.length) { + status = STATUS.PAST_END; + return; + } + buffer.set(image.subarray(start, start + PAGE)); + status = STATUS.DONE; + }, + read(offset) { + reads++; + const glitch = glitchEvery && reads % glitchEvery === 0 ? 0x41 : 0; + return device.answer(offset) | glitch; + }, + answer(offset) { + if (offset === 0x0d) { + if (busy > 0) { + busy--; + return STATUS.BUSY; + } + return status; + } + if (offset === 0x0e) { + if (busy === 0 && stale > 0) { + const glitch = stale === staleEcho ? echoGlitch : 0; + stale--; + return previousToken | glitch; + } + return echo; + } + const shown = stale > 0 ? previousBuffer : buffer; + if (offset >= 0x40 && offset < 0x80) return shown[regs.page * QUARTER + offset - 0x40]; + return 0xff; + }, + }; + return device; +} + +// A C128 as the boot ROM sees it: two 64K RAM banks behind the MMU's configuration register, the shared RAM at the +// bottom, I/O at $D000 (the block device at $DF00) and the cartridge's function ROM at $8000 and $C000. +function c128(cart, device) { + const ram = [new Uint8Array(0x10000), new Uint8Array(0x10000)]; + const io = new Map([[MMU_RAM_SHARE, KERNAL_RESET_SHARE]]); + const ioWrites = []; + const ramWrites = []; + const machine = { ram, io, ioWrites, ramWrites, cr: FUNCTION_ROM_AT_8000 }; + + const bank = (address) => { + const share = io.get(MMU_RAM_SHARE); + const sharedTop = [0x0400, 0x1000, 0x2000, 0x4000][share & 3]; + if (share & 0x04 && address < sharedTop) return 0; + return (machine.cr >> 6) & 1; + }; + const ioMapped = (address) => address >= 0xd000 && address < 0xe000 && !(machine.cr & 0x01); + const romAt = (address) => { + if (address >= 0x8000 && address < 0xc000) return ((machine.cr >> 2) & 3) === 2 ? cart.roml : null; + if (address >= 0xc000) return ((machine.cr >> 4) & 3) === 2 ? cart.romh : null; + return null; + }; + const systemRom = (address) => + (address >= 0x4000 && address < 0x8000 && !(machine.cr & 0x02)) || + (address >= 0x8000 && address < 0xc000 && ((machine.cr >> 2) & 3) < 2) || + (address >= 0xc000 && ((machine.cr >> 4) & 3) < 2 && !ioMapped(address)); + + machine.bus = { + read(address) { + if (address === MMU_CR) return machine.cr; + if (ioMapped(address)) { + if (address >= BLOCK) return device.read(address - BLOCK); + return io.get(address) ?? 0xff; + } + const rom = romAt(address); + if (rom) return rom[address & 0x1fff]; + if (systemRom(address)) throw new Error(`the boot ROM read the C128's own ROM at $${address.toString(16)}`); + return ram[bank(address)][address]; + }, + write(address, value) { + if (address === MMU_CR) { + machine.cr = value; + return; + } + if (ioMapped(address)) { + ioWrites.push([address, value]); + if (address >= BLOCK) device.write(address - BLOCK, value); + else io.set(address, value); + return; + } + const b = bank(address); + ramWrites.push([b, address]); + ram[b][address] = value; + }, + }; + return machine; +} + +function bootImage(pages, last = PAGE) { + const image = new Uint8Array((pages - 1) * PAGE + last); + for (let i = 0; i < image.length; i++) image[i] = (i * 7 + (i >> 8)) & 0xff; + return image; +} + +// The kernal's cold start calls the function ROM with IRQs on and the ROM mapped at $8000. +function boot({ load = 0x2b19, pages = 3, entry = 0x2f3c, image = bootImage(pages), device: options, limit } = {}) { + const device = blockDevice(image, options); + const machine = c128(cartridge({ load, pages, entry }), device); + const cpu = new Mos6502(machine.bus); + cpu.pc = COLD_START; + cpu.p &= ~0x04; + return { cpu, machine, device, image, load, entry, limit }; +} + +const run = (b, done) => b.cpu.run(() => done(b), b.limit ?? 5_000_000); +const entered = (b) => b.cpu.pc === b.entry; +const borderFlashes = (b) => b.machine.ioWrites.filter(([a]) => a === 0xd020).length >= 3; + +function assertLoaded(b) { + const bank1 = b.machine.ram[1]; + const expected = new Uint8Array(Math.ceil(b.image.length / PAGE) * PAGE); + expected.set(b.image); + assert.deepEqual(bank1.subarray(b.load, b.load + expected.length), expected); +} + +test('the boot ROM pulls every page into bank 1 and enters GEOS with I/O in, the screen on and device 8 current', () => { + const b = boot({ pages: 150, image: bootImage(150, 0x19) }); + run(b, entered); + assertLoaded(b); + assert.equal(b.machine.cr, 0x7e, 'bank 1 with I/O'); + assert.equal(b.machine.io.get(0xd011), 0x1b); + assert.equal(b.machine.io.get(0xd030), 0, '1 MHz'); + assert.equal(b.machine.ram[0][0xba], 8, 'curDevice, in the shared zero page'); + assert.equal(b.device.commands, 150); +}); + +test('the boot ROM writes no bank 0 RAM outside the shared bottom 8K, and nothing in bank 1 but the image', () => { + const b = boot(); + run(b, entered); + const pagesEnd = b.load + 3 * PAGE; + const stray = b.machine.ramWrites.filter(([bank, a]) => (bank === 0 ? a >= 0x2000 : a < b.load || a >= pagesEnd)); + assert.deepEqual(stray, []); +}); + +test('the boot ROM waits out a busy device and a result still naming the previous command', () => { + const b = boot({ device: { busyReads: 40, staleEcho: 3 } }); + run(b, entered); + assertLoaded(b); +}); + +// Tokens run 1, 2, 3, so for the first and third pages the previous token with bit 0 ORed in reads as this one. +test('the boot ROM reads ECHO until two reads agree, so a glitch cannot pass off the previous page as this one', () => { + const b = boot({ device: { staleEcho: 2, echoGlitch: 0x01 } }); + run(b, entered); + assertLoaded(b); +}); + +test('the boot ROM reads each register until two reads agree, so a glitched read does not reach memory', () => { + for (const glitchEvery of [5, 7, 11]) { + const b = boot({ device: { glitchEvery } }); + run(b, entered); + assertLoaded(b); + } +}); + +test('a page the device refuses stops the boot with the screen on and the border flashing', () => { + const b = boot({ pages: 4, image: bootImage(3) }); + run(b, borderFlashes); + assert.equal(b.machine.io.get(0xd011), 0x1b); + assert.notEqual(b.cpu.pc, b.entry); + assert.equal(b.device.commands, 4); +}); + +test('a device that never finishes stops the boot instead of hanging it', () => { + const b = boot({ device: { neverDone: true }, limit: 20_000_000 }); + run(b, borderFlashes); + assert.equal(b.device.commands, 1); +}); From 1ec98c3bc2fb5f8ee87162fe61f66e9e33af6819 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 10:43:48 -0500 Subject: [PATCH 13/17] fix: GEOS 128's boot ROM is not overwritten by the GeoRAM preload With no crt loaded, AllocRAMBanks takes RAM_Image as free, so the GeoRAM handler, which starts before the C128 resets, loaded georam.ram over the boot ROM and the boot image. The C128 then found no function ROM and booted to BASIC. GEOS 128 now lists its ROM as a C128 crt's two chips, ending past the boot image, and launches through SetC128Type. GEOS 64 is unaffected: its PRG has been transferred before the handler starts. --- Source/Teensy/GeosLauncher.ino | 19 +++++++++++++------ Source/Teensy/MinimalBoot/Common/C128Crt.h | 9 --------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index efaa8ae3c..77dd65a1b 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -257,6 +257,17 @@ FLASHMEM static bool GeosSetUpImage(const struct GeosLauncher *Launcher, const c return GeosUse(Launcher, CopyName, BootName); } +// The boot ROM and the boot image must outlast the launch in RAM_Image, which the RAM expansion handlers take as +// free unless a crt's chips are in it: so they are listed as a C128 crt's two chips, ending past the image. +FLASHMEM static bool GeosUseC128Boot(const uint8_t *ImageEnd, StructMenuItem *Item) +{ + CrtChips[0] = { RAM_Image, 0x8000, GEOS128_BOOT_CHIP_SIZE, 0 }; + CrtChips[1] = { RAM_Image + GEOS128_BOOT_CHIP_SIZE, 0xC000, GEOS128_BOOT_CHIP_SIZE, 0 }; + NumCrtChips = 2; + ptrRAM_ImageEnd = (uint8_t *)ImageEnd; + return SetC128Type(Item); +} + FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char *Name, StructMenuItem *Item) { char Path[MaxNamePathLength]; @@ -267,6 +278,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * bool Changed, ClockSet, Built; File Image; + FreeCrtChips(); GeosPath(Path, sizeof Path, Launcher, Name); Image = SD.open(Path, FILE_WRITE_BEGIN); if (!Image) @@ -287,12 +299,7 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); return false; } - if (Launcher->Machine == GeosC128) - { - SetC128Image(RAM_Image, RAM_Image + GEOS128_BOOT_CHIP_SIZE, GEOS128_BOOT_CHIP_SIZE); - Item->ItemType = rtBinC128; - return true; - } + if (Launcher->Machine == GeosC128) return GeosUseC128Boot(Boot + Length, Item); Item->ItemType = rtFilePrg; Item->Code_Image = RAM_Image; Item->Size = Length; diff --git a/Source/Teensy/MinimalBoot/Common/C128Crt.h b/Source/Teensy/MinimalBoot/Common/C128Crt.h index 36bbdd4a7..cfb0373a5 100644 --- a/Source/Teensy/MinimalBoot/Common/C128Crt.h +++ b/Source/Teensy/MinimalBoot/Common/C128Crt.h @@ -49,12 +49,3 @@ static void LaunchC128Cart() LOROM_Mask = C128Map.ROMLMask; HIROM_Mask = C128Map.ROMHMask; } - -// A C128 cart the TR built in memory rather than read from a crt, launched as LaunchC128Cart launches one -static inline void SetC128Image(const uint8_t *ROML, const uint8_t *ROMH, uint16_t ChipSize) -{ - C128Map.ROML = ROML; - C128Map.ROMH = ROMH; - C128Map.ROMLMask = ChipSize - 1; - C128Map.ROMHMask = ChipSize - 1; -} From 1bdeaf0cd6f7fb56852a3421adbac83ba9136dde Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 12:52:04 -0500 Subject: [PATCH 14/17] fix: launching GEOS 128 no longer copies its menu record over the boot ROM --- Source/Teensy/GeosLauncher.ino | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index 77dd65a1b..2954eb9b7 100644 --- a/Source/Teensy/GeosLauncher.ino +++ b/Source/Teensy/GeosLauncher.ino @@ -299,9 +299,10 @@ FLASHMEM static bool GeosStart(const struct GeosLauncher *Launcher, const char * SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); return false; } + // HandleExecution copies a built-in item's Code_Image into RAM_Image unless it already points there + Item->Code_Image = RAM_Image; if (Launcher->Machine == GeosC128) return GeosUseC128Boot(Boot + Length, Item); Item->ItemType = rtFilePrg; - Item->Code_Image = RAM_Image; Item->Size = Length; return true; } From 18aa20539582e6afc22249631ece57ec84d5b1d2 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 13:12:40 -0500 Subject: [PATCH 15/17] fix: review findings for 61cc762 A random NFC pick from a TeensyROM directory counted the directory's full size, so on a C64 it could pick the GEOS 128 row the menu hides and fail with "needs a C128". It now counts the rows the menu lists. --- Source/Teensy/nfcScan.ino | 2 +- Source/Teensy/tests/menu-selection.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Source/Teensy/nfcScan.ino b/Source/Teensy/nfcScan.ino index 48d2608b7..81f4a228c 100644 --- a/Source/Teensy/nfcScan.ino +++ b/Source/Teensy/nfcScan.ino @@ -360,7 +360,7 @@ bool nfcReadTagLaunch(uint8_t* uid, uint8_t uidLength) { //point at sub-dir LocalDirMenu = (StructMenuItem*)TeensyROMMenu[MenuNum].Code_Image; - LocalNumItems = TeensyROMMenu[MenuNum].Size/sizeof(StructMenuItem); + LocalNumItems = TeensyDirItemCount(&TeensyROMMenu[MenuNum]); } Printf_dbg("TR Dir Num = %d\n", MenuNum); } diff --git a/Source/Teensy/tests/menu-selection.test.js b/Source/Teensy/tests/menu-selection.test.js index f217c53a7..15eb3f758 100644 --- a/Source/Teensy/tests/menu-selection.test.js +++ b/Source/Teensy/tests/menu-selection.test.js @@ -25,3 +25,11 @@ test('a menu change drops the old selection along with the old item count', () = const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'Teensy.ino'), 'utf8')); assert.match(functionBody(source, 'SetMenu'), /\bSelItemFullIdx\s*=\s*0\s*;/); }); + +test('a random NFC pick from a TeensyROM directory chooses among the rows the menu lists', () => { + // On a C64 the menu hides the GEOS 128 row, which a pick over the directory's full size can still land on + const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'nfcScan.ino'), 'utf8')); + const body = functionBody(source, 'nfcReadTagLaunch'); + assert.doesNotMatch(body, /\.Size\s*\/\s*sizeof\s*\(\s*StructMenuItem\s*\)/); + assert.match(body, /\bTeensyDirItemCount\s*\(\s*&\s*TeensyROMMenu\s*\[\s*MenuNum\s*\]\s*\)/); +}); From 20f02608b2cbae22881a38824029706f8b0cb36c Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 29 Oct 2026 13:04:42 -0500 Subject: [PATCH 16/17] Apply suggestions from code review Travis's review of #76: the GEOS 128 row shows on every machine, named +C128 like the other rows that need something, and LaunchGeos's refusal covers a C64; GeosReadFile, GeosBuildBootImage and Geos128SetClock keep FLASHMEM now that they have a second caller. --- Source/Teensy/DriveDirLoad.ino | 2 +- Source/Teensy/FileTransfer.ino | 2 +- Source/Teensy/GEOS/GeosBoot.h | 6 +++--- Source/Teensy/MainMenuItems.h | 2 +- Source/Teensy/Teensy.ino | 8 -------- Source/Teensy/nfcScan.ino | 2 +- Source/Teensy/tests/menu-selection.test.js | 8 -------- 7 files changed, 7 insertions(+), 23 deletions(-) diff --git a/Source/Teensy/DriveDirLoad.ino b/Source/Teensy/DriveDirLoad.ino index 2be4ba34b..670750f3e 100644 --- a/Source/Teensy/DriveDirLoad.ino +++ b/Source/Teensy/DriveDirLoad.ino @@ -252,7 +252,7 @@ FLASHMEM void HandleExecution() if(strcmp(MenuSelCpy.Name, UpDirString)==0) MenuChange(); //only 1 level, returning to root else { - SetMenu((StructMenuItem*)MenuSelCpy.Code_Image, TeensyDirItemCount(&MenuSelCpy)); + SetMenu((StructMenuItem*)MenuSelCpy.Code_Image, MenuSelCpy.Size/sizeof(StructMenuItem)); strcat(DriveDirPath, MenuSelCpy.Name); //append selected dir name } return; diff --git a/Source/Teensy/FileTransfer.ino b/Source/Teensy/FileTransfer.ino index 83d75d85f..f775f955a 100644 --- a/Source/Teensy/FileTransfer.ino +++ b/Source/Teensy/FileTransfer.ino @@ -275,7 +275,7 @@ FLASHMEM void SendTRDirectory(const char *dirName, StructMenuItem *TROMMenu, uin if(strcmp(TROMMenu[itemNum].Name, UpDirString)!=0) // Skip Up Dir entries { SendDirInfo(TROMMenu[itemNum].Name, dirName); - SendTRDirectory(TROMMenu[itemNum].Name, (StructMenuItem*)TROMMenu[itemNum].Code_Image, TeensyDirItemCount(&TROMMenu[itemNum])); + SendTRDirectory(TROMMenu[itemNum].Name, (StructMenuItem*)TROMMenu[itemNum].Code_Image, TROMMenu[itemNum].Size/sizeof(StructMenuItem)); } } else diff --git a/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h index 49a709787..abb4d6ced 100644 --- a/Source/Teensy/GEOS/GeosBoot.h +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -110,7 +110,7 @@ static bool GeosReplaceTransport(uint8_t *Driver, const struct GeosDiskDriver *W } // 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) +FLASHMEM 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]; @@ -275,7 +275,7 @@ static const uint8_t Geos128StartUp[] = 0xa9, 0x17, 0x8d, 0x18, 0x85, // day 23 }; -static void Geos128SetClock(uint8_t *Run, const struct GeosDate *Now) +FLASHMEM static void Geos128SetClock(uint8_t *Run, const struct GeosDate *Now) { const uint8_t StartUp[sizeof Geos128StartUp] = { @@ -345,7 +345,7 @@ static bool GeosBreakResetKey(struct GeosDisk *Disk, uint8_t *Image, uint32_t Le // The boot file as GEOS starts from it, load address first: the disk driver's transport replaced, any reset key // broken, and the clock set to Now where the start-up code is one the target knows -static inline bool GeosBuildBootImage(struct GeosDisk *Disk, const struct GeosDate *Now, uint8_t *Out, uint32_t Capacity, +FLASHMEM static inline bool GeosBuildBootImage(struct GeosDisk *Disk, const struct GeosDate *Now, uint8_t *Out, uint32_t Capacity, uint32_t *Length, bool *ClockSet) { const struct GeosTarget *Target = Disk->Target; diff --git a/Source/Teensy/MainMenuItems.h b/Source/Teensy/MainMenuItems.h index 33154639a..46e27c685 100644 --- a/Source/Teensy/MainMenuItems.h +++ b/Source/Teensy/MainMenuItems.h @@ -187,7 +187,7 @@ StructMenuItem dirUtilities[] = rtFilePrg , IOH_Swiftlink , (char*)"Simple SwiftLink Term (dev ref)" , (uint8_t*)SST_prg , sizeof(SST_prg) , rtFilePrg , IOH_TR_BASIC , (char*)"Segment+Dot Matrix Digital Clock" , (uint8_t*)shclock_tr_prg , sizeof(shclock_tr_prg) , rtGEOS , IOH_GeoRAM , (char*)"GEOS 64" , (uint8_t*)&Geos64Launcher , sizeof(Geos64Launcher) , - rtGEOS , IOH_GeoRAM , (char*)"GEOS 128" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , //last: hidden unless on a C128 + rtGEOS , IOH_GeoRAM , (char*)"GEOS 128 +C128" , (uint8_t*)&Geos128Launcher , sizeof(Geos128Launcher) , // rtFilePrg , IOH_Swiftlink , (char*)"V-1541.19k.sl +SwiftLink ", (uint8_t*)v1541wrap_prg , sizeof(v1541wrap_prg) , }; diff --git a/Source/Teensy/Teensy.ino b/Source/Teensy/Teensy.ino index 174fb9f21..3530c17f6 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -556,14 +556,6 @@ FLASHMEM bool HostIsC128() return VidTODClksKnown & IO1[wRegVid_TOD_Clks] & rvtcC128; } -// The rows of a TeensyROM menu directory this machine can run: the last Utilities row starts GEOS 128 -FLASHMEM uint16_t TeensyDirItemCount(const StructMenuItem *DirEntry) -{ - uint16_t NumItems = DirEntry->Size/sizeof(StructMenuItem); - if (DirEntry->Code_Image == (uint8_t*)dirUtilities && !HostIsC128()) NumItems--; - return NumItems; -} - // Whether a card is in the slot, from card detect on DAT3. Takes the pin back from the // SD controller first, so it reads true whoever had it last, a failed SD.begin included. // The pin is left a pulldown input, as the SD library itself leaves it when a card is diff --git a/Source/Teensy/nfcScan.ino b/Source/Teensy/nfcScan.ino index 81f4a228c..48d2608b7 100644 --- a/Source/Teensy/nfcScan.ino +++ b/Source/Teensy/nfcScan.ino @@ -360,7 +360,7 @@ bool nfcReadTagLaunch(uint8_t* uid, uint8_t uidLength) { //point at sub-dir LocalDirMenu = (StructMenuItem*)TeensyROMMenu[MenuNum].Code_Image; - LocalNumItems = TeensyDirItemCount(&TeensyROMMenu[MenuNum]); + LocalNumItems = TeensyROMMenu[MenuNum].Size/sizeof(StructMenuItem); } Printf_dbg("TR Dir Num = %d\n", MenuNum); } diff --git a/Source/Teensy/tests/menu-selection.test.js b/Source/Teensy/tests/menu-selection.test.js index 15eb3f758..f217c53a7 100644 --- a/Source/Teensy/tests/menu-selection.test.js +++ b/Source/Teensy/tests/menu-selection.test.js @@ -25,11 +25,3 @@ test('a menu change drops the old selection along with the old item count', () = const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'Teensy.ino'), 'utf8')); assert.match(functionBody(source, 'SetMenu'), /\bSelItemFullIdx\s*=\s*0\s*;/); }); - -test('a random NFC pick from a TeensyROM directory chooses among the rows the menu lists', () => { - // On a C64 the menu hides the GEOS 128 row, which a pick over the directory's full size can still land on - const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'nfcScan.ino'), 'utf8')); - const body = functionBody(source, 'nfcReadTagLaunch'); - assert.doesNotMatch(body, /\.Size\s*\/\s*sizeof\s*\(\s*StructMenuItem\s*\)/); - assert.match(body, /\bTeensyDirItemCount\s*\(\s*&\s*TeensyROMMenu\s*\[\s*MenuNum\s*\]\s*\)/); -}); From 413ce7732aec28b9108811fc310c33a1ed276012 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 29 Oct 2026 13:09:14 -0500 Subject: [PATCH 17/17] fix: review findings for 20f0260 --- Source/Teensy/tests/menu-selection.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Source/Teensy/tests/menu-selection.test.js b/Source/Teensy/tests/menu-selection.test.js index f217c53a7..c5b3aefea 100644 --- a/Source/Teensy/tests/menu-selection.test.js +++ b/Source/Teensy/tests/menu-selection.test.js @@ -25,3 +25,10 @@ test('a menu change drops the old selection along with the old item count', () = const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'Teensy.ino'), 'utf8')); assert.match(functionBody(source, 'SetMenu'), /\bSelItemFullIdx\s*=\s*0\s*;/); }); + +test('GEOS 128, listed on every machine, starts only once the menu has reported a C128', () => { + // Its boot ROM cannot run on a C64, where the menu still lists it + const source = blankComments(fs.readFileSync(path.join(FIRMWARE_ROOT, 'GeosLauncher.ino'), 'utf8')); + assert.match(functionBody(source, 'LaunchGeos'), + /^\{[^{}]*?\bif\s*\(\s*Launcher->Machine\s*==\s*GeosC128\s*&&\s*!\s*HostIsC128\s*\(\s*\)\s*\)\s*\{[^}]*\breturn\s+false\s*;/); +});