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/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/Source/Teensy/GEOS/GeosBoot.h b/Source/Teensy/GEOS/GeosBoot.h index 9d596509a..abb4d6ced 100644 --- a/Source/Teensy/GEOS/GeosBoot.h +++ b/Source/Teensy/GEOS/GeosBoot.h @@ -2,8 +2,8 @@ // 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" -#include "TRDriver.h" #define GEOS_DRIVER_AREA 0x9000 #define GEOS_DRIVER_SIZE 0xd80 @@ -14,17 +14,30 @@ #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 #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 }; 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 +76,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,22 +96,21 @@ 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; } // 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]; @@ -138,9 +146,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 +176,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 +198,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 +214,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 + +FLASHMEM static void Geos64SetDate(uint8_t *Run, const struct GeosDate *Now) +{ + Run[1] = Now->Day; + Run[6] = Now->Month; + Run[11] = Now->Year; +} + +FLASHMEM 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 +}; + +FLASHMEM 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 }; + +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 (!Date || !Clock) return false; - Date[1] = Now->Day; - Date[6] = Now->Month; - Date[11] = Now->Year; - memcpy(Clock + GEOS_CLOCK_TIME, Time, sizeof Time); + 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 +FLASHMEM 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); @@ -271,3 +387,30 @@ static bool GeosBuildBootPrg(struct GeosDisk *Disk, uint8_t *Out, uint32_t Capac *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/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..953a32101 100644 --- a/Source/Teensy/GEOS/GeosTargets.h +++ b/Source/Teensy/GEOS/GeosTargets.h @@ -1,22 +1,56 @@ // 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 "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; }; +static const struct GeosDiskDriver TRDiskDriver64 = +{ + TRDriverCode, sizeof TRDriverCode, TR_DRIVER_BASE, + 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 }, + 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/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/Source/Teensy/GeosLauncher.ino b/Source/Teensy/GeosLauncher.ino index b2278fb7e..2954eb9b7 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) @@ -257,15 +257,28 @@ 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]; struct GeosDisk Disk; uint32_t Length = 0; + const uint8_t *Boot = NULL; struct GeosDate Now = GeosToday(); - bool Changed, Built; + bool Changed, ClockSet, Built; File Image; + FreeCrtChips(); GeosPath(Path, sizeof Path, Launcher, Name); Image = SD.open(Path, FILE_WRITE_BEGIN); if (!Image) @@ -274,28 +287,38 @@ 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 = 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)) + if (!TRBlockMount(Path, Boot, Boot ? Length : 0)) { SendMsgPrintfln(PSTR("%s\r\ncould not be mounted"), Name); return false; } - Item->ItemType = rtFilePrg; + // 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->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..46e27c685 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 +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/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/Teensy.ino b/Source/Teensy/Teensy.ino index 0b07c07bd..3530c17f6 100644 --- a/Source/Teensy/Teensy.ino +++ b/Source/Teensy/Teensy.ino @@ -550,6 +550,12 @@ 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; +} + // 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 f919f1eaa..995c6b4b7 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; @@ -66,6 +73,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 +94,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 +121,17 @@ function runClock(code) { return tod; } -function bootFile(driver = stockDriver(), startUp = []) { - const body = Buffer.alloc(BODY_SIZE); - for (let i = 0; i < BODY_SIZE; i++) body[i] = (i * 31 + 7) & 0xff; - driver.copy(body, DRIVER_AT_IN_BODY); - Buffer.from(startUp).copy(body, START_AT_IN_BODY); - const file = Buffer.alloc(2 + BODY_SIZE); - file.writeUInt16LE(LOAD_ADDRESS, 0); +const GEOS64 = { load: LOAD_ADDRESS, driverAt: DRIVER_AT_IN_BODY, startUpAt: START_AT_IN_BODY }; + +function bootFile(driver = stockDriver(), startUp = [], layout = GEOS64) { + 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 + size); + file.writeUInt16LE(layout.load, 0); body.copy(file, 2); return { file, body }; } @@ -164,10 +184,17 @@ 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)]); - 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 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)]); + 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 { @@ -182,6 +209,31 @@ 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 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) { + 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 +333,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 +342,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 +351,104 @@ 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_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'); + + 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/); + 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/); + }); +}); + +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'); + 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`); + 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 24b75d574..291232f1f 100644 --- a/Source/Teensy/tests/geos_boot_harness.c +++ b/Source/Teensy/tests/geos_boot_harness.c @@ -1,9 +1,10 @@ // 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 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/GeosBoot.h" #include "../GEOS/GeosTargets.h" #define MAX_IMAGE 819200 @@ -27,35 +28,45 @@ int main(int argc, char **argv) { struct GeosDate Now = { 26, 10, 21, 15, 7 }; struct GeosDisk Disk = { 0 }; - uint32_t Length; - bool Changed; + uint32_t Length, BootSize = 0; + const uint8_t *Boot = NULL; + 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 ? &Geos128Launcher.Target : &Geos64Launcher.Target; Disk.Context = Image; Disk.Read = ReadBlock; Disk.Write = WriteBlock; - if (!GeosBuildBootPrg(&Disk, Prg, sizeof Prg, &Length) || !GeosDisableConfigure(&Disk, &Changed)) + 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, GeosSetClock(Prg, Length, &Now)); - File = fopen(argv[2], "wb"); + 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); - 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..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 }; 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; 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*;/); +}); diff --git a/Source/Teensy/tests/trblock.test.js b/Source/Teensy/tests/trblock.test.js index e73e331b1..40992fca4 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,32 @@ 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 }, () => { + 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(got[0], '1', 'the main loop ran it'); + assert.deepEqual(bytesOf(got.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}`); + } + 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 }, () => { + 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`. 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); +}); 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'); +}); 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 }); + } + }); +} diff --git a/tools/lib/mos6502.mjs b/tools/lib/mos6502.mjs new file mode 100644 index 000000000..50fe97eee --- /dev/null +++ b/tools/lib/mos6502.mjs @@ -0,0 +1,409 @@ +// 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(); + } + if (done(this)) return; + 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..1abae6d73 --- /dev/null +++ b/tools/lib/mos6502.test.mjs @@ -0,0 +1,270 @@ +// 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('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.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', () => { + 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/); +}); + +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); +});