From 854b61ea1eeeebe4d1c525d17e8b6e18d2dfd9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:34:44 +0000 Subject: [PATCH] docs + example: HUD demo and language-guide VRAM buffer section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 807c9c7 (the VRAM update buffer core). Adds the realistic-HUD example the core was missing, plus a language-guide section that explains when and how to use the three buffer intrinsics. **examples/hud_demo.ne** A bouncing-ball playfield with a classic status bar across the top: - 5-cell lives indicator that ticks down once per second and resets at zero, drawn via `nt_fill_h` (plus a second `nt_fill_h` to erase the stale tail). - Score counter at the right edge that bumps on every wall bounce, drawn via `nt_set`. - One-shot `nt_attr` call on the first frame flipping the top-left metatile group to sub-palette 1 (the red HUD palette) so the UI chrome reads as distinct from the playfield. The demo's point is the `last_score != score` / `last_lives != lives` shadow-compare pattern: on the ~58-of-60 frames where nothing changed, the buffer stays empty and drain work is zero. That's the whole reason the VRAM buffer exists — per-frame cost scales with what moved, not with HUD complexity. Committed `.nes` + pixel/audio goldens. **docs/language-guide.md** New "VRAM Update Buffer" section between "Hardware Intrinsics" and "Inline Assembly". Covers: - Why user code can't just poke `$2006` / `$2007` directly. - The three intrinsics + their coordinate systems (cell, not pixel). - The HUD pattern with a ready-to-paste code snippet and a pointer at `examples/hud_demo.ne`. - A per-entry budget table + worked 1000-cycle drain example against the ~2273-cycle vblank budget. - Known limits: horizontal-only, no overflow check, no coalescing — all already tracked under `future-work.md` §G. **examples/README.md** `vram_buffer_demo.ne` reframed as the minimal test-case exercise it actually is, with a pointer at `hud_demo.ne` for the realistic pattern. New table row for `hud_demo.ne`. All 758 tests pass. Clippy clean. 48/48 emulator goldens match. --- docs/language-guide.md | 87 ++++++++++++ examples/README.md | 3 +- examples/hud_demo.ne | 152 +++++++++++++++++++++ examples/hud_demo.nes | Bin 0 -> 24592 bytes tests/emulator/goldens/hud_demo.audio.hash | 1 + tests/emulator/goldens/hud_demo.png | Bin 0 -> 37721 bytes 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 examples/hud_demo.ne create mode 100644 examples/hud_demo.nes create mode 100644 tests/emulator/goldens/hud_demo.audio.hash create mode 100644 tests/emulator/goldens/hud_demo.png diff --git a/docs/language-guide.md b/docs/language-guide.md index ab79922..23fa708 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -1325,6 +1325,93 @@ The address argument to both is a compile-time constant. Zero-page addresses compile to `STA $XX` / `LDA $XX`; anything larger compiles to absolute addressing. +## VRAM Update Buffer + +The PPU's `$2006` / `$2007` registers can only be written safely +during vblank — writing during active rendering corrupts the +internal address register and garbles every subsequent tile. +`on frame` handlers run partly during vblank and partly during +rendering, so user code can't directly `poke` the PPU. + +The VRAM update buffer solves this: user intrinsics **queue** PPU +writes to a 256-byte ring at `$0400-$04FF` during `on frame`, and +the NMI handler **drains** the ring to `$2007` during vblank. User +code never touches `$2006` or `$2007` directly. + +Three intrinsics cover the common patterns: + +``` +nt_set(x, y, tile) // one tile at nametable cell (x, y) +nt_attr(x, y, value) // one attribute byte covering the + // 4×4-cell metatile at (x, y) +nt_fill_h(x, y, len, tile) // horizontal run of `len` copies + // of `tile` starting at (x, y) +``` + +`x` and `y` are nametable cell coordinates (`0..32`, `0..30`) — not +pixel coordinates. The compiler computes the PPU address +(`$2000 + y*32 + x` for nametable, `$23C0 + (y/4)*8 + (x/4)` for +attribute) and emits the buffer-append inline at each call site. + +### HUD pattern + +Queue an update only when the underlying state changes. That +makes per-frame cost scale with what actually moved, not with HUD +complexity: + +``` +var score: u8 = 0 +var last_score: u8 = 255 // 255 forces the first-frame paint + +on frame { + // ... gameplay that may or may not bump `score` ... + + if score != last_score { + last_score = score + var digit: u8 = score & 0x0F + nt_set(28, 1, digit) // one 4-byte buffer entry + } +} +``` + +A typical HUD touches two or three cells per change, so the 256- +byte buffer is more than enough for any realistic frame. See +`examples/hud_demo.ne` for a worked example with a bouncing-ball +playfield, a score cell that updates on each wall hit, a 5-cell +lives indicator drawn via `nt_fill_h`, and a one-shot `nt_attr` +call at startup that paints the HUD row in a distinct palette. + +### Budget + +Per-entry buffer cost: + +| Intrinsic | Buffer bytes | Drain cycles | +|----------------|--------------------|----------------------| +| `nt_set` | 4 | ~20 | +| `nt_attr` | 4 | ~20 | +| `nt_fill_h` | `3 + len` | `~12 + 8*len` | + +The 256-byte buffer holds ~50 single-tile writes that drain in +~1000 cycles, well inside vblank's ~2273-cycle budget. Programs +that don't call any of the three intrinsics pay zero bytes and +zero cycles — the drain routine isn't linked, the NMI doesn't +JSR it, and the 256-byte buffer region stays available for user +variables. + +### Limits + +- Only **horizontal** writes (PPU auto-increment 1). Vertical + writes (column-fill) would need to toggle `$2000` bit 2; that's + a known follow-up documented in `docs/future-work.md` §G. +- `nt_fill_h` takes a runtime `len`. If `len` overflows the + remaining space in the buffer (head + 3 + len > 256) the writer + scribbles into neighbouring RAM. The runtime does not bounds- + check; a debug-mode overflow trap is a known follow-up. +- The buffer does not coalesce adjacent writes. Calling + `nt_set(0, 0, 1)` then `nt_set(1, 0, 2)` emits two separate + entries (8 buffer bytes) even though a single `len=2` entry + would fit both. + ## Inline Assembly For more elaborate sequences, use `asm { ... }` blocks: diff --git a/examples/README.md b/examples/README.md index eab92cb..3b0b8db 100644 --- a/examples/README.md +++ b/examples/README.md @@ -52,7 +52,8 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. | | `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10` → `$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. Comparisons currently use the unsigned 16-bit compare path (matching existing `i8` behaviour) — fine for positive ranges, wrong for negative compares. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. | | `sram_demo.ne` | `save { var ... }` | Battery-backed save block. The analyzer allocates `high_score` and `coins` at `$6000+` (cartridge SRAM window) instead of main RAM, and the linker flips iNES header byte-6 bit-1 so emulators (FCEUX, Mesen, Nestopia) load and persist the region from a `.sav` file alongside the ROM. SRAM is uninitialized at first power-on; production games should reserve a magic-byte sentinel and validate it before trusting the rest of the data — the compiler doesn't auto-initialize and emits W0111 if you try. | -| `vram_buffer_demo.ne` | `nt_set`, `nt_attr`, `nt_fill_h` | VRAM update buffer. User code queues PPU writes during `on frame` via three intrinsics; the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007` during vblank. Each entry is `[len][addr_hi][addr_lo][data...]` with a zero-byte sentinel; the codegen lays down a fresh sentinel after every append. The runtime drain uses `LDA $0400,X` indexed-absolute (4 cycles per data byte, no ZP cost). Gated on the `__vram_buf_used` marker — programs that never call any of the three intrinsics keep the 256 bytes free for analyzer allocation and the NMI never JSRs the drain. | +| `vram_buffer_demo.ne` | `nt_set`, `nt_attr`, `nt_fill_h` | Minimal VRAM update buffer exercise — three single-tile writes, a 16-tile horizontal fill, and an attribute write firing every frame. Useful as a test case; see `hud_demo.ne` for a realistic usage pattern. | +| `hud_demo.ne` | VRAM buffer driving a classic status bar | A bouncing ball playfield with a HUD across the top: a 5-cell lives indicator that ticks down once per second via `nt_fill_h`, a score counter at the right edge that bumps on every wall hit via `nt_set`, and a one-shot `nt_attr` call at startup that flips the top-left metatile group to a red "UI chrome" palette. Shadow-comparing `score` / `lives` to their `last_*` copies keeps the buffer empty on the ~58-of-60 frames when nothing changed — per-frame cost scales with what actually moved. This is the pattern every nesdoug scoreboard / dialog box / destroyed-metatile animation is built on. | ## Emulator Controls diff --git a/examples/hud_demo.ne b/examples/hud_demo.ne new file mode 100644 index 0000000..02a773e --- /dev/null +++ b/examples/hud_demo.ne @@ -0,0 +1,152 @@ +// HUD demo — the VRAM update buffer driving a classic status-bar +// layout above a scrolling playfield. +// +// The playfield shows a ball bouncing back and forth; every wall +// hit bumps a score counter that the HUD renders at the top of +// the screen. A "lives" indicator to the left ticks down +// periodically and resets, demonstrating both `nt_set` (for +// single-cell updates when state changes) and `nt_fill_h` (for +// repeatedly painting a multi-cell indicator bar). An `nt_attr` +// call at reset gives the HUD row a distinct colour palette so +// it reads as "UI chrome" instead of "gameplay background". +// +// The HUD never touches `$2006` / `$2007` directly — user code +// just appends records to the 256-byte ring at `$0400-$04FF` +// and the NMI handler drains them during vblank. `nt_attr` / `nt_set` +// / `nt_fill_h` each cost one buffer entry (4..19 bytes); the +// ~2273-cycle vblank budget drains ~200 single-cell writes, so a +// full HUD refresh fits comfortably. +// +// Only cells that *change* need a buffer entry: the demo tracks +// `last_score` and `last_lives` so the common "state didn't +// change this frame" path appends nothing. That's the whole +// point of the update buffer — per-frame cost scales with what +// actually changed, not with HUD complexity. + +game "HUD Demo" { + mapper: NROM + mirroring: horizontal +} + +palette GameColors { + universal: black + bg0: [dk_blue, blue, sky_blue] // playfield background + bg1: [dk_red, red, lt_red] // HUD row + bg2: [dk_green, green, lt_green] + bg3: [dk_gray, lt_gray, white] + sp0: [dk_blue, blue, sky_blue] + sp1: [red, orange, white] + sp2: [dk_teal, teal, lt_teal] + sp3: [dk_olive, olive, yellow] +} + +background Playfield { + legend { ".": 0 } + // Fill the nametable with tile 0 so sprite-0 hit plumbing + // works and the HUD cells have opaque source pixels when we + // overwrite them. The map's three rows zero-pad to the + // full 30-row nametable automatically. + map: [ + "................................", + "................................", + "................................" + ] +} + +// Ball position + velocity. +var bx: u8 = 64 +var by: u8 = 100 +var dx: u8 = 1 // 1 = moving right, 0 = moving left +var dy: u8 = 1 // 1 = moving down, 0 = moving up + +// HUD state. `score` increments on each wall bounce; `lives` +// ticks down once a second and resets from 5 when it hits zero. +// The `last_*` shadows let us skip the HUD write when nothing +// has changed this frame. +var score: u8 = 0 +var lives: u8 = 5 +var last_score: u8 = 255 // 255 forces an initial paint on frame 0 +var last_lives: u8 = 255 +var life_tick: u8 = 0 +var attr_set: u8 = 0 // 1 once the HUD attribute has been painted + +on frame { + // ── One-shot: paint the HUD attribute byte on the first + // frame only. The top metatile group picks up sub-palette + // 1 (the red HUD palette) instead of sub-palette 0 (the + // blue playfield). `0b01010101` means all four 16×16 + // quadrants of the metatile use sub-palette 1. + if attr_set == 0 { + nt_attr(0, 0, 0b01010101) + attr_set = 1 + } + + // ── Playfield: bounce the ball, count bounces as score. ── + if dx == 1 { + bx += 1 + if bx >= 240 { + dx = 0 + score += 1 + } + } else { + bx -= 1 + if bx == 16 { + dx = 1 + score += 1 + } + } + if dy == 1 { + by += 1 + if by >= 200 { dy = 0 } + } else { + by -= 1 + if by == 32 { dy = 1 } + } + draw Ball at: (bx, by) + + // ── HUD: tick the lives timer. One "life" ticks every 60 + // frames (~1 sec); at zero, reset to 5. ── + life_tick += 1 + if life_tick >= 60 { + life_tick = 0 + if lives == 0 { + lives = 5 + } else { + lives -= 1 + } + } + + // ── HUD: rewrite only the cells whose backing state changed. + // When `score` ticks, update the single-digit cell at + // (28, 1) via `nt_set`. When `lives` ticks, repaint + // the whole lives bar via `nt_fill_h` (5 cells + // starting at column 2 of row 1, filled with tile 0 + // for "alive"). A pre-flight shadow-compare keeps + // the buffer empty on the ~58 of 60 frames when + // nothing changed. + if score != last_score { + last_score = score + // Low nibble of score → tile index 0..15. A production + // HUD would use CHR-authored digit glyphs; this demo + // relies on the fact that whatever tiles happen to live + // at CHR indices 0..15 will render as *visible* changes + // frame-to-frame, making the update mechanism obvious. + var digit: u8 = score & 0x0F + nt_set(28, 1, digit) + } + if lives != last_lives { + last_lives = lives + // Fill `lives` cells at (2, 1) with the smiley tile, then + // clear the stale tail with a second fill of blank cells. + // `nt_fill_h` emits one buffer entry per call, so the + // two-step "draw, then erase" pattern costs two entries + // (~22 bytes) only on the frame the value changes. + nt_fill_h(2, 1, lives, 0) + if lives < 5 { + var blanks: u8 = 5 - lives + nt_fill_h(2 + lives, 1, blanks, 1) + } + } +} + +start Main diff --git a/examples/hud_demo.nes b/examples/hud_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..964c28ad389d56c9885381bfbce6e68dc10acde8 GIT binary patch literal 24592 zcmeI(&ubGw6bJCx*`%@ckXHI*(`4Ie3u@3qZ+eKh6ogQM2c=i>U?Bm)tEZB-42ZDe z(I^289q1Gk{R4z1(jO@v_NG_C#&~j1=|Qo++1k*me}VmO!v2~!Z})w&$vfrxwVR`s z#k$M=k2UYnh*#Txs+rG(ZwY_M)3#Yn`^@uWo<8MUo*(x#yU0cVcg+ z?b%fptFm~N^;B7+9eg^7D^t3cT=KjqPViZOps39wX}qAL$LPkjCmnr9rnDtTWm+?t(Q)bOeK{t_ z=}5>4X)6}~pnaz7CJXJtxAhu}%4pLP?9oWCnutbj)b-q}-GsKNwzi&%)-0RpqieI- zS!bSZYCSqRXV#A>k|1Jq=?OL3v?%qxddpExGkk3q?$#;sXe(1F*Eh|%ei|;<&26?a zDjh{XtG7}r)!e4&Cnh>wSgUWEGlF8*Ol(@ER(e&cV#q5NuW+uMip^WSzh6R$W{#$R zHPeN1qbk1C4FR3obX8_!EU*jNhRVpXY*J2ktDB{IN+K%8>NAT|%9tZHd9*a;Zj96X zt%h>txGd8}mMMdB59a+N2R9=tWwmU!4}!fzBf7QtW0Oe7FYmf?N^@u4m1Q+07pcr8 zlk|Yw`a9`-IZC>ymi|Z@eUfikEOb}fw4o>J=q4c^1f6!_eS@CE-Gt9#x0BYuM6zf2 zY_d0%O|mP~(ed!P>E!A3h2$yceE0i-9SA_+#0lgd=AYyh&nr7mzZtgB+#mGRKzJ)_ zg{fm*jat>2!)~SqvOK(Wtfxi2Y%mHiy=>6*vOzUeVseOj8BHFJ#!L@mazN}f=r@oR zrCpS~xLRB*dfq-32tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00d5qz{nO`f3cODQH!cFlgllb z!WVKU?w_707y=N000bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_@E-{L E0@Q|($p8QV literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/hud_demo.audio.hash b/tests/emulator/goldens/hud_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/hud_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/hud_demo.png b/tests/emulator/goldens/hud_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..872d11a79f76827f69b418907aa2f6a54d16d4ca GIT binary patch literal 37721 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 g2<-HZyd&^>{MWumRXe`2gMXnb!&m&{