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:
parent
807c9c7318
commit
854b61ea1e
6 changed files with 242 additions and 1 deletions
|
|
@ -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
|
addresses compile to `STA $XX` / `LDA $XX`; anything larger compiles
|
||||||
to absolute addressing.
|
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
|
## Inline Assembly
|
||||||
|
|
||||||
For more elaborate sequences, use `asm { ... }` blocks:
|
For more elaborate sequences, use `asm { ... }` blocks:
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
## Emulator Controls
|
||||||
|
|
||||||
|
|
|
||||||
152
examples/hud_demo.ne
Normal file
152
examples/hud_demo.ne
Normal file
|
|
@ -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
|
||||||
BIN
examples/hud_demo.nes
Normal file
BIN
examples/hud_demo.nes
Normal file
Binary file not shown.
1
tests/emulator/goldens/hud_demo.audio.hash
Normal file
1
tests/emulator/goldens/hud_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
a82b6ff5 132084
|
||||||
BIN
tests/emulator/goldens/hud_demo.png
Normal file
BIN
tests/emulator/goldens/hud_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Loading…
Add table
Add a link
Reference in a new issue