MAD-65
Contents

MAD-65 Firmware Changelog#

Firmware = the two OS ROMs in roms/: CPU OS (cpu_os.bin <- cpu_os.s + cpu_demo.s) and GPU OS (gpu_os.bin <- gpu_os.s + gpu_diag_list.s + fonts.s + sprite_zero.s). Hardware (RTL / CPLD / PCB) revisions are not tracked here — see CLAUDE.md and the per-subsystem docs for those.

Dates are commit dates from this repository's history.


How the firmware is versioned#

Two numbers are stamped into every build, one hand-maintained and one automatic:

What Where Value now Maintained
Firmware version string roms/os_version.inc.define OS_VERSION "V0.9". Included by cpu_os.s (for the cpu_demo.s scroller: MAD-65 PROTOTYPE V0.9 - MONOCHROME ARCADE DEVICE) and by gpu_diag_list.s (GPU diagnostic screen: MAD-65 PROTOTYPE V0.9), which is its own translation unit V0.9 by hand — one place
Build stamp YYMMDD roms/build_date.inc, generated by roms/gen_build_date.py from the Makefile before every assembly; drawn as GPU OS: <date> (GPU boot background) and CPU OS: <date> (CPU demo background) 260903 automatic

Keep OS_VERSION four characters long ("V0.9", "V1.0"): both texts are hand-fitted to the 50-character grid, so a five-character string ("V0.10") would shift the rest of the row and needs both to be re-checked.

A fourth, stricter number exists and is deliberately not the same thing:

The version has been bumped exactly four times in the project's life: V0.1 (2026-06-04, diagnostic screen only), V0.5 (2026-06-13), V0.8 (2026-07-12) and V0.9 (2026-08-22) — 0.6 and 0.7 never existed. Until 2026-08-19 the string was duplicated by hand in both source files; os_version.inc replaced that with the single definition above (byte-identical ROMs).

To bump the version: edit os_version.inc, run make in roms/, and add a section below.

Roadmap#

Version Gate
V0.8 closed 2026-08-22
V0.9 current, and still open. Opened by TATE-mode text ($62 / $63, done 2026-08-22); comb sprites ($51, done 2026-08-23); the polygon family ($4C$4E) and CIRCLE16 ($4F), both done 2026-09-01. The comb background landed 2026-09-03 as part of the transport block ($31$33 + RLE on both CPUs — CPU1's side is LZ since 2026-09-11); the polygon family's OPEN flag landed 2026-09-06. Still wanted under this number: the rotate-90 asset script and DOT_CIRCLE16
V1.0 a successful bring-up on real hardware (no boards built yet)

V0.9 — since 2026-08-22 (current)#

Build stamp of the ROMs committed in the tree: 260911.

An OPEN version — work continues inside it. V0.9 was opened by the vertical (TATE) text opcodes and now also carries the comb sprite ($51), the polygon family ($4C$4E), CIRCLE16 ($4F) and the polygon family's own OPEN flag; the rotate-90 asset script and DOT_CIRCLE16 are still wanted under this number, so expect more entries here before V1.0.

The drawing block $40$4F is now full. The next opcode takes $41 (retired PIXEL_BG) or moves into $52$5F, and it also takes the last free slot in the dispatch chain, which is capped at 29 and holds 28.

Three more collisions went with it. PP_STATUS splits the handshake byte from the region — PPRAM had been both the 2 kB window and the byte at its base, so sta PPRAM read as a store to the window; twelve such stores across both ROMs now name the byte. CART_BANK_MIR replaces CART_SHADOW, which read as "the cartridge's shadow RAM" when it is in fact the mirror of a write-only bank latch, and "shadow" means the shadow code-RAM everywhere else; the old name survives as an equate alias. And the register block gained names the documentation can actually be checked against: AUDIO_SN1_PORT / AUDIO_SN2_PORT (the _REG suffix had meant two different things on one I/O page) and AUDIO_YM_ADDR — which the docs had been calling AUDIO_AY_*, naming an AY-3-8910 that is not in the machine. OS_SCRATCH and OS_STATE became real equates instead of doc-only labels.

Not an ABI break of any kind: no address moved. cpu_os.bin, gpu_os.bin and every cartridge image in the tree, EfS/cart.bin included, rebuild byte-for-byte identical — which is the whole proof that this was a rename and nothing else. Cartridges carry their own copy of the equates (mad65.inc), so no game is forced to rebuild; EfS was updated in step, CETAS deliberately was not. Hardware sources are untouched: CPLD signal names (/CS_YM, CS_BANK_LO, /CS_JOY) keep their own vocabulary.

spk_say copies the string and returns, so the engine needs no knowledge of cartridge banks; the pointer only has to be readable at the moment of the call, which satisfies itself because a game already has its message bank open to display the text it is about to speak. Two bytes are published: SPK_BUSY ($024D) for feeding long text a sentence at a time, and SPK_CUR ($0252), the phoneme cursor, so a caption arrives with the voice instead of on a guessed timer.

The phoneme indices are ABI; the formant values behind them are not — retuning the table improves every cartridge already shipped, renumbering would silently make them all say something else. 34 phonemes, 5 bytes each.

Two rules make sharing a PSG safe, and both are in the engine: every register is re-asserted every frame (~10 writes, 1.2% of a frame) so nothing else writing the same chip can corrupt the voice — except the noise control register, whose write resets the LFSR, so it goes out only on a rate change; and spk_stop always silences, which is what makes a register shadow in the VGM player unnecessary. The tick runs after audio_tick and vgm_tick so the write order is deterministic.

Cost: 1 068 B of ROM (898 B of engine, a 170 B table) and two jump slots. That takes the OS half from 1 254 free bytes to 186, and the table has 3 slots left — see the ROM budget note in MAD65_CPU_OS.md. If room is ever needed back, a reserved scale value lets a cartridge supply its own phoneme table and returns 170 B without breaking the ABI; that is the first lever, not cutting features.

carts/speech_test was rewritten onto this path (1 484 B → 864 B) and carries the check that matters: it decodes the captured chip writes through an independent model of the register file and compares the formant track against the table read straight out of cpu_os.bin. That check earned its place immediately — it caught a glide whose step was computed over a different number of frames than the glide actually ran for, which left /i/ at 1 721 Hz instead of 2 283 and simply made it the wrong vowel. Nothing else in the suite noticed.

What moved: the vector stubs ($C000/$C003$E000/$E003; the hardware vectors follow, and an assert pins the OS to CART_HIRAM_END + 1) and the OS code, now $E000–$F919 (6 426 B). The demo is $C000–$CBB2 (2 995 B, 5 197 B of fill behind it). The price is a ceiling: OS code must fit $E000–$FDFF for good — 1 254 B left — where before it could have grown over the demo; the jump table's five spare slots bind about as soon. A demo that outgrows its half is a link error ("memory area DEMOROM overflow"), never a silent spill.

New: carts/hiram_test/ — an 8 KB cartridge that fills the range, decrunches into it and runs code from it, then re-checks a page a frame and prints OK or FAIL — and roms/test_cart_hiram.py, which boots the real ROM from RESET in py65 with that cartridge plugged in, plays 70 VSYNCs through the real IRQ handler and frame loop, and attributes every access to $C000–$DFFF to the instruction that made it. The OS never reads, writes or executes there; the region ends byte-for-byte as the cartridge left it; and a negative control (the OS told to print a string from $C000) proves the watch reports an OS access when there is one. Confirmed in madsim too: make run in carts/hiram_test reads OK on the top line, so madsim models run-mode writes to $C000+ the way cpld_cpu1 does.

Cost: 23 bytes of demo_init and 2.08 frames (~34 ms) at boot, with interrupts still off and nothing on screen. Gain: CODE ends at $E4CC instead of $F9D05 380 B back, 6 451 B free below SINTAB — and the whole demo, song included, is now 2 995 B.

roms/test_vgm_clean.py now calls the real demo_init instead of copying its argument blocks (a copy that could drift from the code it claims to mirror), and takes every address from a debug build. It checks that the ROM holds the packed song and not the raw one, that demo_init expands it byte-exact and arms VGM_PTR/VGM_BASE on the RAM copy, and then, as before, that 1 400 ticks of chip writes match the independent decode across the loop point.

The codec follows the sink, so the two sides now differ on purpose. The GPU keeps RLE because the background plane is write-only to it — a decoder there can never read back what it wrote. CPU1 writes ordinary RAM, so its decoder can copy from its own output, which is all LZ is. Format (roms/lz.py): LZSS, 4 KB window, byte-aligned — a flags byte per eight items, a literal is one byte, a match two (12-bit offset, 4-bit length 3–17) or three (length 18–255 in an extension byte, so long runs stay cheap). Measured against RLE: song 6 464 → 1 025, 1-bit art 1 147 → 977, 6502 code 5 579 → 4 500 B. Decoder 174 B against 135, 73–95 cycles per output byte.

Bank $FF = flat source, the vgm_play convention: ROM, RAM, or the window with the cartridge off, and the bank register is never touched. That needed four bytes in cart_next — a BIT SC_TMP/BMI on the rare page-crossing path only — so a flat cursor steps from $9FFF to $A000 instead of re-banking. Banks are 0–127, so bit 7 alone tells them apart; the shipped streaming callers never pass $FF. A mutation build without those four bytes fails exactly the two flat-crossing checks and nothing else.

Two shapes kept from the 2026-09-03 lessons: the decoder is driven by the output count (it stops mid-match if need be, and lz.py asserts its cursor then sits exactly on the stream's last byte), and no branch trusts the flags a JSR hands back — dz_put returns none, and callers test DC_REM themselves.

OS ROM +43 B (CODE ends at $F9D0, 1 071 B free below SINTAB). New: roms/lz.py (codec, optimal parse) and roms/test_decrunch.py — the real ROM against real data and the codec's edge cases, every banked blob across a bank seam, flat from RAM and across $9FFF$A000 with the cartridge off, bank restore, no read past the blob, and no zero page touched beyond the documented clobbers.

polygon_core splits the flag from the count immediately on read, before the zero-count early-out — a raw $80 (OPEN, K=0) has to be treated exactly like $00, not fall through as if 128 vertices followed. The flag lives in a new byte, PG_OPEN ($D5), carved from the $D5$FF "general working variables, TBD" reserve rather than shifting every address comment after the polygon block by one. The @close label gates the closing edge on it; N=1 OPEN now falls through to a plain rts (one point has no segment to draw).

The three CPU1 builders needed one fix, not a new argument. They already copy the 7-byte header verbatim, so the flag rides along for free — but they mask N to K before using it to size SC_CNT/SC_LEN, because an unmasked OPEN header would read its own flag bit as 128 extra vertices and walk the copy loop off the end of the caller's buffer. Builder addresses ($FFD2/$FFD5/$FFD8) are unchanged.

No new opcode, no dispatch-chain slot spent — $4C$4E already had theirs. roms/test_polygon.py gained an independent open-chain oracle (K vertices, K-1 segments, no join) run against every shape already used for the closed-chain identity check, the K=0/K=1/K=2 degenerate cases, the new 127-vertex boundary (both flag states), and a builder round trip. Design writeup lived in MAD65_future_features.md and was removed from there on shipping, per that file's own rule.

What OP_LOAD could not do. $30 is a linear 256-byte copy to a page-aligned destination, and a page straddles 5.12 screen rows, so it is structurally row-agnostic — no arbitrary rectangle, and certainly no comb. $31 takes a byte column and a row and walks the destination by 50·(GAP+1), which makes the comb background a parameter rather than the second opcode the old design had reserved.

The rule the block is built on: compress at the source, decompress at the SINK. The background layer is transport-bound (~3.5 pages a frame through PPRAM), so a payload bound for GPU memory crosses PPRAM compressed and the GPU expands it; decompressing on CPU1 would push raw bytes through the one bottleneck that binds and win nothing. A payload bound for CPU1's own RAM never touches PPRAM, so api_decrunch does that side. The codec is therefore paid for twice (~110 B on the GPU, ~130 on CPU1) — the price of the rule, not an oversight. Measured on the repository's own 1-bit art: 1.4×–2.3×, about three quarters of what a general-purpose compressor finds. Measured on the VGM stream: 1.00× — it expands it, because vgm_clean.py already removed exactly the redundancy RLE would find, so the song stays uncompressed and that is a decision.

The replay record still fits in six bytes, which was the open question in the spec. XB/WB/GAP/H are constant for a whole load, so they live in job state ($0249$024C) and the record carries {RP_TYPE_RECTBG, bank, addr.16, Y.16} — the same trick gpu_load_cart_bg uses when it lets the destination page carry the position. RP_RECSIZE stays 6 and capacity stays 41, so no existing cartridge changes behaviour. A band's compressed length is read back off the cartridge instead of being carried in the record.

The bottom edge is checked; the horizontal one deliberately is not. Background writes go through a 16-bit pointer based at $C000, the framebuffer ends at $FA97, and past $FFFF the pointer wraps into zero page and the stack. The unused tail gives 27 rows of slack at GAP = 0 and exactly one at GAP = 15. Out-of-range rows are sunk — decoded and discarded — rather than skipped, because an RLE payload cannot be skipped without decoding and stopping early would leave the rest of the stream in PPRAM for the dispatch loop to execute.

Three bugs worth recording, because none was visible until something ran. The third is the interesting one. rb_raw_get ended on inc PPWP, so the flags it returned came from the pointer rather than the fetched byte and rle_next's BMI branched on the wrong thing; it now carries a PHA/PLA like cart_next always did. And the output-count decoder is only safe against an over-long run, not a short stream: the picture's 83 rows are not a multiple of the 8-row band, and the 3-row tail band left the decoder reading into the next command, where a stray $20 executed as OP_CLEAR_BG and wiped the finished screen one frame after it completed. rle.py now pads every band to a full H rows and says why.

And the one that only appeared in madsim, never in py65: both decoders read their control byte through a subroutine and then branched on it with BMI — on whatever flags the JSR left behind. Both fetch routines end on PLA. That is fine on silicon, where PLA sets N and Z, and fine under py65; it is not fine anywhere PLA leaves the flags alone, because the branch then reads bit 7 of the pointer the fetch had just incremented, and every control byte decodes at random. The symptom looked nothing like a flag problem — an unreadable picture and a background that half-arrived and flickered — because a run decoded as a literal (or the reverse) consumes the wrong number of source bytes and walks the decoder off the end of its stream into the next command. Both decoders now use an explicit CMP #$80 / BCS: two bytes, and the right shape regardless, because relying on the flags a JSR leaves behind is a contract nothing in this codebase writes down. Confirmed by patching py65 so PLA leaves N/Z untouched — the old code then fails 18 of test_rect_bg's checks, every RLE one and no raw $31 one, and the new code passes all of them. Nothing else in the project, CETAS included, ever depended on PLA's flags, which is why such a gap could sit unnoticed; carts/_pla_probe/ is kept to answer the question directly next time.

New: roms/rle.py (codec + band splitter), roms/test_rect_bg.py (geometry, the bottom clamp at every GAP, $31 == $32, and that PPWP always lands exactly past the command), and carts/rect_bg_test/ — the comb_test planet streamed onto the background at 2.20× less PPRAM than raw, with the captions readable through the gaps. Space after: GPU 1280 B, CPU1 1118 B, 5 jump-table slots.

Alignment now goes only where an indexed load can actually cross a page: row_lo/row_hi and the four quarter-square pages keep theirs, pg_sinmag drops to 128-alignment because it only has 128 entries, and everything reached through a pointer (SPRITE0, via SPR_PTR_L/H) or bounded well inside a page (glyph, text-row and vtext_col tables) is simply packed. CODE now owns $C000$EFFF in one piece and ends at $E9AA.

$C000-$EFFF CODE 12288 B <- one run $F000-$F2FF FONT_V 768 B $F300-$F37F PG_SINMAG 128 B $F380-$F3DD VTEXT_COL 94 B $F400-$F9FF TABLES_LO/HI + PG_QS/QR x4 1536 B $FA00-$FAFF DIAG_LIST (82 B used) 256 B $FB00-$FBBF FONT_GLYPH_LO/HI 192 B $FC00-$FEFF FONT 768 B $FF00-$FF49 TEXT_ROW_LO/HI 74 B $FF4A-$FFB1 SPRITE0 104 B

The image proves nothing moved: of 10 667 CODE bytes exactly 52 differ from the previous build, and every one is an operand naming a table that relocated. The whole py65 suite passes, as do the comb_test and tate_test end-to-end harnesses.

Two linker asserts went in with it. ld65 does fail on a CODE overrun, but it fails with "Segment 'FONT_V' start address is too low", which reads as a font problem rather than "the code got too big"; an assert at the end of the CODE segment now says it plainly. A second keeps DIAG_LIST inside its page, because growing past $FB00 would silently overwrite the glyph base table and corrupt every TEXT the GPU draws.

The one real cost: DIAG_LIST had 512 bytes reserved and now has 256 (it uses 82), and the assert is what makes that a build error rather than a mystery. Four islands CODE cannot reach remain — 34 B at $F3DE, 174 B at $FA52, 64 B at $FBC0, 72 B at $FFB2. They take tables, not code, and gpu.cfg names them as reserve.

Two reclaims were measured and deliberately not taken, so they are on the shelf if a later feature needs them: merging the four _noov sprite blitters into the _comb ones (they differ only in the row-advance block, so _comb with SPR_VSTEP = 50 is _noov748 B for ~3 cycles a row), and dropping pg_qr_lo/hi to add the +64 rounding term in code (462 B net for ~5 % slower polygons).

$48 DOT_CIRCLE is untouched and is NOT superseded. It stays the dotted, half-res call whose centre must be on screen, and it is about half the cycles$4F runs the identical midpoint walk at full resolution, which is precisely what makes its outline solid (8-connected, 1 px, no gaps).

Three things were considered and rejected, recorded here so they are not re-proposed. $4F was reserved with a CX.16, CY.16, ANGLE, SCALE, R header and did not take it:

Clipping a circle is exact. It happens per POINT, not geometrically — a point is on screen or it is not, with no intersection to compute and nothing to round. That gives $4F the tightest test in the suite: roms/test_circle16.py holds it to midpoint_walk(cx, cy, r) INTERSECT screen with no tolerance anywhere, on the fast path and the clip path alike, and checks the two agree on the same circle translated. The clip decision itself is made once from the bounding box: miss (200 cycles), wholly inside (no per-point tests at all), or straddling.

Solid circles are not cheap — 6 426 cycles at R=10, 13 983 at R=25, 27 571 at R=50, 54 123 at R=100 (a quarter of a frame for one circle), linear in R because every point is a read-modify-write to a computed VRAM address. The inner loop is tuned as far as was worth it: the column offset is built with an ORA instead of five shifts, and each pixel goes through (CI_RB),y rather than a constructed pointer — 17 cycles a plot against $48's 33, ~19 % off the first working version. Splitting the walker into separate fast and clipped copies would buy another ~11 % for ~350 bytes, which CODE no longer has to spare.

Two housekeeping consequences worth knowing next time. The dispatch chain is capped at 29 entries (its first BEQ must reach the first trampoline within ±128 bytes); CIRCLE16 took a slot, so COPY_DIS_ON/_OFF joined BG_REG_ON/_OFF and GPU_LED in dispatch_rare and the chain now holds 28. And CODE is getting tight: it ends at $E9AA against a $EB80 ceiling, ~470 bytes, after the polygon tables were moved to $EB80$EFFF so that pg_sinmag — only half a page — hands the other half back. (The 2026-09-03 repack lifted that ceiling to $EFFF; the addresses in this entry are the ones that were current on the day.)

There is no _CLIP suffix because there is no unclipped variant to distinguish it from: the anchor is signed-16 and is allowed off screen, so clipping is inherent. The docs simply say "always clipped".

No existing opcode or builder changed. $48 DOT_CIRCLE in particular is untouched — CETAS is released against it — even though the polygon family is a close relative of it (a shape anchored at its centre, but with a signed-16 anchor that may sit off screen). $4F is reserved for a matching CIRCLE with an identical header, so a composite object can be one data structure rather than two of different shape; not implemented.

This is a transfer, not a speed-up, and a deliberate one. Both processors are the same 65C02 at the same clock. The client is EfS, whose worst frame spent 190 113 of 237 404 cycles (80 %) on CPU1 while the GPU sat at 41–45 %. What CPU1 stops doing is every per-vertex step — rotate, scale, sign-extend, 16-bit centre add, outcode, the three-way dispatcher and its own bisection clipper — leaving a two-byte copy per vertex. Measured on a 12-vertex figure, the GPU pays 623–692 cycles a vertex for the transform and clip, i.e. ~8 000 a figure; five figures is ~40 000 cycles, about 17 % of a frame, on top of the raster it was already drawing. That is more than the ~10 % the transfer was modelled at, so budget from the measured number and lean on level-of-detail: the cost is linear in vertices, and sending fewer of them is entirely CPU1's lever (there is no shape table in GPU RAM and no SHAPE_ID — vertices go inline with every draw). A figure that misses the screen costs ~480 cycles flat.

What $4E unlocks: full-res figures, which could not be produced at all before. A CPU-side quarter-square multiply indexes its table with |x| + |cos|, which must stay inside a byte, so a vertex CPU1 transforms cannot carry better than half-res precision — and doubling a half-res result only ever gives an EVEN coordinate. Transforming on the GPU lets the same ±127 offset simply be read as full-res. Limit worth writing down: a figure wider than 254 full-res pixels needs ±128 and no longer fits.

Three implementation notes worth keeping:

The dispatch chain was full at 29 entries (its first BEQ must reach the first trampoline within ±128 bytes), so BG_REG_ON, BG_REG_OFF and GPU_LED moved into a new dispatch_rare tail — set-once opcodes, never inside a per-frame loop.

Tests: roms/test_polygon.py. At ANGLE = 0, SCALE = 128 each opcode must light exactly the pixels its closed chain does; crossing cases are held against an independent Liang–Barsky clip; the transform is probed with N = 1 (a one-vertex figure closes onto itself, so the framebuffer reports the matrix directly); and the rotation direction is compared with CPU1's own trig.

The policy is "the new frame wins", and there was never a choice: the ping-pong swap is unconditional hardware, so by the time the nested VSYNC arrives, the command list the interrupted dispatch was reading has been swapped away. Resuming it would splice one list into the middle of another — the same failure the CPU-side fix of 2026-08-27 addressed from the other end. So the in-flight frame is abandoned (the SP reset at the top of frame_isr already discards it) and the new one starts. The viewer sees one partially drawn image: the GPU now draws into the other double-buffer half, which the hardware background copy re-lays every frame, so a truncated image never persists.

What is new is that it is detected and counted: FRAME_BUSY ($65) is armed before the dispatch loop and cleared at the list's WAI; finding it set on entry bumps OVERRUN_CNT ($66), saturating at 255. Nothing in the OS reads it — it is there so a bench or a debugger can. CPU1 is not told, because it has no handshake waiting on GPU_READY, and its own overrun is a different failure that gpu_end already catches.

The ping-pong swap is unconditional hardware. When VSYNC caught a build mid-flight, irq_handler did its housekeeping and RTI'd back into the builder — correct by design, the CPU's thin-ISR model exists precisely so an overrun cannot re-enter and corrupt game state — but PPWP kept its address while the chip underneath it changed. Every byte emitted from that point landed in the buffer the GPU reads next frame, on top of the complete list already sitting there, and gpu_end then stamped CPU_READY over the result. The GPU has no way to tell: it decoded from a misaligned boundary, so argument bytes became opcodes. TEXT_BG / TILE_BG / CLEAR_BG with random arguments explain the background writes; BLINDER / BG_REG / COPY_DIS explain the video-register ones.

Why it was intermittent: the head of the doomed buffer is the previous frame's list. If both frames had the same command shape, the splice landed on a matching boundary and the result decoded cleanly (half old scene, half new — invisible). It only broke when the list shape changed before the splice point, i.e. exactly when something spawned or despawned.

The CPU_READY handshake itself was never the problem — frame_isr has always refused to draw without it (MAD65_GPU_OS.md), and it correctly skipped the frame the overrun happened in. The hole was the frame after.

The fix is one invariant — a list that straddles a swap is never published — in four small places:

With no handler this costs two undrawn frames instead of one drawn-wrong frame. The new published hook ABORT_VEC ($0246, $0000 = none) makes it one: the OS unwinds to FRAME_SP, JSRs the game's handler from whatever depth the render pass had reached, and closes the frame on return. The handler may call gpu_begin and build a fresh, degraded scene in the buffer the CPU now owns — the abandoned list's bytes end up beyond the new WAI, unreachable. Cost when nothing is late: ~5 cycles per emitted command (~0.6% of a frame at 300 commands), 3 bytes of OS RAM, no ABI change, and no behaviour change for a cart that leaves ABORT_VEC at zero.

Deliberately not done, after weighing it: giving CPU1 the GPU's never-return ISR (VSYNC abandons the frame and restarts it). It would close the same hole, but on CPU1 it deterministically livelocks a game that needs 1.05 frames — abandoned at the same point every time, never reaching gpu_end, and 64 frames later the GPU trips into diagnostic mode — where the current model degrades to 30 Hz. It would also abandon the game's simulation mid-update and leave the cartridge bank register wherever a half-finished routine left it. The abort path fires only at a builder call, a point the author can see in the source, which is a far weaker claim on the code than "any instruction may be the last".

New regression: roms/test_frame_abort.py — a three-deep render pass in py65 with the VSYNC store injected mid-build; pins the sink path, the handler path, that the stale bytes stay beyond the terminator, and that the stack returns to the frame loop's watermark so an abort every frame cannot creep. Developer-facing write-up: Software Developer's Guide, Chapter 23, "When you blow the budget anyway".

The byte-fill fast paths walk PTR past the end of their run and never track LN_MASK, so a chain cannot carry its cursor across an axis-aligned segment. l16_segment / lc_segment handle that by clearing L16_CHAIN so the next segment computes its own start address — and then op_lines16 / op_lines immediately wrote $80 back over it, so the next segment drew from a stale cursor anyway. The flag now belongs to the segment routines: they set it on the generic path (the octant loops end with the cursor on the endpoint) and clear it on the axis-aligned paths, and the drivers clear it once before the loop and never touch it again.

Worth recording why the tests missed it: the condition is an axis-aligned segment with another segment after it. A lone axis-aligned segment, or one that comes last, is correct even with the bug — and the original "axis-aligned box" case happened to be built from closed rectangles that re-anchored for other reasons. roms/test_line_chains.py now carries eight cases that deliberately put a fast-path segment where a later segment follows it, on both $4A and $4B, including the exact face quad from the dump.

mesh_xform_raw16 ($FFCC) and project_raw16 ($FFCF) are the same single divide with the focal multiplier and the screen centre both doubled — 256 and (200, 150) — selected from 2-entry tables indexed by a new VEC_RES16 flag, so the half-res path stays bit-for-bit identical (the test pins six known values). The quotient now lands on the 400 × 300 grid and comes out odd about half the time, which is exactly what doubling can never do.

Cost: 44 B of ROM, 6 B of jump table, +10…22 cycles per vertex out of ~3 340 (≈ 1 %, ~256 cycles a frame for an eight-vertex cube), and one zero-page byte. VEC_RES16 went to $7F, the OS-expansion reserve, not to the free OS_SCRATCH byte: the flag has to survive a whole mesh_xform_raw16 call, and that call goes through mul16/div16 — public API entries, which the OS_SCRATCH contract explicitly allows to clobber that block.

Two caveats now documented: the clamped byte outputs VEC_SX/VEC_SY are meaningless in full-res mode (399 does not fit a byte — full-res callers read the 16-bit pair, which is what *_raw is for), and persp_div saturates at |coord|·D >= 65536, so doubling D halves the world-coordinate range before saturation (|coord| >= 256 instead of 512).

carts/line_bench now projects modes 4–5 through the full-res entry and stops doubling; its README no longer claims the cart shows precision it could not. The jump table is 70 entries, $FF00–$FFD1.

single, clamped single, clipped chain
dotted half-res gpu_dotline gpu_dotline_clip gpu_dotlines $45
solid half-res gpu_line gpu_line_clip $FFC3 gpu_lines $FFC6$4A
solid full-res gpu_line16 $FFC0 gpu_line16_clip $FFBD gpu_lines16 $FFC9$4B

Two new opcodes, $4A LINES and $4B LINES16, and the point of a chain is not the PPRAM bytes — it is that each joint reuses the previous segment's endpoint, so lc_setaddr runs once for the whole polyline instead of once per segment. Measured ~290 cycles saved per joint ($4B; ~245 for $4A), constant regardless of edge length: 1.44× on 5 px edges, 1.30× on 10 px, 1.19× on 21 px, 1.11× on 42 px. Bytes drop from 9N to 4N+6 ($4B) and 5N to 2N+3 ($4A).

line_core and line16_core were split into an argument reader and a lc_segment / l16_segment drawing half, gated by one L16_CHAIN flag. The octant loops already end with the cursor on the endpoint, so they needed no change at all; the axis-aligned fast paths did, because the byte-fill emitters walk PTR past the run and never track LN_MASK. The first fix — rebuild the cursor after such a run — measured a 1 % net loss on an axis-aligned chain, because the segment then paid lc_setaddr twice. The fix that shipped simply clears the chain flag so the next segment computes its own address: one lc_setaddr instead of two, and the same chain turns into a 1.04× win.

A latent bug fell out of writing the test: read_byte_ppwp returns the byte in A but leaves Z set from its own INC PPWP, so bne after it tests the pointer, not the value. $45 had never range-checked N at all (N = 0 drew one segment); all three chains now share one contract — N = 0 draws nothing.

CLIP_MODE moved from $7F to $1D: $1D–$1F were unassigned OS_SCRATCH, which is the right home for a byte that only has to survive inside one API call, and it gives the reserved $7F back. The clipper's byte tail now emits SC_EMITOP ($1E) instead of a literal, which is the whole of gpu_line_clip. VEC_* and CLIP_* were checked for a future overlay — the clipper touches zero VEC_ bytes and neither block is in the ZP ABI assert — so 13 more bytes are available there if the OS ever needs them.

Budgets: GPU CODE 7 269 → 8 404 B (3 884 free), CPU CODE 13 666 → 14 073 B (1 799 free), jump table 63 → 68 entries, $FF00–$FFCB, all appended so every existing address is untouched. New test: roms/test_line_chains.py — a chain must light exactly the pixels the same segments sent separately do (axis-aligned runs, repeated vertices and screen corners included), plus the four new builders and a builder → GPU round trip.

Two error widths, because Bresenham keeps err in [0, major delta). The major delta now reaches 399, which in general needs a 16-bit error term and ~9 more cycles per pixel. But a line no longer than 255 px on its major axis still fits the 8-bit term — and 255 px is 64 % of the screen width, 85 % of its height, so that is nearly every line a game draws. op_line16 therefore has two sets of octant loops and picks between them off the major delta's high byte (for X-major, DY ≤ DX, so the one test suffices). Measured on the same physical line: +5–8 % over $42 on the narrow path, +32–37 % on the wide one. All eight loops come from one L16_LOOP macro parameterised by direction / major axis / error width, the way SPR_BLITTER does it, so there is a single source for the loop body.

The axis-aligned fast paths are shared, not duplicated: lc_hline / lc_vline were split into a head that computes the run and a tail (lc_hline_run / lc_vline_run) that emits it, and $43 jumps straight into the tails. The run length travels in L16_DX/L16_DY rather than PX_TMP, because lc_setaddr clobbers PX_TMP.

CPU side: one clipper, two rectangles. gpu_line16_clip is the same Cohen–Sutherland body as gpu_dotline_clip — the boundary constants moved into a clip_bounds / clip_limits table indexed by a new CLIP_MODE byte, so the loop, clip_vert, clip_horiz, clip_outcode and the whole 186-byte muldiv/div32 tail are literally shared. Only the emit tail is new (9 bytes with 16-bit coordinates, plus a clip_clamp16 edge guard). A second copy of that clipper would have cost ~540 B; this cost 146 B (CPU CODE 13 666 → 13 812, leaving 2 060 B below SINTAB). $43's outcode also had to become a real 16-bit compare — the half-res shortcut "high byte non-zero means off-screen" is wrong once X can legitimately be 256–399.

CLIP_MODE took $7F, the last free OS zero-page byte; $80–$FF still belongs entirely to the game. GPU CODE grew 951 B (7 269 → 8 220, 4 068 B still free). The jump table is now 64 entries, $FF00–$FFBF — appended, so every existing address is untouched and $42 is unchanged in behaviour and speed. New test: roms/test_line16.py — every octant on both loop families against one independent model, the 255/256 boundary, the $42-equivalence, and the CPU clipper against the full-res rectangle.

The error term stays 8-bit, which is what makes this free: doubling both deltas leaves the full-res error even, so the whole recurrence halves back to single bytes — err = DX>>1, err -= DY, borrow → err += DX. The loop keeps its DX iterations (the counter still lives in the X register) and unrolls two half-steps per iteration instead of plotting one doubled pair. Over 2·DX steps err provably returns to its initial value, so the line still starts on (2·X1, 2·Y1) and ends on (2·X2, 2·Y2)endpoints are bit-identical to before.

The line is now 8-connected and 1 px thick: the old loops had to plot an extra "bridge" pixel at every minor-axis step to stay 4-connected, because with a 2 px jog the line would otherwise fall apart into 2×2 blocks. At full res the jog is 1 px and the bridge would only fatten the staircase back, so it is gone — a 45° line is a clean 1 px diagonal. That also makes all three paths agree at last: generic, lc_hline and lc_vline all draw exactly 2·max(DX,DY)+1 pixels, 1 px thick.

Cost is close to a wash, because the extra per-pixel error update is paid for by the vanished bridge pixels: a 45° line is ~5 % cheaper (7 172 → 6 822 cycles for 50 half-res steps), a shallow or steep one ~12–16 % dearer, and a mixed fan of 20 lines came out +2.8 % overall — while writing a third fewer pixels. The four octant loops came out 96 bytes smaller in total (540 → 444). DOT_LINE/DOT_LINES are deliberately untouched — they skip every other row, so half-res stepping is invisible there. New regression test: roms/test_line_smooth.py pins the lit-pixel set against an independent full-res Bresenham in every octant, checks 8-connectivity and the exact pixel count, and fails if any shallow line's runs are all even again.

The raw export was 10 886 bytes against 8 650 bytes of usable ROM, so roms/vgm_clean.py was written to shrink it losslessly at the chips: drop writes that leave chip state unchanged (tracker macros re-send their value every tick whether or not it moved) and collapse silent frames into $61 waits. 10 886 → 6 432 B (−41 %), leaving 2 206 B free below SINTAB. Two traps the cleaner must respect, both of which cost a debugging session to find: YM2413 $0E is edge-sensitive (re-writing a set bit is how a drum retriggers, so deduping it deletes hits) and a write to SN76489 register 6 resets the noise LFSR. Correctness is proved by comparing chip state and the rhythm key-on sequence across intro + 2 full loop passes — a dedup computed on one linear pass can be right on lap 1 and wrong on lap 2, because the state entering the loop body is end-of-song state, not end-of-intro state.

demo_init now calls vgm_play_loop ($FFAE) rather than vgm_play, with the anchor at vgm_song + VGM_SONG_LOOP (offset 2231, frame 385) — the tune's own loop point, so its 6.4 s intro is heard once and the 12.8 s body repeats. roms/test_vgm_clean.py pins all of it, including the check that matters most: that the anchor lands on a command boundary. One byte off does not fail loudly — the player reads an operand as an opcode and the second lap is noise.

V0.8 — 2026-07-12 -> 2026-08-22#

V0.5 — 2026-06-13 -> 2026-07-12#

The build-out of the CPU OS and of the API surface games actually use.

V0.1 — 2026-06-04 -> 2026-06-13#

The GPU OS from nothing to a usable drawing engine. (The CPU side did not exist yet.)

Before the OS — 2026-05-26#