1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

docs + example: HUD demo and language-guide VRAM buffer section

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.
This commit is contained in:
Claude 2026-04-18 21:34:44 +00:00
parent 807c9c7318
commit 854b61ea1e
No known key found for this signature in database
6 changed files with 242 additions and 1 deletions

View file

@ -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: