diff --git a/examples/sd81/bounce_sd81.bas b/examples/sd81/bounce_sd81.bas new file mode 100644 index 000000000..8a60112fc --- /dev/null +++ b/examples/sd81/bounce_sd81.bas @@ -0,0 +1,191 @@ +' 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. +' +' 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 ubyte + dim newX as ubyte + + if moven <> 0 then + newY = bally + dy + if newY >= 177 then + dy = -dy + else + bally = newY + end if + + newX = ballx + dx + if newX >= 31 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/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 6efddcb0b..70c32131c 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) @@ -47,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) @@ -98,7 +100,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 +157,23 @@ 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. + # 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 e9f2d7fef..880005ead 100644 --- a/src/arch/zx81sd/doc/MAP.md +++ b/src/arch/zx81sd/doc/MAP.md @@ -558,3 +558,276 @@ 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. + +## 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. + +### 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. + +### 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 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. + +## `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. + +## 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. + +## 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..4d4a6b5dc --- /dev/null +++ b/src/arch/zx81sd/doc/PORTING_GUIDE.md @@ -0,0 +1,212 @@ +# 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 +``` + +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 +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..b5b41f8f8 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 +## 4. There are no interrupts: never `EI`, never wait on `HALT` 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. +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 @@ -207,8 +234,58 @@ 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 + 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..dceccf77e --- /dev/null +++ b/src/arch/zx81sd/tools/build_sd81.py @@ -0,0 +1,171 @@ +#!/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] [--asm] + [-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, the .P + 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.). + +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( + "--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", + "--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) + + 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] + 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) + + 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: + 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() 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) 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/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/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 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 ec0a4c01a..3cfeb609e 100644 --- a/src/lib/arch/zx81sd/runtime/sysvars.asm +++ b/src/lib/arch/zx81sd/runtime/sysvars.asm @@ -66,7 +66,38 @@ 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 + +; --- 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 --------------------------------------------- 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 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