Skip to content

Latest commit

 

History

History
442 lines (382 loc) · 160 KB

File metadata and controls

442 lines (382 loc) · 160 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

Ozmoo is a Z-machine interpreter for the Commodore 64, C128, Plus/4 and MEGA65 (plus an X16 target), written in 6502 assembly (ACME cross-assembler syntax) and driven by a Ruby build script. It runs Infocom/Inform story files.

This checkout is the z6 branch. The character-units → pixel-units refactor planned at the top of todo.txt was carried out on a z6-pixel branch (July 2026) so the two models could be compared side by side, and merged back into z6 on 2026-07-24 — a fast-forward, since z6 had gained nothing meanwhile. z6-pixel is left where it is as the record of that work; master still tracks upstream and has none of it. The refactor is done and the pixel model is now the DEFAULT for v6 builds; -pu:0 goes back to counting character cells. Ozmoo reports the 320x200 art pixel space Infocom authored for (font 4 wide x 8 tall, picture_data in native art pixels), because that is what the games' own layout arithmetic assumes. Measured against sfrotz: Zork Zero's compass lands within one physical pixel (it was 13 off), Shogun's left margin exactly on column 5 (it was three columns right), and Arthur's map connectors meet its room boxes instead of leaving a six-pixel gap. Both picture engines place a picture at any art pixel on both axes, generating the cells it covers from the picture itself (see the X16 bullet under Pictures). testz6 renders pixel-for-pixel identically under either model, so it is a regression check for both. What is left is in todo.txt and is optimisation rather than function: most pictures are now generated rather than copied, each taking a fresh tile run and palette bank against only 14 banks, so reusing a picture's bank when only its placement changed, and runtime tile dedup, are both worth doing. Everything below describes the z6 work it was built on.

The branch's purpose is adding Z-machine version 6 support. The window model works on every target; graphics work on the MEGA65 full colour screen — 80 columns (H640) since July 2026, with the pictures pixel-doubled to keep their 320-wide scale — where Arthur draws its first room like the reference interpreter, and the mouse works there too (Zork Zero's clickable controls, Arthur's click-to-dismiss-[MORE]). The X16 draws pictures too (July 2026): the same engine ported to VERA's layer-0 tile map behind the text, with the pictures loaded from SD on demand — no picture disks. Arthur's first room and title art render there like the reference, and the mouse works on that screen as well (the KERNAL's PS/2 pointer). All four v6 games boot at 80 columns and their opening screens now render like the reference interpreter: as of July 2026 Journey's intro wraps and its command menu draws and repaints correctly, Zork Zero's drop-cap paragraphs flow around the illuminated initials, and the games' pics builds link and run on both the MEGA65 and the X16 (the X16 story split moved to make room). On the X16 layer 0 now scrolls and erases with the text (drop-cap initials travel up, erase_line clears the picture behind it) and pictures placed at odd text columns render at their exact position (Arthur's centred scenes), verified in play including room-to-room transitions. Journey's command-menu boxes render on the text builds too (July 2026): the game draws them with Z-machine font 3 (character graphics), and Ozmoo now accepts font 3 and maps its glyph codes to each target's native PETSCII box-drawing characters (font3_translate in screen-z6.asm), verified in play on the X16 and MEGA65 text builds. Each z6 window now keeps its own background/reverse look on every target (July 2026): the X16 renders real per-cell VERA backgrounds (Z6_WINDOW_BG, x16_apply_window_colour, applied on set_colour/window-switch/init — the background fills on erase and survives scrolling), while the reverse-video targets (C64, C128, Plus/4, MEGA65 -fcm, which have no per-cell background hardware) now track the swap per window (window_swap, restored by apply_window_swap on window switch) instead of a global flag — so a reversed field no longer bleeds onto the next window or gets lost through scrolling. This fixed Journey's selected-command menu highlight (X16 and MEGA65) and Shogun's flickering start screen in play. On the X16 pics build, a picture's transparent pixels used to reveal VERA's black backdrop, so Zork Zero's in-text insets (drop-cap initials, the banquet-hall statue, room icons) sat in a black box inside the white text window; they now take the screen background (x16_screen_bg — the last set_colour background, like the MEGA65's $d021), injected into a free palette index when the picture's own palette lacks it (.pic_compute_bg_index in pictures-x16.asm), so they sit on white like the MEGA65 and sfrotz — verified in play. The v6 screen/colour/font-3/mouse/picture work is now essentially complete and play-verified across targets, and Johan's broader play-testing is what turns up what is left — it has now found a run of real bugs, all fixed (July 2026), which is the pattern to expect from here: the work is mature, but each play session still tends to surface one. The first was on the X16: Arthur's frame was destroyed a few rooms in, because the tile-store allocator wrapped onto live tiles while baking composites and boundary tiles — fixed by saturating and degrading instead (see the X16 bullet under Pictures; the MEGA65's composite bake took the same saturate fix). The later run (July 2026): Arthur's intro pictures had a damaged bottom row on the X16 (.pic_seek overflowing the $ffff bank window — see that bullet); Shogun's whole screen went black-on-white after "Start the game" (the colour-swap test was not idempotent, and the game issues the same set_colour twice — see the swap bullet); Arthur's F2 map lost its brown background on the second turn, on both targets, because the fourteen palette banks were keyed on the draw and ran out (now keyed on the picture, and reclaimed when the screen is done — see the palette-bank bullets); and clicking to dismiss [MORE] sprayed decoded garbage, because the mouse click's header write clobbered z_address mid-print (see the mouse bullet). A restart also no longer reloads every picture on the MEGA65: attic RAM survives the reboot, so a signature and the page tables kept beside the pictures let pic_load_all skip the disks. And a MEGA65 game is now a single d81 (July 2026): once a picture disk became one exomizer archive, that archive fit on the boot disk beside the story for all four games — Journey by twelve blocks — so there is no picture disk and no swap. The remaining items in todo.txt are follow-ups (accounting for baked tiles so the composite-bake degradation never triggers — now polish rather than a hazard, since both engines saturate gracefully; scroll_window/downward scroll reaching X16 layer 0; runtime tile dedup; arbitrary non-swap window backgrounds on the reverse-glyph targets) plus two pixel-refactor design questions (how much internal state becomes 2-byte pixel; whether the 640x200-vs-640x400 aspect squash is a real defect on hardware) and older upstream ideas — none is a known bug that breaks a game, though that was also true the day before the frame bug surfaced, so treat it as "nothing found yet", not "nothing there". master tracks upstream Ozmoo and must keep working — every change here has to be checked against both. This is Johan's personal project: make local commits, but never push without explicit permission.

Build and run

Required: acme, exomizer (expected at exomizer/src/exomizer on Linux — a local checkout, not in git), VICE (x64, x64sc, x128, xplus4, c1541), ruby, and inform + frotz/dfrotz for the v6 test game. Tool paths are hardcoded at the top of make.rb (separate Windows/Linux sections).

Acme 0.97 as released changed the syntax of one MEGA65 instruction. The 32-bit indirect quad load is now ldq [zp],z; the 2021/2022 development snapshots (and acme's own docs/cputypes/cpu m65.txt, which was not updated) wanted ldq [zp], and the release rejects that with "CPU does not support this addressing mode for this mnemonic". ldq is the only mnemonic affected — stq/orq/andq/eorq/ adcq/cpq/sbcq still take the bare [zp] and reject a ,z. Both spellings assemble to the same 42 42 ea b2 <zp>, so this is syntax only. It bit the WAV sound parser (-asw) in July 2026, which is the only place in the tree using it; the AIFF parser (-asa) uses plain lda [zp],z and was never affected. The rebuilt WAV playback is confirmed on a real MEGA65 (make sound-sherlock-mega65, Sherlock).

The real games use a consistent grid: <game>-<platform> for text, and <game>-pics-<platform> for the graphics build on the platforms that draw pictures (mega65, x16). Games are arthur shogun journey zorkzero (plus dejavu, the z3 non-z6 regression game); platforms are c64 c128 plus4 mega65 x16. All autostart in the platform's emulator. On the MEGA65, <game>-mega65 is the full colour 80-column screen (-fcm) — the same screen -pics draws on, so the two differ only by the pictures. The grid is generated from a define game_targets template in the Makefile, so a new game is one $(eval $(call game_targets,...)) line.

# The real v6 games (examples; the full grid is games x platforms):
make arthur-c64        # Arthur on the C64 (1581), autostart in VICE
make arthur-mega65     # Arthur on the MEGA65 full colour screen (80 col, -fcm)
make arthur-pics-mega65 # ...and drawing its own pictures. The whole thing.
make arthur-x16        # Arthur on the X16 (VERA text)
make arthur-pics-x16   # ...with pictures (layer 0 tile map, loaded from SD)
make zorkzero-pics-mega65 # Zork Zero + its 396 pictures, all on the one d81
make journey-plus4     # Journey on the Plus/4 (1581) - builds even if too big to play
make shogun-pics-x16   # Shogun on the X16 with pictures
make dejavu-c64        # dejavu.z3 (z3) - the non-z6 regression check; also -mega65 etc.
# Arthur oddities that do not fit the grid:
make arthur-c64-d2     # Arthur on the C64 split over two 1541 drives
make arthur-c128-80    # Arthur on the C128 80-column (VDC) screen (type RUN"STORY")

# testz6, the v6 test game (grown opcode by opcode, compared against frotz):
make z6        # compile testz6.inf with inform -v6, build a d64, autostart in VICE
make ecm       # same, with -ecm (per-window background colours)
make frotz     # compile testz6.inf and run it in frotz — the reference behaviour
make z6-mega65 # testz6 on the MEGA65, 80-column text
make z6-fcm    # testz6 on the MEGA65 full colour screen: 80 columns (H640);
               # compare against `dfrotz -h 25 -w 80`
make z6-fcm40  # the legacy 40-column full colour screen; should match `make z6`
               # line for line -- the C64-vs-MEGA65 regression check
make z6-pics   # z6-fcm, drawing the test pictures in tools/testpics
make z6-x16    # testz6 on the X16: the z6 window model on VERA, 80x60, text only
make z6-pics-x16 # testz6 on the X16 with pictures: text on VERA layer 1 (80x25),
               # pictures behind it on a layer 0 tile map, loaded from SD on demand
make scroll    # testz6scroll: a small window scrolls inside a screenful of '#',
               # which must survive intact. scroll-x16 / scroll-mega65 build the
               # same test for those; all three must draw it identically.
make amfv      # build the large z4 game AMFV as a d81 (checks large files + d81)
make clean

ruby make.rb [options] <storyfile>   # run with no args for the full option list
ruby make.rb -pu:0 ...                # ...and count character cells, not art pixels (v6)

make arthur-pics-mega65 needs z6games/arthur-r74-s890714.blb, the game's blorb, which is not in git. -pics <blorb-or-dir> runs tools/pics2asm.py, which puts one compressed file per PNG picture on the d81 and sets Z6_PICTURES; it needs -fcm. Given a blorb it also reads the game's Rect placeholders and APal adaptive-palette list (see the Pictures section); given a plain directory of numbered PNGs (tools/testpics) it just tiles those.

More than one blorb exists for Arthur and Zork Zero, and the bigger one carries pictures we must not build. Snavig-generated blorbs add a BPal chunk ("Bocfel adaptive palette", https://github.com/cspiegel/bpal): for every (current palette, APal picture) pair it names a third picture holding that adaptive picture with the palette already applied, for Glk interpreters that cannot recolour a Blorb image at runtime. Arthur's has 155 of them, numbered 1000-1154 — pure dead weight for Ozmoo, which does the recolouring itself (pic_adaptive / pic_direct_base), and unnameable by the interpreter's three-digit [Pnnn] filename (.pic_set_filename). pics2asm.py drops any BPal replacement, and as a catch-all anything past MAX_PIC_NUMBER, printing what it skipped; --all-pictures keeps them and then stops on the number check rather than shipping a set the interpreter cannot name. So either Arthur blorb now builds the same 137 pictures — verified: identical output, pics1 1213 blocks, and the game plays. Beware the numbering when looking at extracted chunks: sequential chunk indices (Arthur's run to 335) are not picture numbers. The story file references none of them — both blorbs' .z6 are byte identical, and every picture constant in txd's disassembly is <= 171. Zork Zero's APal lists 165 pictures, so a BPal blorb of it would be enormous.

dfrotz -h 25 -w 40 testz6.z6 gives reference output with the same screen size as a C64, which makes line-for-line comparison possible.

There is no automated test suite. test/ holds standard conformance games (czech, praxix, strictz, oztest, etude) that are built and played manually. testz6.inf is the v6 test game — grow it opcode by opcode and compare against frotz rather than debugging a commercial game blind.

Before committing anything that touches shared code, rebuild the matrix: testz6.z6, -ecm testz6.z6, examples/dejavu.z3, test/praxix.z5, and -t:c128 / -t:mega65 / -t:mega65 -fcm / -t:mega65 -fcm:40 / -t:plus4 / -smooth:1 variants. Building is not enough for the screen layer: run testz6 on the C64 and on -t:mega65 -fcm:40 and compare — both are 40 columns and should agree line for line — and check -t:mega65 -fcm (80 columns) against dfrotz -h 25 -w 80.

Conditional assembly is the architecture

Everything is one assembly program (asm/ozmoo.asm !sources all other files) specialized at build time by ACME -D flags that make.rb derives from the story file and command line:

  • Z-version: make.rb reads the story file's version byte and passes -DZ6=1 etc. ozmoo.asm derives cumulative flags: Z3PLUS, Z4PLUS, Z5PLUS, Z6PLUS, Z7PLUS, plus Z6_Z7 (those two versions use packed-address offsets).
  • Target: -DTARGET_C128=1, -DTARGET_MEGA65=1, etc. (no define for C64, the default).
  • Feature/debug flags: edit $GENERALFLAGS / $DEBUGFLAGS at the top of make.rb to enable e.g. DEBUG, TRACE, VICE_TRACE, CHECK_ERRORS. TRACE_SCREEN (in screen-z6.asm) traces the v6 opcodes.
    • DEBUG + TRACE together are the fastest way to place a crash: fatalerror names the error and prints the last ten opcodes with their z_pc, which usually identifies the guilty instruction outright. Decode them against the story file, or better, against txd's disassembly (below).
    • CHECK_ERRORS is forced on for -t:mega65 (make.rb) and off elsewhere, so a MEGA65 build can stop dead on something every other target runs straight through. -re:0 turns it off. Do not reach for that first: an error only the MEGA65 reports is usually a real bug the other targets are silently living with.
    • ztools-master/txd -n <story> disassembles a story file and infodump dumps its header, objects and dictionary. Between the TRACE opcode list and txd, a v6 crash usually resolves in a couple of minutes. Not in git; keep a checkout in the working directory.

So "does this code run?" always depends on which !ifdef blocks are active for the given version/target.

Source layout (asm/)

  • ozmoo.asm — entry point, init, main loop; sources everything else.
  • zmachine.asm — opcode dispatch and most z_ins_* routines.
  • stack.asm — Z-stack, stack_call_routine, and the opcodes that touch the stack.
  • vmem.asm, memory.asm — virtual memory: the story beyond dynamic memory is demand-paged from disk in 512-byte blocks. set_z_pc / get_page_at_z_pc page in the code being executed.
  • screenkernal.asm / screen.asm — the non-z6 screen layer. screenkernal.asm replaces the C64 kernal screen routines (s_printchar instead of $ffd2); screen.asm implements the Z-machine screen model on top.
  • screenkernal-z6.asm / screen-z6.asm — the z6-only equivalents, sourced instead of the above under !ifdef Z6. Keep them as close to their non-z6 counterparts as possible; when master changes the originals, re-fork rather than hand-patch. screen-z6.asm holds the z6 window model, the shared picture opcodes (draw_picture/erase_picture/picture_data and helpers), and the per-target picture hooks in erase_window/erase_line/scroll.
  • textbg-mega65.asm — the MEGA65 full colour screen's per-window background (Z6_FCM_WINDOW_BG): a text cell in a window whose background is not the screen's becomes a baked full colour tile. Hooked in through the clear_cell_high_byte macro, which every text write site already calls.
  • pictures-mega65.asm / pictures-x16.asm — the two target-specific picture engines, sourced (mutually exclusively) from screen-z6.asm under !ifdef Z6_PICTURES in the two branches of !ifdef TARGET_X16. Both expose the same entry points (pic_load_all, .pic_find, .pic_draw, .pic_erase, …) so the opcode layer above them is target-agnostic; a new graphics target (e.g. Apple IIgs) is a third such file. The MEGA65 engine draws on the VIC-IV full-colour screen, the X16 on VERA's layer-0 tile map (see the Pictures section).
  • streams.asm, text.asm, dictionary.asm — I/O streams (must precede text.asm), zchar decoding, tokenizing, read_text.
  • disk.asm, reu.asm, constants*.asm — disk access, REU, per-target memory maps.

tools/ holds the picture pipeline: pics2asm.py (PNGs → the files on the disk), gen_testpics.py (the pictures testz6 draws, ours, in tools/testpics), png2fcm.py (the reference for the tile format) and fcm-prototype.asm (a standalone prg, the only place the working VIC-IV register setup is written out). make_blorb.py builds a v6 Blorb from a folder of source files described by a YAML contents.yaml (top-level blorb/outdir/srcdir plus a pictures: list, each id/file with optional name/location/width/height per-picture size caps; run as make_blorb.py <folder> or <contents.yaml>) — the front end that feeds -pics <blorb>. examples/wyrmward/ (gitignored) keeps its own copy for that side project. It also takes an optional sounds: list (id/file, id 3..255 since the Z-machine's sounds 1 and 2 are the bleeps), so one Blorb carries a game's pictures and its sound effects and sfrotz can play what Ozmoo plays. Two things to know there: Blorb has no WAV chunk — the spec's sound types are AIFF (FORM), OGGV, MP3 and MOD /SONG, and WAV exists only in ADRIFT blorbs — so a listed .wav is converted to AIFF on the way in (hand-rolled: Python 3.13 removed both aifc and audioop, and the result is byte-identical to sox's own conversion, which is the regression check; .aiff sources are embedded verbatim). And the sounds are for the other interpreter only: Ozmoo's MEGA65 build still reads the wavs straight off the source folder via -asw, which is why the same 8-bit mono wav can feed both. pics2asm.py skips any non-Pict index entry and walks an embedded AIFF as an opaque FORM chunk, so adding sounds leaves the built disks byte-identical.

The z6 screen model

Eight windows, each with the property array the spec requires (window_y, window_x, window_y_size, …, window_attributes, window_linecount), laid out contiguously so get_wind_prop/put_wind_prop can index them as window_y + 8 * property + window.

  • Coordinates are stored 0-based internally; the opcodes convert to/from the z-machine's 1-based coordinates.
  • Printing, wrapping, scrolling, the cursor, the MORE prompt and erasing are all per window: text wraps at the window's right edge, scrolls its own rectangle, and the MORE prompt appears at the current window's bottom-right cell. Each window keeps its own cursor and line count.
  • Ozmoo always shows a MORE prompt when a game quits, so the last message can be read. A lone * in a corner after quit is that, not a bug.
  • Property 13 (font size) is the only window property that is a real word (height in the high byte, width in the low). It cannot live in the byte-per-window arrays, so get_wind_prop answers it directly with 1,1. It must never be zero: Arthur divides by it. window_font_size_slot exists only to keep properties 14 and 15 where the spec puts them.
  • -ecm (Z6_ECM_MODE, C64 + v6 only) turns on VIC-II Extended Color Mode: the top two bits of each screen code pick one of four background registers ($d021-$d024), giving each window its own background. The cost is a 64-character charset, so screen codes are masked to 6 bits (uppercase renders as lowercase) and reverse video is unavailable.
  • -fcm (Z6_FCM_MODE, MEGA65 + v6 only) puts the VIC-IV into Full Colour Mode with 16-bit character codes on an 80x25, 640x200 (H640) screen — v6 games assume 80 columns (Zork Zero's layout breaks and Journey is unplayable at 40), while their pictures stay at their 320-wide scale by pixel doubling (see Pictures). Text glyphs are the ROM font's 8-pixel ones, so a text cell is half the width of a doubled picture cell's 16 physical pixels; codes below 256 stay ordinary glyphs (FCLRLO clear), codes from 256 up are 64-byte tiles. -fcm:40 (Z6_FCM_40) keeps the old 320x200, 40x25 screen and builds its disks with an _fcm40 suffix; it renders text identically to the C64, which makes it the line-for-line regression target (make z6-fcm40), while the 80-column screen is compared against dfrotz -h 25 -w 80. The z6 kernal has no separate text path — s_printchar always writes two-byte FCM cells — so everything, including the splash screen, renders in FCM; the splash lines are centred for 40 columns and get the same +20 re-centring as the 80-column text build (only -fcm:40 must skip it, where +20 would wrap into garbage). A custom font is refused with -fcm (CHARPTR is pinned to the ROM font). Per-window colour: the VIC-IV in FCM has an 8-bit foreground in the colour RAM's odd byte and a single global $d021 background, so there is no free per-cell background to write (unlike VERA's colour nybble). The reversed-glyph swap the other reverse targets use covers the exact swap of the screen's pair — all Infocom's games ask for — and is tracked per window (window_swap/apply_window_swap) so each window keeps its own reverse look through window switches and scrolling. Since August 2026 an arbitrary window background is drawn too, by baking the cell into a full colour tile (Z6_FCM_WINDOW_BG, textbg-mega65.asm — see the bullet under "Watch out for"); the VIC-IV reverse attribute stays rejected (it writes the colour-RAM even byte that must stay zero). On the way out, leave_fcm_mode (screenkernal-z6.asm, beside the init_mega65 that sets it all up) puts back every VIC-IV register the full colour setup took over, because neither the C64 reset nor BASIC restores them: the FCM mode bits ($d054 CHR16/FCLRHI), the colour RAM offset, the mouse sprite, and — since July 2026 — CHARPTR, LINESTEP, CHRCOUNT and SPRPTRADR, from the BASIC_* constants beside the routine. Both ways out call it: z_ins_quit before its reset (it also hands $d031/$d016 back to BASIC's 80-column pair first), and z_ins_restart before its reboot — a restart reloads the interpreter from disk, and until July 2026 spent those seconds showing a full-colour screenful of tiles and stray colour.
    • The emulator cannot test any of this, and said the opposite for months (the restore is confirmed on a real MEGA65, July 2026: quit and restart both come back to a clean BASIC screen, with Zork Zero). xemu's reset restores the VIC-IV side itself, so BASIC came up perfect there while a real MEGA65 came up in the picture font at twice the row stride (z6games/after-quit.png against z6games/after-quit-emulator.png). Proved rather than assumed: pointing BASIC_CHARPTR at the lower case font ($001800) and quitting leaves CHARPTR reading $001000 in xemu, i.e. the emulator overwrote our value. Reading mffd3050/mffd3060 over the uartmon at a plain BASIC prompt is where the BASIC_* values came from — xemu runs the real ROM, so they are the ROM's own choices even though the chip behaviour around them is not. The corollary for anything else on this path: a clean post-quit screen in xemu is not evidence, so state must be restored explicitly rather than left to the reset. Related: the hot-register enable ($d05d bit 7) in both exit paths now re-knocks with mega65io first, since a hypervisor call sits just before it in z_ins_quit and may leave the VIC-IV registers out of I/O mode on hardware — a plausible reason the reset recomputed nothing there.
  • Colour RAM under FCM goes through 32-bit pointers, not $d800. The 80-column screen needs 80x25x2 = 4000 colour bytes, which outgrow the CPU's 2 KB $d800 window (colour2k), so under Z6_FCM_MODE every colour access uses sta [zp],z into colour RAM at $ff80000 plus FCM_COLOUR_OFFSET ($0800, set in $d064/5): zp_colourline is a 4-byte pointer at $e5 (low word = offset + row offset + the usual +1 bias, high word always $0ff8), with scratch pointers at $d9/$dd (scroll row copies) and $e1 (the [More] cell). The high words must never live at $f5/$f6 — the kernal keyboard scan rewrites those two bytes with its decode-table pointer on every IRQ. $d9-$f2 is the kernal screen editor's line-link table, which Ozmoo's own screen code replaces, so that region is interrupt-safe.
  • The screen's colour region must not start at colour offset 0. The first 2 KB of colour RAM is also mapped at $1f800-$1ffff in bank 1, whose top is CBDOS workspace: every SD/disk access rewrites colour bytes around $7f2-$7fb. In FCM's 16-bit colour cells those land in attribute bytes, and $f8 there is a GOTOX token that blanks the rest of the row — this was the white stripe over Arthur's intro pictures and the intermittently vanishing line tails. The 40-column screen's 2000 bytes stopped just short of the DOS bytes, which is why only 80 columns suffered. FCM_COLOUR_OFFSET keeps the screen clear; scrollback's temporary colour sits at $1000; z_ins_quit puts the offset back to 0 for BASIC. When dumping colour RAM in the xemu monitor, the screen's colour now starts at $ff80800, not $ff80000.

The FCM cell is two bytes, and that keeps biting

Under -fcm a screen cell and a colour cell are two bytes each. Ozmoo writes the character into the even byte of a screen cell and the colour into the odd byte of a colour cell; the other two must be zero and stay zero.

  • zp_screencolumn still holds a column. Only the sites that index the screen double it, which leaves the window edges, margins and every comparison alone.
  • zp_colourline is biased by +1, so one doubled index writes both character and colour. A row offset's low byte is a multiple of 32, so the bias never carries.
  • Every character store must also zero the cell's high byte — the clear_cell_high_byte macro. Miss one and text printed over a picture leaves the cell pointing at a tile.
  • Most text does not go through s_printchar. It goes through print_line_from_buffer in screen-z6.asm, which writes the screen directly. Four separate bugs have come from forgetting one of these sites; audit them all with grep -n 'sta (zp_screenline),y'.

Pictures (MEGA65, -fcm -pics)

Off-grid placement works here too, the same generator as the X16's (see that bullet below): a screen cell is 8 pixel rows down and GEN_CELL_W art pixels across — 4 at 80 columns, where the art is doubled and a cell is half an art cell, 8 under -fcm:40 where it is not — and when a picture's corner misses that grid the cells it covers are generated straight from the picture in attic, with .gen_expand adding the window's palette bank and doubling each pixel into the store's 64-byte form. This engine never needed it before the pixel model: at 80 columns a text column is a map cell, so every cell position was already on the grid.

tools/pics2asm.py reads the blorb (or a PNG directory) and writes one exomizer archive per picture disk; make.rb puts that archive on the boot disk when it fits there (it does for all four games — see below), and otherwise on separate picture disks (mega65_<game>_pics_1.d81, _pics_2.d81, …), one d81 per disk, packed to fill each before the next; pic_load_all decompresses them into attic RAM at $08300000 at boot, next to where sound.asm preloads the WAVs, under a "loading graphics" label with a /-per-few-pictures progress bar (scaled to stay ~30 wide) like the story preloader's, then "unpacking pictures" while each archive is decrunched (seconds of otherwise-silent work; no bar there, because the decruncher walks a crunched stream and has no cheap measure of its progress, unlike the staging read that counts pages), and finally an s_erase_window so the game opens on a clean screen. That erase is at the end of the load path, not the top of pic_load_all — the restart path below returns without printing a word and must not erase a screen it did not write. Only an index is assembled in: picture numbers, the Rect placeholder sizes, the pic_adaptive flags, and the disk each picture is on.

  • A restart reuses the pictures already in attic (July 2026). z_ins_restart reboots the machine and reloads the interpreter from disk, so pic_load_all runs again — but attic RAM survives the reset, so the whole preload (and any disk swap) is skipped when the pictures are still there. The catch is that skipping the load is not enough on its own: pic_load_all also builds the pic_page_lo/pic_page_hi tables as it goes, and those live in interpreter RAM, which the reboot has just re-read from disk and zeroed. They cannot be recomputed — a picture's attic page depends on its decompressed size, known only by reading the file — so they are kept in attic too. $08300000 is now an attic header: an 8-byte signature followed by the two page tables (PIC_ATTIC_HEADER_PAGE; 2 pages for Arthur's 137 pictures, 8 at the 999 maximum), with the pictures themselves starting at PIC_ATTIC_PAGE just above it. Undo at $08600000 is ~3 MB clear, so the shift costs nothing. The signature is the story's serial + checksum (header_serial, header_checksum), not reu_filled's game_id — that is !ifdef VMEM and the MEGA65 has no VMEM — and Ozmoo never rewrites either field, unlike flags_2 or the screen dimensions. It is written last, after the tables, so a load interrupted partway leaves no signature claiming they are good. The check runs on every boot, not just restarts, so a cold reboot of the same game skips the load too.

  • The pictures go on the boot disk when they fit, and for every v6 game they do (July 2026). Once a picture disk became a single exomizer archive rather than one file per picture, each game's whole set came to well under half a d81, and the story plus the interpreter leave room for it: Arthur 1213 blocks into 2039 free, Shogun 862 into 1742, Zork Zero 687 into 1917, and Journey 1975 into 1987 — twelve blocks to spare. So all four ship as one d81, which is the point of the exercise. add_pictures_to_boot_disk in make.rb decides it and adds the archive through disk.add_file before the image is saved, so it takes the same $i81 sector interleave the story and sound do. This is a big real-hardware win, measured on a MEGA65 with a physical floppy: loading Zork Zero's graphics dropped from 76 s to 18 s once the pictures were interleaved instead of laid down sequentially by c1541 -write (July 2026). The fit is decided against the BAM's real free count (disk.bam_free_blocks minus the interpreter's blocks — disk.free_blocks is a counter only add_story_data maintains, so it still reads the whole disk here and would ignore the story), and because add_story_data has already written the directory, a disk.flush_directory writes the pictures' entry afterwards (their sectors are already allocated in the BAM; only the directory pointer was missing). It also deletes any _pics_N.d81 left over from an earlier build so a stale one can't be mistaken for current. A set that needs two archives, or one that no longer fits (Journey is twelve blocks from that), falls back to build_picture_disks and its own d81s, unchanged. Journey's twelve blocks are known and intended, not a risk to flag — but they are also only ~3 KB, so anything that grows the interpreter should be checked against a Journey pics build, which reports Pictures written to the boot disk, N blocks free. The fallback is a normal outcome there, not an error, so a change that pushes it over goes back to a two-disk game quietly. The interpreter is not told which it gotpic_load_all looks for PICS<n> and finds it wherever it is.

    • That second flush_directory walks the whole directory again, and it used to claim the overflow sector twice (August 2026). add_directory re-runs add_files_to_dir(3,2) from the start, so a directory that has already spilled past its eight entries reaches the overflow branch a second time and called allocate_sector(40, 4) again: the bitmap bit was already clear, but the track's free count was decremented once more, leaving the BAM saying 34 free on track 40 where its own bitmap said 35. CBDOS answers a save on such a disk with 71,BAM CORRUPTED,40,03 and then stops maintaining the BAM — the file and its directory entry are written correctly, so the save looks fine and restores fine, but its blocks stay marked free and the next save is allocated straight over it (measured: two saves in a row both starting at block 19/2). It needs both a spilled directory and the second flush, so only a game with sound and pictures showed it (Wyrmward's nine files); the four commercial v6 games have three files each, and Sherlock's sixteen spill in a single pass. Fixed by claiming the sector only when it is not already claimed, with a SECTOR ALLOCATED TWICE guard inside allocate_sector so any future double claim is loud rather than a silently skewed count. Two lessons worth keeping: a build step that runs twice must be idempotent including its side effects on the free-space accounting, and a disk image that passes every structural check (chains, orphans, directory) can still carry a one-byte accounting error that a real DOS treats as corruption.
    • A d81 is now checked against itself before it is written (verify_image, called from Disk_image#save, implemented by the D81 writer; the D64/D71 writers have no second-flush path and inherit the no-op): every track's free count against its own bitmap, and every chain — the directory's and each file's — for leaving the disk, looping, sharing a block, running through a block marked free, or disagreeing with the block count in its directory entry. Partition entries (type $85, the C64 -81 story data) name a start rather than a chain and are skipped. A bad image fails the build with exit 1 instead of being written, so this class of defect can never ship again. Proved both ways: with the double-claim put back it reports track 40: the BAM says 34 blocks free, its own bitmap says 35 and refuses; and every image we build passes — the four v6 pics games, Sherlock's sounds, testz6, dejavu, and the -81 builds for C64, C128 and Plus/4.
    • The drive's own verdict is now read after every save (check_drive_status, disk.asm, MEGA65 only): Ozmoo had never looked at the DOS status after writing a save file, so a genuine failure — disk full, write protect, the BAM above — was silent. It prints the error channel unless it reads 00, OK, and waits for a key, since save_game erases the window immediately afterwards. DEBUG_DISK_STATUS in $DEBUGFLAGS also prints it before the save, which tells a latched error from boot apart from one the save itself caused. xemu runs the real ROM's CBDOS, so the error is reproducible there even though it does not model the drive LED at all ($d080, whose bit 6 is the LED, sits at $08 through a whole boot and picture load) — which is why the bug could only be noticed on hardware, and why a headless save is worth driving when disk behaviour is in question: poke the whole string into the KERNAL keyboard buffer at $0277 with its length at $c6 (one key at a time gets dropped), in PETSCII, i.e. ASCII upper case for letters.
    • The on-disk name comes from name_to_c64 (used by add_file), which uppercases the ASCII basename, so pics1.bin becomes the bytes $50 $49 $43 $53 $31 — exactly what the interpreter's !text "PICS1" assembles to. The old c1541 -write path this replaced had to be handed the name in lower case to reach the same PETSCII through c1541's own ASCII→PETSCII conversion (PICS1 came out shifted, $d0 $c9 …, and the interpreter sat at insert picture disk 1 with the pictures right there); that pitfall is gone now that the pictures go through add_file.
  • Boot drive, then second drive, then swap. pic_load_all groups its load by picture disk; for each it tries the boot drive, then the second drive (boot_device + 1, e.g. 9), and only if neither holds the disk does it print insert picture disk N and wait for a key. The boot drive comes first because that is where the pictures normally are now, and a drive that isn't there answers with a KERNAL timeout rather than a quick "file not found" — it is also safer, since another Ozmoo game's picture disk left in drive 9 has a PICS<n> of its own. make.rb still auto-mounts -9 with the first picture disk when a build really did produce one. Presence is checked by reading the drive error channel (.pic_probe): a missing file OPENs "successfully" but leaves error 62 and reads back a stale buffer page, so the status byte alone can't be trusted.

  • Picture numbers run to 999 (pic_number_lo/_hi, rect_number_lo/_hi; the P### filename is three digits) and the per-build count is 16-bit too: .pic_index is a word, the parallel pic_* tables are indexed through .pic_addr (base + word index → .pi_ptr), and pic_win_number is a word per window. So a build can hold up to 999 pictures — Zork Zero's 396 (numbered to 504) build and load. The old per-picture files ran into a d81 directory's ~296-entry ceiling, which is what forced Zork Zero onto two disks; one archive per disk retired that limit, and the 396 now fit in 687 blocks on the boot disk.

  • On the 80-column screen a logical picture cell is two tiles. The games' art is 320-wide, so each 8x8-pixel cell of a picture becomes two screen cells (left half, right half) whose pixels are doubled — the picture keeps its visual size while text stays 8 pixels wide. pics2asm.py emits two cell-map entries per logical cell and stores the halves undoubled on disk and in attic (16 bytes a tile: 4 source pixels a row); .pic_copy_tiles writes each pixel twice as it bakes the 64-byte store tile (.pic_emit_pixel). Deduplication happens on the halves, so the doubling costs nothing on disk — the picture sets are the same size as the 40-column ones, and every one of them fits on its game's boot disk. Arthur's worst live set (frame + scene + status) measures 1894 of the 2048-tile store; --stats in pics2asm prints the numbers without writing anything.

  • The tile store is $40000-$5ffff — 2048 tiles — because Arthur keeps a border, a scene and a status panel on screen at once. Sound moves down to bank 1 and undo out to attic to make room; see the memory map in the techreport. Without -pics the store stays in bank 1. A cell's screen code is its tile's address / 64, so FCM_TILE_CODE_HI + tile index.

  • Each drawn picture gets its own tile run; the palette bank is keyed on the picture, not the draw (pic_win_base/pic_win_number/pic_win_bank, keyed by window; only the same picture at the same placement redrawn into a window reuses its run). This is because a window holds several pictures at once — Arthur composites a scene inside a frame — so a per-window bank (the old 16 + 16 * window) had them fighting over one palette. Banks are 16 * bank, bank running 1..14; bank 15 is skipped because its top colour would be pixel value 255, which FCM takes from colour RAM. When a fresh tile run won't fit, .pic_alloc compacts the store instead of wrapping (.pic_gc): every tile still shown by a cell is moved to the bottom of the store (ascending order, safe in place) and its cells repointed, so Arthur's Merlin scene can be drawn centred over the sword picture with the sword's frame kept intact, as the reference shows it. Only if survivors + new picture still exceed 2048 does the old wrap-to-zero happen. A fully opaque cell composited over another picture reuses its own tile rather than baking a copy (.pcf_make_tile counts transparent pixels while loading its buffer).

    • The compaction is two-tier, because "the incoming picture is about to overwrite these cells" is not true of all of them (.gc_excl, both engines, July 2026). The sweep used to exclude the cells inside the incoming picture's rectangle, on the grounds that the draw replaces them — but a fully transparent cell is left alone (.pfc_advance, .pgf_advance, the show-through a frame's hole depends on) and a partly transparent one composites against the tile behind it, so both still need that tile after the sweep. Reclaiming it left the cell pointing at whatever the compaction moved into that slot: Zork Zero's compass rose came back with pieces of other pictures in it every few turns, reproducibly on the third move, because the game builds the rose out of eight overlapping mostly-transparent petals drawn over each other in one 12x5 box. Tier one now keeps every tile a cell still shows and can never dangle; only if the picture still won't fit does tier two fall back to the old rule. Arthur's intro does reach tier two — the store cannot hold two full-screen pictures at once — so that approximation is still live there; making it exact needs a per-cell "opaque here" bit, which the cell map does not carry ($ffff is fully transparent; nothing distinguishes fully opaque from partly). The diagnosis is worth copying: dump screen RAM and flag every cell whose tile index is >= pic_next_tile, since those point above the compacted store and are exactly the corrupt ones.
    • The generated path hands back the tail of the run it did not use (.pgf_done, July 2026). .pic_alloc must reserve a tile for every cell an off-grid picture covers, but a cell it is fully transparent in never takes one, and most of a small overlay is transparent — a Zork Zero petal reserved 65 tiles and used 7. Three turns used to leave pic_next_tile at 2037 of 2048 and force a compaction; they now leave it at 843 and force none. pic_win_count shrinks with the run so a redraw's reuse test measures what is really there. This matters beyond memory: the compaction is visible on screen — pass 2 moves every survivor tile while the cells still point at the old indices, and pass 3 only repoints them afterwards, so the whole screen shows moved tiles for the ~60 ms it takes. That is the "banner blink" half of the same bug report; it is now rare rather than every few turns, and making it invisible would mean compacting into a scratch buffer and DMAing the result back (see todo.txt).
  • The fourteen palette banks are allocated per distinct picture, not per draw (pic_bank_pic, .pa_pick_bank, both engines, July 2026). A picture's palette does not depend on where it is drawn, so a picture redrawn — at any placement, into any window — gets back the bank it is already in; only a picture whose palette is in no bank takes the next one round. Adaptive pictures claim none at all: they draw in the last direct picture's palette anyway. A bank per draw did not survive Arthur's F2 map, which redraws the same handful of pictures (paper, room boxes, connectors) a dozen-plus times: the allocator wrapped round onto banks the paper's own cells were still using, and on the second turn the map's brown background came back black on both targets, in different patches because the two engines consume banks at different rates. Measured on the MEGA65 through the uartmon: a room costs 5 banks and the first map 7 more (12 of 14), the second room 2 — and the second map then costs none, because every picture is already loaded. Before, one map draw alone consumed ~16. A bank is also given back once the screen has finished with it (.pa_reclaim_banks, July 2026): when a new picture finds all fourteen claimed, the engine works out which banks a cell on screen still shows and frees the rest. The X16 reads that straight off the layer 0 map, where each entry carries its cell's palette-offset nybble; the MEGA65 has to read the tile pixels, since it bakes absolute indices (bank * 16 + colour) into them and a composite tile legitimately mixes two pictures' banks — up to 80x25x64 reads, which is why it only runs when the banks are actually exhausted. Two things it must do beyond clearing the entry: never free pic_direct_bank (the next adaptive picture draws in it and may have no cells of its own yet), and clear pic_win_number for any window whose run was built for a freed bank, or .pic_alloc's reuse path would take the stale pic_win_bank and reload a palette into a bank that now belongs to something else. So the ceiling is "distinct pictures on screen at once", not "distinct pictures drawn"; past fourteen of those it still steals the oldest, which is all that is left to do. bank runs 1..14 and bank 15 is skipped because its top colour would be pixel value 255, which FCM takes from colour RAM. To exercise the reclaim, build with a small PIC_PAL_BANKS (6 makes Arthur's first map trigger it every time) — the same trick as shrinking PIC_MAX_TILES to prove .pic_gc.

  • A picture keeps its PNG palette indices; they are not compacted. A pixel is its own index (0 transparent, 1..15 straight into the bank), and the bank is loaded in index order. This is what lets an adaptive picture line up with the palette it borrows (below). Four bits a pixel on disk and in attic, one byte in the store; 255 comes from colour RAM, so a picture has at most 15 colours. Arthur's pictures put transparency at index 0 and never colour index 0.

  • Compositing works two ways. pics2asm.py writes $ffff for a cell that is transparent through and through, and pic_fill_cells leaves such a cell untouched — so a frame with a hole (like Arthur's) drawn over a scene shows the scene through the hole. A partly transparent cell (some opaque, some transparent pixels) is composited per pixel: before writing an opaque cell, pic_fill_cells reads what is already there, and if a full colour tile is underneath it bakes a fresh tile taking the overlay's pixel where opaque and the underlying tile's pixel where transparent (.pcf_make_tile). This works because the tile store holds absolute palette indices with every bank loaded at once, so one tile can legitimately mix the overlay's colours and the scene's. Cells with only text or blank underneath keep the old behaviour (a transparent pixel shows the screen background), so pictures drawn over an empty screen are unchanged. The composite tiles are taken from pic_next_tile, bumped as the normal allocator does — cheap for Arthur (~9 a room), but a game that redraws a full-screen picture straight over another without clearing would churn the store. These tiles are baked mid-draw and belong to no window's run, so pic_alloc never reserved them and the store genuinely can run out. It saturates rather than wraps (.pmt_full, July 2026, ported from the X16's .pcf_alloc_and_write): pic_next_tile stops at PIC_MAX_TILES and the bake returns leaving .pcf_newcode at the seed set at the top of .pcf_make_tile — our own un-composited tile — so the cell shows our pixels flat, losing only the show-through of what was behind. Self-correcting: a full pic_next_tile fails .pic_alloc's fit check, so the next picture compacts and the bakes have room again. It used to wrap to 0, which is the same defect that destroyed Arthur's frame on the X16 — a wrap lands on live tiles (the frame sits at the bottom of the store) and leaves pic_next_tile low, so .pic_gc never runs again and the allocator marches back up through the frame. The MEGA65 never bit only because it has 2048 tiles and half the X16's per-picture cost. Note the branch is hard to reach in play: Arthur's frame-over-scene uses fully-transparent $ffff holes, not partly-transparent composites, so .pcf_make_tile is not exercised by its opening at all (verified: at a forced bake cap of 256 the intro and first room render byte-identically to the 2048 build, meaning no composite occurred). The fix is a mechanical port of the proven X16 path rather than a play-tested one.

  • Adaptive-palette pictures (blorb APal, pic_adaptive) — Arthur's frame and side bars — ignore their own (placeholder) palette and are drawn in the palette of the last direct picture (pic_direct_base), so the UI recolours to match the scene. pic_read_palette is skipped for them and their tiles are baked into the current direct bank.

  • Drawing is clipped to the screen: pic_fill_cells and pic_erase stop at the last row/column, so a picture placed partly (or, from a bad coordinate, wholly) off screen cannot scribble past screen RAM into the interpreter.

  • make.rb upper-cases the names it puts in the disk directory, so the interpreter asks for P004. A wrong name fails silently: OPEN reports success and one page of the copy buffer lands in attic.

Mouse (MEGA65 -fcm, X16 -pics)

asm/mouse.asm (sourced under Z6, gated on Z6_MOUSE — the MEGA65's full colour screen or the X16's pictures screen) implements the z-spec 10.3 mouse. Text-only z6 has no pointer on either machine. Everything the spec asks for is shared: the press edge, the click cell, the header extension table, the window the mouse is confined to; only reading the hardware and moving the pointer differ, which is .mouse_read.

  • The 1351/Amiga mouse on control port 2 is read from the MEGA65's direct pot registers $d620/$d621 (no SID/CIA multiplexing) and its button from $dc00 bit 4. The pot is a wrapping 6-bit counter, so mouse_poll tracks the signed change between reads and accumulates a pixel position, clamped to 640x200 (320x200 under -fcm:40). Port 2 is where the MEGA65 puts the mouse by default (confirmed against the User's Guide), and it is the right choice anyway: port 1's lines are shared with the keyboard matrix, so $dc01 bit 4 reads keyboard row 4 (SPACE, etc.) and can't distinguish a click from a keypress. A real MEGA65 mouse in port 1 would need $d622/$d623 and $dc01, with the keyboard caveat.
  • A joystick is an alternative to the mouse (July 2026, .mouse_read_joy in mouse.asm): either device, or both, moves the pointer and clicks, so a player without a mouse can still use the mouse-driven UI (Zork Zero's compass, Arthur's click-to-dismiss-[MORE]). On a real MEGA65 both the mouse and a joystick work in control port 1, and nothing in port 2 (tested; the user is happy to keep it this way — one port for either device). That is where Ozmoo's reads land: the mouse's pots ($d620/$d621) and the joystick ($dc01, JOY_PORT1 — directions bits 0-3, fire bit 4, active low) both read physical port 1 on hardware, and the click is $dc00 bit 4 or $dc01 bit 4, so the port-1 device's button (on $dc01) is caught even though the mouse-button comment says $dc00. (Note xemu maps the mouse pots to port 2, so headless tests move a "port 2" mouse; the register-to-port mapping simply differs between emulator and hardware. Trust the hardware.) The joystick read deselects the keyboard columns ($dc00 = $ff) with interrupts off so our read sees only the joystick; movement is rate-limited to one JOY_STEP (6px) every JOY_JIFFIES (4) jiffies via kernal_readtime; with nothing plugged in $dc01 reads $ff and the whole path is a no-op. The divider is the pointer's speed: a joystick is all-or-nothing, so a step every jiffy (JOY_JIFFIES 1, what it was until July 2026) crossed the screen several times faster than the mouse does — tested on hardware, and a quarter of that is right. Both constants are at the top of mouse.asm and JOY_JIFFIES takes a -DJOY_JIFFIES=n override from $GENERALFLAGS; raising JOY_STEP and the divider together keeps the speed but makes the motion steppier, lowering both keeps it smooth and costs polls.
    • The KERNAL keyboard scan turns a held joystick into phantom keypresses (real hardware only — xemu injects clean joystick state and never shows it, so this was not caught until Johan tested on a real MEGA65). A joystick shares the CIA matrix lines, and the KERNAL's own IRQ scan reads it as keys ("2", "e", left-arrow…) — our column-deselect cleans only our read, not the KERNAL's. So getchar_and_maybe_toggle_darkmode flushes the keyboard buffer (keyboard_buff_len = 0) and takes the pointer path whenever mouse_joystick_held reports a direction or fire (and for one poll after, .joy_was_held, to catch a late phantom key). While the joystick is pushed the keyboard is ignored — you are not typing then anyway — and it works normally otherwise. This block is TARGET_MEGA65-gated (the X16 uses the KERNAL mouse and has no joystick). Confirmed on real hardware: the phantom keypresses are gone, and mouse and joystick both move the pointer and click in port 1. In xemu, movement, fire→click, and ordinary keyboard input all still work through the new path (the suppression itself can only be seen on hardware).
    • The column deselect is skipped unless something is actually held (July 2026). .read_joy_bits deselects the columns by writing $ff to $dc00 — CIA1 port A, whose bits 6 and 7 are also the SID's paddle select, so each write puts the pot multiplexer into its "both ports" state a few instructions from the pot reads at the top of .mouse_read, thousands of times a second. A real MEGA65 is unaffected ($d620-$d623 are direct and sampled independently of the CIA). Since a false direction or a false button can only come from a line pulled low, both ports are now read raw first and the guarded read runs only when a bit we care about is low; ordinary mouse play (no joystick held, no key down) never touches $dc00. Anything held still takes the guarded path and gets the same answer, so both fixes below are behaviour-identical. This was chased as the cause of a slow, jerky pointer in xemu, and it is not that — do not repeat the hunt. Johan checked builds back to 17 July, before any of the joystick work, and the emulated pointer is equally jerky there: it is a standing property of xemu's mouse, not a regression, and on a real MEGA65 the mouse is fine (tested with a USB mouse through a Mouster). Removing the writes did improve xemu and is worth keeping on its own merits — strictly less CIA traffic, behaviour-identical — but the emulated pointer is not an Ozmoo defect. The measurements taken along the way, so nobody takes them twice: mouse_poll runs at 55 polls a second, identical before and after the joystick work, which is the rate an ordinary 1351 driver polls at anyway (once per IRQ); and $dc01 reads $ff in xemu whether the mouse is moving or not, so the joystick path is inert there and JOY_JIFFIES cannot be affecting the pointer. The pointer cannot be tested headlessly at all: xemu's mouse is only grabbed from a real window, and under Xvfb a pre-joystick build tracks the host pointer no better than a current one — which is what made this take a detour, since the harness cannot tell a good build from a bad one.
    • The port 2 button had to be read the same guarded way, and until July 2026 was not — found while chasing a report of the fire button double-clicking (unreproduced; an autofire joystick is at least as likely an explanation, and the hold itself is provably harmless — see the click bullet below). $dc00 is not just port 2, it is the keyboard column-select port, so reading it returns a column the KERNAL's scan is driving low, not only what a device pulls low: while the scan has column 4 selected ($ef) bit 4 reads 0, which is exactly "the button is down". That is ~25 cycles a jiffy, but the input loop polls thousands of times a second, so it lands in the window every few seconds and delivers a click nobody made — anywhere the pointer happened to be. It bites hardest with no port 2 device at all (Johan's setup: both mouse and joystick in port 1, so the real button arrives on $dc01 and $dc00 bit 4 is nothing but the column latch). The read now happens inside .read_joy_bits, in the same sei + columns-deselected window as the port 1 read, and is left in .joy_port2 for .mouse_read to OR with the joystick's fire; nothing reads $dc00 directly any more (the MOUSE_BUTTON equate is gone, replaced by JOY_PORT2 with that warning on it). Confirmed on a real MEGA65 (July 2026), along with the joystick speed and the debounce: no phantom clicks, no double click, and the pointer moves at a reasonable speed. None of the three could be seen in xemu, where the emulated scan is cleaner and the joystick state is injected clean.
  • A click is a press edge, so holding the button cannot repeat — but a momentary release can (July 2026). mouse_button is written only in .mouse_poll_body and a press registers only when it was 0, so a hold of any length is one click; read_mouse reads that byte and never writes it. What the edge test cannot tell from a second click is contact bounce: a fire button chatters for a few milliseconds and the input loop polls far faster, so one press reads as press-release-press. MOUSE_DEBOUNCE (6 jiffies, !ifndef-guarded so -DMOUSE_DEBOUNCE=n overrides it; 0 disables) therefore ignores presses for a tenth of a second after an accepted click — slower than any bounce, faster than a deliberate second click, and it also thins out an autofire joystick. It is counted down in jiffies by .mouse_debounce rather than differenced against kernal_readtime at the press: the jiffy byte wraps every 256 jiffies (~4 s), and a difference against a stale reading would silently drop a genuine click a few percent of the time. The window costs a KERNAL call per poll only while it is open. Shared by both targets, since the X16's mouse_get button is raw too.
  • The pointer hides itself after five seconds of stillness, and comes back the moment the mouse moves (August 2026, .mouse_autohide, both targets): an arrow parked in the middle of the text is a distraction while the player is typing. mouse_poll compares mouse_px/mouse_py against the position at the last movement, exactly, so the smallest nudge brings it back; a click does not, which is no loss, since nobody aims a pointer they cannot see. The idle time is measured against the jiffy clock (MOUSE_IDLE_JIFFIES, 300; the kernal jiffy is 60 Hz whatever the TV standard, and -DMOUSE_IDLE_JIFFIES=0 turns the whole thing off) rather than counted in polls, because polling only happens while the game waits for input — a turn the interpreter spends two seconds printing must still count towards the five. Two bytes of the clock are enough: the difference is only looked at while the pointer is up, which is never long enough to reach the 16-bit wrap. Hiding is the sprite, not the driver: the MEGA65 clears $d015 bit 0, and the X16 clears VERA's sprite layer (DC_VIDEO bit 6) rather than calling mouse_config 0, which would stop the KERNAL scanning the mouse at all and so stop it ever noticing the movement that is meant to bring the pointer back. mouse_visible (beside mouse_active) says which state it is in, and mouse_enable starts the clock so an untouched mouse hides five seconds after the game asks for it. Verified on both: on the X16 headlessly under Xvfb — idle leaves mouse_visible 0 with the arrow gone from the recorded frame, xdotool mousemove brings both back — and on the MEGA65 over the uartmon by poking mouse_prev_potx to fake a pot movement, where $d015 bit 0 follows mouse_visible and a real-time run (no -sleepless, which runs ~15x fast and makes the five seconds pass in a third of one) had the pointer still up at 4 s and gone at 5.
  • The pointer is sprite 0, addressed through the VIC-IV 16-bit sprite pointer ($d06c-$d06e, SPRPTR16), because the classic pointer slot (screen + $3f8) lands inside the FCM screen RAM. mouse_poll moves it; there is no hardware "sprite follows mouse", the MEGA65 KERNAL just does the same in an interrupt. Sprite X coordinates stay in the 320-wide space under H640 (one sprite unit = two physical pixels), so .mouse_place_sprite halves the 0..639 position; the pointer therefore moves in 2-pixel steps on the 80-column screen.
  • mouse_poll runs from getchar_and_maybe_toggle_darkmode on every input wait (so the pointer follows the mouse through read, read_char and the [MORE] prompt). A press becomes input code 254; the click cell goes into the header extension table (mouse_write_header_coords), and read_mouse/z_ins_mouse_window report the live pointer and confine it to a window.
  • Anything getchar_and_maybe_toggle_darkmode does has to be transparent to an in-flight print, because the [MORE] prompt calls it from the middle of one. mouse_write_header_coords was not: it walks the header extension table with set_z_address / read_next_byte / write_next_byte, and print_addr keeps its position in the string in z_address — the same three bytes. So dismissing [MORE] with a click (rather than a key, which touches none of this) left the print resuming from the header extension table, and the rest of the message came out as decoded garbage scattered over the screen, on both graphics targets. mouse_write_header_coords now saves and restores z_address around its work, re-deriving the X16's banked pointer with x16_bank_z_address; print_addr's own abbreviation path saves the same bytes for the same reason, and is the precedent to copy if anything else is ever called from an input wait. Reproduce with l.l in Arthur's first room and click at the prompt (z6games/arhur-mouse-more-x16.png is the old behaviour).
  • z_init keeps Flags 2 bit 5 and calls mouse_enable (which sets mouse_active and shows the sprite) only if the game asked for the mouse — Arthur, Zork Zero and testz6 (whose @read_mouse test asks) all get a pointer. Clicks are ignored unless mouse_active.
  • The pointer is a red arrow (sprite 0 colour, mouse.asm), chosen so it stays visible over the white status line. The click is confirmed working: in xemu, Zork Zero's compass rose responds to a click on the full colour screen. Automated headless testing still can't drive it (xemu can't move or press a mouse without a window), but the sprite, mouse_active, and the terminator table can all be read back.
  • On the X16 the KERNAL owns the mouse, so .mouse_read is one call: mouse_config ($ff68) with A=1 shows the pointer and takes its bounds in 8-pixel units (X=80, Y=25 — the 640x200 pictures screen), and mouse_get ($ff6b) fills four zero page bytes (the KERNAL API registers r0/r1 at $02, which its own interrupt handler saves around its use) with the position and returns the button mask in A. The KERNAL's default interrupt handler calls mouse_scan and moves the pointer sprite itself — Ozmoo never touches $0314 on the X16, so this just works, and there is no sprite of ours to place. The pointer is VERA sprite 0, its image at VRAM $13000, clear of the tile store (bank 0) and the layer 0 map ($10000); mouse_enable only has to switch the sprite layer on (DC_VIDEO bit 6). Because the height is under 31 cells the KERNAL doubles its internal y, and mouse_get halves it back, which is exactly right for a screen whose VSCALE is halved: the pointer moves at the same physical speed and reports 0..199.
  • On quit the X16 hides the pointer (mouse_disable, mouse_config with A=0, which also puts the SMC back to sending key codes only), beside vera_gfx_restore.
  • The X16 mouse can be driven headlessly, unlike the MEGA65's: run x16emu under Xvfb, xdotool mousemove in small steps inside the window (SDL only sees motion while the pointer is over it, and a single big jump gets swallowed), xdotool mousedown 1, then xdotool key ctrl+s to dump RAM (-dump R writes $0000-$9fff, so a label from temp/acme_labels.txt is a direct offset). That is how the click was verified: mouse_button 1, mouse_click_x/_y at the pointer's cell, the click delivered as input code 254 which ended the read, and mouse_active back to 0 through the quit path.

Sound effects (MEGA65 -asw/-asa, X16 -asw)

@sound_effect samples work on the MEGA65 and, since July 2026, on the X16 (any z-version, -asw only). asm/sound.asm holds the target-independent part — the command queue z_ins_sound_effect fills, the argument defaults, the routine-callback queue text.asm drains — and one of two engines behind !ifdef TARGET_MEGA65 / !ifdef TARGET_X16, exactly as the two picture engines sit behind screen-z6.asm. The X16 engine is asm/sound-x16.asm; the MEGA65's is still inline in sound.asm with its sound-wav.asm / sound-aiff.asm parsers. When the ifdef boundaries in sound.asm move, no code moves — that is what keeps MEGA65 output byte identical, and it is worth re-checking with make sound-sherlock-mega65 before and after any change there.

  • VERA has no audio DMA, it has a 4 KB PCM FIFO that the CPU must feed ($9F3B ctrl/volume, $9F3C rate, $9F3D data; $9F27 bit 3 AFLOW is a level, asserted while the FIFO holds < 1024 bytes, enabled by $9F26 bit 3). So init_sound chains $0314 in front of the kernal's handler and pushes up to 1 KB per interrupt. It feeds on every interrupt where a sound is playing and the FIFO has room, not only on AFLOW: the kernal's 60 Hz vsync comes through the same vector, tops the FIFO up in ~130-byte bites at 8 kHz, and means a sound survives anything that clears our IEN bit. The handler must chain unconditionally — returning early kills the keyboard and the mouse pointer, which are the kernal handler's job on this target. (Ozmoo hooks $0314 on the X16 only for sound; the mouse section above says it doesn't, which was true before this.)
  • "The sample has all been pushed" is not "the sound has finished." The FIFO still holds up to half a second of it. The engine therefore fires the game's routine argument only when the FIFO-empty flag comes up, and a repeat re-seeds the cursor at push-exhaustion instead, so a loop has no gap in it. The same distinction bit the queue-cut rule: sound_poll cuts a playing sound short when another is queued (as the MEGA65 callback does), and keying that off "has played once" truncated the sound in front of it by a whole FIFO — Sherlock's 2.20 s effect came out 1.96 s. It now also requires a pass still to come, so a one-shot is left to drain.
  • The queue is advanced from sound_poll, not from the interrupt, because the next sound needs a kernal LOAD from SD and the kernal must not be called from an interrupt (the MEGA65 callback can call sound_effect directly because its load is a DMA). sound_poll is called from getchar_and_maybe_toggle_darkmode, so a sound queued behind another starts at the next input wait rather than the instant the first ends. That same call site is why .snd_stage uses no zero page at all (its store is a self-modified absolute address, like the feed cursor): it can be reached from the [MORE] prompt in the middle of a print, which is the hazard the mouse header-write bug above documents.
  • make.rb converts the wavs at build time; there is no runtime parser. prepare_x16_sounds requires 8-bit mono PCM, XORs every sample with $80 (8-bit wav is unsigned, VERA's PCM is signed), writes temp/s003.bin[S003] in the game folder beside [ZCODE], and emits temp/sounds.asm (the snd_len_* / snd_rate tables, indexed by number − 3, a rate of 0 meaning "not in this build") plus -DSND_COUNT / -DSND_HIGHEST_NUMBER. The AUDIO_RATE byte is hz / 381.47, computed there because the X16 has no hardware multiplier to shortcut the divide the way $d770 does on the MEGA65.
  • The sound banks are stacked above everything else in banked RAM — story, then picture staging and undo in a -pics build, then SOUND_BANK for SOUND_BANKS banks, sized to the largest sound because only one is resident at a time. make.rb (build_interpreter) is the single point of truth, prints the layout with -v, and refuses a file that does not fit; constants-x16.asm carries !error tripwires for an overlap, because an overlap would be silent — a sound would quietly overwrite the staged picture or the undo state. Arthur with pictures, undo and Sherlock's wavs lands at banks 40-46 with 17 free.
  • The loader insists on the exact length the table promises. On this kernal OPEN reports success for a name that does not exist and the read then yields whatever the DOS left in its buffer, so a game folder that does not match the build would otherwise play a bank of arbitrary bytes at whatever rate the table says (observed: seconds of full-volume DC). A short read is refused instead and the sound stays silent. The only way to reach it is editing the folder by hand.
  • SDL_AUDIODRIVER=pulseaudio used to be mandatory here and no longer is (August 2026). SDL did not get on with pipewire on Fedora 44/KDE, and both x16emu and xemu came up silent with no error at all, which reads as a broken sound build; a Fedora update fixed it, and both emulators are now heard without the variable. The Makefile's sound-sherlock-x16 and testsound-x16 targets still pass it (harmless, and right for an older SDL), so a silent emulator is worth one try with it set before believing the build is at fault. (Unrelated: the headless checks below use SDL_AUDIODRIVER=dummy and read the recorded wav rather than trusting the speaker.)
  • Verification is by recording the emulator's own audio. x16emu -wav out.wav,auto works headlessly under SDL_AUDIODRIVER=dummy + Xvfb (the recorder is tapped inside audio_render, which needs an open SDL device but not a real one). Do not use -warp for it: the render path is throttled by the device consuming buffers in real time. testsound.inf (make testsound-x16, make testsound-mega65) plays the cases that have gone wrong — repeats, stop, two queued, loop for ever, routine argument — and each sound's measured duration should match bytes / (AUDIO_RATE * 381.47). Two traps met while doing this: the emulator writes dump-1.bin, dump-2.bin … when Ctrl-S is pressed more than once (not dump.bin), and pkill -f x16emu from a Bash tool call matches the shell running that very command line and kills it, so put emulator kills inside a script file.

Watch out for

  • v6 changes opcode shapes. pull is the classic trap: in v1-v5 it names the variable to store into; in v6 it takes an optional user-stack operand and stores its result. Getting this wrong desyncs the PC and produces garbage, not a clean error. Check the Z-machine standard (see References) before assuming an opcode behaves as in v5.
  • The operand array is not cleared between instructions — only z_operand_count is reset — so an optional trailing operand must never be read without checking the count (lda z_operand_count / cmp #n / bcc, as set_margins and .pic_place_cursor do). window_style read its optional operation raw and Journey calls it with two operands right after a three-operand window_size: the stale width became operation 3 (xor) and stripped window 0 of wrapping, scrolling and buffering — the intro printed one truncated line per paragraph.
  • draw_picture, erase_picture and picture_data are all implemented under -fcm -pics, including Rect placeholders, adaptive palettes and transparent-cell compositing (see the Pictures section); elsewhere draw_picture writes a pic:N note and picture_data reports no picture. Arthur's first room — a scene composited inside a recolouring frame — now renders like the reference interpreter. z_init keeps the "pictures available" bit (Flags 2 bit 3) set under Z6_PICTURES (July 2026), so the flag agrees with picture_data; it clears the bit only on builds that cannot draw, per z-spec 11.1 (the mask is chosen at assembly time so the code stays the same size — the C64 -ecm build has no bytes to spare). Verified on Arthur MEGA65 -pics: flags_2 reads $78 (pictures+undo+mouse+colours) where it used to read $70. The other three -pics games see the bit now too and are worth a play spot-check, though it is their native environment. print_form and scroll_window turned out to be text opcodes and are done. See todo.txt.
  • The z-machine writes "the current window" as -3 (z-spec 8.8.3), and Arthur does so 27 times. Every opcode taking a window number must go through window_from_operand; taking the operand's low byte raw indexes the property arrays at $fd. This produced coordinates like y=244 and hung .pic_draw's row loop, whose counter is one byte. The latest offender (July 2026) was erase_window: its −1/−2 check let −3 through raw, so Arthur's erase_window -3 erased nothing and wrote to window_y_cursor+253 on every parser error.
  • A coordinate operand can be negative, and print_buffer sits in the 6502 stack. set_cursor's column is a signed word, and a game can legitimately ask for one off the screen: Shogun centres every credit line with set_cursor row, (window_width − measured_width) / 2 + 1, measuring the line through stream 3 first. Two of its title lines are 54 and 48 units wide, so at 40 columns it asks for columns −6 and −3 — at 80 they are positive, which is why only the C64/Plus4 (and -fcm:40) saw this. z_ins_set_cursor read the operand's low byte alone, making those columns 250 and 253; buffer positions are absolute screen columns (start_buffering seeds buffer_index from the cursor) and print_buffer is $100 with only SCREEN_WIDTH + 1 bytes, so the credits printed straight through the stack, wrapping past $1ff back into $100 — garbage interleaved with the real text on the title screen, then a reset when a smashed return address was used. CHECK_ERRORS cannot see this: it is 6502 corruption, not a Z-machine error, so the MEGA65 died as silently as the C64. Fixed July 2026 by clamping the column into the screen (negative → left edge, ≥ screen width → right edge). Two lessons: any operand that can be a signed quantity must be read as a word, not a low byte (the same class as the -3 window bug above); and at 80 columns the same overrun lands inside the larger buffer and is invisible, so a 40-column-only symptom is not automatically a 40-column bug.
  • A window without the wrapping attribute truncates its lines; it does not wrap them (August 2026). Windows 1-7 start with buffering on and wrapping off (z-spec 8.8.3.3), and 8.8.3.1.1 spells out what that means: characters are printed "until no more can be fitted in without hitting the right margin, at which point the cursor will move to the right margin and stay there, so that any further text will be ignored". Ozmoo's z6 print path carried the overflow onto the next line whatever the attribute said, and in a one-row window the next line is the row it is already on — so the tail of a too-long line came back written over its own head (this line should be truncated on a narrow screen, printed into a fresh split_window 1, rendered as byelow screenuld be truncated on a; the z5 build of the same program truncates correctly). Two places now read WIN_WRAPPING: printchar_buffered keeps it in .buffer_wrap beside the window's edges and, when it is clear, drops characters once the buffer reaches the right margin and never breaks the line; and s_printchar parks the cursor on the margin (it parked one column short before, so every further character overwrote the last one that fitted) with a matching check at the top of .normal_char that drops anything printed from there until a newline or a cursor move. Buffering only decides where a wrapped line breaks (8.8.3.1.2.2), so a non-wrapping window renders the same buffered or not — which is what the spec's own example table shows. Three things worth keeping: the .normal_char check is gated on the window not wrapping, because testz6 deliberately prints outside window 0's margins (its pic:4 note at column 12 of a 9-column window) and clipping that would be a change with nothing behind it — with the gate, testz6's screen is byte-identical to before; .add_char carries .buffer_edge in x all the way to stx max_chars_on_line, so the new flag must be tested through a, and testing it with ldx moved every wrap point in testz6 (needs to / be became needs / to be) and read exactly like the truncation fix having broken word wrapping; and z5's behaviour past the bottom of the window is not a model for z6 — non-z6 Ozmoo grows window 1 when text is printed below it (.outside_current_window), while z6 clamps to the window's last row, which is also what sfrotz does with the same program, so lines two and three legitimately overwrite line one there.
  • The newline interrupt (window properties 8/9) is implemented (July 2026): increase_num_rows decrements a nonzero countdown on each completed line and calls the property-8 routine like a timed-input interrupt, saving the in-flight print's state around the nested z_execute (see fire_newline_interrupt). Zork Zero rolls text around its drop-cap initials with it; Shogun shapes text past its border pictures. Property 8 is a packed address — a word — with its high byte in window_newline_routine_hi. Spec 8.8.3.2.2.1's ordering quirk (interpreter 6 + exactly Zork Zero r393.890714 fire after the cursor reaches the new line; the game compensates its countdown by the header) is ZORK0_MSDOS_QUIRK, defined by make.rb for that story on the pics builds. set_margins now also flushes and restarts the print buffer around its cursor move, like set_cursor — Zork Zero sets the margin beside the drop cap and prints immediately.
  • The X16 story split is 8 KB at $7f00 (July 2026; it was 16 KB at $5f00 until the pics builds outgrew the interpreter space — Zork Zero now links with ~6 KB to spare). The interpreter + z-stack must end below X16_STORY_BASE; the rest of the story lives in banked RAM from bank 1. The split is X16_STORY_BASE / X16_LOW_STORY_PAGES in constants-x16.asm and must stay a multiple of 32 pages — three separate sites map z-pages to banks with that assumption (x16_prepare_bankmem, x16_load_file_to_reu, and zmachine.asm's .find_global_var), and inc_z_pc_page walks the cached z-pc pointer across bank boundaries with the same arithmetic (its hardcoded cmp #$40 was the last hideout: czech crashed to BASIC when its code crossed z-address $2000). make.rb's story-bank/staging arithmetic hardcodes the matching 8192. If a low-RAM-slice bug is suspected, test/czech.z5 on the X16 finds mapping errors fast (406 tests), and praxix's undo test (build with -u) exercises the undo dynmem copies.
  • The [More] prompt has to put back what it covered, and over a picture it flashes against it (August 2026). The prompt lands on the current window's bottom-right cell, which in a v6 game is often inside a picture, and both graphics targets got that cell wrong. On the X16 vera_hide_more wrote a space rather than restoring the cell, and VERAPrintChar always writes vera_composite_colour — so where pic_fill_cells had left the background nybble at 0, the transparent one that lets layer 0 show through, the prompt turned the cell opaque and left it that way: Shogun's ship kept a blank white square where the prompt had been. vera_save_more_cell now remembers the character and the colour byte, and vera_hide_more puts both back, which is also the other half of the blink. On the MEGA65 a cell is two bytes, and the prompt wrote the '*' screen code into the low byte alone — leaving the picture tile's high byte, so the cell pointed at some other tile (the "static white box"), and the blink, which only alternated colour RAM, was invisible because a full colour tile takes its pixels from the store rather than from colour RAM. .more_fcm_show / .more_fcm_hide now write the whole cell — '*' with a zero high byte in s_colour one way, the saved two bytes and colour the other — so the prompt genuinely flashes between the asterisk and the artwork, which is what a pixel interpreter does. Over text both targets behave as before (the cell alternates with what was there, which is a space). Verified on Shogun: on the X16 by finding the one-cell change in an x16emu -gif recording (frames alternate picture / asterisk) and confirming no opaque cell survives anywhere in the picture's rectangle in a VRAM dump; on the MEGA65 by sampling the cell over the uartmon, where it alternates aa00 (the '*' as a plain glyph) with 0617 (a tile code). Two things worth keeping: the six extra self-modified addresses are patched with ora #1 because a row start is a multiple of the row width and the cell offset is a doubled column, so the cell's high byte is always an odd address that cannot carry into the next page; and this only ever showed up when the window's last cell happened to sit on artwork, which is why it survived so long.
  • The C128's 80-column [More] prompt saved the cell from inside the blink, so it saved its own asterisk (August 2026). The VDC path is the only one where "show the prompt" reads the screen: vdc_show_more read the cell into .more_text_char before writing the '*', which is right the first time and wrong every time after — the blink loop calls show on every other pass, so the second call saved the asterisk it had just written itself. The prompt then alternated between the asterisk and the asterisk (no blink at all), and vdc_hide_more put an asterisk back when the key was pressed, leaving a '*' in the corner of Arthur's screen for the rest of the game. Reading the cell is now vdc_save_more_cell, called once from show_more_prompt beside the first show, exactly as the X16's vera_save_more_cell and the FCM path already did it; hide restores the attribute too, which show had overwritten with the text colour. The same VDC address was also hardcoded to $07cf, the bottom right of the screen, while .set_more_prompt_pos had been patching only the VIC-II addresses in .more_access1-4 — so in a v6 game the prompt sat outside the window it belonged to (Arthur's window 0 ends at column 76, not 79). That routine now derives the VDC pair from zp_screenline/zp_colourline, whose bases differ from the VDC's only by $0400 and $d800 - $0800. Verified headlessly by tracing both routines with a bank vdc; m 07cc 07cc dump of the cell on each hit: it now reads $20 at every show and $aa at every hide, and $20 again when the prompt is dismissed. Note the read-inside-show was not z6-only: screen.asm's non-z6 copy had it too, and only escaped because its blink phase comes from whatever y held on entry — a z5 game got the good parity by luck rather than by construction, which is why Sherlock and czech blink correctly on the same screen. It has the same split now (the hardcoded $07cf is right there: without windows the prompt really is the screen's bottom right corner), verified on czech, whose 80-column run alternates $20 at every show with $aa at every hide. Only the C128 binaries change, z6 and non-z6; every other target's is byte-identical.
  • The input cursor's colour has to follow a window switch, not just set_colour (X16, August 2026). current_cursor_colour is set at init and by z_ins_set_colour — and, since the third-operand work, only when the window being coloured is the current one. Nothing updated it when the current window merely changed, so it kept whatever the last set_colour left: Shogun gives its status window white on black and then prints the body black on white, so on the X16 the cursor stayed white and disappeared into the page, and a read prompt showed no block at all. It is not a missing cursor — the cell holds $e0 (CURSORCHAR) the whole time, in colour $11 where its neighbours are $1b, which is what a VRAM dump settles in one look and a screenshot never will. x16_apply_window_colour now refreshes it from s_colour (which VERASetForegroundColour has just written) whenever the current window's pair goes live, guarded by the same cursorcol = 1 test the other two sites use, so a build that pins the cursor to a colour of its own keeps it. Only Z6_WINDOW_BG — the X16 — has this path; the reverse-glyph targets carry their own per-window foreground through apply_window_swap.
  • [More] pauses only scrolling windows, and the line count is a signed word. increase_num_rows requires WIN_SCROLLING (window 0 has it; windows 1-7 default to buffering only), because nothing is carried off a non-scrolling window — Arthur's one-line parser-message window used to [More] on every error. Property 15 is game-writable with the magic values −999 (postpone) and 999 (never) per 8.8.3.2.6 — Arthur parks window 0 at −999 after intro keypresses — so it has a high byte (window_linecount_hi, placed after the 16 byte arrays to keep the window_y + 8*prop indexing).
  • Property 11 is the window's colour pair, and a swapped pair renders as reverse video. set_colour tracks each window's (bg,fg) z-colour pair nybble-packed in window_colour (operands 0 and −1 keep, 1 is the default) and get_wind_prop answers it (bg high byte, fg low, 8.8.3.2.4). Arthur prints every parser message by setting the exact swap of the pair around erase_window -3 + print_form — the same boxed look as its status line. No target has per-window hardware backgrounds, so an exact swap sets s_colour_swap, which ORs $80 into the glyphs (reversed ROM glyphs: field in the glyph colour, text in the screen background) and makes erase fill with reversed spaces; the real colours never move, and any other set_colour switches it off and applies normally. ECM has no reversed glyphs and only keeps the tracking. Every erase path must honour it: s_erase_line didn't, and Journey's horizontal layout bars — swap + erase_line, its IBM-interpreter stand-in for font 3 (routine 5058) — erased to invisible background on the pics builds.
  • set_colour's third operand names the window, and ignoring it made the last call win everywhere (August 2026). In v6 the opcode is set_colour foreground background [window], the window optional and defaulting to the current one; Ozmoo took the operand only in ECM mode, so a game that coloured a window it was not printing in — the natural way to dress a window before showing it — had the colours land on the current window. z_ins_set_colour now resolves it through window_from_operand (so −3 is the current window) into set_colour_window, which s_track_colours writes the pair, the swap flag and window_bake through; for any window but the current one the pair is recorded and the hardware left alone, and it goes live when that window becomes current (set_window) or is erased. erase_window fills a window with its own background on the X16 — it used to fill with whatever the current window printed in — switching the pair only for a window that is not the current one, so the common case is unchanged instruction for instruction. On the reverse-glyph targets there is one global background register, so a window's background still cannot follow it, but the foreground is per cell: apply_window_swap now makes a window's own foreground live on a window switch, for a window the game actually gave a colour (window_fg_set, set from a real colour operand, not 0 "keep" or 1 "the default" — forcing FGCOL on a default-colour window would undo darkmode). And applying a stored pair has to resolve a colour this target has no entry for: zcolours answers $ff for z-colours 10-15 and for "the default", which z_ins_set_colour's own path turns into the story's default by reading the header, while the appliers wrote the $ff into the hardware and got its low nybble (light grey) — zcolour_to_hw_fg/zcolour_to_hw_bg do that lookup for them. Mapping 10/11/12 to the C64 palette's three real greys is still not done, so a v6 game asking for grey gets the default colour. None of the four commercial games passes the third operand (every SET_COLOUR in txd's disassembly of all four is two-operand), and their opening screens are pixel-identical before and after on both graphics targets; testz6 changes on purpose, since it does use it. Found with testbufferings.inf, Johan's window-buffering test, whose two coloured windows came out red-on-red on the MEGA65 and in the wrong pair on the X16.
  • A window background that is not the screen's is a baked full colour tile on the MEGA65 (August 2026, Z6_FCM_WINDOW_BG, asm/textbg-mega65.asm). Under FCM a text cell is a ROM glyph in the colour RAM's foreground colour on the one global $d021, so a window asking for black on cyan while the screen is white on blue used to print black on blue — the reverse-video swap only covers the exact swap of the screen's pair. Such a cell now becomes a 64-byte tile of the glyph's own bitmap: a set pixel is palette index 255, which the VIC-IV takes from the cell's own colour RAM byte (the ink Ozmoo writes there anyway, so one tile serves every foreground colour), and a clear pixel is 240 + the window's background out of the text-ink palette bank init_mega65 builds. A reversed glyph is the C64 font's own reversed copy at code + $80, so reverse video inside a coloured window falls out for free. The tiles are baked on demand into a store of their own — $14000-$17fff in bank 1, 256 tiles, free in both build types (below it the picture-less build's store at $10000 and CBDOS' unsafe foot, above it the sound sample a -pics build puts at $18000), so this never competes with the picture engine, whose cell scans already treat a code below FCM_TILE_CODE_HI as text. Two backgrounds at a time (FCM_TEXT_SLOTS) of 128 glyphs each; a third degrades to the old behaviour, and the store is dropped when the screen is cleared, the only moment no cell can still be showing one. The hook is the clear_cell_high_byte macro — every text write site already calls it right after storing the character, which is why this needed one hook rather than the eight sites the FCM warning above lists. The paper follows the window: recomputed on set_colour and on every window switch, and set aside for the window an erase_window or scroll_window names when that is not the current one, exactly as ECM does for its background registers. Not baked: a background of hardware colour 15, whose palette index would be the 255 that means "the colour RAM byte". The four commercial games cannot reach any of this (they only ask for the screen pair or its exact swap) and their opening screens are pixel-identical before and after.
  • The swap test must be a property of the pair asked for, not of the pair it replaces (July 2026). "Exact swap" is measured against the screen's pair, s_bg_zcolour / s_fg_zcolour — the colours a non-swapped window prints in — so asking twice gives the same answer. It used to compare the requested background against that window's own previous foreground, which the first call had already changed, so a repeated identical set_colour cancelled the swap it had just set and then applied its background for real. Shogun does exactly that: set_colour 9 2 for its status window, twice in a row. The second call turned the whole screen's background black and left both windows printing the same way — status band and body text alike — on the MEGA65, C64, C128 and Plus/4, while the X16 was right because Z6_WINDOW_BG gives it per-cell backgrounds and it never runs this test. Two general lessons: a state-setting opcode a game may repeat must be idempotent, and a heuristic that reads "the difference from last time" is the shape of bug that hides until a game repeats itself. Diagnosed with DEBUG_SCREENLOG (which now hooks set_colour too, id 13, logging the resulting s_colour_swap and the operand count in the result word) read over xemu's uartmon: the two identical calls logged res=$80 then res=$00, and s_bg_zcolour had moved from 9 to 2.
  • A background colour of −1 means "sample the pixel under the cursor", and that is how the v6 games write text on a picture (July 2026). Zork Zero's room name and score sit on its banner, and Arthur's and Shogun's status lines do the same thing: each sets set_colour fg, -1 before printing, so the letters land on whatever art is behind them instead of in a box of the window's own colour (z-spec 1.1's colour list, "−1 = sample"; the same section defines background 15 = transparent, the better-behaved version for art that is not a flat colour, and says erase_window/erase_line/erase_picture become null operations while it is set). Ozmoo read −1 as "keep the current background", which painted a white box over the banner on both graphics targets. On the X16 it is now genuinely transparent (Z6_TRANSPARENT_BG, defined for TARGET_X16 + Z6_PICTURES): a four-bit cell background cannot name a picture's colour anyway, so s_track_colours stores z-colour 15 in the window's pair (property 11 reads back 15, and a game that saves and restores the pair round-trips), and x16_apply_window_colour gives such a window a background nybble of 0 — the one VERA does not draw — through VERASetTransparentBackground. Zork Zero's status line now renders like sfrotz, black on the banner. This needed the transparent-ink bug fixed first: X16_TEXT_BLACK (palette index 11, set to $000 by vera_gfx_init and put back to the kernal's dark grey by vera_gfx_restore, since quit drops into BASIC without a reset) is what black text and black fields print in, substituted for VERA colour 0 inside VERASetForegroundColour/VERASetBackgroundColour under Z6_PICTURES; index 0 is left to mean transparent, which is exactly what the new background wants. It costs Ozmoo's non-standard z-colour 19 (C64 dark grey), the only spare in the low sixteen. The MEGA65 now bakes the text into the picture too (Z6_FCM_TEXT_BAKE, July 2026, this branch): under FCM a text cell's only background is the global $d021, so matching sfrotz means baking each glyph into a copy of the picture tile beneath it — s_bake_char in pictures-mega65.asm, reached from both text paths (s_printchar and print_line_from_buffer) when the window's window_bake flag is set and a picture is under the cell. A set glyph pixel takes a text-ink colour from palette bank 15 (indices 240-255, copies of the sixteen text colours, the bank the picture allocator already skips because pixel value 255 comes from colour RAM); window_bake is set in s_track_colours from the -1 operand alone, touching neither the colour pair nor the reverse swap, so Journey's and Shogun's swapped status bands are unaffected (getting that wrong regressed them once). The redraw is the hard part: the games overwrite the banner in place each turn (padding a shorter room name with spaces) rather than erasing it — confirmed off DEBUG_SCREENLOG — so a naive re-bake composites new text onto old and accumulates. Ozmoo keeps a per-cell shadow of the clean (text-free) picture and composites onto that, resetting only the pixel rows a glyph occupies (so a space erases to bare art, and an adjacent banner line sharing the cell under the sub-cell split survives). The shadow stores the clean pixels, not a tile code, in a fixed attic buffer (BAKE_PIXELS_ADDR, 128 KB, one tile per screen cell; a small BAKE_SHADOW_ADDR array flags captured cells) — a stored code goes stale when .pic_gc compacts and moves the store, which showed as a corrupt strip in Zork Zero's Moves field after several turns (its e/n/s, with the MORE prompts and input lines, reprints far more than a compound e.n.s). A re-bake also rewrites the cell's own tile in place instead of allocating one, so pic_next_tile does not climb every turn and gc rarely runs at all; only a cell's first bake allocates (its current tile is the shared picture and must not be clobbered), and a full store degrades to a plain glyph box. Verified clean through 18 Zork Zero moves. A picture redrawn under a baked cell used to garble it and DID bite a shipped game — Zork Zero's map command draws a full-screen map and redraws the banner on return, and the banner came back as repeated garbage (z6games/zorkzero-aftermap.png): the stale "captured" flag made the re-bake rewrite the freshly-drawn shared banner tile in place. Fixed (July 2026) by .pfc_invalidate_bakepic_fill_cells and the off-grid .pic_gen_fill clear a cell's BAKE_SHADOW_ADDR flag as they write it, so the next bake is a first bake again: it re-captures the clean pixels from the new picture and allocates a fresh tile. gc-safe (cleared at draw time), and only cells the picture actually writes are cleared (not $ffff show-through), so a transparent picture over baked text leaves the shadow alone. An ordinary turn does not redraw the banner picture (only the compass overlays, away from the text), so rewrite-in-place still applies there. See todo.txt and the techreport's "Text over a picture". And the status text now sits at its correct sub-cell row on the MEGA65: it used to be one text row higher than sfrotz because units_to_cells_y floored the cursor to the cell grid (Zork Zero asks for art row 7, seven-eighths of a cell down), but the discarded remainder is now carried (window_y_sub/text_y_sub, captured at move_window/set_cursor) and the bake draws the glyph that many rows down, splitting it across the cell below. Only a baked cell can do that — the picture is there for the two slices to composite onto — so plain text stays on the cell grid, which is all a game ever moves the cursor in. All of this is behind Z6_FCM_TEXT_BAKE and byte-identical elsewhere; the X16 keeps its layer-based transparency unchanged.
  • .pic_find clobbers x. The 16-bit rewrite uses x as a table-address high byte (for .pic_addr); the old one-byte version kept the picture number in x for its whole loop. Any code that calls .pic_find and then .rect_find (which wants the number in x) must reload x first — draw_picture's .dp_not_image does, picture_data's .pd_try_rect didn't, and Arthur drew the churchyard scene off-screen for it (a bad rect size fed the game's layout). A ring buffer logging each draw_picture's index and computed y found it. When a v6 opcode misbehaves, suspect the game got bad data from one of our query opcodes (picture_data, get_wind_prop) before suspecting the blit.
  • A v6 "unit" is an art pixel here, and the spec lets an interpreter choose. Ozmoo counts the 320x200 art pixel space Infocom authored for — the header reports 320x200, get_wind_prop answers 4x8 for the font size, picture_data returns native art pixel sizes — which is what the games' own layout arithmetic assumes, and what sfrotz effectively counts too (sfrotz reports 640x400 / font 8x16 / doubled picture_data, i.e. exactly twice ours on both axes; the relative model is the same, the absolute numbers are not, so a portable game must derive positions from what it is told rather than hardcode them). -pu:0 reverts to counting whole character cells, which is what Ozmoo did until July 2026. Text still lives on the cell grid: the text engine counts cells and the opcodes convert at the boundary (cells_to_units_* / units_to_cells_* in screen-z6.asm, both no-ops without the flag), because a game only ever moves the cursor in font-size steps. Only pictures carry the sub-cell remainder, which both engines can now draw on both axes. Two things that stay true whatever the unit: the portable conversion is the font size, property 13 (units = cells * get_wind_prop 13), and any arithmetic that mixes units with a row or column count is silently wrong — set_cursor 8*2 1 means row 2 in sfrotz and row 16 under the old cell model. The failure mode was nasty: the picture is drawn below the screen, draw_picture clips it (it must, or it would scribble past screen RAM into the interpreter), and the newlines the game prints afterwards scroll the surviving top back into view, so it reads as "half a picture, roughly in the right place". Nothing errors. Beware heuristics that guess the unit from the magnitude (if height > 25 it must be pixels) — right for a tall picture, quietly wrong for a short one. A half-converted unit model does not look like a wrong layout, it looks like a HANG, because the geometry collapses rather than shifts: a game dividing the reported screen size passes values four to eight times too small, and a window a couple of rows tall sits on a [MORE] prompt for ever. If the unit model is ever suspect, check the header against get_wind_prop 13 first — they must agree. The refactor that brought this about, its measurements and what is left of it are at the top of todo.txt.
  • A 1-based position in units is cell * font-size + 1, and it rounds UP to a character boundary (August 2026). Two halves of the same conversion, which had been cancelling each other out. Positions were floored to the cell they start in: move_window/set_cursor take 1-based units, so a window moved to unit 160 - the middle of a 320 pixel screen, and where the games put things - became 0-based 159, and 159/8 landed it on column 19, eight pixels outside itself, with its text. units_to_cells_x_up/_y_up now round a position up to the first cell at or after it (158 and 160 give column 20, 162 gives 21; the canonical cell * font-width + 1 coordinates have no remainder and are unchanged), used by move_window, set_cursor and put_wind_prop's properties 0 and 1. Sizes still round down, so both edges move inwards and a window can lose the odd part cell but never claim one. And get_wind_prop put the 1-based bias in cells rather than units, returning (cell + 1) * font-size - a window's first row read back as unit 8, not unit 1. get_cursor and mouse_write_header_coords already had it right; properties 0, 1, 4 and 5 now agree with them (.gwp_bias in .gwp_scale; bit 1 of the property number separates the four positions from the two sizes and two margins, which are counts and carry no bias). The games are unambiguous about which convention they expect: Zork Zero reads get_wind_prop 4 and immediately SUB #01 to get a 0-based unit row, Arthur does the same with property 1, and Arthur computes a right edge as x + width - 1, so all three were being handed a value font-size - 1 units too big. Zork Zero's own routine 1c918 is round_up(size / font-height) * font-height - Infocom snapping to the character grid itself, which is the authority for rounding up rather than to nearest. Two things this also fixed: Zork Zero's illuminated initials now sit flush with the text block's left edge and top line on -fcm -pics instead of half a cell in and a row down, and its click hit test (routine 1306c, the compass) compares the mouse's header coordinates against properties 0/1, which are only in the same space now - clicks on a window's first row or column used to be rejected. Rounding up is what exposed the bias: Shogun's title screen came out double-spaced, because routine 11790 reads the cursor row back with get_wind_prop 4 and hands it straight to set_cursor for every centred line, so each round trip gained a row - the old pair of conventions inverted each other only while positions were floored. With both halves fixed that screen is identical to before except two odd-character-count lines whose centred position falls exactly half a cell and now rounds right. Journey and Arthur are byte-identical on -fcm. Two carve-outs: the MEGA65 -fcm -pics build still floors the y axis, because Z6_FCM_TEXT_BAKE carries the remainder and really draws the sub-cell offset (Zork Zero's status line at art row 7), and rounding up would fight the bake; and testz6.inf's @move_window 1 hh ww passes widths as a position, so it moves one cell and no longer draws the same screen under -pu:0 - (hh+1)/(ww+1) would be the correct coordinate. Found with fredrik.inf, Johan's window-placement test. The lesson worth keeping: a round trip that works is not evidence either half is right, and the way to tell which convention a game expects is to read what it does with the value it gets back, not to reason about the spec.
  • split_window's count is in units in v6, and the opcode that ignored it made text vanish rather than sit wrong (July 2026). The opcode dictionary spends one line on it — "In Version 6 ... the line count is in units rather than lines" — and all three v6 games that use the opcode are written to it: Arthur and Journey multiply a line count by the font height they read from the header, Zork Zero passes a picture's height in pixels. Ozmoo took the operand as a row count, so a ten-line split asked for 80 rows, the clamp made window 1 the whole screen, and window 0's top row landed on s_screen_height. That alone would be a layout bug. What turned it into a blank screen is print_line_from_buffer, which answers "is there a line to print on?" by reading window_y — window 0's top row — whatever window is current, an assumption inherited from the non-z6 screen model where buffered text only ever goes to window 0. With window 0 out of rows, every buffered line was dropped in every window, silently. Both are fixed: z_ins_split_window divides by the font height under Z6_PIXEL_UNITS (rounding up, so a game written to the v5 model — PunyInform splits 1 for its status line — gets a whole row rather than nothing, and multiples of the font height are unaffected either way), and the print check reads the current window's own window_y. Neither touches the four commercial games: their opening screens, screen-op logs and window state are byte-identical before and after, and none of them reaches split_window before the first prompt; testz6 is identical too, on -fcm, -fcm:40 and the X16. Found with PunyInform's ext_menu help menu (examples/helptest, gitignored — a minimal Puny game beside a local copy of the extension, with a sfrotz-z5/sfrotz-z6 Makefile for the same screen next to the reference), which drew nothing at all while DEBUG_SCREENLOG showed all twenty of its screen opcodes arriving with the right coordinates — that gap between "the opcodes are right" and "the screen is empty" is what points at the print path rather than the game. Two lessons: a per-window screen model can inherit a window-0 assumption from the code it was forked from and fail this way (s_ignore_next_linebreak was the same shape), and a one-line aside in the opcode dictionary can be the whole of a version's semantics. The extension needed fixing too — it positioned everything in lines and columns — which is the other half of this: a v5 library ported to v6 has to scale every set_cursor and split_window by the font size, exactly as the Inform standard library's own DoMenu does (ch = $26, cw = $27, and beware the v6 swap of those two header bytes).
  • Inline pictures — a picture between text on a scrolling window — are barely exercised. Of the commercial v6 games only Zork Zero has them, and only as small icons. Anything larger goes through code paths (clipping at the screen bottom, scrolling a picture with its paragraph, a window shrinking under one) that no shipped game reaches, so treat a bug there as likely-ours rather than likely-fine.
  • v6 is a "large" version, like v7/v8, not like v4/v5. Story files run to 512 KB, the header file length is divided by 8, and block addresses need two high bits. make.rb has always known this ($zcode_version > 5); the assembly used to express it as Z7PLUS, which excludes v6. Use Z6PLUS for anything size-related, and be suspicious of any new Z4PLUS/Z7PLUS split. See todo.txt for the three bugs this caused.
  • No v6 interpreter ever ran on a C64, so v6 code paths have never been exercised against a real game. Expect more latent assumptions — minimum screen size, stack depth, story size — that no other version happens to violate.
  • An error only the MEGA65 reports is usually a real bug everyone else lives with. CHECK_ERRORS is compiled into MEGA65 builds and out of the others. Arthur's FATAL ERROR: 17 turned out to be a modulo by zero the C64 executed too, caused by get_wind_prop returning 0 for the font size. Reaching for -re:0 would have hidden a genuine defect.
  • The window model now works on every target — C64, Plus/4, MEGA65, X16 and, since July 2026, the C128's 80-column (VDC) screen. The VDC took the same shape as the X16: .s_scroll_vdc goes through the shared .calc_window_rect / .sw_up_one / .sw_blank_row helpers and only the row copy (.vdc_copy_row) is VDC-specific, using the chip's blitter — with the copy bit of register 24 set, writing a byte count to VDC_COUNT copies that many bytes from the copy-source address to the current address, so a window row is one register write, not 80 round trips through the data port. Characters are at $0000 and attributes at $0800, so each row is copied twice. A count of 0 means 256 bytes, so a zero-width window must return early rather than blit. ECM is still C64-only.
  • The X16 draws pictures too (-pics without -fcm, July 2026): the MEGA65 FCM engine ported to VERA (asm/pictures-x16.asm). Text stays on layer 1; pictures live behind it on layer 0, a 64x32 map of 16x8-pixel 4bpp tiles — VSCALE halved and VSTOP cropped gives 640x200 effective, the MEGA65 80-column geometry, with SCREEN_HEIGHT 25 and interpreter number 6. The tile store is all of VRAM bank 0 (1024 tiles, tile 0 reserved transparent; the store cap that was 2048 on the MEGA65); a map entry carries the tile index plus a per-cell palette-offset nybble, so a picture's 16-colour bank rides in its map cells, not its pixels. No preload and no picture disks: each picture is an uncompressed [P###] file next to [ZCODE], LOADed from SD into a 4-bank staging area above the story (PIC_STAGING_BANK, make.rb) the first time it is drawn, and picture_data answers from assembled-in pic_width/pic_height tables. Because the text layer sits in front, drawing a picture blanks the text cells it covers (bg nybble 0 = transparent) and erase restores opaque background — the MEGA65's replace-the-text semantics. A picture's own transparent pixels (index 0) reveal the VERA backdrop (palette entry 0, black, shared with text colour 0 so it cannot be recoloured), where the MEGA65 shows the global $d021. To match, a picture drawn over nothing has its transparent pixels filled with the screen background (x16_screen_bg — the last set_colour background, a VERA colour, unchanged by set_window so an inset drawn into a throwaway window still gets the surrounding text's colour): .pic_compute_bg_index finds that colour in the picture's palette bank, or, if it isn't there (Zork Zero's insets have no white), injects it into a free index the picture's pixels don't use (pic_used, recorded during .pic_copy_tiles) — direct pictures only, so an adaptive picture's shared bank is left alone. A composite cell has one palette bank, so underlying pixels are colour-matched into the overlay's bank (through .pcf_xlat, which must hold doubled bytes — a store pixel is a byte of two identical nybbles, so a bare 0..15 there renders as every-second-pixel-black). The undo buffer moves from VRAM to banked RAM in pics builds, and quit restores the composer for BASIC. Off-grid placement generates its cells from staging (z6-pixel, July 2026 — phases 0 and 0b of the pixel-units refactor). A VERA tile is one 8x8 art cell (16 physical px, doubled), so a picture whose corner is not on that grid — an odd text column, and after phase 1 any art pixel on either axis — cannot use its own tiles as they stand. .pic_place_cursor computes .pic_px/.pic_py, the corner in 320x200 art pixels; .pic_map_pos splits both into a first map cell and the offset into it (.pic_shift, .pic_shift_y, the natural remainder); and when either is non-zero .pic_gen_fill builds every covered cell out of the up to four source art cells that reach it (.gen_tile.gen_load_cell + .gen_blit), reading the staged picture directly. .pic_copy_tiles is skipped entirely — the unshifted run is never copied. .pic_seek is the one primitive this needed; .pic_att was a forward cursor only. Downstream is unchanged: .pcf_buf arrives in the same form the old bake produced, so compositing, the background fill and the allocator are shared with the aligned path. Both axes are needed — Arthur's map lattice is 18 art pixels, so its rows land 2, 4 or 6 pixel rows into a cell; "no vertical scaling" does not mean "no vertical sub-cell placement", and believing it did cost phase 0 half its usefulness. Generating rather than baking is what made that affordable: baking costs the run plus a fresh tile per covered cell (Arthur peaks at 1676 against a 1023-tile store), generating costs only the cells (925). tools/tilebudget.py measures this over a blorb. It is also exactly accountable — the cell count is known before the draw, so .pic_alloc reserves the run and nothing is allocated mid-draw, which retires the old baked-tile estimate problem. A picture needing more cells than the whole store (40x25 full-screen ones) falls back to the tile grid in .pic_gen_size; no game shifts those. .pic_erase leaves the shared edge cells on both axes. Z6_PIC_XSUB/Z6_PIC_YSUB in make.rb's $GENERALFLAGS (an entry may carry a value, 'Z6_PIC_XSUB=-2') shift every picture to exercise this, DEBUG_PIC_GEN records the last generated draw's geometry in dbg_gen, and testz6 draws picture 4 a second time at an odd column so the path runs without playing a commercial game to a scene. The trap this sprung: .pic_alloc reused a window's tile run whenever the same picture was redrawn into it — sound while a run held the picture's own tiles wherever it was put, but a generated run is only right at the position it was generated for, so a redraw at another offset rewrote tiles the first placement's cells still pointed at. Reuse now requires the placement to match too (pic_win_shift). erase_line and window scrolling clear/scroll layer 0 too. See todo.txt.
  • A window with no rows on screen used to scroll the rest of memory (August 2026). .calc_window_rect clamps a window's bottom to the screen and then subtracts one, so a window with no height — or one sitting below the screen — comes back with .win_bottom above .win_top, and all three row loops walk from top to bottom by equality, which such a rectangle never reaches: .s_scroll took .win_bottom - .win_top as its row count and wrapped to 255, .sw_up_one/.sw_down_one (the scroll_window opcode, and the VDC and VERA scrolls that share them) compared with beq and walked to 255, and s_scroll_window's own clamp produced zero, which dec/bne then ran 256 times. Each copies SCREEN_ROW_BYTES a row, so the damage was a march through whatever follows screen RAM — on the MEGA65 it landed $f0 in darkmode, and since darkmode is an index into the two-byte bgcol/fgcol/bordercol/cursorcol tables, the next colour operation read 240 bytes past one and the screen came back in BASIC's blue. All three paths now check .sw_has_rows first. Two things worth keeping: a degenerate window is not theoretical — a game only has to ask for a window shorter than a line, or move one off the bottom, and the clamped rectangle inverts; and the diagnosis came from dumping a memory window before and after and seeing bytes moved up by exactly $a0, which says "a scroll ran here", not "a stray store". testz6scroll was the program that provoked it, because it was written in character cells (window_size 1 6 20) and six pixels is no rows at all under the pixel unit model — it now reads the font size from property 13 and converts, like testz6.inf, so make scroll, scroll-mega65 and scroll-x16 test what they claim again.
  • An array inherited from the non-z6 screen model may be sized for three windows, not eight. z6 has eight windows and indexes per-window arrays by current_window, so anything carried over from screenkernal.asm/screen.asm has to be re-checked for length. s_ignore_next_linebreak was the one that bit (July 2026): three bytes in every constants*.asm zero page map, with s_reverse immediately after it, so window 3 was s_reverse (4 and 5 s_stored_x/_y, 6 s_current_screenpos_row). Arthur prints every parser message into window 3 in reverse video, so s_reverse held $80; .normal_char read that as the window's "ignore next linebreak" flag, took the sign bit for "set", INCed it to $81, and the ora s_reverse two instructions later set bit 0 of the character. Only the last character of each line was wrong — that is the only one s_printchar prints, the rest going through print_line_from_buffer, which ORs print_buffer2 instead — so " came out # and word came out wore. The rest were audited when this was found (July 2026) and are clean, so do not redo it: all eighteen window_* arrays declared in screen-z6.asm/screenkernal-z6.asm are eight bytes, the sixteen-property block really is contiguous in the built binary (checked as window_y + 8 * prop against acme_labels.txt, not by reading), the two _hi arrays sit just past it, the init loop's ldx #(9 * 8) - 1 covers exactly those 144 bytes, the picture engines' word-per-window tables are sixteen bytes indexed asl/tax (pic_win_bank eight, indexed by y) with ldx #15 resets, and the only remaining short per-window arrays (window_start_row, cursor_row, cursor_column) live in screen.asm/screenkernal.asm, which are sourced only when Z6 is undefined. Indices are bounded too: window_from_operand masks and #7 and every write to current_window is a literal 0/1/2, an and #7, or a restore. Two traps for anyone repeating the search: a window number reaches an array by two paths — ldx/ldy current_window and jsr window_from_operand, which returns it in y and accounts for eight arrays the first misses — and the shared zero page maps in constants*.asm, not the z6 files, are where a short array hides. Fixed by giving z6 its own eight-byte array outside the zero page (the equates stay, !ifndef Z6, so non-z6 output is byte-identical) — cheaper than finding five more zero page bytes on each of five targets. Two lessons: the X16 escaping a bug is not evidence the bug is target-specific — it only escaped because Z6_WINDOW_BG means it never sets s_reverse, and its map has the very same adjacency ($aa + 3 = $ad); and a symptom of "+1 on one character" is worth chasing to the exact instruction, because ora with a mask that should be $80 and is $81 looks like an off-by-one in a table until you read the mask. Diagnosis was by measurement, not reading: dump screen RAM for the actual screen code, then trace exec the OR chain (r on each of the three instructions) to see which OR introduced the bit, then trace store on the variable to find who wrote it.
  • There are three zero page maps, and moving a symbol out of one leaves it defined in the other two (August 2026). cursor_column was moved out of the zero page into screen.asm as !byte 0,0,0 (the same trade as s_ignore_next_linebreak above: 3 bytes of code to give the zero page back), and its equate was commented out in constants.asm — the shared map, which covers the C64, Plus/4 and MEGA65 — but not in constants-c128.asm or constants-x16.asm, which those two targets source instead. Both then had the symbol twice and every z5 build for those two targets failed to assemble with screen.asm, line 3: Symbol already defined. It survived because no z6 build reaches that line at all (screen.asm is sourced only when Z6 is undefined) and the C64 z5 build, the one usually checked, was the one target the edit had covered. Fixed by commenting the two stragglers out; the C128 and X16 get 2 zero page bytes back with it ($a6 and $f1). The lesson is the same shape as the Z4PLUS/Z7PLUS one: a constants*.asm edit is not done until every target's file has been grepped, and the build matrix at the top of this file exists precisely so a target-specific assembly failure cannot hide behind a working C64 build.
  • Under -fcm, printing a character clobbers the Z register, so no z6 code may hold z across a jsr. The sta_colour_ram macro (screenkernal-z6.asm) reaches the 80-column colour RAM with phy / plz / sta [zp_colourline],z, so s_printchar returns with z holding a colour byte index (2 * column + 1) instead of 0. sound.asm's directory reader set ldz #0 once and then walked the listing in attic RAM with lda [sound_dir_ptr],z while printing progress through s_printchar — the $ per sound file found and the delete that rubs it out after each load. Every access after the first print landed dozens of bytes off, the parse of the second directory entry desynced, and only sound effect 3 was ever registered: sound_start_page_high stayed 0 for the rest, which makes .play_sound_effect return silently rather than error. Symptom (July 2026, Wyrmward on the MEGA65): the first @sound_effect heard, every later one silent, and swapping the wav files around moves the sound, not the silence — because it is the order that matters, not the data. Only z6 has it; the non-z6 screenkernal.asm reaches colour RAM through colour2k and never touches z, which is why Sherlock (z5, make sound-sherlock-mega65) was always fine. Fixed by setting ldz #0 at each access (read_sound_dir_char and the directory copy loop) instead of once per loop. The picture engine already knew this — .esa_loop re-loads z per byte and .pic_progress_tick saves its pointer around the print. Verified by dumping sound_files_read and the sound_start_page_* tables out of a headless xemu run (1 file before, 2 after; Sherlock's 15 unchanged), and the audio DMA's own base/stop registers ($d721-$d728) then read back the second wav's exact data chunk in bank 1. Everything else that uses [zp],z was audited and is clean: m65_read_far_byte/write_word_to_far_dynmem and friends do tya / taz immediately before the access, and the remaining loops (disk.asm's save/load, reu.asm's bank probe) call only KERNAL ROM, which leaves z alone.
  • A window that shrinks orphans the pictures behind the rows it gives up (X16, July 2026). split_window hands the top rows to window 1, and on the X16 the pictures under window 0's text stay on layer 0 — where window 0 will never scroll or erase them again, because they are outside its rectangle now. On the MEGA65 the same orphan is harmless: one layer, so the status line's own text overwrites the tiles. split_window therefore calls pic_clear_map_rows for the rows window 1 has just gained (growth only — shrinking gives rows back to window 0, which erases and scrolls them itself). Full width, so unlike pic_erase_win_rect there is no half-cell boundary to preserve. Found with a game whose z6 status line is PunyInform's ordinary window 0/window 1 split: the room picture had legitimately scrolled to layer-0 row 0 while window 0 still owned the whole screen, and the status line printed later could not hide it. The reason it could not hide it was a separate, deeper bug (fixed July 2026, see the transparent-background bullet below): VERA renders palette index 0 as transparent and zcolours mapped z-colour 2 (black) to VERA colour 0, so black text had transparent ink and a reverse-video field was transparent edge to edge. It looked fine over a blank layer 0 — the backdrop is palette 0, black, the same colour the ink was meant to be — and was wrong over any picture. Do not be fooled by a screen dump here: the colour byte read $10, bg white and opaque, and the cell still showed the picture through it.
  • The tiles baked mid-draw belong to no window's run, and the store can run out while baking. .pic_alloc reserves a picture's own tiles and nothing more, but both draw paths then bake extra from pic_next_tile: the even path a composite per partly-transparent cell (.pcf_make_tile), the odd path a boundary tile per cell (up to (cw+1)*ch, on top of the copied run it reads as the source — the 2x cost that fills the store). .pcf_alloc_and_write therefore saturates rather than wraps (July 2026): pic_next_tile stops at PIC_MAX_TILES and the bake fails with carry clear, and each caller degrades (the even paths keep their own un-composited tile — .pcf_newcode is seeded with it before the call, so don't move that seed; the odd path keeps what is behind). This is self-correcting: a saturated pic_next_tile fails .pic_alloc's fit check, so the next picture compacts and the bakes have room again. It used to wrap to PIC_FIRST_TILE, and that destroyed Arthur's frame — the frame is drawn once and never redrawn, so it sits at the bottom of the store, exactly where a wrap lands. The damage surfaced two rooms after the wrap, because the wrap also left pic_next_tile low, which convinced .pic_alloc the store was empty and stopped .pic_gc ever running again; the allocator simply marched back up through the frame (measured: pic_next_tile 698 → 7 → 343 → 679 against a frame at 347..697). Two lessons: an allocator that wraps onto live data must be treated as a bug, not a fallback; and a wrap that hides the "store is full" signal disables the compactor that would have saved it. .pic_gc itself was proven correct here — shrinking PIC_MAX_TILES to force it to run every room made the frame stable. Note PIC_MAX_TILES must keep a zero low byte: .dp_odd_fits compares #>PIC_MAX_TILES alone. todo.txt has the accounting fix that would avoid the degradation entirely.
  • .pic_seek addresses a banked window, so its offset must be split into banks before it is added, not after (X16, July 2026). The staged picture lives at $a000 in an 8 KB bank, and the generator seeks into it by a 16-bit byte offset — up to a 32 KB picture's whole tile block. $a000 + offset overflows $ffff, and the carry has nowhere to go: the bank is a byte of its own, and the cmp #$c0 normalisation that walks the pointer up through the banks never sees a wrapped address as too high. So every tile past index ($ffff - .gen_tiles) / 32 was read from low memory in the wrong bank. Only Arthur's two full-screen intro pictures reach that far (37x25 cells, 723 tiles; the break was at index 710 — measured, and it is exactly where the damage starts), and the bottom two rows of both were built out of whatever the wrapped address held: a band of screen background with scattered stray pixels across the frame's lower border. The MEGA65 engine has the same routine and is fine — attic is flat, so its version just carries into the third byte. Three things worth keeping: .pic_seek is used only by the off-grid generator, so this could not appear until the pixel model made real pictures land off the grid; the symptom looked like an allocator or compositing fault (a strip of frame destroyed, exactly like the tile-store wrap above) and was neither; and it was settled in one step by rendering the layer 0 map out of a VRAM dump and diffing it against the [P###] file cell by cell — every wrong cell needed a source tile index ≥ 710 and every cell below that threshold was pixel-exact.
  • .pa_reset falls through to .pa_place, and nothing may be inserted between them (X16, July 2026, found while chasing the above and fixed with it). .pa_pack_shift had been dropped in there, so a .pic_alloc that ran out of store even after compacting returned without placing: .pic_slot still pointed at the last picture's run, the new picture's tiles were written over one the screen was still showing, the window's bookkeeping was never updated, and pic_next_tile was left at the bottom of the store. The allocator is a chain of fall-throughs; a helper subroutine belongs outside it, which is where the MEGA65 engine keeps its copy. .pic_gc's rectangle also treated a vertically shifted picture's top edge row as interior (the x axis had the matching inc .gc_x0 and the y axis had nothing), so a tile shown only by cells in that shared row could be reclaimed while the draw still needed it to composite against; .gc_y0 now mirrors .gc_x0. The MEGA65's .pic_gc had the adjustment on neither axis and now has both — it has 2048 tiles and compacts far less often, so nothing had surfaced there. On the grid both engines are unchanged, which is every full-screen picture.
  • A map cell is two text columns wide on the X16, so an odd-column window shares its edge cells (August 2026). VERA's layer 0 uses 16-pixel-wide tiles — two text cells — and pic_scroll_win_up and pic_erase_win_rect therefore only touch the whole cells inside a window, leaving the half-cell at each edge to whatever lies beside it. For the scroll that stranded the window's own first (or last) text column: Zork Zero's window 0 starts at text column 11 (move_window to picture_width + 1, its border picture being 43 art pixels wide), and its drop-cap initials and room icons left a stale four-pixel sliver behind every line that scrolled. .psu_edge now rebuilds those cells half by half — its own outside half, the inside half of the cell below — so the window's column scrolls while what is beside it stands still. Nothing is baked when the two cells' outside halves already agree, which is the case all the way through a picture's own run, where the cell below is the composite and its entry is simply copied; only the leading and trailing edges of a moving picture take a tile, and a cell that empties goes back to the transparent tile 0. Measured on Zork Zero: pic_next_tile at 710 of 1023 after fourteen scrolled lines, about where it was before. A full store fails the bake and leaves the cell alone, as it always did. Two traps met on the way, both settled by reading tile pixels out of a VRAM dump rather than reasoning about them: the stale pixels are in the half inside the window, and the half outside is not transparent — it carries an opaque background fill, because drawing a picture into an odd-column window blanks the text cell outside it too and layer 0 has to paint that. So a transparency test cannot decide this, which is what a first attempt tried. The erase paths still skip their shared cells, so an erase_window in an odd-column window leaves that column's art behind; the whole class goes away if layer 0 ever moves to 8-pixel tiles (32-byte tiles, 2048 of them in bank 0 against a doubled requirement, a 128-column 8 KB map), which is a rewrite of the engine's cell arithmetic rather than a patch. See todo.txt.
  • erase_picture must blank the cells the picture really covers, not the cells its file is stored in (August 2026). A picture file holds whole cells, so a picture whose size is not a multiple of the cell size is stored rounded up, with the slack transparent: Arthur's room scenes are 130x72 art pixels and stored 136 wide (34 half-cells at 80 columns). .pic_erase took its rectangle from that stored cell count, so it reached a column and a half past the art and blanked cells of whatever the picture stood on — and .pic_draw then left those cells alone, because a cell it is fully transparent in is skipped by design (that is how a frame with a hole shows the scene through it). The result was a white stripe beside Arthur's room picture on the second and every later turn, on both graphics targets: the frame's own cells, erased and never redrawn, since Arthur redraws only the scene per room (erase_picture 10 16 82 then draw_picture 10 16 82, off DEBUG_SCREENLOG). The first turn escapes because there is nothing to erase yet and the frame is drawn after the scene. Both engines now compute the rectangle from the picture's real pixel size (the pic_px_width_lo/_hi, pic_px_height tables picture_data answers from): first cell = ceil(corner / cell), one past the last = floor((corner + size) / cell). That subsumes the .pic_shift edge adjustment it replaces — a cell an off-grid picture only half covers is not inside it either — and for a picture whose size is a whole number of cells the rectangle is identical to before, aligned or shifted, which is why testz6 (all its pictures 8-pixel multiples) and every full-screen picture are unchanged by construction. Three things worth keeping: this was not a regression from the July/August unit work, though it reads like one — a build of 2c4fec8^ shows the same stripe one column further left, so what those commits changed is where it lands; the damage takes a different form on each target, a blanked text cell ($0020) on the MEGA65 against an opaque text cell (colour $1b where its neighbours are $0b) in front of an intact layer 0 map on the X16, so the X16 half cannot be found by reading the map; and the remaining approximation is that an edge cell the picture only partly covers is now never blanked at all, so erasing a picture standing on bare background can leave a sliver of up to one cell — erasing that properly means rebuilding the edge tile pixel by pixel, as .psu_edge does for the X16's scroll. -pu:0 keeps the old cell-based rectangle, where a cell is the unit and the padded cells really are the picture. See todo.txt.
  • A z6 X16 build forces kernal screen mode 0 at startup (August 2026). Non-z6 Ozmoo reads whatever mode the machine is in (sec / jsr $ff5f) and lays itself out at that size, anything from 80x60 down to 20x15 — but a v6 game is written for 80 columns, and the pictures build reshapes that mode into its 80x25 fat rows in vera_gfx_init, so a machine left in another mode (SCREEN 3 at the BASIC prompt before RUN) came up as a 40x30 game with the picture geometry built on the wrong screen. deletable_init_start now asks for mode 0 (clc / lda #0 / jsr kernal_screen_mode) before the font is selected and before anything reads the screen size, under !ifdef Z6 only, so every non-z6 binary is byte-identical (checked on -t:x16 test/praxix.z5). It has to go there rather than in s_init, which the splash screen calls twice around its own deliberate switch to mode 3 and would fight. Verified headlessly by starting x16emu without -run, typing SCREEN 3 then RUN: before the change testz6 reports size (chars) 40 x 30, after it reports 80x60 (80x25 on the pics build), and Arthur's pics build boots to VRAM — tiles, layer 0 map, text and palette — byte-identical to a normal start. The boot mode is not put back on quit; BASIC comes back in 80x60.
  • The X16 text-only build is unchanged — VERA's 80x60 screen. It has no separate window code: .s_scroll_vera and the scroll_window opcode go through the same .calc_window_rect / .sw_up_one / .sw_blank_row helpers as every other target, and only the row copy is VERA-specific (.vera_copy_row). A VERA text cell is two bytes (character, colour) and a row is 256 bytes whatever the width, so a cell lives at $1b000 + row * 256 + column * 2: the row is the address high byte and the column never carries into it, which is why the screen code can keep a row in zp_screenline + 1 and nothing else. Port 0 reads and port 1 writes; everything else in the screen layer assumes port 0 is selected with stride 1 and bank 1 ($11 in VERA_addr_bank), so any routine that borrows either port must hand port 0 back that way — VERAPrintChar writes only the address low and high bytes, so a routine that leaves port 0 on bank 0 (which .pic_tile_addr does, the tile store living there) sends the next character or cursor into the tile store instead of the screen; pic_scroll_win_up restores it explicitly for that reason — including the picture engine, whose map fills walk port 0 (read) and port 1 (write) in step, and which saves and restores both ports' addresses around a mid-row detour into the tile store (.pfc_save_ports).
  • Under -t:x16, do not lean on VERA's auto-increment across a jsr. print_line_from_buffer used to print its first character through s_printchar and let the rest of the line ride on the address pointer that call left behind. Now that a window can scroll, s_printchar can end up inside .s_scroll_vera, whose copy loop leaves the pointer wherever it finished — so each cell is addressed on its own, as on every other target.
  • The C64 and Plus/4 will never draw pictures. The pic:N notes have to stay for them.
  • Terminating characters were a general bug (all targets, not just mouse). z-spec 10.5.2.1: the table ($2e) holds function key codes 129-154 and 252-254, and 255 means "any of them". Ozmoo rejected everything above 140 and its 255 wildcard omitted the mouse clicks, so a click never ended a line. parse_terminating_characters now accepts the whole valid range, and the pre-filled default set the wildcard activates includes 252-254 on the full colour screen. If you touch that array, keep it in step with NUM_DEFAULT_TERMINATORS. And a terminator must not be echoed unless it is Enter (July 2026): read_text ended by printing whatever character terminated the line, which is right for $0d and wrong for every other legal terminator, since those are all function key codes with no glyph. Petscii 254 (the single click) renders as screen code 126, the ▘ quarter block, so every click that ended a read left a small black box on the line — Zork Zero's compass printed one before the west the game then echoes. Upstream master has the same line; the fix belongs there too. This was invisible in testz6, which supplies no terminating-characters table at all, so only a commercial game reaches it.
  • Every MEGA65 game is now a single d81 (July 2026): the pictures are one exomizer archive and it fits on the boot disk beside the story (see the Pictures section), so all four v6 games boot, load their graphics and reach their opening screen from one disk with no swap and no second drive — verified headlessly for each. The multi-disk machinery is still there for a set that outgrows one archive or one boot disk: pics2asm.py packs across as many _pics_N.d81 disks as needed and pic_load_all sweeps them at boot, asking for a swap only when a disk is in neither drive. Picture numbers and the build count are both 16-bit, so Zork Zero's 396 pictures build and load. The disk swap itself was confirmed by hand back when Zork Zero needed two disks (July 2026) — headless testing can only reach the "insert picture disk N" prompt, since xemu can't swap a disk — and nothing we ship reaches that path any more, so a change to it has to be checked in a windowed run. Text-only at 80 columns, Zork Zero is playable and Journey boots into its command-menu layout, whose box-drawing dividers now render via font-3 translation (see todo.txt; the selected-command reverse-video highlight is the remaining follow-up there). The squished right-aligned headers turned out to be the stream-3/live-cursor pair fixed with Shogun's menu screen (below).
  • Stream 3 has a second, v6-only table format. output_stream 3 table width (the three-operand form) makes the table a sequence of word-wrapped line records — length word, characters, zero-word terminator — the same format print_form reads; width ≥ 0 names a window whose width wraps the text, width < 0 is a box −width units wide (spec 1.0/dfrotz convention; all four games always pass 0, window 0). Arthur prints every parser complaint through this (buffer, count lines, print_form into a pop-up window 3), so getting it wrong showed up as "I beg your pardon?" followed by gibberish forever, while normal commands were fine. streams.asm keeps per-level formatted state (streams_form_*) beside the width-measuring state.
  • The v6 games measure text by printing it to output stream 3 and reading the width back from header word $30 (z-spec 7.1.2.1.1) — Shogun sizes its whole menu screen that way, Arthur its right-aligned status line. Three things had to hold: the stream-3 close writes the widest buffered line into $30 (streams.asm counts units per line, newline starts a new one); print_table goes through streams_print_output even for a single row, since Shogun measures its menu items with print_table into stream 3 (printing them straight to the screen both left the width at 0 and leaked text onto the screen); and get_wind_prop answers the live cursor (zp_screenrow/zp_screencolumn) for the current window, because the per-window arrays only sync on window switches and games read the cursor back after every centred line.
  • set_cursor interacts with the print buffer: pending buffered text belongs where the cursor was, so set_cursor flushes first, and restarts the buffer at the new position after the move (start_buffering inside the flush captures the pre-move column, so the restart must come after restore_cursor). set_cursor -1/-2 is cursor visibility (z-spec 8.7.2.3): cursor_hidden suppresses both drawing and deleting the input cursor — the delete writes a space, which ate the first letter of Shogun's selected menu item where the game parks the (hidden) cursor.
  • 80-column pictures builds report interpreter number 6 (IBM) (make.rb defaults -in: when -fcm is 80 wide and -pics is given). Infocom's v6 games reserve their full layout for the IBM interpreter — Shogun only draws its right-hand border picture (a separate image, picture 59) when the header says IBM, and hardcodes its margins otherwise. Text and 40-column builds stay a C64 (8).
  • DEBUG_SCREENLOG (add to $DEBUGFLAGS in make.rb) assembles a 128-entry ring buffer (screenlog_buf, 8 bytes an entry: id, current window, operands, and a result word for get_wind_prop/stream-3 close/set_colour) recording every v6 screen opcode — set_colour is id 13 and reports the s_colour_swap it produced plus the operand count, which is what found the swap-idempotency bug above. Reading it out through the xemu monitor gives the exact sequence and arguments a game used to lay out a broken screen — this is how Shogun's menu page and its negative set_cursor column were diagnosed; far better than TRACE_SCREEN, which scrolls the screen it is tracing. The block lives in screen-z6.asm and is now outside the Z6_PICTURES !ifdef that used to enclose it, so it assembles on text builds too (it did not, and the hooks still referenced it — a text build with the flag failed to link). It is listed, commented out, in $DEBUGFLAGS.
  • Fixed and play-tested: the -fcm bug where games dropped or changed the odd stretch of body text, differently on each run — Arthur's intro lost a line's tail, Zork Zero's banquet paragraph likewise — was CBDOS scribbling colour-RAM attribute bytes (the colour-offset bullet above), the same root cause as the white stripe over Arthur's intro pictures. Fixed July 2026 by FCM_COLOUR_OFFSET, and Arthur's prologue, first room and status line are clean in play. If a stretch of text ever vanishes mid-row again, that is where to look.

Debugging under VICE (headless)

Symbol addresses come from temp/acme_labels.txt after a build. It is rewritten by every build, whatever the target, so a label read after building something else points into the wrong binary — and it fails silently, since the address is still plausible (a stale window_y read 32 instead of 1 and made a working fix look broken). Take the labels from the build you are about to run, and re-take them if anything else is built in between.

x64sc -default -warp +sound -limitcycles 60000000 -exitscreenshot shot.png c64_testz6.d64
  • Drive commands from a -moncommands file. Use tracepoints (trace exec $addr), not breakpoints — breakpoints halt the emulator and wait for stdin.
  • Tracepoint hits are not printed to stdout; only the confirmation of the tracepoint's creation is. Start the moncommands file with logname "<file>" and log on to capture the hits.
  • Loading a d81 under true drive emulation takes over 100 million cycles before the first Ozmoo instruction runs, so -limitcycles needs to be generous (200000000+) or the trace will be empty and look like a crash.
  • Attach an action with command <n> "<cmd>". Several tracepoints may share an address, one command each.
  • To type text: command 1 "keybuf hello". keybuf does not interpret \n, so append Return by poking the kernal keyboard buffer: command 2 "> 027c 0d" and command 3 "> 00c6 06" (buffer, then length).
  • -exitscreenshot can catch a mid-frame redraw and show a character that isn't really there. To see the truth, dump screen RAM: command N "m 0400 07e7" and decode the log (screen codes; in ECM the top two bits are the background register).
  • VICE randomizes the autostart delay, so cycle counts are not reproducible between runs.
  • A headless run stops at the first MORE prompt and at read, so it never reaches the end of a test game. Put a tracepoint on show_more_prompt with command <n> "keybuf n" to answer them.
  • Don't hang a memory dump on read_char: it is a polling loop, so the dump repeats for as long as the game waits (44 million lines in one run here). read_text is hit once. Either way, take the first dump from the log. (z_ins_read_char, the opcode routine, is hit once — hang the dump on that when a test game ends in @read_char, as testz6scroll does.)

The C128's 80-column (VDC) screen

The VDC's screen RAM is not in the CPU's address space, so none of the usual dumps see it, and -exitscreenshot only captures the VIC-II (a plain BASIC boot in 80 columns screenshots as pure black — the option is not broken, it is just the wrong chip). Two things make it testable:

  • Dump VDC RAM through the monitor's vdc bank, as one command: command 1 "bank vdc; m 0000 07cf". A bank vdc on its own checkpoint does not persist into the next one's dump, and m vdc:0000 is not valid syntax — either way you silently get CPU RAM instead. Characters are at $0000 and attributes at $0800; the codes are ordinary screen codes.
  • VICE cannot autostart a disk with the 80-column screen selected. x128 -80col <disk> boots BASIC on the VDC correctly, but autostart waits for a READY. prompt on the VIC-II screen that never appears, and hangs at the load prompt for ever (this is what make arthur-c128-80 avoids: it only attaches the disk with -8, and you type RUN"STORY"). So -80col plus autostart is not a way to test anything.
  • Consequently, for a headless 80-column run, boot in 40 columns (where autostart works) and poke the kernal's 40/80 flag, $d7 (COLS_40_80), which is all Ozmoo reads: a tracepoint on program_start with command N "> d7 80". Check you got it by dumping $d7 at the read prompt — if it reads 0, the whole run was 40 columns and proved nothing about the VDC. (-80col alone doesn't help: the kernal sets $d7 at reset, and -default resets it either way.)
  • -exitscreenshot on x128 is the VDC; -exitscreenshotvicii is the VIC-II. A black 856x288 VDC shot usually means the run was in 40 columns, not that the screenshot failed. The monitor's screenshot "<file>" 2 (PNG) also captures the VDC, and can be hung on a tracepoint — use it: a RAM dump shows the screen codes, so it cannot see a wrong charset or wrong-case glyphs, which is the same blind spot that hid the MEGA65's FCM_CHARSET bug.

Debugging the MEGA65 under xemu (headless)

xemu-xmega65 is better instrumented for this than VICE: it dumps the screen as plain ASCII, so there is nothing to decode and no mid-redraw artifact.

xemu-xmega65 -headless -sleepless -besure -skipunhandledmem \
    -8 mega65_testz6.d81 -autoload -dumpscreen screen.txt -screenshot shot.png
  • It never exits by itself — there is no cycle limit. Wrap it in timeout; the dumps are still written on the way out.
  • Sound needed SDL_AUDIODRIVER=pulseaudio until August 2026 and no longer does. SDL did not get on with pipewire on Fedora 44/KDE, and xemu came up silent with no error, so a working WAV build looked broken; a Fedora update fixed it and xemu (like x16emu) is now heard without it. The sound-sherlock-mega65 target still passes it, which is harmless. It stays the first thing to try on a silent emulator — the failure mode is total silence with no diagnostic — but it is no longer the default explanation.
  • Like VICE, a headless run halts at the first MORE prompt. There are no tracepoints, but -uartmon <socket> gives a monitor that can do it: poll screen RAM ($0800; 80x25 one byte a cell on the text screen, or two bytes a cell under -fcm — 80x25 of them, 40x25 with -fcm:40) for the MORE character $aa (reverse *) in a cell whose high byte is zero — a picture tile's code can have $aa as its low byte, and treating those as MORE prompts stuffs the keyboard forever — and when it appears poke Return into the kernal keyboard buffer with s0277 0d then s00c6 01. That reaches read, where the game waits harmlessly, so stop there and dump rather than answering the read — the quit path shows a final MORE prompt and then resets, wiping the screen.
  • Monitor commands over the socket: m<addr> reads 16 bytes, M<addr> reads a block, s<addr> <bytes> writes. Addresses are bare hex in the full 28-bit space, so colour RAM is Mff80000, not Md800. Reply lines look like :00000800:440F2019… — the bytes are packed hex with no spaces after the second colon, and the reply ends with a lone . line.
  • -dumpscreen only fires on exit, so it cannot capture an intermediate state; read $0800 over the monitor instead. Screen codes there, not ASCII. Under -fcm take every second byte for the characters; a non-zero odd byte means the cell is a picture tile, not text.
  • -dumpscreen decodes screen codes in software, so it cannot see a bug in the character-rendering path. It reports the code in the cell, never the glyph the VIC-IV fetched for it: a wrong CHARPTR is invisible in the dump and obvious in -screenshot. FCM_CHARSET pointed at the wrong 2 KB half of the C64 font for months because of this. testz6 hid it too — its text is all lowercase, which under the uppercase/graphics charset renders as plausible-looking capitals. Anything touching the charset needs a screenshot, of text with a capital letter in it.
  • $e0 in screen RAM is the cursor (CURSORCHAR in ozmoo.asm), not a corrupt cell. It overwrites the character under it, so a dump taken while the cursor is up shows $e0 where the text character belongs.
  • Monitor addresses are the linear 28-bit map, so $d000 there is RAM, not I/O. Reading md054 returns zeros; the VIC-IV registers are at $ffd3xxx (mffd3050). Colour RAM is $ff80000.
  • To try something without dragging Ozmoo along, build a bare prg and load it directly: xemu-xmega65 -headless ... -prg foo.prg -prgmode 64 -screenshot shot.png. Combined with -screenshot, a picture can be compared against its source PNG pixel by pixel. xemu renders the red channel one LSB low ($bb shows as 186), so compare within a tolerance of 1, not exactly.
  • -dumpmem writes memory, and -uartmon <socket> opens a monitor, if the screen isn't enough.
  • Non-printable screen codes come out as {$xx} in the dump.
  • The uartmon socket takes one client: a second connect appears to succeed but its commands time out, and the first client goes with it. One driver process must own the socket for the whole run.
  • Reading the monitor is slow (~16 bytes per round trip), so a full -fcm screen takes ~25 s — too slow to catch Arthur's timed intro screens. Sample one row (160 bytes, ~1.5 s) and dump more only on a change.
  • -screenshot writes the frame xemu is showing when it finishes shutting down, not when it is signalled. Under -sleepless the game races seconds ahead between SIGTERM and teardown, so a timed screen is gone from the shot; drop -sleepless (real-time) when the screenshot must catch a moment, and accept the slower boot.
  • The keyboard-buffer poke (s0277 <petscii> then s00c6 01) answers game prompts fine (Arthur's Y/N, MORE), one key per poke.

Debugging the X16 under x16emu (headless)

make.rb -t:x16 builds a zip (x16_<story>.zip) and a directory of the same name holding <STORY>.PRG and [ZCODE]; the emulator is run from inside that directory. The emulator is not in git — keep a checkout (e.g. x16-emulator46/x16emu). The directory name does not encode -pics, so a text build of the same story overwrites the pictures build in place: the next run then boots a 60-row text screen with no pictures at all, which looks like the graphics have broken rather than like the wrong binary. If a pics build suddenly has no layer 0, rebuild it before believing the screen.

cd x16_testz6 && ../x16-emulator46/x16emu -prg TESTZ6.PRG -run -warp -zeroram -sound none
  • A headless run halts at every MORE prompt, like the other emulators; there are no tracepoints, so answer them by pressing keys with xdotool under Xvfb (below). The pics builds hit MORE earlier than the text builds — the screen is 25 rows, not 60.
  • There is no headless flag and no monitor socket. Two ways to see the screen:
    • -gif out.gif records every frame. It works with SDL_VIDEODRIVER=dummy, so it needs no display at all — take the last frame. A text cell is exactly 8x8 pixels on the 640x480 output, so a cell's contents can be cropped by its row and column. Beware: a long -warp run makes a large gif (50k frames, ~90 MB).
    • -dump V plus Ctrl-S writes VRAM to dump.bin, which is the exact screen, not pixels. Ctrl-S needs a real window, so run under Xvfb and press the key with xdotool (xdotool search --name "Commander X16" key ctrl+s). The same trick types input into the game, which is the only way to drive a game headlessly here. What works is xdotool windowfocus <id> once, then plain xdotool key Return / xdotool type "look" (XTEST to the focused window): there is no window manager under Xvfb, so focus never lands by itself, but setting it explicitly is enough and XTEST then goes through. The chained form, xdotool search --name "Commander X16" type --delay 200 "look", is XSendEvent and delivers nothing — SDL2 ignores synthetic key events. (It is recorded here as working, and was believed to be; a July 2026 -log K run showed an empty log for it and a full one for the focus + XTEST form, on the same emulator and game.) Note key ctrl+s works either way, because SDL handles it as its own hotkey — so a working Ctrl-S is not evidence that game input is getting through, and a dump taken that way can show a game frozen where you left it rather than where you think you drove it. -log K settles it in one run: it prints DOWN 0x04 / UP per key as SDL receives them (0x28 is Return). A whole session was once lost to "Return isn't reaching the emulator" when it was arriving fine and the game's state was being misread.
    • A screen dump is screen codes, not ASCII. Decode code & 0x7f, with 1..26a..z, and bit 7 meaning reverse video. Read as ASCII, ordinary text looks like corruption — >looklook at a prompt decodes to a row of dots and reads exactly like a smashed screen.
    • make.rb autostarts the emulator even with -s, and it inherits stdout, so a build inside a pipeline appears to hang after "Successfully built" until the emulator is killed (pkill -f x16emu; pkill -f xemu on the MEGA65). When scripting a build, call ruby make.rb ... directly rather than make <target>, and expect to kill the emulator to get your exit code.
  • The dump's layout follows the -dump flags: it ends with 128 KB of VRAM and 1806 bytes of video registers, with whatever RAM was asked for in front. So VRAM starts at len(dump) - 131072 - 1806, and the text screen is $1b000 into it. Cells are two bytes (character, colour) and rows are 256 bytes regardless of the 80-column width. In a pics build the layer 0 map is at $10000 (128 bytes a row, two bytes a cell: index low, then palette-offset/flips/index-high), the tile store fills $00000-$0ffff, and the palette is at $1fa00 (GB then 0R per entry) — this is how the engine was debugged: the map says exactly which tile and bank every cell shows. -dump C prepends 7 bytes of CPU state (a, x, y, sp, status, pc), which places a hang.
  • -testbench is a headless command mode, but it only re-enters its command loop when the code under test returns (RTS to $fffd), so it cannot watch a running game. Its RQM does a real read6502, though, so it can read VRAM through VERA's data port if you ever need to.
  • -zeroram zeroes RAM but not VRAM: a screen dump taken before the program has drawn anything is random, not blank. If a dump looks like noise, the game had not started (or had crashed) — check with a gif before believing it.

References

  • The Z-machine standard, version 1.0/1.1 — essential for v6 window and opcode semantics, and worth checking rather than trusting memory. Not in the repo; it is easy to find online (z-spec10.pdf). Keep a copy in the working directory when doing v6 work: pdftotext z-spec10.pdf - makes it greppable.
  • Two reference interpreters, for different questions: dfrotz -h 25 -w 40 (or -w 80) for line-for-line text comparison, and sfrotz (SDL Frotz, windowed) for how a v6 game presents — colours, reverse-video message boxes, pictures. Both parser-message bugs of July 2026 (a spurious [More], a missing reverse-video band) were invisible in dfrotz text and obvious next to sfrotz. Note sfrotz's grey background is its default scheme, not the game's doing. sfrotz doubles pictures for Infocom's four v6 games only, and decides by story ID — its screen is always 640x400, and os_init_screen sets the 2x graphics scale inside if (sf_IsInfocomV6()), a switch (story_id) over ARTHUR/JOURNEY/SHOGUN/ZORK_ZERO (src/sdl/sf_resource.c). So Infocom's 320-wide art fills sfrotz's screen just as it fills Ozmoo's pixel-doubled 640-pixel one, but any other v6 game's 320-wide picture covers only the left half there, which reads as a bug in the game or in the blorb and is neither. Run such a game as sfrotz --xscale 2 --yscale 2 <story> to compare like with like. A blorb Reso chunk does not help (sfrotz uses it only to enlarge the window, never for per-picture ratios), and neither does the Display / Infocom V6 Scaling X config key, which is read but applied only inside that same story-ID branch. sfrotz also plays the Blorb's sound effects, which makes it the reference for a game with sound too (July 2026). It needed SDL_AUDIODRIVER=pulseaudio for the same reason xemu and x16emu did — SDL against pipewire on Fedora 44/KDE, silent with no error — which an August 2026 Fedora update fixed for both emulators; sfrotz has not been re-checked since, so try the variable first if it is silent. tools/make_blorb.py now writes those sounds (see below); Ozmoo's own MEGA65 build still takes the wavs off the source folder through -asw, so one folder feeds both. To check a sound headlessly, set SDL_AUDIODRIVER=disk and SDL_DISKAUDIOFILE=<path>: SDL writes the mixed stream (S16LE stereo 44100) to that file, and correlating its non-silent runs against the source wavs proves which effect played and when — that is how both of Wyrmward's were verified without a speaker.
  • documentation/techreport_15.pdf — Ozmoo's internal design. The PDFs are generated, and both they and their sources are in git. The sources are documentation/manual/{techreport,manual,player_manual}.md and the Makefile beside them builds all three with pandoc (cd documentation/manual && make all, or make techreport alone), writing ../<name>_$(VERSION).pdf. So editing a .md is only half the job — rebuild, or the shipped PDF still says the old thing. VERSION in that Makefile is the major version from version.txt (15 now, so techreport_15.pdf) and has to be bumped in step with it. The LaTeX "requested release 2026/06/01" and "float too large" warnings are pre-existing noise.
  • todo.txt — known bugs and remaining v6 work.