From 40fa23dcef466cb8c5bb0c2ad90910b3c91569d7 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:22:17 +0200 Subject: [PATCH 01/10] zx81sd: clear screen and attributes on program start Confirmed on real hardware: block 6 (screen RAM) survives a reset, so programs that never call CLS themselves (many quick debug tests just PRINT sequentially) inherited leftover pixels/attributes from whatever ran before. Unlike a real Spectrum ROM cold boot, which always starts from a cleared display file. SD81_INIT_SYSVARS now clears 6144 bytes of bitmap to 0 and 768 bytes of attributes to ATTR_P right after setting up SCREEN_ADDR/ SCREEN_ATTR_ADDR, inlined rather than calling CLS to avoid depending on cls.asm being linked into every binary. Runs automatically for every zx81sd program via the existing #init hook. Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/doc/MAP.md | 24 +++++++++++++++++++++++ src/lib/arch/zx81sd/runtime/bootstrap.asm | 21 ++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index e9f2d7fef..83e6c85f7 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -558,3 +558,27 @@ exactly the 3 new instructions' bytes: `01 EF 7F` / `3E 27` / `ED 79`). This is the shared stage 1 loader used by every zx81sd program, so no program needs recompiling — just copy the updated `BOOT1.BIN` to the SD card. + +## Screen/attributes not cleared at program start — RESOLVED (2026-07-06) + +Reported after real-hardware testing: block 6 (screen RAM, $C000-$DFFF) +is physical RAM that survives a reset, unlike a real Spectrum ROM cold +boot which always starts from a cleared display file. Any zx81sd +program that doesn't call `CLS` itself (many quick debug tests just +`PRINT` sequentially) could show leftover pixels/attributes from +whatever ran before it. + +Fix: `runtime/bootstrap.asm`'s `SD81_INIT_SYSVARS` (already run via +`#init` before every program, right after setting `SCREEN_ADDR`/ +`SCREEN_ATTR_ADDR`/`ATTR_P`) now also clears the physical screen: +6144 bytes at `$C000` to 0, 768 bytes at `$D800` to the just-set +`ATTR_P` value ($38 = INK 0/PAPER 7). Same approach as the shared +`cls.asm`, inlined here to avoid depending on CLS being linked into +every binary. + +This is in the shared bootstrap, so every zx81sd binary gets it +automatically on recompilation — no per-test changes needed. Verified +by simulation: pre-filled screen/attr RAM with garbage before boot, +confirmed both regions read back as fully cleared (0 / $38) right +before the program's own first PRINT, via a Python harness tracing +PC/BC/HL through the LDIR loops. diff --git a/src/lib/arch/zx81sd/runtime/bootstrap.asm b/src/lib/arch/zx81sd/runtime/bootstrap.asm index a9ddd84f9..b98741573 100644 --- a/src/lib/arch/zx81sd/runtime/bootstrap.asm +++ b/src/lib/arch/zx81sd/runtime/bootstrap.asm @@ -79,6 +79,27 @@ SD81_INIT_SYSVARS: ld hl, $0038 ; ATTR_T=$38, MASK_T=$00 ld (ATTR_T), hl + ; Borra la pantalla física (bitmap + atributos). El bloque 6 es RAM + ; que sobrevive a un reset (no se limpia sola como al encender una + ; ROM real), así que sin esto un programa hereda el contenido que + ; dejó el anterior hasta que él mismo llame a CLS. + ld hl, $C000 + ld (hl), 0 + ld d, h + ld e, l + inc de + ld bc, 6143 + ldir + + ld hl, $D800 + ld a, (ATTR_P) + ld (hl), a + ld d, h + ld e, l + inc de + ld bc, 767 + ldir + ; Flags a cero xor a ld (FLAGS2), a From ad5312cf9d96e2f8aa8e431ec6bdf08d9e38ce96 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:52:50 +0200 Subject: [PATCH 02/10] zx81sd: add SD81 Booster double buffer library and bounce demo Wraps the FPGA's present-blit double buffer (SD81DBufOn/SD81DBufOff/ SD81WaitVSync in stdlib/dbuf.bas), controlled via port $E7's pseudo-block-8 encoding since memory-mapped IO (the classic POKE 2057 path) is disabled by boot1.asm before any compiled program runs. examples/sd81/bounce_sd81.bas ports SD81-Booster/EXAMPLES/DBUF/ bounce.asm's flicker-comparison demo to zxbasic. This is the first test of the port $E7 dbuf path (only POKE 2057 had been validated on real hardware before). Verified in simulation: SD81DBufOn(5) emits the expected OUT $E7 (A=8, B=37), border changes correctly, main loop runs many frames without hanging -- whether it actually eliminates tearing needs real hardware. Co-Authored-By: Claude Sonnet 5 --- examples/sd81/bounce_sd81.bas | 184 ++++++++++++++++++++++++++++ src/arch/zx81sd/doc/MAP.md | 41 +++++++ src/lib/arch/zx81sd/stdlib/dbuf.bas | 80 ++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 examples/sd81/bounce_sd81.bas create mode 100644 src/lib/arch/zx81sd/stdlib/dbuf.bas diff --git a/examples/sd81/bounce_sd81.bas b/examples/sd81/bounce_sd81.bas new file mode 100644 index 000000000..5f0b39cee --- /dev/null +++ b/examples/sd81/bounce_sd81.bas @@ -0,0 +1,184 @@ +' bounce_sd81.bas — SD81 Booster double buffer (dbuf) demo, port $E7 +' +' ZX BASIC port of bounce.asm (SD81-Booster/EXAMPLES/DBUF), reworked for +' the zxbasic runtime: +' - the screen ($C000), Superfast HiRes Spectrum mode and Chroma81 +' colour are already set up by tools/boot1.asm for every zx81sd +' program -- no need to POKE 2043/2044/2045 or write to $7FEF here. +' - screen + attributes are already cleared at program start by +' runtime/bootstrap.asm -- no need to clear them here either. +' - memory-mapped IO (POKE 2056/2057) is disabled by boot1.asm before +' the compiled program starts, so the double buffer is controlled +' through port $E7's pseudo-block-8 path (SD81DBufOn/SD81DBufOff, +' see stdlib/dbuf.bas) instead of the classic POKE 2057. +' +' A 16x16 ball bounces around the screen. Each frame: wait for VSYNC, +' erase the ball, waste ~6ms on purpose (simulates heavy drawing that +' crosses the beam), move, redraw. +' - dbuf ON (green border): solid image, no flicker. +' - dbuf OFF (red border): visible flicker/tearing. +' SPACE toggles the double buffer live; M freezes/unfreezes movement; +' Q quits. +' +' This is also the first real test of the port $E7 pseudo-block-8 dbuf +' path (only the POKE 2057 path had been validated on real hardware +' before this): see stdlib/dbuf.bas for the front-buffer-block notes. + +#include + +dim bally as ubyte +dim ballx as ubyte +dim dy as byte +dim dx as byte +dim moven as ubyte +dim dbufst as ubyte +dim k$ as string + +bally = 80 +ballx = 14 +dy = 2 +dx = 1 +moven = 1 +dbufst = 1 + +' PaintBall(y, x, fillVal): fills 16 rows x 2 bytes at screen row y, +' byte-column x, with fillVal (0 = erase, 255 = draw). Same pixel +' address formula as the Spectrum, but with $C0 as the screen's base +' high byte instead of Spectrum's $40 (zx81sd's screen is fixed at +' $C000, set once by boot1.asm). +sub PaintBall(y as ubyte, x as ubyte, fillVal as ubyte) + asm + PROC + LOCAL PB_LOOP, PB_PIXAD, PB_DONE + + ld a, (ix + 5) ; y + ld d, a + ld a, (ix + 7) ; x + ld e, a + ld a, (ix + 9) ; fillVal + ld c, a + ld b, 16 + +PB_LOOP: + push bc + push de + call PB_PIXAD + ld (hl), c + inc l + ld (hl), c + pop de + pop bc + inc d + djnz PB_LOOP + jr PB_DONE + +PB_PIXAD: + ld a, d + and $C0 + rrca + rrca + rrca + ld h, a + ld a, d + and 7 + or h + or $C0 ; screen base high byte ($C000) + ld h, a + ld a, d + and $38 + add a, a + add a, a + or e + ld l, a + ret + +PB_DONE: + ENDP + end asm +end sub + +' ~6ms busy loop at 3.25MHz: simulates a heavy redraw that crosses the +' video beam, so the difference between dbuf ON/OFF is visible. +sub Delay + asm + PROC + LOCAL DL_LOOP + + ld bc, 1024 +DL_LOOP: + dec bc + ld a, b + or c + jr nz, DL_LOOP + + ENDP + end asm +end sub + +' On bounce, the position is left unchanged for this frame (matching +' bounce.asm) and only the direction flips; it moves next frame. +sub Move + dim newY as integer + dim newX as integer + + if moven <> 0 then + newY = bally + dy + if newY < 0 or newY > 176 then + dy = -dy + else + bally = newY + end if + + newX = ballx + dx + if newX < 0 or newX > 30 then + dx = -dx + else + ballx = newX + end if + end if +end sub + +' Returns 0 if Q was pressed (caller should quit), 1 otherwise. +' SPACE toggles dbuf live; M freezes/unfreezes movement. +function Keys() as ubyte + k$ = INKEY$ + + if k$ = " " then + dbufst = 1 - dbufst + if dbufst <> 0 then + SD81DBufOn(5) + border 4 + else + SD81DBufOff() + border 2 + end if + do + loop while INKEY$ <> "" + end if + + if k$ = "m" or k$ = "M" then + moven = 1 - moven + do + loop while INKEY$ <> "" + end if + + if k$ = "q" or k$ = "Q" then + return 0 + end if + + return 1 +end function + +SD81DBufOn(5) +border 4 + +do + SD81WaitVSync() + PaintBall(bally, ballx, 0) + Delay + Move + PaintBall(bally, ballx, 255) +loop while Keys() <> 0 + +SD81DBufOff() +border 7 diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index 83e6c85f7..33ddda8a2 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -582,3 +582,44 @@ by simulation: pre-filled screen/attr RAM with garbage before boot, confirmed both regions read back as fully cleared (0 / $38) right before the program's own first PRINT, via a Python harness tracing PC/BC/HL through the LDIR loops. + +## SD81DBufOn/SD81DBufOff/SD81WaitVSync — double buffer library, port $E7 (2026-07-07) + +New `stdlib/dbuf.bas`: wraps the SD81 Booster's present-blit double +buffer feature. Ported from `SD81-Booster/EXAMPLES/DBUF/bounce.asm` +(reference demo authored alongside the FPGA feature), adapted for +zxbasic because memory-mapped IO is disabled by `tools/boot1.asm` +(`POKE 2056,0`) before any compiled program runs, so the classic +`POKE 2057` control path is unusable from zxbasic — everything goes +through port `$E7`'s pseudo-block-8 encoding instead (`OUT` with +A=8, B=32+frontBlk to enable, B=0 to disable). `boot1.asm` also leaves +the mapper in full-paging mode permanently, which is exactly the state +this path requires, so it works unconditionally on every zx81sd binary. + +The front buffer is *not* one of the program's own blocks 0-5 (code/ +data, see `_PAGE_MAP` in `backend/main.py`), nor block 6 (the live +screen) nor block 7 (data banking, `Map()` in `mcu.bas`) — it lives in +the FPGA's private screen shadow RAM, addressed with its own +independent 0-7 index. Per the hardware spec, blocks 4/5 are the +recommended choice and 0-3/6-7 should be avoided; the library and demo +use 5. + +`examples/sd81/bounce_sd81.bas` (mirrored to the private repo as +`tests_debug/bounce_sd81.bas`, packaged as `BOUNCE.P`/`BOUNCEP8.BIN`) is +a straight port of the reference demo's logic (erase/~6ms delay/move/ +draw synced to VSYNC, SPACE toggles dbuf live, M freezes movement, Q +quits) — much shorter than the original because zx81sd's boot already +sets up the screen address, Superfast HiRes Spectrum mode, Chroma81 +colour and the initial screen/attribute clear (see the previous +section), none of which bounce.asm can assume when running standalone. + +This is the **first test of the port $E7 dbuf path** — only the +`POKE 2057` path had been validated on real hardware before this. +Verified so far only in simulation (Python `z80` harness): confirmed +`SD81DBufOn(5)` emits exactly `OUT $E7` with A=8/B=37 (bit5 enable, +front=5) and the border changes to green as expected; the main loop +runs correctly through many frames (10M+ simulated T-states) with +`PaintBall` writing the expected 32-byte block each time. The simulator +doesn't model the FPGA's video timing/blit, so whether the dbuf +actually eliminates tearing on screen can only be confirmed on real +hardware — that's the point of this demo. diff --git a/src/lib/arch/zx81sd/stdlib/dbuf.bas b/src/lib/arch/zx81sd/stdlib/dbuf.bas new file mode 100644 index 000000000..39a7453da --- /dev/null +++ b/src/lib/arch/zx81sd/stdlib/dbuf.bas @@ -0,0 +1,80 @@ +' SD81 Booster — double buffer (present-blit) control, port $E7 pseudo-block 8. +' +' Hardware spec (SD81 Booster, ay author): while enabled, the video +' stops reading the live screen block (HFILE) and instead scans out a +' private front buffer inside the FPGA's own screen shadow RAM. On every +' vertical blanking period the hardware copies the live screen into that +' front buffer, so what's on screen is always a complete snapshot taken +' at the last VSYNC -- no tearing even if the program erases/redraws in +' the middle of a scan. +' +' Two control paths exist on real hardware: POKE 2057 (memory-mapped IO) +' and this port $E7 pseudo-block-8 path. zx81sd's boot (tools/boot1.asm) +' always disables memory-mapped IO (POKE 2056,0) and leaves the $E7 +' mapper in full-paging mode before jumping to the compiled program, so +' every zx81sd binary is in exactly the state the port path needs -- the +' POKE 2057 path is NOT usable from zxbasic (memory-mapped IO is off). +' +' Front buffer block: this is NOT one of the program's own blocks 0-5 +' (code/data, mapped via boot1.asm/vectors.asm) nor block 6 (the live +' screen, $C000) nor block 7 (data banking, see Map() in mcu.bas) -- the +' front buffer lives in the FPGA's private screen shadow RAM, addressed +' with its own independent 0-7 block index. The hardware spec recommends +' 4 or 5 and explicitly says to avoid 0-3 and 6-7. +' +' Untested on the port $E7 path as of this writing (the POKE 2057 path +' was the one validated on real hardware) -- this library is meant to be +' its first test. See examples/sd81/bounce_sd81.bas. + +' Turns the double buffer ON. frontBlk: 0-7, see notes above (4 or 5 +' recommended). +sub fastcall SD81DBufOn(frontBlk as ubyte) + asm + ; A = frontBlk (fastcall) + and 7 + or 32 ; bit5 = enable + ld b, a + ld a, 8 ; pseudo-block 8 (D3=1, D2:0=0) + ld c, $E7 + out (c), a + end asm +end sub + +' Turns the double buffer OFF. +sub SD81DBufOff() + asm + ld b, 0 + ld a, 8 + ld c, $E7 + out (c), a + end asm +end sub + +' Waits for the rising edge of VSYNC (bit 0 of port $AF): if currently +' inside the vsync pulse, waits for it to end, then waits for the next +' one to start. This is the point right after the double buffer's +' automatic blit finishes (the copy starts as soon as the visible area +' ends), so it's the right place to erase/redraw when using SD81DBufOn. +' +' Note: this is a different wait than VSYNC_TICK/PAUSE (which just waits +' for at least one pulse since the last read, using bits 6-1 as a pulse +' counter) -- this one specifically detects the edge to align with the +' double buffer's blit timing. +sub SD81WaitVSync() + asm + PROC + LOCAL SDVS_INSIDE, SDVS_WAIT + +SDVS_INSIDE: + in a, ($AF) + rrca + jr c, SDVS_INSIDE ; still inside the vsync pulse: wait for it to end + +SDVS_WAIT: + in a, ($AF) + rrca + jr nc, SDVS_WAIT ; wait for the next pulse to start + + ENDP + end asm +end sub From 4b009597c49cc244128eb988c79314b5614f7f62 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:05:15 +0200 Subject: [PATCH 03/10] zx81sd: fix bounce_sd81 bouncing early at y~128 instead of 176 Move() widened ubyte(bally) + byte(dy) to integer for the range check. The generated code sign-extends the 8-bit sum's own bit 7 to fill the high byte, which is wrong when one operand is genuinely unsigned and can exceed 127 (bally=126, dy=2 -> 8-bit sum 128 gets read as -128). Fixed by keeping Move() entirely in ubyte/mod-256 arithmetic with an unsigned comparison, matching bounce.asm's own "add a,b / cp 177" approach instead of widening to a signed type at all. Co-Authored-By: Claude Sonnet 5 --- examples/sd81/bounce_sd81.bas | 15 +++++++++++---- src/arch/zx81sd/doc/MAP.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/examples/sd81/bounce_sd81.bas b/examples/sd81/bounce_sd81.bas index 5f0b39cee..8a60112fc 100644 --- a/examples/sd81/bounce_sd81.bas +++ b/examples/sd81/bounce_sd81.bas @@ -117,20 +117,27 @@ end sub ' On bounce, the position is left unchanged for this frame (matching ' bounce.asm) and only the direction flips; it moves next frame. +' +' Kept entirely in ubyte (8-bit, mod-256) arithmetic on purpose, same +' as bounce.asm's "add a,b / cp 177": mixing ubyte+byte and widening to +' integer sign-extends the 8-bit sum based on its own bit 7 (bally=126, +' dy=2 -> 8-bit sum 128 gets read as -128, not +128), which is wrong +' here since bally is genuinely unsigned. Comparing unsigned in ubyte +' space sidesteps that entirely. sub Move - dim newY as integer - dim newX as integer + dim newY as ubyte + dim newX as ubyte if moven <> 0 then newY = bally + dy - if newY < 0 or newY > 176 then + if newY >= 177 then dy = -dy else bally = newY end if newX = ballx + dx - if newX < 0 or newX > 30 then + if newX >= 31 then dx = -dx else ballx = newX diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index 33ddda8a2..5acae22cb 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -623,3 +623,35 @@ runs correctly through many frames (10M+ simulated T-states) with doesn't model the FPGA's video timing/blit, so whether the dbuf actually eliminates tearing on screen can only be confirmed on real hardware — that's the point of this demo. + +### 2026-07-07 fix: `Move()` bouncing early (~y=128 instead of 176) + +Reported after real-hardware testing: the ball in `bounce_sd81.bas` +bounced roughly mid-screen instead of reaching the bottom, as if it +had hit an early floor. + +Root cause: `Move()`'s original version widened `bally (ubyte) + dy +(byte)` to an `integer` for the range check. The generated code +(`ld a,(_bally) / ld h,a / ld a,(_dy) / add a,h / ld l,a / add a,a / +sbc a,a / ld h,a`) computes the 8-bit sum correctly in the low byte, +then **sign-extends that 8-bit result based on its own bit 7** to fill +the high byte. That's correct if both operands were meant to be signed +bytes, but `bally` is genuinely unsigned (0-176) — `bally=126, dy=2` +gives an 8-bit sum of 128 ($80, bit 7 set), which gets sign-extended to +-128 instead of +128. `if newY < 0` then true firing the bounce 48 +pixels early. + +Fix: keep `Move()` entirely in `ubyte` (mod-256) arithmetic, matching +`bounce.asm`'s own `add a,b / cp 177` approach — never widen the +ubyte+byte sum to a signed integer at all, compare unsigned instead +(`if newY >= 177`). Verified by simulation: `bally` now climbs smoothly +through 128, 130, ... past the point where it used to bounce. + +General lesson for this port: **`ubyte + byte` widened to `integer` +sign-extends the 8-bit sum's own bit 7, not the semantically correct +signed value** — safe when both operands are small/signed, wrong when +one operand (like a screen coordinate) is genuinely unsigned and can +exceed 127. Do that arithmetic in a type wide enough from the start +(assign the ubyte to an integer/uinteger variable *before* adding), or +stay in 8-bit space with an unsigned comparison if the range allows it, +as done here. From beac62711c00f436bed0c3983f5126d0f03e1a0d Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:02:26 +0200 Subject: [PATCH 04/10] zx81sd: RST $38 waits for VSYNC and returns instead of hanging forever Interrupts stay disabled for the whole program by design, so RST $38 "should never be reached" -- but the old di+halt handler locked the machine up permanently if something did reactivate them. Now waits for the next VSYNC pulse (port $AF) and rets, matching what a real Z80 HALT does (resume after the next interrupt). Found along the way: runtime/vectors.asm was dead code, never #include'd by anything -- the real RST vector table is emitted directly in backend/main.py::emit_prologue() (added in the same original commit, apparently never wired together). A first attempt editing vectors.asm compiled fine but had zero effect on the output binary. Fixed the actual source in main.py and deleted the misleading duplicate. Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/backend/main.py | 20 ++++++-- src/arch/zx81sd/doc/MAP.md | 49 ++++++++++++++++++ src/lib/arch/zx81sd/runtime/vectors.asm | 66 ------------------------- 3 files changed, 64 insertions(+), 71 deletions(-) delete mode 100644 src/lib/arch/zx81sd/runtime/vectors.asm diff --git a/src/arch/zx81sd/backend/main.py b/src/arch/zx81sd/backend/main.py index 6efddcb0b..e5fa3e574 100644 --- a/src/arch/zx81sd/backend/main.py +++ b/src/arch/zx81sd/backend/main.py @@ -18,7 +18,8 @@ # --------------------------------------------------------------------------- # Memory map (flat, ORG $0000): -# $0000-$00FF vectors.asm — RST vectors + padding up to $0100 +# $0000-$00FF RST vector table, emitted directly below (emit_prologue) +# + padding up to $0100 # $0100-$0FFF stage 2 bootstrap (prologue) + system routines # $1000-$7FFF ZX BASIC runtime + user code (28 KB) # $8000-$80FF runtime sysvars @@ -27,7 +28,7 @@ # $D800-$DAFF screen attributes # $E000-$FFFF block 7 — data banking (maps, sprites...) -_ORG = 0x0000 # the binary starts at $0000 (vectors.asm pads up to $0100) +_ORG = 0x0000 # the binary starts at $0000 (RST vectors pad up to $0100) _STAGE2_ENTRY = 0x0100 # stage 2 bootstrap's entry point _HEAP_ADDR = 0x8100 # heap in the data area ($8100-$BFFF) @@ -98,7 +99,7 @@ def emit_prologue() -> list[str]: Program prologue for ZX81 + SD81 Booster. Structure of the generated binary (ORG $0000): - $0000-$00FF vectors.asm (RST vectors, included from sysvars.asm) + $0000-$00FF RST vector table (emitted directly below) $0100 START_LABEL (stage 2 bootstrap's entry point) - mapping blocks 1-5 to their final pages - SP = $7FFF @@ -155,8 +156,17 @@ def emit_prologue() -> list[str]: output.append("di") output.append("halt") output.append("org $0038") - output.append("di") # $0038: RST $38 IM1 — permanent DI, never reached - output.append("halt") + # $0038: RST $38 (IM1 interrupt). Interrupts stay disabled for the + # whole program, so this should never really be reached; if + # something did reactivate them, behave like a real Z80 HALT + # instead of hanging forever (di+halt): wait for the next VSYNC + # pulse (port $AF, bits 6-1 = pulse counter, reset on read) and + # return, resuming right after the RST $38 that got us here. + output.append(".core.__RST38_WAIT:") + output.append("in a, ($AF)") + output.append("and $7E") + output.append("jr z, .core.__RST38_WAIT") + output.append("ret") output.append("org $0066") output.append("retn") # $0066: NMI disabled, but the vector must exist diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index 5acae22cb..684b5f924 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -655,3 +655,52 @@ exceed 127. Do that arithmetic in a type wide enough from the start (assign the ubyte to an integer/uinteger variable *before* adding), or stay in 8-bit space with an unsigned comparison if the range allows it, as done here. + +### 2026-07-14: RST $38 behaves like a real HALT instead of hanging forever + +User request: interrupts stay disabled for the whole program (by +design), so RST $38/IM1 "should never be reached" — but if something +did reactivate them, the old `di`+`halt` handler locked the machine up +permanently. Changed it to wait for the next VSYNC pulse (port `$AF`, +bits 6-1 = pulse counter, reset on read) and `ret`, approximating what +a real Z80 HALT does (resume after the next interrupt) instead of +hanging. + +**Found along the way: `src/lib/arch/zx81sd/runtime/vectors.asm` was +dead code, never `#include`d by anything.** The actual RST vector table +is emitted directly as Python-generated ASM text in +`backend/main.py::emit_prologue()` (`org $0038` / `di` / `halt`, etc.) +— a duplicate of what `vectors.asm` described, added in the same +original commit but apparently never wired together. First attempt at +this fix edited `vectors.asm` and got silently ignored (compiled +binaries kept the old `di`/`halt` bytes at $0038) until byte-inspecting +the actual output caught it. Fixed the real source in `main.py` instead +and deleted `vectors.asm` to remove the misleading duplicate — if +`vectors.asm` (or any other zx81sd runtime `.asm` file) needs editing +again, first grep for whether it's actually `#include`d/`#require`d by +anything, don't assume a file with a plausible name and location is +live. + +Verified: compiled binary now has `DB AF E6 7E 28 FA C9` at `$0038` +(`in a,($AF)` / `and $7E` / `jr z,-6` / `ret`) instead of `F3 76` +(`di`/`halt`); simulated a stray RST $38 with a port callback that +returns "no pulse" 3 times then "pulse" — confirmed the loop reads the +port exactly 4 times (3 waits + the one that breaks it) before +falling through to `ret`. + +**Also discussed and shelved: returning to the ZX81 BASIC prompt after +a program ends**, instead of the permanent `di`/`halt` at +`__END_PROGRAM`. Concluded not practically feasible with the current +architecture: `tools/boot1.asm` remaps block 0 away from the ZX81's own +ROM/RAM to run the compiled program, and `src/arch/zx81sd/doc/ +USAGE.md`'s own loader documentation notes the mapper's full-paging +mode "doesn't go back to simple mode until the next reset" — i.e. it's +a one-way transition by design, with no discovered mapper page value +that passes through to the original ZX81 memory. Even if that existed, +the original BASIC session's own RAM (variables, display file, stack) +has already been overwritten by the compiled program by the time it +runs, so there's no state left to return *to* — the closest realistic +equivalent is a full reset into a fresh BASIC prompt, which RST 0 +already provides (restarts the current program, not a reset to BASIC). +Revisit only if real value is identified in a genuine "soft reset to +fresh BASIC" exit path distinct from what already exists. diff --git a/src/lib/arch/zx81sd/runtime/vectors.asm b/src/lib/arch/zx81sd/runtime/vectors.asm deleted file mode 100644 index 64a3df145..000000000 --- a/src/lib/arch/zx81sd/runtime/vectors.asm +++ /dev/null @@ -1,66 +0,0 @@ -; VECTORS — Tabla de vectores RST del Z80 para ZX81 + SD81 Booster -; -; Ocupa $0000-$00FF (primer bloque de la zona de sistema). -; Reemplaza la ROM del ZX81 (bloque 0 remapeado a página 8 por el stage 1). -; -; Tras el remapeo de bloque 0, el stage 1 salta a $0100 (stage 2 bootstrap). -; El vector RST 0 redirige allí también, por si algo fuerza un reset software. - - push namespace core - - org $0000 - -; $0000 — RST 0 / Reset software: salta al stage 2 - jp $0100 - - defs $0008 - $, $00 - -; $0008 — RST $08 (handler de errores Spectrum): redirige a __ERROR - jp __ERROR - - defs $0010 - $, $00 - -; $0010 — RST $10 (PRINT A en Spectrum): no usado, cuelga de forma segura - di - halt - - defs $0018 - $, $00 - -; $0018 — RST $18: no usado - di - halt - - defs $0020 - $, $00 - -; $0020 — RST $20: no usado - di - halt - - defs $0028 - $, $00 - -; $0028 — RST $28 (FP calculator Spectrum): stub, no debe llegarse aquí -; con __ZXB_NO_FLOAT activo el compilador no genera RST $28 - di - halt - - defs $0030 - $, $00 - -; $0030 — RST $30: no usado - di - halt - - defs $0038 - $, $00 - -; $0038 — RST $38 (IM1 interrupt): DI permanente, nunca debería llegar - di - halt - - defs $0066 - $, $00 - -; $0066 — NMI handler: NMI desactivadas por el cargador BASIC (modo FAST), -; pero el vector debe existir por si acaso - retn - - defs $0100 - $, $00 ; relleno hasta inicio del stage 2 en $0100 - - pop namespace From d670b641f2ea85d67f53e6c4b3a1861b6b5badf6 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:07:07 +0200 Subject: [PATCH 05/10] zx81sd: close out the exit-to-BASIC investigation Confirmed with the SD81 Booster's hardware author: there is no software-triggerable reset in the FPGA (nRESET is a plain input wire), so a genuine "exit to BASIC" isn't possible from a running zx81sd program regardless of the memory-mapping concerns already noted. RST 0 (restart the current program) remains the closest equivalent. Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/doc/MAP.md | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index 684b5f924..64982b5f7 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -688,19 +688,25 @@ returns "no pulse" 3 times then "pulse" — confirmed the loop reads the port exactly 4 times (3 waits + the one that breaks it) before falling through to `ret`. -**Also discussed and shelved: returning to the ZX81 BASIC prompt after -a program ends**, instead of the permanent `di`/`halt` at -`__END_PROGRAM`. Concluded not practically feasible with the current -architecture: `tools/boot1.asm` remaps block 0 away from the ZX81's own -ROM/RAM to run the compiled program, and `src/arch/zx81sd/doc/ -USAGE.md`'s own loader documentation notes the mapper's full-paging -mode "doesn't go back to simple mode until the next reset" — i.e. it's -a one-way transition by design, with no discovered mapper page value -that passes through to the original ZX81 memory. Even if that existed, -the original BASIC session's own RAM (variables, display file, stack) -has already been overwritten by the compiled program by the time it -runs, so there's no state left to return *to* — the closest realistic -equivalent is a full reset into a fresh BASIC prompt, which RST 0 -already provides (restarts the current program, not a reset to BASIC). -Revisit only if real value is identified in a genuine "soft reset to -fresh BASIC" exit path distinct from what already exists. +**Also discussed and closed: returning to the ZX81 BASIC prompt after a +program ends**, instead of the permanent `di`/`halt` at +`__END_PROGRAM`. Not feasible, for two independent reasons: + +1. **No software reset path exists in the FPGA at all** (confirmed by + the hardware's author): `nRESET` in `SD81.v` is a plain input wire + driven by the board's own reset circuit — nothing in the Z80-facing + register set can pulse it. There is no port write or mapper page + value that re-triggers the power-on sequence from software. +2. Even if there were, `tools/boot1.asm` remaps block 0 away from the + ZX81's own ROM/RAM to run the compiled program, and `USAGE.md`'s own + loader documentation notes the mapper's full-paging mode "doesn't go + back to simple mode until the next reset" — a one-way transition by + design. And the original BASIC session's own RAM (variables, display + file, stack) has already been overwritten by the compiled program by + the time it runs, so there's no state left to return *to* anyway. + +Bottom line: the closest thing to "exit to BASIC" a zx81sd program can +offer is `RST 0` (restarts the *current* program, already wired in the +vector table above) — a genuine return to a ZX81 BASIC prompt requires +the physical reset. Not revisiting unless the hardware itself gains a +software-triggerable reset line. From bb21b030d9d433568443bfc145bb3039c2c7cc5a Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:17:35 +0200 Subject: [PATCH 06/10] zx81sd: fix array.asm corrupting code on multi-dimensional arrays The shared __ARRAY routine (zx48k/runtime/array/array.asm, used by every zxbasic program with 2D+ arrays) uses the Spectrum's MEMBOT system variable (23698) as scratch storage for its internal pointers. On zx81sd that address falls inside the program's own compiled code (block 2) instead of safe RAM, silently corrupting a few bytes on every array access -- a general zx81sd bug, not specific to any one program, that simply hadn't been exercised by earlier examples. Found while porting a third-party game (heavy 2D-array user) and traced live on real hardware: a write breakpoint on the stack pointer's own descent caught a PUSH corrupting FP_STKEND, which cascaded into the FP calculator operating on garbage addresses and eventually executing screen memory as code. Fix: new src/lib/arch/zx81sd/runtime/array/array.asm override (only change: LBOUND_PTR points at a new dedicated ARRAY_SCRATCH sysvar instead of the hardcoded Spectrum address). Verified by simulation: 1.2 billion T-states with no writes to the old address and no crashes, versus reliably crashing within a few hundred million before the fix. Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/doc/MAP.md | 40 +++++ src/lib/arch/zx81sd/runtime/array/array.asm | 178 ++++++++++++++++++++ src/lib/arch/zx81sd/runtime/sysvars.asm | 12 +- 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/lib/arch/zx81sd/runtime/array/array.asm diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index 64982b5f7..db3cdfcd7 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -710,3 +710,43 @@ offer is `RST 0` (restarts the *current* program, already wired in the vector table above) — a genuine return to a ZX81 BASIC prompt requires the physical reset. Not revisiting unless the hardware itself gains a software-triggerable reset line. + +## `array.asm` corrupted code on every multi-dimensional array access — RESOLVED (2026-07-22) + +Found while porting a third-party game (ZXOilPanic, heavy user of 2D +arrays like `sprite(29,3)`, `down(4,1)`, etc.) to zx81sd. Caught live on +real hardware with EightyOne's debugger: a write breakpoint on the Z80 +stack pointer's own descent showed a `PUSH DE` corrupting `FP_STKEND` +(`$8024`), which cascaded into the floating-point calculator operating +on garbage addresses, eventually executing screen/attribute memory as +code and crashing (`RST 8`, `RST 30`, or a wild jump into `$C000+`, +depending on what garbage byte the CPU happened to land on). + +Root cause, once traced back far enough: `src/lib/arch/zx48k/runtime/ +array/array.asm` (`__ARRAY`, the shared N-dimensional array indexing +routine used by **every** zxbasic program that declares a +multi-dimensional array) uses `LBOUND_PTR EQU 23698` — the real +Spectrum's `MEMBOT` system variable — as scratch storage for 4 pointer +pairs (8 bytes: `LBOUND_PTR`/`UBOUND_PTR`/`RET_ADDR`/`TMP_ARR_PTR`). +On a real Spectrum that's safe ROM-reserved RAM. On zx81sd, address +23698 ($5C92) falls **inside the program's own compiled code** (block 2, +$4000-$5FFF) instead of a real sysvar — every array access silently +corrupted a few bytes of code there. This is a **general zx81sd port +bug**, not specific to this game: any zx81sd program using +multi-dimensional arrays was at risk, it just hadn't been exercised +heavily enough by earlier examples/tests to surface (none of them used +2D+ arrays this intensively). + +Fix: new `src/lib/arch/zx81sd/runtime/array/array.asm`, an override of +the shared file (Boriel's rule: the shared one stays untouched) with +the only change being `LBOUND_PTR EQU ARRAY_SCRATCH` instead of `EQU +23698` — `ARRAY_SCRATCH` is a new dedicated 8-byte sysvar +(`sysvars.asm`, `SYSVAR_BASE + $83`, right after `FP_MEM_AREA`, still +well within the $8000-$80FF sysvar block before the heap at $8100). + +Verified by simulation: 1.2 billion T-states with a write-monitor on +both the old MEMBOT address (zero writes now) and the two known +hang-vector addresses (`$0008`/`$0009`, `$0030`/`$0031` — never +reached). Previously, with the same binary before this fix, the +program reliably crashed within a few hundred million T-states via one +of those two RST vectors. diff --git a/src/lib/arch/zx81sd/runtime/array/array.asm b/src/lib/arch/zx81sd/runtime/array/array.asm new file mode 100644 index 000000000..ec71e353e --- /dev/null +++ b/src/lib/arch/zx81sd/runtime/array/array.asm @@ -0,0 +1,178 @@ +; vim: ts=4:et:sw=4: +; Copyleft (K) by Jose M. Rodriguez de la Rosa +; (a.k.a. Boriel) +; http://www.boriel.com +; ------------------------------------------------------------------- +; Simple array Index routine +; Number of total indexes dimensions - 1 at beginning of memory +; HL = Start of array memory (First two bytes contains N-1 dimensions) +; Dimension values on the stack, (top of the stack, highest dimension) +; E.g. A(2, 4) -> PUSH <4>; PUSH <2> + +; For any array of N dimension A(aN-1, ..., a1, a0) +; and dimensions D[bN-1, ..., b1, b0], the offset is calculated as +; O = [a0 + b0 * (a1 + b1 * (a2 + ... bN-2(aN-1)))] +; What I will do here is to calculate the following sequence: +; ((aN-1 * bN-2) + aN-2) * bN-3 + ... +; +; zx81sd override: the only change from zx48k's version is +; LBOUND_PTR's storage address. The original uses the Spectrum ROM's +; MEMBOT system variable (23698, safe scratch RAM on real hardware) -- +; on zx81sd that address falls inside the program's own compiled code +; (block 2, $4000-$5FFF) instead of a real sysvar, silently corrupting +; it on every multi-dimensional array access. ARRAY_SCRATCH (sysvars.asm) +; is a dedicated 8-byte scratch area in the zx81sd sysvar block instead. + +#include once +#include once + +#ifdef __CHECK_ARRAY_BOUNDARY__ +#include once +#endif + + push namespace core + +__ARRAY_PTR: ;; computes an array offset from a pointer + ld c, (hl) + inc hl + ld h, (hl) + ld l, c ;; HL <-- [HL] + +__ARRAY: + PROC + + LOCAL LOOP + LOCAL ARRAY_END + LOCAL TMP_ARR_PTR ; Ptr to Array DATA region. Stored temporarily + LOCAL LBOUND_PTR, UBOUND_PTR ; LBound and UBound PTR indexes + LOCAL RET_ADDR ; Contains the return address popped from the stack + +LBOUND_PTR EQU ARRAY_SCRATCH ; zx81sd: dedicated scratch, not MEMBOT +UBOUND_PTR EQU LBOUND_PTR + 2 ; Next 2 bytes for UBOUND PTR +RET_ADDR EQU UBOUND_PTR + 2 ; Next 2 bytes for RET_ADDR +TMP_ARR_PTR EQU RET_ADDR + 2 ; Next 2 bytes for TMP_ARR_PTR + + ld e, (hl) + inc hl + ld d, (hl) + inc hl ; DE <-- PTR to Dim sizes table + ld (TMP_ARR_PTR), hl ; HL = Array __DATA__.__PTR__ + inc hl + inc hl + ld c, (hl) + inc hl + ld b, (hl) ; BC <-- Array __LBOUND__ PTR + ld (LBOUND_PTR), bc ; Store it for later + +#ifdef __CHECK_ARRAY_BOUNDARY__ + inc hl + ld c, (hl) + inc hl + ld b, (hl) ; BC <-- Array __UBOUND__ PTR + ld (UBOUND_PTR), bc +#endif + + ex de, hl ; HL <-- PTR to Dim sizes table, DE <-- dummy + ex (sp), hl ; Return address in HL, PTR Dim sizes table onto Stack + ld (RET_ADDR), hl ; Stores it for later + + exx + pop hl ; Will use H'L' as the pointer to Dim sizes table + ld c, (hl) ; Loads Number of dimensions from (hl) + inc hl + ld b, (hl) + inc hl ; Ready + exx + + ld hl, 0 ; HL = Element Offset "accumulator" + +LOOP: + ex de, hl ; DE = Element Offset + ld hl, (LBOUND_PTR) + ld a, h + or l + ld b, h + ld c, l + jr z, 1f + ld c, (hl) + inc hl + ld b, (hl) + inc hl + ld (LBOUND_PTR), hl +1: + pop hl ; Get next index (Ai) from the stack + sbc hl, bc ; Subtract LBOUND + +#ifdef __CHECK_ARRAY_BOUNDARY__ + ld a, ERROR_SubscriptWrong + jp c, __ERROR + push hl ; Saves (Ai) - Lbound(i) + add hl, bc ; Recover original (Ai) value + push hl + ld hl, (UBOUND_PTR) + ld c, (hl) + inc hl + ld b, (hl) + inc hl + ld (UBOUND_PTR), hl + pop hl ; original (Ai) value + scf + sbc hl, bc ; HL <- HL - BC - 1 = Ai - UBound(i) - 1 => No Carry if Ai > UBound(i) + jp nc, __ERROR + pop hl ; Recovers (Ai) - Lbound(Ai) +#endif + + add hl, de ; Adds current index + + exx ; Checks if B'C' = 0 + ld a, b ; Which means we must exit (last element is not multiplied by anything) + or c + jr z, ARRAY_END ; if B'Ci == 0 we are done + dec bc ; Decrements loop counter + + ld e, (hl) ; Loads next dimension size into D'E' + inc hl + ld d, (hl) + inc hl + push de + exx + + pop de ; DE = Max bound Number (i-th dimension) + + call __FMUL16 ; HL <= HL * DE mod 65536 + jp LOOP + +ARRAY_END: + ld a, (hl) + exx + +#ifdef __BIG_ARRAY__ + ld d, 0 + ld e, a + call __FMUL16 +#else + LOCAL ARRAY_SIZE_LOOP + + ex de, hl + ld hl, 0 + ld b, a +ARRAY_SIZE_LOOP: + add hl, de + djnz ARRAY_SIZE_LOOP + +#endif + + ex de, hl + ld hl, (TMP_ARR_PTR) + ld a, (hl) + inc hl + ld h, (hl) + ld l, a + add hl, de ; Adds element start + ld de, (RET_ADDR) + push de + ret + + ENDP + + pop namespace diff --git a/src/lib/arch/zx81sd/runtime/sysvars.asm b/src/lib/arch/zx81sd/runtime/sysvars.asm index ec0a4c01a..f8025d33b 100644 --- a/src/lib/arch/zx81sd/runtime/sysvars.asm +++ b/src/lib/arch/zx81sd/runtime/sysvars.asm @@ -66,7 +66,17 @@ FP_CALC_STACK EQU SYSVAR_BASE + $29 ; 60B — pila de números FP (12 n FP_CALC_STACK_END EQU FP_CALC_STACK + 60 FP_MEM_AREA EQU SYSVAR_BASE + $65 ; 30B — área MEM (6 celdas de 5 bytes) -; Tamaño total del bloque de sysvars: $83 bytes +; --- Scratch del indexador de arrays (runtime/array/array.asm) --------- +; El array.asm compartido de zx48k usa la sysvar MEMBOT de la ROM Spectrum +; (dirección fija 23698 = $5C92) como almacenamiento temporal para sus +; punteros (LBOUND_PTR/UBOUND_PTR/RET_ADDR/TMP_ARR_PTR, 2 bytes cada uno). +; En zx81sd esa dirección cae dentro del propio código compilado del +; programa (bloque 2, $4000-$5FFF): cualquier array multidimensional lo +; corrompía en cada acceso. El override zx81sd de array.asm usa esta +; zona en su lugar. +ARRAY_SCRATCH EQU SYSVAR_BASE + $83 ; 8B — LBOUND/UBOUND/RET/TMP_ARR_PTR + +; Tamaño total del bloque de sysvars: $8B bytes ; --- Constantes de pantalla --------------------------------------------- From 1e21332e0ae703113ae7abc8780a9aa68a3f4b9a Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:39:26 +0200 Subject: [PATCH 07/10] zx81sd: fix chr.asm and arith/divf.asm, same MEMBOT-style pattern Found while continuing to debug ZXOilPanic on real hardware after the array.asm fix -- the code corruption just moved to a different address once the array.asm fix shifted the binary's layout. A project-wide grep for hardcoded Spectrum sysvar addresses (EQU 2[34]...) turned up about a dozen shared zx48k runtime files with this same pattern (scratch storage at a fixed "safe on real Spectrum" address, bypassing sysvars.asm entirely); these two are the ones actually observed corrupting a real, running program: - chr.asm: CHR$()'s TMP (23629, Spectrum's DEST) landed inside __DIVU32 in this build. - arith/divf.asm: division's TMP (23629, same as chr.asm) and ERR_SP (23613) run on every division, not just on error -- worth noting the ERR_SP-based recovery this saves for doesn't actually do anything on zx81sd anyway (__ERROR halts directly, never restores SP from it). Same fix pattern as array.asm: new overrides with only the scratch address changed (CHR_SCRATCH, DIVF_SCRATCH in sysvars.asm). Verified with a corrected VSYNC-pulse simulation mock, monitoring writes across the entire program's address space: 200M T-states with zero corruption, versus reliably corrupting within a few hundred thousand before. Not yet audited: arith/modf16.asm (same MEMBOT address, unused by this game) and break.asm's PPC -- a full audit of every zx48k runtime file for this pattern is worth doing separately. Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/doc/MAP.md | 51 +++++++++++++ src/lib/arch/zx81sd/runtime/arith/divf.asm | 74 +++++++++++++++++++ src/lib/arch/zx81sd/runtime/chr.asm | 86 ++++++++++++++++++++++ src/lib/arch/zx81sd/runtime/sysvars.asm | 23 +++++- 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/lib/arch/zx81sd/runtime/arith/divf.asm create mode 100644 src/lib/arch/zx81sd/runtime/chr.asm diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index db3cdfcd7..e0e4a909e 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -750,3 +750,54 @@ hang-vector addresses (`$0008`/`$0009`, `$0030`/`$0031` — never reached). Previously, with the same binary before this fix, the program reliably crashed within a few hundred million T-states via one of those two RST vectors. + +## Same bug, two more places: `chr.asm` and `arith/divf.asm` — RESOLVED (2026-07-22) + +The array.asm fix above turned out to be one instance of a **systemic +pattern** across the shared zx48k runtime: a project-wide grep for +`EQU 2[34]...` found roughly a dozen files that hardcode raw Spectrum +system-variable addresses as local scratch/temp storage, bypassing +`sysvars.asm` entirely (so zx81sd's own sysvars.asm override never +gets a chance to relocate them). Confirmed two more of these actually +firing while continuing to debug ZXOilPanic on real hardware after the +array.asm fix — the corruption just moved to a different address once +the binary's layout shifted: + +- `runtime/chr.asm` (the `CHR$()` function, called 6 times per score + update in this game) uses `TMP EQU 23629` (Spectrum's `DEST`) to + stash the return address while calling `__MEM_ALLOC`. On zx81sd, + 23629 ($5C4D) landed inside `__DIVU32` (32-bit division) in the + build being tested. +- `runtime/arith/divf.asm` (floating-point division, used by + `score/66.0` in this game) uses `TMP EQU 23629` (same address as + chr.asm) and `ERR_SP EQU 23613` to save/restore a stack recovery + point around the division (a "longjmp" trick for divide-by-zero, + matching the real Spectrum ROM's error-trapping convention). This + code runs on **every** division, not just on error — worth noting + that on zx81sd this recovery mechanism doesn't actually do anything + useful anyway, since `__ERROR` does `DI`+`HALT` directly rather than + restoring `SP` from `ERR_SP` to unwind — but it still unconditionally + writes to both addresses every time regardless. + +Fix, same pattern as array.asm: new `runtime/chr.asm` and +`runtime/arith/divf.asm` overrides, each with only the address changed +(`CHR_SCRATCH` at `$808B`, `DIVF_SCRATCH` — TMP+ERR_SP, 4 bytes — at +`$808D`, both in `sysvars.asm` after `ARRAY_SCRATCH`). + +Verified by simulation with a corrected VSYNC-pulse mock (the earlier +simulations in this session had used an oversimplified port-toggle mock +that doesn't match the real bit-6-1 pulse-counter semantics `PAUSE` +expects, which risked mid-testing false "hangs"): monitored writes +across the **entire** program's address space (not just block 2) for +200 million T-states — zero unexpected corruption, versus reliably +corrupting code within a few hundred thousand T-states before these +two fixes. + +**Not yet audited**: the same grep turned up further untouched +hardcodes in `runtime/arith/modf16.asm` (`TEMP EQU 23698`, same as +array.asm's old MEMBOT) and `runtime/break.asm`'s `PPC` — the former +isn't exercised by this particular game (no `MOD` operator used) so it +was left alone for now; a full systemic audit of every zx48k runtime +file for this pattern is worth doing separately at some point, this +session only fixed the ones actually observed corrupting a real +program. diff --git a/src/lib/arch/zx81sd/runtime/arith/divf.asm b/src/lib/arch/zx81sd/runtime/arith/divf.asm new file mode 100644 index 000000000..42e092006 --- /dev/null +++ b/src/lib/arch/zx81sd/runtime/arith/divf.asm @@ -0,0 +1,74 @@ +#include once +#include once +#include once + +; ------------------------------------------------------------- +; Floating point library using the FP ROM Calculator (ZX 48K) + +; All of them uses C EDHL registers as 1st paramter. +; For binary operators, the 2n operator must be pushed into the +; stack, in the order BC DE HL (B not used). +; +; Uses CALLEE convention +; ------------------------------------------------------------- +; +; zx81sd override: the only change from zx48k's version is where +; TMP/ERR_SP live. The original uses the Spectrum ROM's DEST (23629) +; and ERR_SP (23613) system variables to save/restore a stack recovery +; point around the division (a "longjmp" trick for divide-by-zero) -- +; on zx81sd that mechanism never actually gets used for real recovery +; (__ERROR does DI+HALT directly, it doesn't restore SP from ERR_SP), +; but this code writes there on every division regardless of whether +; an error happens, and those addresses fall inside the program's own +; compiled code (block 2) instead of real sysvars. DIVF_SCRATCH +; (sysvars.asm) is dedicated scratch space instead -- same mechanism, +; just relocated. + + push namespace core + +__DIVF: ; Division + PROC + LOCAL __DIVBYZERO + LOCAL TMP, ERR_SP + +TMP EQU DIVF_SCRATCH ; zx81sd: dedicated scratch, not DEST +ERR_SP EQU DIVF_SCRATCH + 2 ; zx81sd: dedicated scratch, not ERR_SP + + call __FPSTACK_PUSH2 + + ld hl, (ERR_SP) + ld (TMP), hl + ld hl, __DIVBYZERO + push hl + ld (ERR_SP), sp + + ; ------------- ROM DIV + rst 28h + defb 01h ; EXCHANGE + defb 05h ; DIV + defb 38h; ; END CALC + + pop hl + ld hl, (TMP) + ld (ERR_SP), hl + + jp __FPSTACK_POP + +__DIVBYZERO: + ld hl, (TMP) + ld (ERR_SP), hl + + ld a, ERROR_NumberTooBig + ld (ERR_NR), a + + ; Returns 0 on DIV BY ZERO error + xor a + ld b, a + ld c, a + ld d, a + ld e, a + ret + + ENDP + + pop namespace diff --git a/src/lib/arch/zx81sd/runtime/chr.asm b/src/lib/arch/zx81sd/runtime/chr.asm new file mode 100644 index 000000000..a6942465e --- /dev/null +++ b/src/lib/arch/zx81sd/runtime/chr.asm @@ -0,0 +1,86 @@ +; CHR$(x, y, x) returns the string CHR$(x) + CHR$(y) + CHR$(z) +; +; zx81sd override: the only change from zx48k's version is TMP's storage +; address. The original uses the Spectrum ROM's DEST system variable +; (23629, safe scratch RAM on real hardware) to stash the return address +; while it calls __MEM_ALLOC -- on zx81sd that address falls inside the +; program's own compiled code (block 2, landed inside __DIVU32 in one +; observed build) instead of a real sysvar. CHR_SCRATCH (sysvars.asm) is +; a dedicated 2-byte scratch slot instead. + +#include once +#include once + + push namespace core + +CHR: ; Returns HL = Pointer to STRING (NULL if no memory) + ; Requires alloc.asm for dynamic memory heap. + ; Parameters: HL = Number of bytes to insert (already push onto the stack) + ; STACK => parameters (16 bit, only the High byte is considered) + ; Used registers A, A', BC, DE, HL, H'L' + + PROC + + LOCAL __POPOUT + LOCAL TMP + +TMP EQU CHR_SCRATCH ; zx81sd: dedicated scratch, not DEST + + ld a, h + or l + ret z ; If Number of parameters is ZERO, return NULL STRING + + ld b, h + ld c, l + + pop hl ; Return address + ld (TMP), hl + + push bc + inc bc + inc bc ; BC = BC + 2 => (2 bytes for the length number) + call __MEM_ALLOC + pop bc + + ld d, h + ld e, l ; Saves HL in DE + + ld a, h + or l + jr z, __POPOUT ; No Memory, return + + ld (hl), c + inc hl + ld (hl), b + inc hl + +__POPOUT: ; Removes out of the stack every byte and return + ; If Zero Flag is set, don't store bytes in memory + ex af, af' ; Save Zero Flag + + ld a, b + or c + jr z, __CHR_END + + dec bc + pop af ; Next byte + + ex af, af' ; Recovers Zero flag + jr z, __POPOUT + + ex af, af' ; Saves Zero flag + ld (hl), a + inc hl + ex af, af' ; Recovers Zero Flag + + jp __POPOUT + +__CHR_END: + ld hl, (TMP) + push hl ; Restores return addr + ex de, hl ; Recovers original HL ptr + ret + + ENDP + + pop namespace diff --git a/src/lib/arch/zx81sd/runtime/sysvars.asm b/src/lib/arch/zx81sd/runtime/sysvars.asm index f8025d33b..3cfeb609e 100644 --- a/src/lib/arch/zx81sd/runtime/sysvars.asm +++ b/src/lib/arch/zx81sd/runtime/sysvars.asm @@ -76,7 +76,28 @@ FP_MEM_AREA EQU SYSVAR_BASE + $65 ; 30B — área MEM (6 celdas de 5 b ; zona en su lugar. ARRAY_SCRATCH EQU SYSVAR_BASE + $83 ; 8B — LBOUND/UBOUND/RET/TMP_ARR_PTR -; Tamaño total del bloque de sysvars: $8B bytes +; --- Scratch de CHR$() (runtime/chr.asm) -------------------------------- +; El chr.asm compartido de zx48k usa la sysvar DEST de la ROM Spectrum +; (dirección fija 23629 = $5C4D) para guardar temporalmente la dirección +; de retorno mientras reserva memoria para la cadena. Esa dirección cae +; dentro del código compilado en zx81sd (bloque 2, en concreto dentro de +; __DIVU32). El override zx81sd de chr.asm usa esta zona en su lugar. +CHR_SCRATCH EQU SYSVAR_BASE + $8B ; 2B — dirección de retorno + +; --- Scratch de la división FP (runtime/arith/divf.asm) ----------------- +; El divf.asm compartido de zx48k usa DEST (23629, igual que chr.asm) y +; ERR_SP (23613) de la ROM Spectrum para guardar/restaurar un punto de +; recuperación de pila alrededor de la división en coma flotante (truco +; de "longjmp" para división por cero). En zx81sd ese mecanismo de +; recuperación no llega a usarse de verdad (__ERROR hace DI+HALT +; directamente, no restaura SP desde ERR_SP), pero el código escribe ahí +; en TODAS las divisiones igualmente, no solo cuando hay error — y esas +; direcciones caen dentro del código compilado (bloque 2). El override +; zx81sd usa esta zona en su lugar (mismo mecanismo, solo cambia dónde +; vive el scratch). +DIVF_SCRATCH EQU SYSVAR_BASE + $8D ; 4B — TMP (2B) + ERR_SP (2B) + +; Tamaño total del bloque de sysvars: $91 bytes ; --- Constantes de pantalla --------------------------------------------- From 3053b64c745b885b137a38eaea3bf05b5771e3c3 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:16:18 +0200 Subject: [PATCH 08/10] zx81sd: fix stray EI corrupting state, add sysvar/port docs and build tools - backend/main.py: the $0038 (RST38) vector now preserves AF around the VSYNC wait. Ported Spectrum code that leaves interrupts enabled (a stray EI) makes the ZX81's continuous IM1 INTs land there mid-flight; without this, A/flags got clobbered and corrupted whatever computation was interrupted (root cause of a heisenbug in a ported game's sprite(N, col) array reads, see MAP.md). - doc/SYSVARS.md (new): full Spectrum ROM sysvar equivalence table, plus a quick-reference for screen/attribute base addresses, the ULA port (border/beeper, $FE -> $FB), and the AY sound chip ports/clock ($FFFD/$BFFD -> $CF/$0F, 1.7734MHz -> 1.625MHz). - doc/PORTING_GUIDE.md (new): step-by-step checklist for porting a Spectrum program, cross-referencing the existing docs. - doc/PRECAUTIONS.md, doc/USAGE.md, doc/MAP.md, README.md: updated to link the new docs and document the EI/RST38 finding. - tools/build_sd81.py (new): one-shot compile + SD81 page-split + .P loader generation, with an optional --copy-to to avoid testing against a stale package. - tools/gen_map.py (new): regenerate a .map via the explicit bas -> asm -> map pipeline, for when the .asm is being inspected or hand-edited between steps. --- src/arch/zx81sd/README.md | 4 + src/arch/zx81sd/backend/main.py | 6 + src/arch/zx81sd/doc/MAP.md | 30 ++++ src/arch/zx81sd/doc/PORTING_GUIDE.md | 205 +++++++++++++++++++++++++++ src/arch/zx81sd/doc/PRECAUTIONS.md | 51 +++++-- src/arch/zx81sd/doc/SYSVARS.md | 165 +++++++++++++++++++++ src/arch/zx81sd/doc/USAGE.md | 45 ++++++ src/arch/zx81sd/tools/build_sd81.py | 131 +++++++++++++++++ src/arch/zx81sd/tools/gen_map.py | 142 +++++++++++++++++++ 9 files changed, 769 insertions(+), 10 deletions(-) create mode 100644 src/arch/zx81sd/doc/PORTING_GUIDE.md create mode 100644 src/arch/zx81sd/doc/SYSVARS.md create mode 100644 src/arch/zx81sd/tools/build_sd81.py create mode 100644 src/arch/zx81sd/tools/gen_map.py diff --git a/src/arch/zx81sd/README.md b/src/arch/zx81sd/README.md index 8e781eb6c..1d2858b4c 100644 --- a/src/arch/zx81sd/README.md +++ b/src/arch/zx81sd/README.md @@ -63,12 +63,16 @@ fully working. ## Documentation +- **[doc/PORTING_GUIDE.md](doc/PORTING_GUIDE.md)** — step-by-step + checklist for porting a Spectrum program to zx81sd. Start here. - **[doc/USAGE.md](doc/USAGE.md)** — how to compile a program, package it for the ZX81 and load it from the SD card. - **[doc/PRECAUTIONS.md](doc/PRECAUTIONS.md)** — what to keep in mind when writing or porting software for this architecture (memory map, sysvars, keyboard, things that do NOT exist here even though they do on a Spectrum). +- **[doc/SYSVARS.md](doc/SYSVARS.md)** — full Spectrum ↔ zx81sd sysvar + and I/O port (ULA, screen address) equivalence table. - **[doc/BASIC_CHANGES.md](doc/BASIC_CHANGES.md)** — what BASIC source changes were needed to port each official example in `examples/` (with the why for each one). Mandatory starting point before porting diff --git a/src/arch/zx81sd/backend/main.py b/src/arch/zx81sd/backend/main.py index e5fa3e574..63afe5731 100644 --- a/src/arch/zx81sd/backend/main.py +++ b/src/arch/zx81sd/backend/main.py @@ -162,10 +162,16 @@ def emit_prologue() -> list[str]: # instead of hanging forever (di+halt): wait for the next VSYNC # pulse (port $AF, bits 6-1 = pulse counter, reset on read) and # return, resuming right after the RST $38 that got us here. + # AF must be preserved: ported code that executes EI (Spectrum + # habit) makes stray ZX81 INTs land here mid-computation, and + # clobbering A/F corrupted arithmetic at random (see MAP.md, + # oilpanic sprite(13,2) bug). + output.append("push af") output.append(".core.__RST38_WAIT:") output.append("in a, ($AF)") output.append("and $7E") output.append("jr z, .core.__RST38_WAIT") + output.append("pop af") output.append("ret") output.append("org $0066") output.append("retn") # $0066: NMI disabled, but the vector must exist diff --git a/src/arch/zx81sd/doc/MAP.md b/src/arch/zx81sd/doc/MAP.md index e0e4a909e..880005ead 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -801,3 +801,33 @@ was left alone for now; a full systemic audit of every zx48k runtime file for this pattern is worth doing separately at some point, this session only fixed the ones actually observed corrupting a real program. + +## Stray EI in ported code corrupted arithmetic at random — RESOLVED + +**Symptom (ZXOilPanic)**: `sprite(13,2)` — a plain 2D-array read — +returned -1 instead of 2072, but only when the game ran at full speed: +single-stepping in the debugger never reproduced it, the array's data +in RAM was verified intact (no writes ever hit it), and an isolated +test reading the same array/index worked perfectly. The wrong value +(-1) made the sprite blit read from `@sprites - 1`, painting garbage. + +**Root cause**: the game's `tilexy` asm block ends with `EI` (and the +BeepFX engine's exit path too) — normal on the Spectrum, but zx81sd's +runtime runs with interrupts disabled at all times. After the first +`tilexy` call, interrupts stayed enabled, and the ZX81 fires IM1 INTs +continuously (A6 low during refresh), landing on `$0038`. Our +`RST38_WAIT` vector clobbered **A and flags** (`in a,($AF)` / `and`), +so any interrupted computation (e.g. `__ARRAY`'s offset arithmetic) +resumed with corrupted A/F. Classic heisenbug: stepping in EightyOne +doesn't deliver INTs the same way, so it vanished under the debugger. + +**Fix (two layers)**: +1. Game side: `#ifndef __ZX81SD__` around both `EI`s (ported programs + must never re-enable interrupts on zx81sd). +2. Runtime side (defense-in-depth, `backend/main.py`): the `$0038` + vector now does `push af` / wait for VSYNC pulse / `pop af` / `ret`, + so a stray INT costs only time instead of corrupting registers. + +**Lesson**: when porting Spectrum code, grep for `\bei\b` and `\bhalt\b` +in every asm block. A heisenbug that disappears under single-stepping +on zx81sd should immediately suggest interrupts got re-enabled. diff --git a/src/arch/zx81sd/doc/PORTING_GUIDE.md b/src/arch/zx81sd/doc/PORTING_GUIDE.md new file mode 100644 index 000000000..d3faab595 --- /dev/null +++ b/src/arch/zx81sd/doc/PORTING_GUIDE.md @@ -0,0 +1,205 @@ +# Step-by-step guide: porting a Spectrum ZX BASIC program to zx81sd + +This is a checklist, not a tutorial — each step links to the doc that +has the full detail. Work through it roughly in order; most of it only +matters if the specific pattern shows up in the program you're +porting. Everything here was distilled from real ports (official +`examples/`, and a full third-party game, ZXOilPanic) that hit one or +more of these in practice — see [MAP.md](MAP.md) for the investigation +traces if you want the "why" behind any of them. + +## 0. The one rule that makes everything else optional + +**Keep the exact same `.bas` source compiling for both `zx48k` and +`zx81sd`.** Don't fork the file, don't overwrite Spectrum-targeting +code unconditionally — wrap every zx81sd-specific change: + +```basic +#ifdef __ZX81SD__ + ' zx81sd-specific code +#else + ' original Spectrum code +#endif +``` + +This works identically inside `asm ... end asm` blocks (the +preprocessor runs at the text level, before the BASIC/ASM split). +Compile for each target with: + +``` +python zxbc.py --arch=zx48k -o out_spectrum.bin +python zxbc.py --arch=zx81sd -D __ZX81SD__ -o out_zx81sd.bin -M out_zx81sd.map +``` + +(or the one-shot [`build_sd81.py`](../tools/build_sd81.py) for the +zx81sd side, see step 8). `zxbasic` never defines `__ZX81SD__` +automatically — you always pass it by hand with `-D`. + +## 1. There is no ROM: hunt down hardcoded Spectrum addresses + +Grep the source (and any library `.bas`/`.asm` it pulls in) for: +- Raw numeric `POKE`/`PEEK`/`CALL` addresses (4-5 digit decimals, or + hex like `$5C..`/`$22..`). +- Anything that looks like a Spectrum ROM sysvar name in a comment + (`UDG`, `COORDS`, `BORDCR`, `FRAMES`, `ATTR_P`...). + +Every one of these needs translating via the **full equivalence +table** in [SYSVARS.md](SYSVARS.md) (Spectrum address ↔ zx81sd +address, for every sysvar zx81sd exposes). A few you'll hit almost +immediately even in simple programs: + +- **Screen/attribute base address**: Spectrum `$4000`/`$5800` → + zx81sd `$C000`/`$D800`. Common in raw `LDIR`/`PEEK`/`POKE` screen + code that doesn't go through `SCREEN_ADDR`/`SCREEN_ATTR_ADDR`. +- **ULA port** (border color / beeper, `OUT`/`IN` on `$FE`/254): + zx81sd emulates the same border+beeper bit layout at `$FB`/251 + instead. Full detail and the keyboard-port caveat in SYSVARS.md's + "Quick reference" table — **don't** just s/254/251/ on a raw `OUT`, + route it through the shadow-byte mechanism `BORDER`/`BEEP` already + use, or you'll clobber whichever of border/beeper you didn't intend + to touch. +- **AY sound chip ports** (Spectrum 128's `$FFFD`/`$BFFD` register + latch/data) → zx81sd's SD81 Booster ZonX-81 interface at `$CF`/`$0F` + instead — see `stdlib/play.bas`'s `_PLAY_WRITE_TO_REGISTER` for the + reference. If the program pokes raw note dividers into AY registers + directly (not through `PLAY`), also recompute them for the SD81's + 1.625 MHz AY clock (vs the Spectrum 128's 1.7734 MHz) — same + SYSVARS.md section has the formula. + +See [PRECAUTIONS.md](PRECAUTIONS.md) section 1 for the full rationale +and [BASIC_CHANGES.md](BASIC_CHANGES.md) for a line-by-line catalog of +every case already found in the official examples. + +## 2. Never `EI`, never wait on `HALT` + +zx81sd runs the **entire program** with interrupts disabled. Grep +every ASM block (inline `asm...end asm` and any hand-written `.asm` +library) for `\bei\b` and `\bhalt\b`: + +- `EI` (with or without a following `HALT`) is dangerous — see + [PRECAUTIONS.md](PRECAUTIONS.md) section 4 for exactly how bad + (a real, hours-long heisenbug came from a single stray `EI` at the + end of a sprite-blit routine, corrupting unrelated array reads + later on). +- A `HALT`-based wait for the Spectrum's 50Hz interrupt never wakes + up on zx81sd. Replace it with `Pause n` (BASIC) or `call + .core.VSYNC_TICK` (ASM) — see PRECAUTIONS.md section 4 for the + exact substitution and namespace-prefix gotcha. + +**Diagnostic tell**: if a bug reliably disappears while single-stepping +in the debugger but reproduces at full run speed, suspect a stray `EI` +first, before anything else. + +## 3. Register contracts and ASM label namespaces + +If the program includes hand-written `.asm` that calls into runtime +routines directly (not just through BASIC statements), check +[PRECAUTIONS.md](PRECAUTIONS.md) sections 2 and 5: some routines have +non-negotiable register contracts (a replaced ROM routine must +preserve exactly what the original did), and zx81sd's namespace +mangling means a bare label reference from inside an `asm` block that +isn't already inside `push namespace core` needs the full `.core.` +prefix or you'll get `Undefined GLOBAL label`. + +## 4. The keyboard is the ZX81's, not the Spectrum's + +Any code that reads the keyboard by hand (not through `INKEY$`/ +`INPUT`) via raw port I/O needs rework — the ZX81's keyboard matrix +layout is different from the Spectrum's, even where a port number +happens to coincide (see SYSVARS.md's ULA-port note). Full detail in +[PRECAUTIONS.md](PRECAUTIONS.md) section 3. Also remember: **the ZX81 +keyboard has no shift-driven case distinction** — `SHIFT`+letter +produces a symbol, not uppercase, a common surprise when porting a +game that expects to read letter case directly (see +`zx81sd-keyboard-case` project memory if you're the original author +of this port — not a zxbasic-level fact, a physical hardware one). + +## 5. `zxbasm` binary literal syntax + +If the program has hand-written `.asm` using the trailing-`b` Pasmo/ +Zilog binary literal convention (`10110010b`), it won't assemble — +`zxbasm` only accepts the leading form: `%10110010` or `0b10110010`. +Convert with a straightforward `sed`/regex pass before anything else, +it's a pure syntax issue with no semantic risk. + +## 6. Dead-code elimination and `@function` references + +If a subroutine or array is only ever referenced via its **address** +(`@myFunction`, `@myArray`) from inside another function's body — never +called directly, and never referenced from global/top-level code — +zxbasic's dead-code eliminator can fail to see it as "used" and strips +it, causing an `Undefined GLOBAL label` at link time (a real gap: the +optimizer's call-graph walker tracks `CALL`/`FUNCCALL` nodes, not +`ADDRESS` nodes, and address-of-inside-a-function-body is a separate +code path that isn't marked as "accessed"). Workaround: a throwaway +address-of reference at **global** scope keeps it alive: + +```basic +' Compiler dead-code-elimination gap: @sprites is only ever taken from +' inside a function body, which isn't enough to mark it as used. +dim __keep_sprites as uinteger: __keep_sprites = @sprites +``` + +## 7. Sysvar scratch bugs already fixed in the runtime — no action needed + +Several shared `zx48k/runtime/` files historically hardcoded a raw +Spectrum sysvar address as scratch storage (bypassing `sysvars.asm` +entirely) — on zx81sd that address lands inside the program's own +compiled code, corrupting it. `array.asm` (multi-dimensional array +reads), `chr.asm` (`CHR$()`), and `arith/divf.asm` (floating-point +division) all had this bug and are **already fixed** with zx81sd +overrides — nothing to do here, just don't be surprised if you find +the override files while reading the runtime. `arith/modf16.asm` +(the `MOD` operator) has the same pattern and is **not yet fixed** +(not triggered by any program so far) — if a program using `MOD` +shows array-adjacent corruption, this is the first suspect. Full list +and the exact addresses in [SYSVARS.md](SYSVARS.md)'s hardcoded-scratch +tables. + +## 8. Compile, package, and copy to the right place + +``` +python src/arch/zx81sd/tools/build_sd81.py [PREFIX] --copy-to +``` + +Does the compile (with `-D __ZX81SD__` and `-M` for the symbol map) +and the SD81 page-splitting + `.P`-loader generation in one command. +**Get `--copy-to` right, or point it at the emulator/hardware's real +SD folder by hand afterwards** — a stale package the emulator wasn't +actually reading was itself the cause of a multi-hour false-alarm +debugging session once, see [USAGE.md](USAGE.md) section 3b. If you +only need a fresh `.map` without repackaging (e.g. after hand-editing +the generated `.asm`), see `gen_map.py` in the same section. + +## 9. Test and debug + +- Simulating with Python's `z80` package before touching real + hardware/emulator catches a lot, but has real limits: an + oversimplified VSYNC-port mock can get a program stuck early, and a + bug that depends on genuine interrupt timing (step 2) **cannot** be + reproduced by a simulator that never delivers interrupts in the + first place. See [MAP.md](MAP.md) and USAGE.md section 4 for the + methodology and its known traps. +- Live debugging in EightyOne's Debug Window: execution breakpoints + with hit-counts (`EXE=$addr`), write breakpoints (`WR=$addr`), and + the Memory viewer (cross-referenced against the `.map` file) are the + main tools — see MAP.md for worked examples of narrowing down a bug + this way, including how to avoid re-triggering on the wrong call + when many call sites share the same target address. +- **`.map` must match the binary actually loaded.** Regenerate it on + every rebuild (`build_sd81.py`/`gen_map.py` both do); a mismatched + `.map` gives confidently-wrong addresses that waste debugging time. + +## See also + +- [PRECAUTIONS.md](PRECAUTIONS.md) — the detailed rationale behind + steps 1-4, one section each. +- [SYSVARS.md](SYSVARS.md) — full Spectrum ↔ zx81sd sysvar and I/O + port equivalence table. +- [BASIC_CHANGES.md](BASIC_CHANGES.md) — catalog of source changes + already needed in official examples. +- [USAGE.md](USAGE.md) — compiling, packaging, and simulating in + detail. +- [MAP.md](MAP.md) — full technical log, bug by bug, with the + investigation traces (the primary source almost every step above + was distilled from). diff --git a/src/arch/zx81sd/doc/PRECAUTIONS.md b/src/arch/zx81sd/doc/PRECAUTIONS.md index 3ab31ace0..b056e3089 100644 --- a/src/arch/zx81sd/doc/PRECAUTIONS.md +++ b/src/arch/zx81sd/doc/PRECAUTIONS.md @@ -20,9 +20,12 @@ a runaway `HALT`/reset that's very hard to trace back to its cause (several bugs in this port took whole sessions to diagnose because of this). -- **Spectrum sysvars → zx81sd sysvars**: the equivalence table is in - [`../../../lib/arch/zx81sd/runtime/sysvars.asm`](../../../lib/arch/zx81sd/runtime/sysvars.asm) - (all live at `$8000+`, not `$5C00+`). Examples already solved: +- **Spectrum sysvars → zx81sd sysvars**: the full equivalence table + (every symbol, its Spectrum ROM address, and its zx81sd address) is + in [SYSVARS.md](SYSVARS.md) — check it first when a numeric address + turns up while porting code. The zx81sd definitions themselves live + in [`../../../lib/arch/zx81sd/runtime/sysvars.asm`](../../../lib/arch/zx81sd/runtime/sysvars.asm) + (all at `$8000+`, not `$5C00+`). Examples already solved: `UDG` (23675 → `$8002`), `COORDS` (23677/23678 → `$8004`/`$8005`). See [BASIC_CHANGES.md](BASIC_CHANGES.md) for the line-by-line detail of every case found so far. @@ -94,13 +97,37 @@ Differences that matter when porting/writing code: any key right after a dot without risking forming a symbol by accident). -## 4. There are no interrupts: never wait on `HALT`/`EI` to synchronize - -The zx81sd runtime always runs with interrupts disabled (`DI`); the -`$0038` vector is only a `DI;HALT` trap, not a real interrupt handler. -Any code (typically inline ASM in an example, not the stdlib) that -does `EI` followed by `HALT` waiting for the Spectrum ROM's 50Hz pulse -**hangs forever** — nothing ever wakes it up. +## 4. There are no interrupts: never `EI`, never wait on `HALT` to synchronize + +The zx81sd runtime always runs with interrupts disabled (`DI`) for the +**entire program**, not just around specific routines. Any ported code +(typically inline ASM copied from a Spectrum source, not the stdlib) +that does `EI` — anywhere, even if it's never followed by a `HALT` — +is dangerous on zx81sd in two different ways: + +- `EI` followed by `HALT` waiting for the Spectrum ROM's 50Hz pulse + **hangs forever** — nothing ever wakes it up. +- `EI` **on its own**, with no following `HALT`, is more insidious: + interrupts stay enabled for the rest of the program's execution, and + the ZX81 fires IM1 INTs continuously (roughly every scanline, tied + to video refresh). Every one of those jumps to `$0038`. That vector + preserves `AF` (`push af` / wait for VSYNC / `pop af` / `ret` — see + `backend/main.py`) precisely because this bug was found the hard + way, but it does **not** preserve anything else — if the interrupt + lands mid-computation in code that's mid-flight on `BC`/`DE`/`HL` + (typical of `__ARRAY`'s offset arithmetic, which uses the shadow + register set across an extended, uninterruptible-on-a-real-Spectrum + span), the result is silent, apparently-random data corruption whose + symptom shows up somewhere completely unrelated later. This exact + bug cost a multi-hour live-hardware debugging session on a ported + game (`sprite(N, col)` reading `-1` instead of its real value) before + being traced back to a stray `EI` at the end of an XOR-blit routine + — full writeup in [MAP.md](MAP.md) and the `oilpanic-portability` + project memory. **Grep every ported ASM block for `\bei\b` before + testing, the same way you'd grep for hardcoded addresses (section + 1).** A bug that reliably disappears when single-stepping in the + debugger but reproduces at full speed is the signature to watch for + — EightyOne doesn't deliver INTs identically while paused. - **Replacement**: `VSYNC_TICK` (namespace `core`, in `runtime/vsync.asm`) polls the SD81 Booster hardware's real VSYNC @@ -209,6 +236,10 @@ practice, not a hack. ## See also +- [PORTING_GUIDE.md](PORTING_GUIDE.md) — step-by-step checklist for + porting a Spectrum program, built from everything in this file. +- [SYSVARS.md](SYSVARS.md) — full Spectrum ↔ zx81sd sysvar and I/O + port equivalence table. - [BASIC_CHANGES.md](BASIC_CHANGES.md) — catalog of source changes already needed in official examples, with the general pattern to look for in any new example. diff --git a/src/arch/zx81sd/doc/SYSVARS.md b/src/arch/zx81sd/doc/SYSVARS.md new file mode 100644 index 000000000..85fdf411e --- /dev/null +++ b/src/arch/zx81sd/doc/SYSVARS.md @@ -0,0 +1,165 @@ +# zx81sd system variables — equivalence table with the Spectrum ROM + +zx81sd has no ROM: every "system variable" the shared `zx48k/runtime/` +code expects at a fixed Spectrum address is instead a real RAM +variable allocated by zx81sd at `SYSVAR_BASE` (`$8000`) upwards, laid +out in [`src/lib/arch/zx81sd/runtime/sysvars.asm`](../../../lib/arch/zx81sd/runtime/sysvars.asm). +This table is the single place that maps each one back to the +Spectrum ROM address it substitutes for, so a search for a Spectrum +address (`23698`, `$5C7B`, etc.) found while porting code has +somewhere to land. + +## Quick reference (the things every port hits first) + +| Concept | Spectrum | zx81sd | Source | +|---------|:--------:|:------:|--------| +| Pixel screen base | `$4000` (16384) | `$C000` (49152) | `SCREEN_ADDR`, see below | +| Attribute screen base | `$5800` (22528) | `$D800` (55296) | `SCREEN_ATTR_ADDR`, see below | +| ULA port (border color, `OUT`) | `$FE` (254) | `$FB` (251) | `border.asm` — `SD81_ULA_PORT` | +| ULA port (beeper, `OUT`) | `$FE` (254), bit 4 | `$FB` (251), bits 4-3 | `beep.asm` — same port as border, see note below | +| AY register-latch port (`OUT`) | `$FFFD` (65533, Spectrum 128) | `$CF` (207) | `stdlib/play.bas` — `_PLAY_WRITE_TO_REGISTER` | +| AY data port (`OUT`) | `$BFFD` (49149, Spectrum 128) | `$0F` (15) | ditto — SD81 Booster's ZonX-81 AY interface | + +The ULA port note: on the real Spectrum, border color (bits 2-0) and +beeper (bit 4) share the **same** write-only port `$FE`, so any code +poking a raw byte to it needs to preserve the other's bits. zx81sd's +SD81 Booster (Superfast HiRes Spectrum mode) emulates this exact +sharing at `$FB` instead, bits laid out the same way (2-0 border, +4-3 beeper) — `border.asm`/`beep.asm` keep a shadow byte +(`__ZX81SD_ULA_SHADOW`) for the same reason the real ROM effectively +needs one. **If a ported program pokes the ULA port directly instead +of using `BORDER`/`BEEP`, change `$FE`→`$FB` (or `254`→`251`) and route +it through the shadow byte, not a raw `OUT`** — see +[PORTING_GUIDE.md](PORTING_GUIDE.md) step 3. + +Note: `$FE` is *also* a real, working port on zx81sd — but for the +**native ZX81 keyboard matrix** (`break.asm` reads it directly for the +BREAK key), not the Spectrum ULA. A Spectrum program that does its own +manual keyboard scanning via `IN A,($FE)` (bypassing `INKEY$`) will +read a **different** row/bit layout on zx81sd's actual hardware — that +port number being valid on both machines for unrelated purposes is a +coincidence worth being aware of, not a compatibility shortcut. + +The AY port note: besides the port numbers themselves, the AY chip's +**clock speed differs too** — the SD81 Booster's FPGA clocks its AY at +1.625 MHz (3.25 MHz / 2) versus the Spectrum 128's 1.7734 MHz. A +program that pokes raw note-divider values into AY registers 0-13 +(rather than going through `PLAY`/`SetChipTone` etc.) needs those +dividers recalculated for the new clock (`divider = round(1625000 / +16 / frequency)`), or every note will play at the wrong pitch — see +`stdlib/play.bas`'s `_Play_NoteDividers` table for the reference +conversion, and the closed `sd81-sound-calibration` project memory for +how this was measured and confirmed on real hardware (BEEP runs off a +separate, unrelated 3.25 MHz clock — don't confuse the two chips' +clocks when converting timings). + +See [PRECAUTIONS.md](PRECAUTIONS.md) section 1 for *why* this matters +(zx81sd has no ROM at all — a numeric literal like `23675` is never +valid on zx81sd, it lands inside the program's own compiled code) and +section 8 for how to safely reference these from a new library file +(`#require "sysvars.asm"`, never `#include once`). + +## Main block (mirrors `zx48k/runtime/sysvars.asm`) + +These are the ones the shared zx48k runtime/stdlib code accesses **by +symbolic name** (`CHARS`, `UDG`, `ATTR_P`...) — since the symbol +resolves against whichever `sysvars.asm` got linked in, zx81sd's own +definitions satisfy them automatically, no override file needed. + +| Symbol | Spectrum ROM addr | zx81sd addr | Size | Purpose | +|-------------|:-----------------:|:-----------:|:----:|---------| +| `CHARS` | 23606 (`$5C36`) | `$8000` | 2B (DW) | Pointer to charset (8×8) | +| `UDG` | 23675 (`$5C7B`) | `$8002` | 2B (DW) | Pointer to UDG charset | +| `COORDS` | 23677 (`$5C7D`) | `$8004` | 2B (DW) | Last `PLOT` coordinate (X,Y) | +| `FLAGS2` | 23681 (`$5C81`) | `$8006` | 1B | Screen flags (OVER/INVERSE/etc.) | +| `ECHO_E` | 23682 (`$5C82`) | `$8007` | 1B | (reserved, unused by zx81sd) | +| `DFCC` | 23684 (`$5C84`) | `$8008` | 2B (DW) | Next screen (bitmap) address for `PRINT` | +| `DFCCL` | 23686 (`$5C86`) | `$800A` | 2B (DW) | Next screen-attribute address for `PRINT` | +| `S_POSN` | 23688 (`$5C88`) | `$800C` | 2B | Cursor position (H=row, L=col) | +| `ATTR_P` | 23693 (`$5C8D`) | `$800E` | 1B | Permanent attribute (INK/PAPER/...) | +| `MASK_P` | 23694 (`$5C8E`) | `$800F` | 1B | Permanent mask (implicit `ATTR_P+1`, not a separate EQU on either side) | +| `ATTR_T` | 23695 (`$5C8F`) | `$8010` | 1B | Temporary attribute | +| `MASK_T` | 23696 (`$5C90`) | `$8011` | 1B | Temporary mask (implicit `ATTR_T+1`) | +| `P_FLAG` | 23697 (`$5C91`) | `$8012` | 1B | Print flags (permanent OVER/INVERSE) | +| `MEM0`/`MEMBOT` | 23698 (`$5C92`) | `$8013` | 5B | Temp buffer used by ROM char routines. **Same physical Spectrum address as `MEMBOT`** below — the real ROM reuses this scratch for two unrelated purposes depending on context; zx81sd keeps them as two clearly-named, non-overlapping zones instead (`MEM0` here, `ARRAY_SCRATCH` separately) | +| `TV_FLAG` | 23612 (`$5C3C`) | `$8018` | 1B | Output-to-screen control flags | + +Not in the shared file, zx81sd-only additions used by the same +symbolic-name mechanism: + +| Symbol | Nearest Spectrum concept | zx81sd addr | Size | Notes | +|-------------|--------------------------|:-----------:|:----:|-------| +| `ERR_NR` | `ERR_NR` — 23610 (`$5C3A`) | `$8019` | 1B | Error code (-1 = no error) | +| `FRAMES` | `FRAMES` — 23672 (`$5C58`), 3B on real ROM | `$801A` | 2B (DW) | Software VSYNC frame counter — zx81sd has no hardware interrupt to auto-increment it (see `VSYNC_TICK`/`PAUSE`), only needs 16 bits | +| `RANDOM_SEED_LOW` | `SEED` low word — 23670 (`$5C76`) | `$801C` | 2B (DW) | RNG seed | +| `SCREEN_ADDR` | not a ROM sysvar — zxbasic's own indirection variable (`zx48k/runtime/sysvars.asm`: `DW 16384`) | `$801E` | 2B (DW) | Framebuffer pointer, init `$C000` (vs `16384` on Spectrum) | +| `SCREEN_ATTR_ADDR` | ditto (`DW 22528`) | `$8020` | 2B (DW) | Attribute pointer, init `$D800` (vs `22528`) | + +## Floating-point calculator (`fp_calc.asm`) + +Real Spectrum ROM sysvars, but zx81sd points them at a **fixed** +buffer instead of the ROM's dynamically-growing area (there's no +"free memory between program and stack" concept here): + +| Symbol | Spectrum ROM addr | zx81sd addr | Size | Notes | +|---------------|:-------------------:|:-----------:|:----:|-------| +| `FP_STKBOT` | `STKBOT` — 23651 (`$5C63`) | `$8022` | 2B (DW) | Base of the FP number stack | +| `FP_STKEND` | `STKEND` — 23653 (`$5C65`) | `$8024` | 2B (DW) | Next free slot in the FP stack | +| `FP_BREG` | `BREG` — 23655 (`$5C67`) | `$8026` | 1B | Literal currently being parsed | +| `FP_MEM` | `MEM` — 23656 (`$5C68`) | `$8027` | 2B (DW) | Pointer to the MEM area (6×5B cells) | +| `FP_CALC_STACK` | n/a (dynamic on real ROM) | `$8029` | 60B | Fixed FP number stack (12 numbers max) | +| `FP_MEM_AREA` | n/a (dynamic on real ROM) | `$8065` | 30B | Fixed MEM area (6×5B cells) | + +`FP_BREG` **must** sit immediately after `FP_STKEND` — the CALCULATE +engine (`ENT-TABLE`/`fp-calc-2`/`dec-jr-nz`) exploits the real ROM's +memory contiguity (`STKEND_hi` immediately followed by `BREG`) to load +both with a single `LD BC,(FP_STKEND+1)`. Don't reorder these two. + +## Hardcoded-address scratch fixes (not exposed by symbolic name) + +These shared `zx48k/runtime/` files bypass `sysvars.asm` entirely and +hardcode a raw Spectrum address as scratch storage — on zx81sd that +address lands inside the program's own compiled code (block 2, +`$4000-$5FFF`) instead of safe RAM, silently corrupting it. Each one +needed its own zx81sd **override file** (only the address changed, see +[MAP.md](MAP.md) for the full incident writeups) rather than a plain +`sysvars.asm` entry, since the shared code never references them by a +name `sysvars.asm` could satisfy: + +| Symbol (shared file's own name) | Spectrum addr | zx81sd override | zx81sd addr | +|----------------------------------|:-------------:|------------------|:-----------:| +| `LBOUND_PTR`/`UBOUND_PTR`/`RET_ADDR`/`TMP_ARR_PTR` (`array.asm`, aliases `MEMBOT`) | 23698 (`$5C92`) | `runtime/array/array.asm` | `ARRAY_SCRATCH` = `$8083` (8B) | +| `TMP` (`chr.asm`, alias `DEST`) | 23629 (`$5C4D`) | `runtime/chr.asm` | `CHR_SCRATCH` = `$808B` (2B) | +| `TMP`/`ERR_SP` (`arith/divf.asm`, aliases `DEST`/`ERR_SP`) | 23629 (`$5C4D`) / 23613 (`$5C3D`) | `runtime/arith/divf.asm` | `DIVF_SCRATCH` = `$808D` (4B: TMP 2B + ERR_SP 2B) | + +Total sysvar block size: `$91` bytes (`$8000`-`$8090`), well before the +heap start at `$8100`. + +**Not yet audited / no override exists** (same antipattern, not yet +observed corrupting a real program — see `oilpanic-portability.md` +project memory and `MAP.md` for the hunting method if one of these +ever bites): + +| Symbol | Spectrum addr | Shared file | Only triggered by | +|--------|:-------------:|-------------|--------------------| +| `TEMP` (alias `MEMBOT`) | 23698 (`$5C92`) | `runtime/arith/modf16.asm` | the `MOD` operator | + +A quick grep for the pattern across all of `zx48k/runtime`: +`EQU 2[34][0-9][0-9][0-9]` — anything that turns up and *doesn't* +already have a zx81sd override (see next section) is a candidate. + +## Shared files with hardcoded Spectrum addresses that zx81sd already +## bypasses entirely (full rewrite, not just an address patch) + +These also matched the grep above, but zx81sd ships a **complete** +override with no numeric Spectrum address left at all (not just a +scratch-address swap) — listed here so they're not mistaken for +unaudited risks: + +| Shared file | Hardcoded Spectrum addr(s) | zx81sd override | +|-------------|------------------------------|------------------| +| `runtime/break.asm` (`PPC`) | 23621 (`$5C45`) | `zx81sd/runtime/break.asm` | +| `runtime/load.asm` (`BREG` alias) | 23655 (`$5C67`) | `zx81sd/runtime/load.asm` | +| `runtime/save.asm` (`MEMBOT` alias) | 23698 (`$5C92`) | `zx81sd/runtime/save.asm` | +| `runtime/val.asm` (`STKBOT`/`ERR_SP`/`CH_ADD`) | 23651/23613/23645 | `zx81sd/runtime/val.asm` | +| `runtime/random.asm` (`RANDOM_SEED_LOW`) | 23670 (`$5C76`) | `zx81sd/runtime/random.asm` | diff --git a/src/arch/zx81sd/doc/USAGE.md b/src/arch/zx81sd/doc/USAGE.md index 43571baf8..a7c33137d 100644 --- a/src/arch/zx81sd/doc/USAGE.md +++ b/src/arch/zx81sd/doc/USAGE.md @@ -76,6 +76,51 @@ Then on the ZX81 (or in EightyOne pointing at the SD image): load and run it with `LOAD FAST ""` — no need to select anything afterwards, it runs straight from there. +## 3b. Steps 1+2 in one command: `build_sd81.py` + +[`../tools/build_sd81.py`](../tools/build_sd81.py) chains the compile +(step 1, including `-M`) and the packaging (step 2) into a single +call, and can optionally copy the result straight to an emulator's +virtual SD folder: + +``` +python src/arch/zx81sd/tools/build_sd81.py [PREFIX] [--copy-to DIR] +``` + +`__ZX81SD__` is always passed as `-D` (needed for game sources that +guard architecture-specific code with `#ifdef __ZX81SD__`, see +[BASIC_CHANGES.md](BASIC_CHANGES.md)); extra `-D MACRO` or unrecognized +flags are forwarded to `zxbc.py` as-is. `--copy-to` doesn't copy +`BOOT1.BIN` (shared across every program, usually already in place). + +A stale package the emulator doesn't actually see was itself the cause +of a multi-hour false-alarm debugging session once (see MAP.md / +`oilpanic-portability` project memory) — `--copy-to` exists mainly to +make that mistake harder to repeat. + +## 3c. Regenerating just the `.map`, via the `.asm` stage: `gen_map.py` + +Sometimes the `.asm` intermediate is worth keeping around for +inspection or hand-editing, and only a fresh, matching `.map` is +needed afterwards — without paying for `split_sd81.py`'s page-split + +`.P`-tokenize step again. [`../tools/gen_map.py`](../tools/gen_map.py) +does the two-stage version of step 1 explicitly: + +``` +python src/arch/zx81sd/tools/gen_map.py [output_base] +``` + +1. `zxbc.py --arch=zx81sd -D __ZX81SD__ -f asm` (BASIC → `.asm`) +2. `zxbasm.py -M` (`.asm` → `.bin` + `.map`; a `.bin` comes out too, + zxbasm always needs some output format) + +Note: `zxbasm.py`, unlike `zxbc.py`, has no `--arch`/include-path flag +of its own, so it can't resolve `charset.asm`'s bare +`INCBIN "specfont.bin"` through the normal architecture search path — +`gen_map.py` works around this by temporarily copying `specfont.bin` +next to the generated `.asm` before invoking `zxbasm.py`, then +removing it again. + ## 4. Debugging without hardware: simulation with Python To diagnose hangs, HALTs or incorrect results without having to test on diff --git a/src/arch/zx81sd/tools/build_sd81.py b/src/arch/zx81sd/tools/build_sd81.py new file mode 100644 index 000000000..972adf224 --- /dev/null +++ b/src/arch/zx81sd/tools/build_sd81.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +build_sd81.py — one-shot build: BASIC source -> zx81sd binary -> SD81 +Booster package (page files + tokenized .P loader), in a single command. + +Chains the two steps that had to be run by hand throughout zx81sd +development: + 1. zxbc.py --arch=zx81sd -D __ZX81SD__ -o .bin -M .map + (compiles the BASIC source; -M keeps the symbol map in sync with + the binary — a stale/missing .map was a repeated source of + confusion when live-debugging on real hardware/emulator) + 2. split_sd81.py .bin (splits into 8KB SD81 pages and + generates .P, the tokenized BASIC loader) + +Usage: + python build_sd81.py [output_base] [--copy-to DIR] + [-O LEVEL] [-D MACRO ...] [zxbc.py extra args...] + + output_base defaults to the source file's name (without extension), + uppercased — same convention split_sd81.py already enforces (must be + representable in the ZX81 charset: letters/digits, no underscore). + + --copy-to DIR: after building, also copy the page files and the .P + loader to DIR (e.g. an emulator's virtual-SD test folder). BOOT1.BIN + is not copied — it's shared across projects and normally lives once + in the SD root already. + + __ZX81SD__ is always defined; pass more -D flags to add extras. + Any unrecognized argument is forwarded to zxbc.py as-is (e.g. to + tweak heap size, warnings, etc.). + +Example: + python build_sd81.py C:/ClaudeCode/ZXOilPanic/oilpanic.bas OILPANIC ^ + --copy-to C:/ClaudeCode/Eightyone2/EightyOne/SD81/test +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parent +ZXBASIC_ROOT = TOOLS_DIR.parents[3] # tools -> zx81sd -> arch -> src -> +ZXBC = ZXBASIC_ROOT / "zxbc.py" +SPLIT_SCRIPT = TOOLS_DIR / "split_sd81.py" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("source", help="BASIC source file (.bas)") + ap.add_argument( + "output_base", nargs="?", help="Output base name (default: source file name)" + ) + ap.add_argument( + "--copy-to", metavar="DIR", help="Also copy the page files + .P loader here" + ) + ap.add_argument("-O", "--optimize", default="2", help="zxbc.py optimization level (default 2)") + ap.add_argument( + "-D", + "--define", + action="append", + default=[], + metavar="MACRO", + help="Extra -D macro for zxbc.py (repeatable). __ZX81SD__ is always defined.", + ) + args, extra = ap.parse_known_args() + + if not ZXBC.is_file(): + sys.exit(f"Error: zxbc.py not found at {ZXBC} (unexpected repo layout)") + if not SPLIT_SCRIPT.is_file(): + sys.exit(f"Error: split_sd81.py not found at {SPLIT_SCRIPT}") + + source = Path(args.source).resolve() + if not source.is_file(): + sys.exit(f"Error: source file not found: {source}") + + base = args.output_base or source.stem + workdir = source.parent + bin_path = workdir / f"{base}.bin" + map_path = workdir / f"{base}.map" + + zxbc_cmd = [ + sys.executable, + str(ZXBC), + str(source), + "--arch=zx81sd", + "-O", + args.optimize, + "-o", + str(bin_path), + "-M", + str(map_path), + ] + for d in ["__ZX81SD__"] + args.define: + zxbc_cmd += ["-D", d] + zxbc_cmd += extra + + print("== zxbc.py ==") + print(" ".join(zxbc_cmd)) + subprocess.run(zxbc_cmd, check=True) + + print() + print("== split_sd81.py ==") + split_cmd = [sys.executable, str(SPLIT_SCRIPT), str(bin_path), base] + print(" ".join(split_cmd)) + subprocess.run(split_cmd, check=True, cwd=workdir) + + if args.copy_to: + dest = Path(args.copy_to) + dest.mkdir(parents=True, exist_ok=True) + base_upper = base.upper() + copied = [] + for pattern in (f"{base_upper}P*.BIN", f"{base_upper}.P"): + for f in workdir.glob(pattern): + shutil.copy2(f, dest / f.name) + copied.append(f.name) + + print() + print(f"== copied to {dest} ==") + if copied: + for name in sorted(copied): + print(f" {name}") + else: + print(f" Warning: nothing matched {base_upper}P*.BIN / {base_upper}.P in {workdir}") + + +if __name__ == "__main__": + main() diff --git a/src/arch/zx81sd/tools/gen_map.py b/src/arch/zx81sd/tools/gen_map.py new file mode 100644 index 000000000..4098cae36 --- /dev/null +++ b/src/arch/zx81sd/tools/gen_map.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +gen_map.py — regenerate a zx81sd .map symbol file from BASIC source via +the intermediate .asm stage, instead of compiling straight to .bin. + +Two-stage pipeline (this is what zxbc.py does internally in one step +when given -f bin -M map; this script exposes the split explicitly): + 1. zxbc.py --arch=zx81sd -D __ZX81SD__ -f asm -o .asm + (BASIC -> assembly, kept around for inspection/hand-editing) + 2. zxbasm.py -M .map -o .bin .asm + (assembly -> binary + .map; zxbasm always needs *some* output + format, so a .bin comes out too as a byproduct) + +Useful when the .asm is being hand-inspected or edited between steps +(a common retro workflow) and only a fresh, consistent .map is needed +to match it — skips split_sd81.py's page-splitting / .P-tokenizing +step entirely, since a plain zxbc.py -f bin -M compile already covers +the common "just rebuild everything" case (see build_sd81.py). + +Usage: + python gen_map.py [output_base] [-O LEVEL] [-D MACRO ...] + [--keep-asm] [zxbc.py extra args...] + + output_base defaults to the source file's name (without extension). + --keep-asm: don't delete the intermediate .asm afterwards (kept by + default anyway — flag exists only for symmetry/clarity, see below). + +Example: + python gen_map.py C:/ClaudeCode/ZXOilPanic/oilpanic.bas oilpanic +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parent +ZXBASIC_ROOT = TOOLS_DIR.parents[3] # tools -> zx81sd -> arch -> src -> +ZXBC = ZXBASIC_ROOT / "zxbc.py" +ZXBASM = ZXBASIC_ROOT / "zxbasm.py" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("source", help="BASIC source file (.bas)") + ap.add_argument( + "output_base", nargs="?", help="Output base name (default: source file name)" + ) + ap.add_argument("-O", "--optimize", default="2", help="zxbc.py optimization level (default 2)") + ap.add_argument( + "-D", + "--define", + action="append", + default=[], + metavar="MACRO", + help="Extra -D macro for zxbc.py (repeatable). __ZX81SD__ is always defined.", + ) + ap.add_argument( + "--keep-asm", + action="store_true", + help="No-op (the .asm is always kept) — accepted for clarity/symmetry only.", + ) + args, extra = ap.parse_known_args() + + if not ZXBC.is_file(): + sys.exit(f"Error: zxbc.py not found at {ZXBC} (unexpected repo layout)") + if not ZXBASM.is_file(): + sys.exit(f"Error: zxbasm.py not found at {ZXBASM} (unexpected repo layout)") + + source = Path(args.source).resolve() + if not source.is_file(): + sys.exit(f"Error: source file not found: {source}") + + base = args.output_base or source.stem + workdir = source.parent + asm_path = workdir / f"{base}.asm" + bin_path = workdir / f"{base}.bin" + map_path = workdir / f"{base}.map" + + zxbc_cmd = [ + sys.executable, + str(ZXBC), + str(source), + "--arch=zx81sd", + "-O", + args.optimize, + "-f", + "asm", + "-o", + str(asm_path), + ] + for d in ["__ZX81SD__"] + args.define: + zxbc_cmd += ["-D", d] + zxbc_cmd += extra + + print("== zxbc.py (BASIC -> asm) ==") + print(" ".join(zxbc_cmd)) + subprocess.run(zxbc_cmd, check=True) + + zxbasm_cmd = [ + sys.executable, + str(ZXBASM), + str(asm_path), + "-o", + str(bin_path), + "-M", + str(map_path), + ] + + # zxbasm has no --arch/--include-path flag at all (unlike zxbc.py), so + # it never learns zx81sd's architecture-specific include search path. + # A bare INCBIN filename (e.g. "specfont.bin", pulled in by + # charset.asm) is resolved with local_first=True against the .asm + # file's OWN directory — so temporarily drop a copy of specfont.bin + # next to the generated .asm and remove it again afterwards. + specfont_src = ZXBASIC_ROOT / "src" / "lib" / "arch" / "zx81sd" / "runtime" / "specfont.bin" + specfont_dst = workdir / "specfont.bin" + specfont_copied = False + if specfont_src.is_file() and not specfont_dst.exists(): + shutil.copy2(specfont_src, specfont_dst) + specfont_copied = True + + print() + print("== zxbasm.py (asm -> bin + map) ==") + print(" ".join(zxbasm_cmd)) + try: + subprocess.run(zxbasm_cmd, check=True) + finally: + if specfont_copied: + specfont_dst.unlink(missing_ok=True) + + print() + print(f"Generated: {asm_path}") + print(f"Generated: {bin_path}") + print(f"Generated: {map_path}") + + +if __name__ == "__main__": + main() From 853b2ff94324465b03bf4da4f15ff4ef521d07b4 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:01:01 +0200 Subject: [PATCH 09/10] zx81sd: build_sd81.py --copy-to also copies boot1.bin boot1.bin is required on the SD card alongside the page files and .P loader for the program to actually boot -- copying everything but that made --copy-to produce an incomplete package. --- src/arch/zx81sd/tools/build_sd81.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/arch/zx81sd/tools/build_sd81.py b/src/arch/zx81sd/tools/build_sd81.py index 972adf224..67bc6f3a6 100644 --- a/src/arch/zx81sd/tools/build_sd81.py +++ b/src/arch/zx81sd/tools/build_sd81.py @@ -20,10 +20,9 @@ uppercased — same convention split_sd81.py already enforces (must be representable in the ZX81 charset: letters/digits, no underscore). - --copy-to DIR: after building, also copy the page files and the .P - loader to DIR (e.g. an emulator's virtual-SD test folder). BOOT1.BIN - is not copied — it's shared across projects and normally lives once - in the SD root already. + --copy-to DIR: after building, also copy the page files, the .P + loader, and boot1.bin (from this tools/ folder) to DIR (e.g. an + emulator's virtual-SD test folder). __ZX81SD__ is always defined; pass more -D flags to add extras. Any unrecognized argument is forwarded to zxbc.py as-is (e.g. to @@ -118,6 +117,13 @@ def main(): shutil.copy2(f, dest / f.name) copied.append(f.name) + boot1 = TOOLS_DIR / "boot1.bin" + if boot1.is_file(): + shutil.copy2(boot1, dest / boot1.name) + copied.append(boot1.name) + else: + print(f" Warning: boot1.bin not found at {boot1}") + print() print(f"== copied to {dest} ==") if copied: From 1af6a113e8a35fb6f70d30331c2bbca747a41da4 Mon Sep 17 00:00:00 2001 From: wilco2009 <43153593+wilco2009@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:48:01 +0200 Subject: [PATCH 10/10] zx81sd: bank large data tables into block 7, --asm build option Programs whose compiled size exceeds the ~32KB code/sysvar/heap budget (sprite bitmaps, screen dumps, sample tables...) can now relocate big constant data out of the way via a manual ORG into block 7 ($E000- $FFFF, previously unused). Adds block 7 to the boot page-map, and teaches split_sd81.py to skip packaging/loading pages that come out as 100% assembler gap-filler (the unused span this creates between code and the relocated data), instead of wastefully shipping/loading them. build_sd81.py gains --asm to also emit the generated assembly listing (a second zxbc.py pass), useful for tracing bugs live against the .map. Documents a related gotcha found while doing this on Mario GW: relocating a sub's asm body with ORG doesn't move where @name resolves to, since the compiler's own entry label is emitted before the ORG takes effect (PRECAUTIONS.md section 9, cross-linked from PORTING_GUIDE.md step 6). Co-Authored-By: Claude Sonnet 5 --- src/arch/zx81sd/backend/main.py | 1 + src/arch/zx81sd/doc/PORTING_GUIDE.md | 7 ++++ src/arch/zx81sd/doc/PRECAUTIONS.md | 46 +++++++++++++++++++++++++ src/arch/zx81sd/tools/build_sd81.py | 36 +++++++++++++++++++- src/arch/zx81sd/tools/split_sd81.py | 50 +++++++++++++++++++++++----- 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/arch/zx81sd/backend/main.py b/src/arch/zx81sd/backend/main.py index 63afe5731..70c32131c 100644 --- a/src/arch/zx81sd/backend/main.py +++ b/src/arch/zx81sd/backend/main.py @@ -48,6 +48,7 @@ (3, 11), # block 3 -> page 11 (4, 12), # block 4 -> page 12 (5, 13), # block 5 -> page 13 + (7, 15), # block 7 -> page 15 (data banking: large static tables via explicit ORG) ] _SD81_PAGE_PORT = 0xE7 # memory mapper port (full mode: OUT (C), A) diff --git a/src/arch/zx81sd/doc/PORTING_GUIDE.md b/src/arch/zx81sd/doc/PORTING_GUIDE.md index d3faab595..4d4a6b5dc 100644 --- a/src/arch/zx81sd/doc/PORTING_GUIDE.md +++ b/src/arch/zx81sd/doc/PORTING_GUIDE.md @@ -140,6 +140,13 @@ address-of reference at **global** scope keeps it alive: dim __keep_sprites as uinteger: __keep_sprites = @sprites ``` +If you're banking a large data table (sprites, a level map...) into +block 7 to fit under the `$8000` budget (step 8) by wrapping a `sub`'s +`asm` body in a manual `ORG $E000`/restore, `@name` stops pointing at +the relocated data entirely (a different, related gap — see +[PRECAUTIONS.md](PRECAUTIONS.md) section 9) — you'll need both this +dead-code workaround *and* to stop using `@name` as an address. + ## 7. Sysvar scratch bugs already fixed in the runtime — no action needed Several shared `zx48k/runtime/` files historically hardcoded a raw diff --git a/src/arch/zx81sd/doc/PRECAUTIONS.md b/src/arch/zx81sd/doc/PRECAUTIONS.md index b056e3089..b5b41f8f8 100644 --- a/src/arch/zx81sd/doc/PRECAUTIONS.md +++ b/src/arch/zx81sd/doc/PRECAUTIONS.md @@ -234,6 +234,52 @@ Any real program using your library will almost certainly also use `sysvars.asm` — so leaving out the `#include` in your file is safe in practice, not a hack. +## 9. Relocating a `sub`'s content with a manual `ORG` leaves `@name` behind + +If a program is too big to fit under the `$8000` sysvar/heap boundary +(step 8 of [PORTING_GUIDE.md](PORTING_GUIDE.md)), a large constant data +block (a sprite bitmap table, a level map...) can be banked out of the +way by wrapping it in `ORG $E000` / restore-the-previous-`ORG` inside +its `sub`'s `asm ... end asm` body, moving it into block 7 instead of +the code/sysvar/heap area. This works for the **data bytes** — but +`@name` (used from BASIC to get the sub's address, e.g. `poke @name,x`) +keeps pointing at the sub's **original**, un-relocated position, not +the `ORG`'d one. + +The reason: `@name` resolves to wherever zxbasic's own auto-generated +`_name:` entry label ends up, and that label is emitted as literal ASM +text **immediately before** your `asm` block's own content — i.e. +before your `ORG` line has had any effect. Labels bind to the +assembler's address counter at the point they're textually defined +(ordinary, correct assembler behavior — `zxbasm` isn't doing anything +wrong here), so redirecting the content elsewhere doesn't drag the +label along with it. If nothing else ever gets emitted at the sub's +"natural" position (because everything real was redirected away by the +`ORG`), the auto-generated `_name` and `_name__leave` (end-of-function) +labels end up at the very same address — a telltale sign in the +debugger/disassembly that `@name` is stale: it'll resolve to +`._name_leave`, not to your relocated data. + +Found while banking Mario GW's sprite bitmap table (`sub sprite()`, +~5.4KB) into block 7: `init_sprites()`/`tilexy()`/`tilexyaddr()` all +used `@sprite+N` to read/poke into it, silently computing addresses +against the *old* position once the data itself moved to `$E000`. +Symptom: no error at compile or load time — the game ran, the intro +screen and music played fine, and it hung in an apparently unrelated +infinite loop deep inside the sprite-blit routine (`tylexyasm`, stuck +forever on `jr nz, sigline`), because the wrong addresses eventually +corrupted the `(IX+n)` scratch fields the blit routine's own loop +termination depends on — several steps removed from the actual bug. + +**Workaround**: don't use `@name` for a sub whose content you've +manually `ORG`'d elsewhere — use the literal target address directly +(e.g. `$E000`) everywhere `@name` was used, under `#ifdef __ZX81SD__`. +The `sub` declaration itself must stay (it's still what emits the +relocated bytes), but since nothing references it via `@name` any more, +guard it against the dead-code eliminator (section 6 above) with a +throwaway global reference, e.g. +`dim __keep as uinteger: __keep = @name`. + ## See also - [PORTING_GUIDE.md](PORTING_GUIDE.md) — step-by-step checklist for diff --git a/src/arch/zx81sd/tools/build_sd81.py b/src/arch/zx81sd/tools/build_sd81.py index 67bc6f3a6..dceccf77e 100644 --- a/src/arch/zx81sd/tools/build_sd81.py +++ b/src/arch/zx81sd/tools/build_sd81.py @@ -13,7 +13,7 @@ generates .P, the tokenized BASIC loader) Usage: - python build_sd81.py [output_base] [--copy-to DIR] + python build_sd81.py [output_base] [--copy-to DIR] [--asm] [-O LEVEL] [-D MACRO ...] [zxbc.py extra args...] output_base defaults to the source file's name (without extension), @@ -24,6 +24,14 @@ loader, and boot1.bin (from this tools/ folder) to DIR (e.g. an emulator's virtual-SD test folder). + --asm: also emit .asm, the generated assembly listing (a second + zxbc.py pass with -f asm, same source/optimize/defines). Off by + default (it's a much bigger file than the .map and rarely needed) — + turn it on when tracing a bug live against the .map/disassembly, to + see the actual instructions around a given address instead of just + its label. The .map (symbol addresses) is still always generated, + unconditionally, as before. + __ZX81SD__ is always defined; pass more -D flags to add extras. Any unrecognized argument is forwarded to zxbc.py as-is (e.g. to tweak heap size, warnings, etc.). @@ -56,6 +64,9 @@ def main(): ap.add_argument( "--copy-to", metavar="DIR", help="Also copy the page files + .P loader here" ) + ap.add_argument( + "--asm", action="store_true", help="Also emit .asm (the generated assembly listing)" + ) ap.add_argument("-O", "--optimize", default="2", help="zxbc.py optimization level (default 2)") ap.add_argument( "-D", @@ -101,6 +112,29 @@ def main(): print(" ".join(zxbc_cmd)) subprocess.run(zxbc_cmd, check=True) + if args.asm: + asm_path = workdir / f"{base}.asm" + asm_cmd = [ + sys.executable, + str(ZXBC), + str(source), + "--arch=zx81sd", + "-O", + args.optimize, + "-f", + "asm", + "-o", + str(asm_path), + ] + for d in ["__ZX81SD__"] + args.define: + asm_cmd += ["-D", d] + asm_cmd += extra + + print() + print("== zxbc.py (asm) ==") + print(" ".join(asm_cmd)) + subprocess.run(asm_cmd, check=True) + print() print("== split_sd81.py ==") split_cmd = [sys.executable, str(SPLIT_SCRIPT), str(bin_path), base] diff --git a/src/arch/zx81sd/tools/split_sd81.py b/src/arch/zx81sd/tools/split_sd81.py index 9f8bfc9e6..c478d7f4e 100755 --- a/src/arch/zx81sd/tools/split_sd81.py +++ b/src/arch/zx81sd/tools/split_sd81.py @@ -13,6 +13,15 @@ Page 11 -> block 3 ($6000-$7FFF): same (external stage 1 resides here before the jump) Page 12 -> block 4 ($8000-$9FFF): sysvars + heap (data, not executable without MC45) Page 13 -> block 5 ($A000-$BFFF): heap continuation + Page 15 -> block 7 ($E000-$FFFF): data banking (large static tables placed + here via explicit ORG, out of the code/sysvar/heap budget) + +Any page that comes out 100% zero bytes is the assembler's own gap filler +(zxbasm zero-fills address ranges nothing was ever written to — see +src/zxbasm/memory.py), not real content: typically the unused span between +the end of code and a data table deliberately ORG'd into block 7. Sysvars +and heap are always freshly initialized at runtime, so this is safe to +skip entirely — the page is neither packaged nor loaded (see split()). The binary starts at $0000 and includes no .tap/.tzx header. Each output file is named P.BIN (uppercase), where N is the page. @@ -76,20 +85,43 @@ LOAD_WINDOW_ADDR = 57344 # $E000, block 7's window -def split(input_path: str, output_base: str) -> list[str]: +def split(input_path: str, output_base: str) -> list[tuple[int, str]]: with open(input_path, "rb") as f: data = f.read() if not data: sys.exit(f"Error: {input_path} is empty") - pages_written = [] + pages_written: list[tuple[int, str]] = [] page_num = FIRST_PAGE offset = 0 while offset < len(data): - chunk = data[offset : offset + PAGE_SIZE] + raw_chunk = data[offset : offset + PAGE_SIZE] + + # A full (unpadded) page that's 100% zero bytes is the assembler's own + # gap filler (zxbasm's MEMORY.dump() zero-fills any address range + # nothing was ever written to — see src/zxbasm/memory.py), not real + # program content. This shows up when a data table is deliberately + # ORG'd far away from the code (e.g. into block 7, $E000+, to keep + # it out of the code/sysvar/heap budget below $8000): everything in + # between is a zero-filled gap that never needs to reach the SD card, + # since sysvars/heap are freshly initialized at runtime anyway and + # nothing reads that memory before writing it first. Skip packaging + # and loading it — the runtime page-mapping (OUT to port $E7) for + # that block is unaffected, it just won't have been pre-loaded with + # this particular (irrelevant) content. + if len(raw_chunk) == PAGE_SIZE and raw_chunk.count(0) == PAGE_SIZE: + print( + f" Page {page_num} (block {page_num - FIRST_PAGE}): " + f"[{offset:#06x} - {offset + PAGE_SIZE - 1:#06x}] " + "all zero filler, skipped (not packaged/loaded)" + ) + offset += PAGE_SIZE + page_num += 1 + continue + chunk = raw_chunk # Pad up to PAGE_SIZE with 0xFF (undefined value for unwritten FLASH/RAM) if len(chunk) < PAGE_SIZE: chunk = chunk + b"\xff" * (PAGE_SIZE - len(chunk)) @@ -98,7 +130,7 @@ def split(input_path: str, output_base: str) -> list[str]: with open(out_path, "wb") as f: f.write(chunk) - pages_written.append(out_path) + pages_written.append((page_num, out_path)) print( f" Page {page_num} (block {page_num - FIRST_PAGE}): " f"{out_path} " @@ -111,15 +143,14 @@ def split(input_path: str, output_base: str) -> list[str]: return pages_written -def generate_loader_lines(page_files: list[str]) -> list[tuple[int, str]]: +def generate_loader_lines(pages: list[tuple[int, str]]) -> list[tuple[int, str]]: lines = [] lines.append((2, "FAST")) lines.append((5, f"LOAD THEN CLEAR {CLEAR_ADDR}")) lines.append((10, f'LOAD FAST "{BOOT1_FILE}"CODE {BOOT1_ADDR}')) line_no = 20 - for i, page_file in enumerate(page_files): - page_num = FIRST_PAGE + i + for page_num, page_file in pages: lines.append((line_no, f"LOAD *MAP 7,{page_num}")) lines.append((line_no + 5, f'LOAD FAST "{page_file}"CODE {LOAD_WINDOW_ADDR}')) line_no += 10 @@ -173,8 +204,9 @@ def main(): print(f"Generated {len(pages)} file(s).") if len(pages) == 1: print("The program fits in a single page (8 KB).") - else: - print(f"Pages {FIRST_PAGE} to {FIRST_PAGE + len(pages) - 1}.") + elif pages: + page_numbers = [p for p, _ in pages] + print(f"Pages {min(page_numbers)} to {max(page_numbers)} (some may have been skipped as filler).") loader_lines = generate_loader_lines(pages) loader_text = generate_loader_text(loader_lines)