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.
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:
$FF00, is exactly
80 x 3 bytes (.assert near the end of roms/cpu_os.s), and is append-only: retired calls keep
their slot as a no-op stub rather than being removed (see $FF12 / $FF18, retired
2026-07-05). Cartridges bind to these addresses, so the ABI number moves only on a
breaking change — independently of V0.x.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.
| 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) |
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.
DEMO_RAM, PP_STATUS, CART_BANK_MIR. A
naming pass over the CPU1 map, triggered by CART_HIRAM — the one CART_* symbol
that had nothing to do with the cartridge window, the bank register or CART_EN. The
three regions a game may use freely are now named positionally: lower RAM
($0400–$77FF), upper RAM ($A000–$BEFF) and DEMO_RAM ($C000–$DFFF),
named for what it holds at boot rather than for who ends up owning it. "HIRAM" is
retired from this repository's vocabulary: cartridge link configs use it for their own
upper-RAM run areas and it means different addresses in different games.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 ($FFEA) / spk_stop ($FFED).
A speech synthesiser in the CPU OS, and not a sample player: three tone voices of
one SN76489 carry the first three formants of an utterance while the noise
voice makes the bursts in the stops, and the engine glides between phoneme targets
once a frame from the VSYNC IRQ. Sine-wave speech (Remez 1981) with squares instead
of sines, which is what a PSG can be asked for. A cartridge supplies a string of
phoneme indices and nothing else — "ENEMY DETECTED" is nineteen bytes, against
the 840 a pre-compiled register stream of the same phrase cost.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.
$C000–$DFFF belongs to the cartridge: the ROM is two halves now
(CART_HIRAM). Stage 3, the one the first two were for. roms/cpu.cfg splits the ROM
into two 8 KB memory areas: DEMOROM $C000–$DFFF holds segment DEMO — the demo, its
packed song, and nothing else — and ROM $E000–$FFFF holds the OS. Boot copies both
into shadow RAM as before; the demo runs only when no cartridge answers, so from
cart_init on those 8 KB are plain full-speed RAM that belong to the game, for code as
well as data. An ABI addition, not a break: the jump table and every entry address
are untouched, no cartridge ever wrote $C000+ (the docs forbade it), and
CART_HIRAM/CART_HIRAM_END are new equates.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.
mad65_theme.lzs — generated from the committed mad65_theme.bin by lz.py in
the Makefile, like fonts_v.s, and never committed — plus mad65_theme_lz.inc with its
two lengths, so the assembler asserts that the RAM copy fits and that the loop anchor
lies inside it without holding a hand copy of either. demo_init expands it once with
api_decrunch (bank $FF: a flat ROM address) into upper RAM at $A000 — free with no
cartridge; boot clears it and nothing claims it — and hands vgm_play_loop that copy.
The frozen player is untouched: flat mode never cared whether a pointer is ROM or
RAM, which is exactly the shape the 2026-08-24 song-size analysis arrived at and parked.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 $F9D0 — 5 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.
api_decrunch ($FFE7) is LZ now, not RLE — and takes a flat
source. Raised while shrinking the built-in demo to fit 8 KB: its 6 432-byte VGM song
is 39 % of the ROM, RLE cannot touch it (1.00×), and LZ packs it to 1 025 B. A second
decoder beside the RLE one would have spent a jump-table slot (five are left) and
~135 B; replacing CPU1's codec spends neither. Same slot, same arguments, same 4-byte
blob header — only the payload codec changed — and it could change because nothing
shipped on it: CETAS never called it, and EfS uses RLE only on the GPU path ($32
through gpu_rect_bg_cart, which streams bytes without decoding them). The one caller,
carts/rect_bg_test, had its caption blob regenerated.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.
POLYGON16 ($4E); it couldn't, because the family was coded and documented as
always-closed, with no suffix or variant that skips the last edge. Fixed by donating
N's bit 7 rather than growing the 2N + 8-byte wire format: clear (what every
existing caller already sends) is CLOSED, unchanged; set is OPEN, a true
polyline through the same rotate+scale matrix — K = N & $7F vertices draw K-1
segments, no join. K caps at 127 instead of 255, which costs nothing real: 127
vertices is already ~85 000 cycles, roughly a third of a frame, for one shape.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.
$31 RECT_BG, $32 RECT_BG_RLE,
$33 LOAD_RLE, and RLE on both processors. Compressed loading, rectangles on the
background plane and the comb background turned out to be one job — they share a
payload format, a dispatch home and a replay record — so they were built together.
The jump table reaches 78 entries, $FF00–$FFE9, with builders
gpu_rect_bg_begin ($FFDE), gpu_rect_bg_cart ($FFE1), gpu_load_rle_cart
($FFE4) and api_decrunch ($FFE7).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.
CODE gets one run, and 469 bytes become
1621. No logic changed; only gpu.cfg and the comments that described it. Every data
segment used to sit on its own page whether or not anything indexed it — a 94-byte table
held 256 bytes, the built-in sprite held 256 to store 104 — which scattered ~1.1 kB of
fill across seven holes CODE could not reach and left CODE walled in at $EB80.
That was not enough to land the rectangular background blit the comb needs, let alone a
decompressor beside it.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 _noov
— 748 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).
$4F CIRCLE16: the circle that goes on the same anchor. A SOLID
circle outline around a signed-16 full-res centre that may sit off screen, and
therefore always clipped — CX.16, CY.16, R, 6 stream bytes, builder
gpu_circle16 $FFDB. The jump table reaches 74 entries, $FF00–$FFDD. It is
the circle member of the family the polygons opened: an enemy that is "a circle plus a
polygon on one anchor" is now expressible, from one struct and two commands.$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:
ANGLE does literally nothing to a circle. In the polygon family it is free
because rotation and scale compose into one matrix; here there is nothing to compose.SCALE is not free once the rotation is gone. It would ride in no existing
product — one extra multiply plus a stream byte, to save the caller a multiply it
already does once per object. The composite-object case that motivated the reserved
header survives anyway: only the ANCHOR has to be shared, not the wire format.CIRCLE buys nothing. For a line, half-res is a real trade ($42
spends 4 argument bytes where $43 spends 8). Here the centre is signed-16 and R
is a byte either way, so both forms are 6 stream bytes and the same cycles for the
same picture; all it would add is a radius range past 255 full-res — a circle
510–1020 px across, on a 400 x 300 screen — for a centre that can only land on even
pixels. The variant that WOULD earn an opcode is the dotted one, DOT_CIRCLE16,
filed as a candidate in MAD65_future_features.md.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.)
$4C DOT_POLYGON, $4D POLYGON,
$4E POLYGON16. A closed figure around a defined centre, rotated and scaled by the
GPU, and always clipped. Arguments are
CX.16, CY.16, ANGLE, SCALE, N, dx0,dy0, … — 2N + 8 PPRAM bytes — with builders
gpu_dotpolygon $FFD2, gpu_polygon $FFD5 and gpu_polygon16 $FFD8. The jump
table grows to 73 entries, $FF00–$FFDA. This completes the line families'
3 × 3 matrix into a 3 × 4 one: a fourth column — "polygon: anchored, rotated,
scaled, always clipped" — for all three families.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:
pg_qs/pg_qr, f(x) = floor(x²/4) and the same table +64 so the rounding term is
free) and a sine. 1152 bytes at $EB00–$EF7F. The sine is |sin| on a 0–128
scale, not the CPU OS's 0–127 Q0.7 — that is what makes ANGLE = 0, SCALE = 128 an
exact identity and a quarter turn drift-free. The cost is that the two processors'
trig can differ by one unit in 128; the direction is identical, and
roms/test_polygon.py checks it against API_SIN/API_COS run out of the CPU ROM.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.
frame_isr did cli on entry
so a second VSYNC could nest, and nothing said what a nested frame should do to a
half-drawn image. The polygon family raises GPU load enough that this stopped being
theoretical.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:
gpu_begin records the caller's stack pointer in FRAME_SP and clears
VSYNC_FLAG. Arming moved here from os_run so that a cart-owned loop, and an
abort handler opening a fresh scene, both arm it by construction. (A cart with
its own loop never armed the detector at all before this.)pp_room — the single gate all 21 builders pass before their first emitted
byte — refuses once VSYNC_FLAG is set, and raises OVERRUN_FLAG there, at
the moment of detection.gpu_end withholds CPU_READY (writes CPU_WORKING) and does not lay down the
WAI, since PPWP is pointing into the other buffer by then.os_run's labels became global so the abort path can re-enter at
os_frame_tail.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".
carts/line_bench modes 3
and 5, which showed a stray edge shooting off the cube.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.
$43/$4B had no producer.
Caught by eye in madsim, not by a test: the cube's corners snapped exactly the
same way in the LINE16 modes as in the LINE ones. The reason is that the
vector library projects to half-res — PROJ_CX/PROJ_CY are 100/75, and
VEC_SX16/VEC_SY16 are unclamped but still half-res — so the only way to feed
a full-res opcode was to double that result, and doubling only ever yields
EVEN coordinates. Which is precisely $42's quantisation. Fed from the 3-D
pipeline, $43 was $42 with four extra bytes.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.
$42 had a clamped builder but no clipped one, $43 had a
clipped builder but no clamped one, and only the dotted family had a chain. Now every
family offers the same three calls:| 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.
LINE16 ($43) + gpu_line16_clip ($FFBD): solid lines
with full-resolution endpoints. $42 takes half-res arguments, so its ends can
only land on even pixels. That is invisible in a still picture and very visible in
motion: a slowly rotating or drifting line snaps in 2 px jumps, and consecutive frames
often draw the same line. Sweeping a 140 px spoke through 10° in 0.4° steps, $42
produces 13 distinct lines out of 25; $43 produces 24. $43 is the same
renderer with the doubling taken out — given the doubled coordinates it draws a
bit-identical picture to $42, which is how the test cross-checks it.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.
LINE ($42) is drawn at full resolution — the staircase is
gone. The opcode's arguments were always half-res (0–199 / 0–149), but so was the
rendering: Bresenham ran in the 200×150 grid and every step was doubled into a 2 px
run, so on a shallow line every horizontal run was an even number of pixels and every
vertical jog was 2 px tall. Now only the endpoints are doubled and Bresenham steps one
full-res pixel at a time.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.
roms/mad65_theme.bin — an original Furnace tune
for YM2413 + SN76489 #1, 19.2 s, with percussion on the YM2413's hardware
rhythm mode ($0E: 37 bass-drum, 27 snare, 97 hi-hat hits) instead of PSG
noise. cyborg_hunter.bin is no longer referenced. The player itself is
untouched — it is frozen ABI and CETAS ships on it; the song is an ordinary
VGM stream it reads directly.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.
COMB_SPRITE ($51) /
gpu_comb_sprite ($FFBA). Source row r is blitted to screen row
Y + r*(GAP+1); the rows in between are never written, so the background the
hardware copies under the scene every frame shows through as hard-edged bands.GAP is an argument (0–15), not a constant. The spec had a fixed one-row gap;
a parameter buys animating the step per frame — the object stretches and thins as
GAP rises, which is a dissolve/explosion, not a zoom. Cost is per source row,
so it is independent of GAP and falls as the object grows past the screen
edges: measured on a 128×41 source, GAP 0 → 1 → 3 costs a flat 11.4 → 11.8 →
11.8 % of a frame for 41 → 81 → 161 px of on-screen height, then drops to 9.6 % at
GAP=7 (321 px) as rows clip away.$50 and $51 — solid and ghost from the
same asset, which is the whole point of making it an opcode rather than a
type-byte bit. The design doc argued the opposite (that the overlay "sells it");
that holds only over a bright background, and over a dark one the punched-black
drawn rows next to untouched gap rows read as shredding the background. Cost of
the decision: a comb sprite can never occlude what is behind it.$50 is untouched. SPR_BLITTER gained a third macro parameter and four new
bitmap-only instantiations (~500 B); the eight existing ones assemble
byte-for-byte identically — verified by diffing the emitted code before and
after, where all 28 differing bytes are address operands shifted by exactly the
relocation amount. spr_setup gained one 4-byte branch (6 cycles for $50) and
its vertical half was split so both paths share the tail. The alternative, a single
16-bit add in the shared macro, would have taxed every ordinary sprite ~6
cycles/row (~3 % on an 8×8).top_skip = -Y = D*q + r the first visible screen
row is (r == 0) ? 0 : D - r, anywhere in 0..D-1, where $50 only ever producesceil((300-fr)/D), not 300-fr — the old
bb >= 256 -> no clamp shortcut is valid only for D = 1, and dropping the /D
would run the row loop past the image end at $BA98 into the video registers at
$BFE0 (BLINDER / COPY_DIS / SHADOW_MODE). GAP=0 deliberately keeps $50's
vertical clip, because there alone the row count reaches 300.gpu_comb_sprite is the 63rd jump-table entry at $FFBA; the table
is 189 bytes, $FF00–$FFBC, and the .assert moved 186 → 189. Append-only, so
every existing vector keeps its address and cartridges built against V0.8 are
unaffected.carts/comb_test, whole frame, 237,404-cycle
budget). The unit is one slot-row — one 64 px slot, one source row; a W×H
sprite is ceil(W/64)*H of them. Aligned (x & 7 == 0) a slot-row is ~330 cycles,
~720 per frame; at the worst bit offset (x & 7 == 4) ~570, ~415 per frame. The
comb costs $50 plus ~2 %: ~6 cycles/row for the 16-bit SPR_VSTEP add, plus
one div_small (~340 cycles) per sprite for the bottom clamp.x & 7 is the single biggest term, and it is a TENT, not a ramp — the comb
blitters inherit spr_setup's shift-direction choice unchanged (it is decided in
the half both opcodes share), so offset 7 costs what offset 1 costs and only 4 is
the peak. On a 4-slot planet: 109k, 129k, 149k, 168k, 188k, 167k, 147k, 128k
for N = 0…7. That is a 1.7× swing on identical output.x = −4 measured 96 % of a frame against 55 %
at x = 8.GAP then makes it as
tall as you like for nothing.roms/test_comb_sprite.py — source→screen mapping across several
steps, every residue class of top_skip mod D for D = 2, 3 and 5, the bottom
clamp at both phases, whole-sprite rejects, horizontal clip under a gapped step,
the overlay-ignored contract, $51 GAP=0 == $50 byte-for-byte, four-part
meta-sprite row agreement, and the CPU-side command bytes. Every case also asserts
nothing was written at or past $BA98. All pre-existing ROM tests pass unchanged.carts/comb_test/ — a 256×83 planet authored for the comb
(vertically halved, round again at GAP=1) drawn as a 4-slot meta-sprite that
flies around on its own over background captions you can read straight through it.
GAP, the planet size and an auto ramp are on port 2; port 1 parks it and nudges
it. Its preview.py runs cart → CPU OS → PPRAM → GPU OS in py65 and measures what
a unit test cannot: every skipped row bit-identical to the background, drawn rows
only ever adding ink, GAP=4 cheaper than GAP=1, and every frame of the run
inside the GPU's budget — in cycles, not guesses.Three things the cartridge cost to learn, all of them general:
joy_read. The kernel's frame ISR samples both
ports before cart_frame runs; calling it again recomputes JOY*_PREV from the
current state and every edge byte comes out zero. Held directions keep working
while every press-driven control silently dies — a very specific symptom worth
recognising. The Software Developer's Guide (Ch. 10) already said so.ldx #n … jsr … dex / bne loop around one
counts garbage and empties its whole queue into a single frame. Keep such
counters in zero page. gpu_text_bg also drops silently when the mailbox is
full and its carry cannot be tested (rp_capture returns carry set on success
too), so the only safe discipline is not to fill the mailbox — sequence heavy
setup jobs into separate frames rather than racing them.2026-08-23 — The built-in demo gained a PSG2 audio test (demo_sound).
The demo drove all three chips on paper but only two in fact: the VGM tune held
SN76489 #1 + the YM2413 while the effects PSG sat silent — nothing ever called
sfx_play. It now answers the joysticks, as the audio counterpart of the arrows
demo_joyread already draws.
sfx_play_ptr's voice hint (Y = 0–2) doing its job —
sfx_play auto-allocates and the walk would have meant nothing.FIRE is identical on both,
because it is about the cube and demo_joyread's arrows already name the port on
screen. Two one-step tables and a DEMO_SND_PTR; the round-robin stays shared, so
the voice walk advances whichever stick produced the blip.DEMO_CLOSE, the counter that pulls the cube in, so the sound and
the cube's approach are locked together by construction: it ends when the cube
arrives or the button is released, and never sounds during the ease-back out.sn_tone_regs writes the attenuation register
either way: silence would have cost exactly what sound costs. The consequence is
that the two cannot be detuned from each other — one divisor, one sound.FIRE2 now pulls the cube in too (demo_update's mask became
FIRE_BIT|FIRE2_BIT). It was decorative before — drawn by demo_joyread, acted
on by nothing. The two buttons are now the same button everywhere except on
screen.roms/test_psg2_demo.py — new, and the first py65 test aimed at CPU1
rather than the GPU. It runs demo_update + demo_sound frame by frame and
checks the exact byte stream reaching $BF10: the voice walk, one blip per press
however long the hold, both sticks matching, the diagonal double-sound, both fire
buttons, the monotonic sweep, the single noise-control write, the stop-on-arrival
and stop-on-release, the channel-6 skip, and that SN76489 #1 and the YM2413 are
never touched. Sound is the one subsystem a screenshot cannot regress.$A8–$AA). No change to cpu_os.s
— the demo reaches the chip only through the frozen jump table, as a cartridge
would.Docs corrected while here. MAD65_CPU_OS.md claimed a FIRE-triggered zap
via sfx_play (never true — nothing called it) and described joystick-controlled
sprite 0 and the orbiting-sprite ring as demo features. demo_sprite and
demo_circle are deliberately left in the source but commented out of
demo_frame, kept for quick hands-on checks; the demo section now describes what
actually runs, with demo_joyread's arrows as the demo's input readout.
2026-08-23 — The horizontal grid is centred: a 2 px margin above and below.
37 rows of 8 px is 296 of the framebuffer's 300 pixel rows. Those 4 spare rows sat in
a lump at the bottom; TEXT_ROW_BIAS (100 bytes = 2 pixel rows) now splits them 2 and
2, so text row 0 starts at fb-y 2 and row 36 ends at fb-y 297.
fb-y
axis into the same 37 slots and was already centred (cell 36 at fb-y 2–9, cell 0
at fb-y 290–297). With the bias in place both grids occupy exactly fb-y 2..297 —
they are now literally the same subdivision seen from two orientations, which is
what "a 90° turn maps the framebuffer onto itself" should have meant all along.text_row_lo/hi generator expression. No code
change, no extra cycle, no extra byte in either lookup. Row 36's last byte moves to
36*400 + 100 + 350 + 49 = 14899, still inside the 15 000-byte image.
calc_text_vram_addr — the unused standalone sibling of the table lookup — gained
the same bias so it cannot drift from the tables.LOADed background art — now sits 2 px higher relative to the text over it.
TILE shares the table and moves with TEXT, deliberately: they are one grid, and
splitting them would need a second table and would misalign text drawn over tiles.Tests: roms/test_tile_render.py carries the bias in its independent model and
asserts the 2 + 2 margin directly; carts/popcorn_demo/preview.py grew a
text_px_row() helper so its pixel-level marquee and cell checks follow the grid.
All ROM tests and both cartridge previews pass.
2026-08-23 — VTEXT coordinates reworked to match TEXT exactly — $62 / $63
now take (X, Y) in the same order, with the same directions and the same origin as
every other cell-grid call: X = character cell 0–36 running left to right,
Y = text line 0–49 running top to bottom, both plain unsigned. Together with the
37th horizontal row added the same day, the two grids are now exact transposes —
50 × 37 upright, 37 × 50 on its side — and nothing about the call shape differs
between them. This changes the wire format and the builder arguments of the two
vertical opcodes; they shipped only the day before, and the jump-table addresses
($FFB4 / $FFB7) are untouched, so ABI v1 is not affected.
−1…38, which meant $FF and $FE were legal
argument values and a caller could aim at cells that hang off the grid. Now anything
outside 0–36 draws nothing, and the CPU builders clamp — the same pair of limits the
horizontal builders use, in the opposite order.TEXT already had:
append one extra character and let scroll reveal it. The mechanisms differ and
cannot be made to match — TEXT shifts bits, so its guard's pixels cross into the
last on-screen cell's byte and the guard is never stored at all; VTEXT translates
whole glyph rows, so pixels never cross a cell boundary and the guard must be
stored at its own address with its off-screen rows dropped. Two cells rather than
one because a cell is 8 px and scroll spans 0–7: at scroll 7 cell 37 starts at
fb-y 1 and the pixel at fb-y 0 comes from cell 38.BMI per row; a negative offset would land in the
PPRAM window at $7800). The left edge needs no test at all: with X ≤ 36 the
largest offset any store can reach is 14500 + 350 + 350 + 49 = 15249, and the
15 000 visible bytes sit inside a 16 352-byte VRAM window whose tail is scratch, so
a glyph row scrolled off the left simply writes into invisible memory and vanishes —
pixel by pixel, for free. The X range check is the safety property that licenses
this, and a .assert on VT_MAX_OFFSET < VT_REG_OFFSET in gpu_os.s fails the
build if a future change widens the cell range, the scroll range or the line count
past that bound. Whole cells now run a completely untested 8-store loop; the old
VT_WHOLE_HI fast-path compare and the whole high-side branch are gone.vtext_col_lo/hi is indexed by the cell directly — the +1 bias and
the cell −1 entry are gone, and it is generated from the new VT_COL0 constant
the assert reads, so the two cannot drift. 39 entries instead of 40; the VTEXT_COL
segment is 94 bytes and still inside page $F3 (ends $F35D).txt_core no longer carries a clamp limit in its flag byte — both limits
depend on the opcode now, so the two clamps moved into the entry stubs as
txt_clamp_h / txt_clamp_v and the flag byte reduced to the single "filter the
payload to the font range" bit.−1 to follow it
into. Those 3 px are a glyph cell's blank right-hand columns, so the seam does not
read on screen; carts/tate_test/ runs a marquee in each direction and shows it.Tests: roms/test_vtext.py reworked for the new order and ranges — 83 checks,
including a sweep that every out-of-range X or Y draws not one byte (the check
that keeps the missing left-side clip safe), a sweep that no (cell, scroll, line)
escapes the VRAM window, two tests that X and Y are not interchangeable, and
monotonic edge tests for entering right and leaving left. carts/tate_test/
(cartridge + preview.py) updated: the marquee anchor moved from cell −1 to
cell 0 with a 39-cell window, and preview.py now asserts the deliberate tail
write instead of forbidding it, plus that nothing reaches the register overlay.
2026-08-23 — The horizontal text/tile grid gains row 36 — it is now 50 × 37
cells, the exact transpose of the vertical grid's 37 × 50. text_row_lo/hi had 36
entries and stopped at row 35, so the bottom 12 pixel rows of the framebuffer were
unreachable by TEXT, TEXT_BG, TILE and TILE_BG. That was an oversight from the
start of the project, not a design choice: row 36 covers pixel rows 288–295 and its
last byte is 36*400 + 350 + 49 = 14799, comfortably inside the 15 000-byte image.
Only 4 pixel rows (296–299) are now outside the grid, matching the 4 the vertical grid
leaves as its 2 + 2 side margins.
TEXT_ROW_HI moves $F724 → $F725 in
gpu.cfg (both tables still end inside page $F7, at $F749, so no indexed load
page-crosses and the lookup stays a flat 4 cycles). No code change — draw_text_core
and draw_tile_core never validated the row, they only ever indexed the tables.txt_core's flag byte goes 35 → 36 for all four
horizontal builders (gpu_text, gpu_text_bg, gpu_tile, gpu_tile_bg).Tests: roms/test_vtext.py gains the row-36 pass-through case and its
clamp assertion moves 35 → 36; every other ROM test is unchanged and still passes.
2026-08-22 — Vertical (TATE) text implemented — the change that opens V0.9.
VTEXT ($62) / VTEXT_BG ($63) and draw_vtext_core (+261 B of
CODE). Grid is 37 cells × 50 lines; scroll 0–7 is a whole-VRAM-row offset
rather than a bit shift, so it needs neither the shift chain nor the scratch
buffer TEXT uses — and unlike TEXT, every character is drawn. (This entry
originally said the offset moved the string toward the on-screen right, which
the next bullet already contradicted; it moves LEFT. Corrected 2026-08-23. The
argument order and cell range described below were reworked the same day — see
the entry above.)scroll moves the string LEFT, the same direction TEXT ($60) scrolls. Not a
detail: a developer writing a vertical game must not have to invert a habit, so the
marquee recipe is identical in both modes (run scroll 0→7, step the string one
character on the wrap). roms/test_vtext.py measures both opcodes and fails if they
ever disagree.−1…38), clipped per GLYPH ROW at both
edges — the same contract SPRITE has for its position, and the thing that makes
a marquee usable: a glyph row is one 8-px-tall column on a rotated screen, so
per-row clipping is hiding a character behind the frame one pixel column at a
time. A row is stored only while its offset is in [0, 15000), which is also the
safety property: below 0 is the PPRAM window (the CPU mailbox), at/past 15000 are
the video registers. Whole cells take a fast path (one compare) so a full line pays
two extra compares per cell; only the cells straddling an edge run the checked loop.
The string stops early once a cell is wholly past the right edge, and skips (but
continues past) one wholly off the left.font_data_v at $F000 (768 B, FONT_V), generated at build
time from fonts.s by roms/gen_font_v.py into fonts_v.s — one authored font,
both orientations. Pinned exactly $0C00 below font_data so draw_vtext_core
reuses font_glyph_lo[] with a fixed high-byte adjust instead of carrying a second
pointer table; a linker assert enforces the gap, and a second assert ties the
generator's ROT_DIR to the asm's -400 advance. New VTEXT_COL segment at
$F300 (90 B) for vtext_col_lo/hi + vtext_scr_lo/hi.gpu_vtext ($FFB4) / gpu_vtext_bg ($FFB7) — the jump table grows
to 62 entries, $FF00-$FFB9 (append-only, so every existing cartridge address is
unchanged). They share txt_core with TEXT/TILE; the second argument's clamp
limit now rides in the flag byte — 35 for the horizontal grid's rows, and 0 =
"do not clamp" for the vertical cell, since clamping a signed coordinate would
destroy $FE/$FF. OP_VTEXT_BG joined the background replay table, so a single
call still lands in both background buffers.roms/test_vtext.py — font rotation pixel-by-pixel out of the built
ROM, the GPU geometry/clip/PPWP contract, the CPU builders, a sweep asserting that
no (cell, scroll, line) can put a byte outside the framebuffer, and two
monotonicity tests that a solid block leaves each edge 8 px at a time rather than
all at once. Plus carts/tate_test/preview.py, which runs cartridge -> CPU OS ->
PPRAM -> GPU OS end-to-end in py65, checks the edge columns really get painted, and
renders the rotated screen to a PNG.carts/tate_test/ — a runnable 8 KB single-bank cartridge.CMPs
the dispatcher now walks past on LOAD/WAI.OS_VERSION centralised in roms/os_version.inc; the diagnostic
screen and the demo scroller now build their text from it instead of each carrying a
literal V0.8. Version deliberately unchanged, and both ROMs re-assemble byte-identical
to the committed binaries apart from the automatic build stamp, which the rebuild moved
260814 -> 260819. This changelog added.cpu_os.bin / gpu_os.bin rebuilt (stamp 260814); OS docs de-versioned
and stale references fixed. No functional ROM change.$C000-$FFFF
SRAM copy, then SHADOW_MODE), and the wai macro was cleaned up so cl65 stops
complaining. Both OS binaries recompiled.GPU OS: YYMMDD / CPU OS: YYMMDD).
The GPU draws it twice with a frame between, so it lands in both double-buffered
background banks; the CPU emits its line once via API_GPU_TEXT_BG and the OS background
auto-replay copies it into the second bank. The BLINDER reveal was deferred so the
datestamp never flashes alone before the rest of the boot screen. Version string bumped
V0.5 -> V0.8 in both places.OP_HDOT_LINE ($49) + CPU builder gpu_hdotline ($FFB1, entry 60):
a byte-aligned horizontal dotted run collapses to a constant $AA memfill —
~13 cycles/byte vs ~194, a ~10-14x speedup over the generic Bresenham dot line.
Self-clipping, and it always consumes its args so the command stream stays aligned.LINE_BG ($43) and PIXEL_BG ($41) removed: setting a single bit
needs a read-modify-write, and the VRAM-background window is write-only, so these opcodes
could never have worked. Their CPU-side slots $FF12 / $FF18 became ABI-reserved no-ops
so every later API address stays fixed. Also fixed the pre-reset background staying
visible after a reset: the boot background clear now runs as a tight back-to-back loop
(BG_CLEAR_PASSES = 12) instead of a frame-phase-locked one, which had orphaned a region
in one bank and flickered it at 30 Hz.vgm_play_loop ($FFAE): loops the stream's 0x66 back to a separate
anchor, so a song can play a one-shot intro once and then repeat only its body
(vgm_play is unchanged). VGM opcode 0x30 (SN76489 #2) is now ignored instead of
faulting the song — the second PSG is reserved for sound effects.The build-out of the CPU OS and of the API surface games actually use.
joy_read mask $1F -> $3F, JOY_* equates added).
Bits 0-4 keep their positions, so single-fire software is unaffected.OP_TEXT patched; font data updated.gpu_load_cart_bg added.WBYTES = 8, two new SPR_BLITTER instantiations,
span buffers relocated to $0200/$0210) — no sprite format change, and the 8/16/32 px
blitters stay byte-identical. Same day: cartridge -> GPU loaders
gpu_load_cart ($FF9C), gpu_load_cart_begin ($FF9F), gpu_load_cart_n ($FFA2) —
stream cart pages straight into LOAD commands (no cart -> RAM -> PPRAM double copy) and
drain a multi-page job cooperatively, non-blocking, across frames.gpu_dotpixels_clip ($FF99) point-cloud builder that back-patches its survivor count
(starfields / particles). Jump table grew to 52 entries.op_tile / op_tile_bg, $70/$71) — previously stubs that
desynced the command parser. Line-wise renderer with a fused fast path for scroll = 0.cart_load ($FF06) — bank-crossing cartridge -> RAM copy that saves/restores the
caller's bank; with it, every jump-table entry became real code (no RTS stubs left).vgm_play takes the documented (VGM_BANK, address) ABI;
the per-frame tick does the bank save/select/restore dance and wraps the cursor across
$9FFF.cart_load groundwork.The GPU OS from nothing to a usable drawing engine. (The CPU side did not exist yet.)
LOAD redesigned for full-page destinations only; pixel drawing, line
drawing and text rendering optimized.LINES instruction.os_text extended for
scrolling. The diagnostic screen said MAD-65 V0.1.