1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-21 06:17:18 +00:00

platformer: end-to-end side-scroller demo + three runtime bug fixes

Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).

Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:

1. **`gen_init` enabled sprite rendering before the linker's
   initial palette/background load runs.** The PPU's v-register
   auto-increments on every `$2007` write *during active
   rendering*, so the palette load (32 B) and nametable load
   (1024 B) were scrambled past the first ~72 bytes — every
   existing program with a `background Level { ... }` block was
   silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
   at the end of `gen_init` and emit a new `gen_enable_rendering`
   call *after* all initial VRAM writes complete.

2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
   driver's period-table lookup reused `$02/$03` as a temporary
   indirect pointer with a comment claiming the slots were free
   because the tick doesn't call mul/div. But `$03` is also
   `ZP_CURRENT_STATE` used by the state dispatch loop, so every
   music note silently overwrote the state index with the high
   byte of `__period_table` (`0xC5` in the platformer ROM),
   wedging the state machine forever. Fix: `gen_nmi` now PHAs
   `$02/$03` on entry and PLA-restores them on exit, and the
   audio tick JSR moves inside that save/restore window (it used
   to be spliced by the linker *before* the register saves, so
   even A/X/Y were technically being trashed pre-save). Only
   `audio_demo`'s audio hash shifts (its note timings move a few
   cycles); every other golden is unchanged.

3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
   blob sequentially causes the sprite sub-palettes' "index 0"
   slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
   universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
   mirroring. The example's palette sets all eight first bytes
   to `$22` (sky blue) for this reason; `docs/future-work.md`
   picks up a TODO to warn on inconsistent first-byte values in
   the analyzer.

Also:

- `docs/platformer.gif` — 6-second recording of the example
  running in jsnes, generated by the new
  `tests/emulator/record_gif.mjs` puppeteer helper (encodes via
  `gifenc`, committed as a dev-dependency under
  `tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
  updated to cover the new example.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
This commit is contained in:
Claude 2026-04-13 13:04:26 +00:00
parent 958f41d340
commit 688d9afcec
No known key found for this signature in database
16 changed files with 1171 additions and 25 deletions

View file

@ -4,6 +4,17 @@ version = "0.1.0"
edition = "2021"
description = "A statically-typed compiler for NES game development"
license = "MIT"
default-run = "nescript"
[[bin]]
name = "nescript"
path = "src/main.rs"
# One-shot tile / nametable generator for examples/platformer.ne. Kept in-tree
# (rather than as a Python script) so the repo stays single-language.
[[bin]]
name = "gen_platformer_tiles"
path = "scripts/gen_platformer_tiles.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }

View file

@ -4,6 +4,14 @@ A statically-typed, compiled programming language for NES game development.
NEScript compiles `.ne` source files directly into playable iNES ROM files, with no external assembler or linker dependencies. The compiler handles everything from source text to a ROM you can run in any NES emulator.
![Platformer demo](docs/platformer.gif)
*An end-to-end side-scrolling platformer compiled from a single
[`.ne` source file](examples/platformer.ne) — custom CHR art, full
background nametable with per-region palettes, physics-driven hero,
scrolling camera, enemies, coins, user-declared SFX/music, and a
Title → Playing state machine, all in 24 KB of 6502 code.*
## Quick Start
```bash
@ -81,6 +89,7 @@ start Main
| [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals |
| [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` |
| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, enemies, coins, user-declared SFX + music, and a Title → Playing state machine with auto-play for the headless harness |
## Compiler Commands
@ -121,7 +130,7 @@ NEScript implements all five planned milestones:
| M4: Optimization | Done | Strength reduction, ZP promotion, type casting, asm-dump |
| M5: Bank Switching | Done | MMC1/UxROM/MMC3, bank declarations, software mul/div |
465 tests across the lexer, parser, analyzer, IR, optimizer, codegen, assembler, linker, runtime, ROM, and asset modules, with CI running fmt, clippy, test, and example compilation on every push.
497 tests across the lexer, parser, analyzer, IR, optimizer, codegen, assembler, linker, runtime, ROM, and asset modules, with CI running fmt, clippy, test, and example compilation on every push.
## License

View file

@ -161,6 +161,15 @@ semantics come back, add the codes at that point.
`while true` or `loop { if cond { continue } }`.
- No warning for `fast` variables that never justify the zero-page slot
(could cross-reference access counts).
- No warning when a `palette` declaration has inconsistent "index 0" bytes
across its eight sub-palettes. The NES hardware mirrors `$3F10/$3F14/
$3F18/$3F1C` onto `$3F00/$3F04/$3F08/$3F0C`, so writing the full 32-byte
blob sequentially causes the last four "sprite sub-palette 0" bytes to
overwrite the background universal colour; the fix is a user-side
convention (every sub-palette's first byte equals the chosen universal
colour) but the analyzer doesn't warn when a declaration violates it.
The mistake produced a solid-black screen in `examples/platformer.ne`
until it was chased down by hand.
---

BIN
docs/platformer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

View file

@ -27,6 +27,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `sprites_and_palettes.ne` | sprites, scroll, cast | Inline CHR data, PPU scroll writes, type casting |
| `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply |
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with an autopilot so the headless harness still hits interesting gameplay at frame 180. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
## Emulator Controls

505
examples/platformer.ne Normal file
View file

@ -0,0 +1,505 @@
// Platformer — an end-to-end side-scrolling demo game that
// exercises nearly every subsystem of the NEScript compiler in
// one program: custom CHR tiles, a full 32×30 background nametable
// with per-region attribute palettes, a multi-tile metasprite
// player with gravity/jump physics, wrap-around horizontal
// scrolling, moving enemies, collectible coins, a user-declared
// SFX envelope, a user-declared music track, and a title →
// playing → game-over state machine driven by both controller
// input and an autopilot so the jsnes golden harness (which runs
// headless with no simulated buttons) still captures an
// interesting frame-180 composition.
//
// Controls (when played by a human):
// D-pad left/right — walk
// A — jump
// Start — on title screen: begin; after game over: retry
//
// Build: cargo run --release -- build examples/platformer.ne
// Output: examples/platformer.nes
//
// All CHR tile art, nametable bytes, and attribute bytes in this
// file are generated by `cargo run --bin gen_platformer_tiles`.
// Regenerate them from `scripts/gen_platformer_tiles.rs` if you
// want to tweak the level.
game "Platformer" {
mapper: NROM
// Horizontal arrangement means nametable 1 mirrors nametable 0
// horizontally, so our single loaded nametable tiles endlessly
// as the camera scrolls right.
mirroring: horizontal
}
// ── Palette ──────────────────────────────────────────────────
// The background palette drives the sky / hill / ground look.
// Each sub-palette reserves index 0 for the shared background
// colour (sky blue $22), plus three working colours.
// bg 0 (sky region): sky blue, white, light gray, black
// bg 1 (air/blocks row): sky blue, red, peach, dark red
// bg 2 (ground row): sky blue, lime green, dark green, dark brown
// bg 3 (unused reserve): sky blue, yellow, orange, gold
// The sprite sub-palette 0 (used by every `draw` statement — the
// OAM attribute byte is always 0 in the current NEScript
// runtime) carries the hero's red/peach/white body colours.
// NOTE: the first byte of every sub-palette must match. The PPU
// mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/$3F04/$3F08/$3F0C, so
// writing 8 distinct "index 0" bytes ends up clobbering the
// background universal colour with the last write. The canonical
// fix — and what every NES game does — is to use the universal
// background colour ($22 sky blue here) in all 8 slots. For sprite
// palettes, index 0 is always transparent regardless of the stored
// value, so this costs nothing visually.
palette Main {
colors: [
// bg palette 0 — sky / clouds
// 0 = sky blue (universal bg)
// 1 = white
// 2 = light gray
// 3 = black
0x22, 0x30, 0x10, 0x0F,
// bg palette 1 — bricks, Q-blocks
// 1 = red, 2 = peach, 3 = dark red
0x22, 0x16, 0x27, 0x06,
// bg palette 2 — grass, hills, bushes, dirt
// 1 = light green
// 2 = light brown
// 3 = dark brown
0x22, 0x1A, 0x17, 0x07,
// bg palette 3 — unused but the mirroring still writes here
0x22, 0x28, 0x27, 0x17,
// sprite palette 0 — hero / enemy / coin
// (OAM attribute is always 0 in the current runtime so
// every sprite uses this one sub-palette)
// 1 = red cap, 2 = peach skin, 3 = white
0x22, 0x16, 0x27, 0x30,
// sprite palette 1 — reserved
0x22, 0x07, 0x17, 0x27,
// sprite palette 2 — reserved
0x22, 0x2A, 0x1A, 0x30,
// sprite palette 3 — reserved
0x22, 0x28, 0x18, 0x38
]
}
// ── Tileset ──────────────────────────────────────────────────
// One big sprite declaration holds every custom CHR tile used by
// both sprites and the background nametable. `draw Tileset ...
// frame: N` overrides the OAM tile-index byte with `N`, so the
// same declaration drives the player, enemies, coins, and every
// background tile after tile 0. Tile 0 is reserved by the linker
// for the built-in smiley — the background leaves it unreferenced.
sprite Tileset {
chr: [
// tile 1: Player head L
0x3F, 0x7F, 0x70, 0x61, 0xCF, 0xCD, 0xC0, 0xC0, 0x00, 0x00, 0x0F, 0x1F, 0x3F, 0x3F, 0x3F, 0x3F,
// tile 2: Player head R
0xFC, 0xFE, 0x0E, 0x83, 0xF8, 0x4C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF,
// tile 3: Player body L
0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x10, 0x18, 0x00, 0x7F, 0x7F, 0x67, 0x07,
// tile 4: Player body R
0xFE, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0x00, 0x08, 0x18, 0x00, 0xFE, 0xFE, 0xE6, 0xE0,
// tile 5: Enemy
0x3C, 0x7E, 0x76, 0xFF, 0x99, 0x7E, 0xBD, 0x99, 0x00, 0x00, 0x18, 0x38, 0x7E, 0x00, 0x00, 0x00,
// tile 6: Coin
0x00, 0x00, 0x18, 0x24, 0x24, 0x18, 0x00, 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x7E, 0x7E, 0x3C, 0x18,
// tile 7: Grass top
0xFF, 0xBB, 0xFF, 0xFF, 0xFF, 0xFF, 0xBD, 0xFF, 0x00, 0x00, 0x00, 0x6A, 0xF7, 0xFF, 0xFF, 0xFF,
// tile 8: Dirt
0xFF, 0xBD, 0xFF, 0xF7, 0xFF, 0xBD, 0xF7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
// tile 9: Brick
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x82, 0x82, 0x82, 0xFF, 0x10, 0x10, 0x10,
// tile 10: Cloud L
0x00, 0x1C, 0x3E, 0x7F, 0x7F, 0x1F, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x3E, 0x07,
// tile 11: Cloud R
0x00, 0x70, 0xF8, 0xFE, 0xFE, 0xF8, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x7C, 0xE0,
// tile 12: Hill
0x00, 0x0C, 0x1E, 0x3F, 0x2F, 0x4B, 0x75, 0xC9, 0x00, 0x00, 0x00, 0x00, 0x10, 0x34, 0x0A, 0x36,
// tile 13: Bush
0x00, 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x02, 0x06, 0x06, 0x55,
// tile 14: Q Block
0xFF, 0xFF, 0xC3, 0xDB, 0xD3, 0xC3, 0xFF, 0xFF, 0xFF, 0x81, 0xBD, 0xBD, 0xBD, 0xBD, 0x81, 0xFF,
// tile 15: Sky (blank)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]
}
// Tile index constants — must match `scripts/gen_platformer_tiles.rs`.
const TILE_PLAYER_HL: u8 = 1
const TILE_PLAYER_HR: u8 = 2
const TILE_PLAYER_BL: u8 = 3
const TILE_PLAYER_BR: u8 = 4
const TILE_ENEMY: u8 = 5
const TILE_COIN: u8 = 6
// ── Background ──────────────────────────────────────────────
// Full-screen 32×30 nametable showing a static level view that
// horizontal scrolling pans across. The attribute table splits
// the screen into three palette regions (sky/blocks/ground) so
// the grass and hills pick up the right colours even without
// per-tile attribute churn.
background Level {
tiles: [
// rows 0-4: clean sky
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 5: clouds at x=4..5 and x=20..21
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 6: cloud at x=12..13
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 10, 11, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// rows 7-14: more clean sky
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 15: brick/Q-block platform left, brick platform right
15, 15, 15, 15, 15, 15, 9, 9, 14, 9, 15, 15, 15, 15, 15, 15,
15, 15, 9, 9, 9, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// rows 16-19: sky
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
// row 20: hills (3 on left, 4 on right)
15, 15, 12, 12, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 12, 12, 12, 12, 15, 15, 15, 15, 15, 15,
// row 21: bushes above the grass line
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 13, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 15, 15, 15,
// row 22: grass top (full width)
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
// rows 23-29: dirt with a few buried bricks for texture
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8,
8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8
]
attributes: [
// 8×8 attribute grid, one byte per 32×32-px metatile group.
// Each byte packs 4 identical 2-bit quadrants selecting a
// sub-palette (0..3):
// 0x00 = sub-palette 0 (sky / clouds)
// 0x55 = sub-palette 1 (bricks / Q-blocks)
// 0xAA = sub-palette 2 (grass / hills / dirt)
//
// Row mapping: ay 0-2 (nt rows 0-11) = sky, ay 3 (nt rows
// 12-15) = brick row, ay 4 (nt rows 16-19) = more sky, ay
// 5-7 (nt rows 20-29) = ground.
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA
]
}
// ── Audio ────────────────────────────────────────────────────
// Custom boingy jump sound — descending pitch arc on pulse 1.
sfx Boing {
duty: 2
pitch: [0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60]
volume: [12, 11, 10, 9, 7, 5, 2]
}
// Custom coin ding — short rising blip.
sfx Ding {
duty: 2
pitch: [0x20, 0x20, 0x20, 0x20]
volume: [14, 12, 8, 3]
}
// Custom looping underworld theme — playful four-bar loop on
// pulse 2 that plays while the player is alive.
music Theme {
duty: 2
volume: 9
repeat: true
notes: [
37, 10, // C4
41, 10, // E4
44, 10, // G4
49, 10, // C5
44, 10, // G4
41, 10, // E4
39, 15, // D4
0, 5, // rest
36, 10, // B3
41, 10, // E4
44, 10, // G4
49, 20, // C5
0, 10 // rest
]
}
// ── Gameplay constants ──────────────────────────────────────
const PLAYER_SCREEN_X: u8 = 80 // fixed camera-relative X
const GROUND_Y: u8 = 160 // feet land here (y of head)
const JUMP_RISE: u8 = 12 // upward frames after take-off
const JUMP_PX: u8 = 3 // px moved each rise frame
const GRAVITY_CAP: u8 = 4 // terminal fall speed
const ENEMY_Y: u8 = 168 // enemies walk on the grass
const COIN_Y: u8 = 144 // coins float a bit above
// World-space X positions of the moving entities. Wrap-around
// arithmetic (u8) makes the level feel like an endless loop.
const ENEMY1_WORLD_X: u8 = 100
const ENEMY2_WORLD_X: u8 = 200
const COIN1_WORLD_X: u8 = 50
const COIN2_WORLD_X: u8 = 150
// ── Game state ──────────────────────────────────────────────
// Player physics
var player_y: u8 = 160
var on_ground: u8 = 1
var rise_count: u8 = 0 // frames of upward motion remaining
var fall_vy: u8 = 0 // gravity accumulator
// World/camera
var camera_x: u8 = 0
var frame_tick: u8 = 0 // free-running frame counter
var jump_timer: u8 = 0 // autopilot: jump every N frames
var anim_tick: u8 = 0 // visual animation phase
// ── Helper functions ────────────────────────────────────────
// Try to start a jump. Only valid when the player is on the
// ground (on_ground == 1); otherwise this is a no-op.
fun try_jump() {
if on_ground == 1 {
rise_count = JUMP_RISE
on_ground = 0
fall_vy = 0
play Boing
}
}
// Advance player physics one frame. Handles the jump-rise phase
// (upward movement while rise_count > 0) and the gravity fall
// phase (increment fall_vy each frame up to GRAVITY_CAP, clamp to
// GROUND_Y on landing).
fun step_physics() {
if rise_count > 0 {
// Clamp underflow so a too-high jump doesn't wrap into the
// bottom of the screen on an 8-bit subtraction.
if player_y >= JUMP_PX {
player_y -= JUMP_PX
} else {
player_y = 0
}
rise_count -= 1
} else {
player_y += fall_vy
if fall_vy < GRAVITY_CAP {
fall_vy += 1
}
if player_y >= GROUND_Y {
player_y = GROUND_Y
on_ground = 1
fall_vy = 0
}
}
}
// Draw the 2×2 hero metasprite at (PLAYER_SCREEN_X, player_y).
// Each `draw` statement writes a fresh OAM slot via the runtime
// cursor, so four calls produce four sprites occupying a 16×16
// block.
fun draw_player() {
draw Tileset at: (PLAYER_SCREEN_X, player_y ) frame: TILE_PLAYER_HL
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y ) frame: TILE_PLAYER_HR
draw Tileset at: (PLAYER_SCREEN_X, player_y + 8) frame: TILE_PLAYER_BL
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR
}
// ── States ──────────────────────────────────────────────────
state Title {
var blink: u8 = 0
on enter {
blink = 0
frame_tick = 0
start_music Theme
}
on frame {
frame_tick += 1
blink += 1
if blink >= 60 {
blink = 0
}
// Blink a "press start" marker at roughly 0.5 Hz. We use
// the coin tile for visual continuity with the game world.
if blink < 30 {
draw Tileset at: (120, 120) frame: TILE_COIN
}
// Auto-advance so the jsnes golden harness (which never
// presses start) still reaches Playing and captures gameplay
// at frame 180. A human player can also press Start to skip
// the wait early.
if frame_tick >= 20 {
transition Playing
}
if button.start {
transition Playing
}
}
}
state Playing {
on enter {
player_y = GROUND_Y
on_ground = 1
rise_count = 0
fall_vy = 0
camera_x = 0
frame_tick = 0
jump_timer = 0
anim_tick = 0
}
on frame {
frame_tick += 1
anim_tick += 1
// ── Input ─────────────────────────────────────────────
// Human controls — these work even while the autopilot
// is running so you can stomp the demo at any time.
if button.a {
try_jump()
}
if button.right {
camera_x += 1 // walk forward by scrolling right
}
if button.left {
// Walk back (wraps the camera — the level is cyclic).
camera_x -= 1
}
// ── Autopilot ────────────────────────────────────────
// The headless golden harness never presses buttons, so
// we advance the camera and periodically hop all by
// ourselves. A human holding right still accelerates
// forward — the two effects compose.
camera_x += 1
jump_timer += 1
if jump_timer >= 40 {
jump_timer = 0
try_jump()
}
// ── Physics ──────────────────────────────────────────
step_physics()
// ── Collisions ───────────────────────────────────────
// Coin pickup when the player's screen X is within ±8 of
// a coin's screen position and the coin's Y range overlaps
// the player. Uses u8 subtraction so "screen X" of a
// world entity is just (world_x - camera_x).
var e1_sx: u8 = ENEMY1_WORLD_X - camera_x
var e2_sx: u8 = ENEMY2_WORLD_X - camera_x
var c1_sx: u8 = COIN1_WORLD_X - camera_x
var c2_sx: u8 = COIN2_WORLD_X - camera_x
// Coin collect: player occupies [80..96). A coin is "hit"
// when its screen X is in [72..96) (8-px tolerance on each
// side of the player's left edge).
if c1_sx >= 72 and c1_sx < 96 {
if player_y <= COIN_Y + 16 {
play Ding
}
}
if c2_sx >= 72 and c2_sx < 96 {
if player_y <= COIN_Y + 16 {
play Ding
}
}
// Enemy stomp: if we're above an enemy horizontally *and*
// we're mid-fall (fall_vy > 0), count the stomp and
// bounce. Otherwise just ignore — the enemy slides past.
if e1_sx >= 72 and e1_sx < 96 {
if fall_vy > 0 {
if player_y + 16 >= ENEMY_Y {
play hit
rise_count = 6
fall_vy = 0
}
}
}
// ── Drawing ──────────────────────────────────────────
draw_player()
// Enemies — walking on the grass line. anim_tick drives
// a small vertical bob so they visibly move on the golden.
var ey: u8 = ENEMY_Y
if (anim_tick & 16) != 0 {
ey = ENEMY_Y - 2
}
draw Tileset at: (e1_sx, ey) frame: TILE_ENEMY
draw Tileset at: (e2_sx, ENEMY_Y) frame: TILE_ENEMY
// Coins — alternate two vertical positions to fake a
// spin via position change (OAM attr flip isn't wired up
// in the current runtime).
var cy: u8 = COIN_Y
if (anim_tick & 8) != 0 {
cy = COIN_Y - 1
}
draw Tileset at: (c1_sx, cy) frame: TILE_COIN
draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN
// ── PPU scroll latch ─────────────────────────────────
// Write the scroll at the very end of the frame so it's
// the last $2005 pair before the implicit wait_frame /
// NMI handshake, minimizing mid-frame artifacts.
scroll(camera_x, 0)
}
}
start Title

View file

@ -0,0 +1,428 @@
//! One-shot generator for the CHR tiles and nametable used by
//! `examples/platformer.ne`.
//!
//! Run with `cargo run --bin gen_platformer_tiles`. The output is
//! intended to be pasted into `platformer.ne` under the
//! `sprite Tileset { chr: [...] }` and
//! `background Level { tiles: [...] }` blocks. Keeping the source of
//! truth here (instead of hand-maintained hex in the `.ne` file)
//! makes the tile art editable as ASCII and ensures the CHR bytes
//! and the nametable stay in sync with named tile indices.
//!
//! Tiles are defined as 8×8 ASCII art where each character selects
//! one of 4 sub-palette slots:
//! '.' = colour 0 (sky / transparent for sprites)
//! 'a' = colour 1
//! 'b' = colour 2
//! 'c' = colour 3
use std::fmt::Write as _;
/// A single 8×8 tile description: name + ASCII art.
struct Tile {
name: &'static str,
art: &'static str,
}
/// All tiles referenced by the platformer example. Order matters —
/// tile 0 is the default smiley reserved by the linker, so entry 0
/// here lands at CHR index 1, entry 1 at index 2, and so on.
///
/// Colour conventions (must line up with the palette + attribute
/// layout in `examples/platformer.ne`):
/// sprite sub-palette 0 (player / enemy / coin):
/// a = red/cap, b = peach/skin, c = white/highlight
/// bg sub-palette 0 (sky row): a = white, b = light gray, c = black
/// bg sub-palette 1 (block row): a = red, b = peach, c = dark red
/// bg sub-palette 2 (ground row): a = light green, b = light brown,
/// c = dark brown
const TILES: &[Tile] = &[
Tile {
name: "Player head L",
art: "\
..aaaaaa
.aaaaaaa
.aaabbbb
.aabbbbc
aabbcccc
aabbccbc
aabbbbbb
aabbbbbb",
},
Tile {
name: "Player head R",
art: "\
aaaaaa..
aaaaaaa.
bbbbaaa.
cbbbbbaa
cccccbbb
bcbbccbb
bbbbbbbb
bbbbbbbb",
},
Tile {
name: "Player body L",
art: "\
.aaaaaaa
aaacaaaa
aaaccaaa
aaaaaaaa
.bbbbbbb
.bbbbbbb
.bb..bbb
aaa..bbb",
},
Tile {
name: "Player body R",
art: "\
aaaaaaa.
aaaacaaa
aaaccaaa
aaaaaaaa
bbbbbbb.
bbbbbbb.
bbb..bb.
bbb..aaa",
},
Tile {
name: "Enemy",
art: "\
..aaaa..
.aaaaaa.
.aacbaa.
aacccaaa
abbccbba
.aaaaaa.
a.aaaa.a
a..aa..a",
},
Tile {
name: "Coin",
art: "\
...bb...
..bbbb..
.bbccbb.
.bcbbcb.
.bcbbcb.
.bbccbb.
..bbbb..
...bb...",
},
// Grass top (sub-palette 2: a=green, c=dark brown).
Tile {
name: "Grass top",
art: "\
aaaaaaaa
a.aaa.aa
aaaaaaaa
accacaca
ccccaccc
cccccccc
cbccccbc
cccccccc",
},
// Dirt (sub-palette 2: c=dark brown bulk, b=light brown speckles).
Tile {
name: "Dirt",
art: "\
cccccccc
cbccccbc
cccccccc
ccccbccc
cccccccc
cbccccbc
ccccbccc
cccccccc",
},
// Brick (sub-palette 1: a=red, c=dark red mortar).
Tile {
name: "Brick",
art: "\
cccccccc
caaaaaca
caaaaaca
caaaaaca
cccccccc
aaacaaaa
aaacaaaa
aaacaaaa",
},
// Cloud left (sub-palette 0: a=white, b=light gray shade).
Tile {
name: "Cloud L",
art: "\
........
...aaa..
..aaaaa.
.aaaaaaa
.aaaaaaa
.bbaaaaa
..bbbbba
.....bbb",
},
// Cloud right (sub-palette 0).
Tile {
name: "Cloud R",
art: "\
........
.aaa....
aaaaa...
aaaaaaa.
aaaaaaa.
aaaaabb.
abbbbb..
bbb.....",
},
// Hill (sub-palette 2: a=green, b=light brown shade).
Tile {
name: "Hill",
art: "\
........
....aa..
...aaaa.
..aaaaaa
..abaaaa
.abbabaa
.aaababa
aabbabba",
},
// Bush (sub-palette 2: a=green, c=dark brown outline).
Tile {
name: "Bush",
art: "\
........
...aa...
..aaaa..
.aaaaac.
aaaaaaca
aaaaacca
aaaaacca
acacacac",
},
// Q Block (sub-palette 1: a=red frame, b=peach face, c=dark red).
Tile {
name: "Q Block",
art: "\
cccccccc
caaaaaac
cabbbbac
cabccbac
cabcbbac
cabbbbac
caaaaaac
cccccccc",
},
// Sky (all transparent — renders as the universal bg colour).
Tile {
name: "Sky (blank)",
art: "\
........
........
........
........
........
........
........
........",
},
];
// ── Named CHR tile indices used by the nametable layout below ──
// (Player/enemy/coin tile indices are referenced by name only in
// the .ne file's `draw` statements, not by the nametable here, so
// the nametable-only constants live here.)
const GRASS: u8 = 7;
const DIRT: u8 = 8;
const BRICK: u8 = 9;
const CLOUD_L: u8 = 10;
const CLOUD_R: u8 = 11;
const HILL: u8 = 12;
const BUSH: u8 = 13;
const QBLOCK: u8 = 14;
const SKY: u8 = 15;
/// Encode one 8×8 tile into its 16-byte NES CHR representation
/// (two 8-byte bitplanes: low bit then high bit).
fn tile_to_chr(art: &str) -> [u8; 16] {
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(rows.len(), 8, "expected 8 rows, got {}", rows.len());
let mut chr = [0u8; 16];
for (y, row) in rows.iter().enumerate() {
assert_eq!(row.len(), 8, "expected 8 cols in row {y}: {row:?}");
let (mut plane0, mut plane1) = (0u8, 0u8);
for (x, ch) in row.chars().enumerate() {
let idx: u8 = match ch {
'.' => 0,
'a' => 1,
'b' => 2,
'c' => 3,
other => panic!("invalid tile char {other:?}"),
};
if idx & 1 != 0 {
plane0 |= 0x80 >> x;
}
if idx & 2 != 0 {
plane1 |= 0x80 >> x;
}
}
chr[y] = plane0;
chr[y + 8] = plane1;
}
chr
}
/// Build a 32×30 nametable for a static Mario-ish level vista.
/// Horizontal scrolling pans this single nametable via `scroll()`,
/// so it only needs to look interesting at any X offset.
fn build_nametable() -> [u8; 960] {
let mut nt = [SKY; 960];
let set = |nt: &mut [u8; 960], y: usize, x: usize, t: u8| {
nt[y * 32 + x] = t;
};
// Row 5 & 6: two clouds at different X positions for parallax feel.
for &cx in &[4usize, 20] {
set(&mut nt, 5, cx, CLOUD_L);
set(&mut nt, 5, cx + 1, CLOUD_R);
}
set(&mut nt, 6, 12, CLOUD_L);
set(&mut nt, 6, 13, CLOUD_R);
// Row 15: a suspended brick platform with a Q-block in the middle.
for x in 6..=9 {
let t = if x == 8 { QBLOCK } else { BRICK };
set(&mut nt, 15, x, t);
}
for x in 18..=20 {
set(&mut nt, 15, x, BRICK);
}
// Row 20: hills sitting behind the grass line.
for x in 2..=4 {
set(&mut nt, 20, x, HILL);
}
for x in 22..=25 {
set(&mut nt, 20, x, HILL);
}
// Row 21: bushes on top of the grass.
for x in 10..=12 {
set(&mut nt, 21, x, BUSH);
}
for x in 27..=28 {
set(&mut nt, 21, x, BUSH);
}
// Row 22: grass top — a full horizontal line.
for x in 0..32 {
set(&mut nt, 22, x, GRASS);
}
// Row 23-29: dirt rows with a few buried bricks for texture.
for y in 23..30 {
for x in 0..32 {
set(&mut nt, y, x, DIRT);
}
}
for &(y, x) in &[(24usize, 5usize), (25, 11), (24, 18), (25, 24), (23, 30)] {
set(&mut nt, y, x, BRICK);
}
nt
}
/// Build a 64-byte attribute table that pairs every 2×2 metatile
/// with a background sub-palette.
///
/// The attribute table covers a 16×15 grid of 16×16 metatiles. Each
/// byte encodes 4 quadrants (top-left, top-right, bottom-left,
/// bottom-right) of a 32×32-pixel "super-metatile" with 2 bits
/// apiece (palette index 0..3). For this demo we simply pick a
/// sub-palette per screen row region:
/// - top region (sky): sub-palette 0
/// - mid region (hills/blocks): sub-palette 1
/// - ground region (grass/dirt): sub-palette 2
fn build_attributes() -> [u8; 64] {
let mut attr = [0u8; 64];
// The attribute table is 8×8 bytes. Row `ay` covers nametable
// rows 4*ay..4*ay+3. Palette layout:
// rows 0-11 (ay 0-2): sub-palette 0 (sky/clouds: whites)
// rows 12-15 (ay 3): sub-palette 1 (brick/Q-block row)
// rows 16-19 (ay 4): sub-palette 0 (more sky above the hills)
// rows 20-29 (ay 5-7): sub-palette 2 (grass/hills/bushes/dirt)
for ay in 0..8 {
let pal: u8 = match ay {
0..=2 => 0,
3 => 1,
4 => 0,
_ => 2, // 5, 6, 7
};
let quad = pal & 0b11;
let byte = quad | (quad << 2) | (quad << 4) | (quad << 6);
for ax in 0..8 {
attr[ay * 8 + ax] = byte;
}
}
attr
}
fn emit_byte_block(bs: &[u8], per_row: usize, indent: &str) -> String {
let mut out = String::new();
for chunk in bs.chunks(per_row) {
out.push_str(indent);
for (i, b) in chunk.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let _ = write!(out, "{b}");
}
out.push_str(",\n");
}
out
}
fn emit_hex_block(bs: &[u8], per_row: usize, indent: &str) -> String {
let mut out = String::new();
for chunk in bs.chunks(per_row) {
out.push_str(indent);
for (i, b) in chunk.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let _ = write!(out, "0x{b:02X}");
}
out.push_str(",\n");
}
out
}
fn main() {
// ── CHR ──
let mut chr_all: Vec<u8> = Vec::new();
println!("// ── CHR tiles (paste into sprite Tileset {{ chr: [...] }}) ──");
for (i, tile) in TILES.iter().enumerate() {
let tile_idx = i + 1;
let chr = tile_to_chr(tile.art);
chr_all.extend_from_slice(&chr);
println!(" // tile {tile_idx}: {}", tile.name);
print!("{}", emit_hex_block(&chr, 16, " "));
}
println!(
"// total tiles: {}, total CHR bytes: {}\n",
TILES.len(),
chr_all.len()
);
// ── Nametable ──
let nt = build_nametable();
assert_eq!(nt.len(), 960);
println!("// ── Nametable (paste into background Level {{ tiles: [...] }}) ──");
print!("{}", emit_byte_block(&nt, 16, " "));
// ── Attributes ──
let attr = build_attributes();
assert_eq!(attr.len(), 64);
println!("\n// ── Attributes (paste into background Level {{ attributes: [...] }}) ──");
print!("{}", emit_byte_block(&attr, 16, " "));
}

View file

@ -261,6 +261,14 @@ impl Linker {
// `palette` blocks, use the first one; otherwise fall back
// to the built-in default palette so sprites show up in a
// reasonable colour scheme without any user setup.
//
// IMPORTANT: `gen_init` leaves rendering fully disabled so
// these $2006/$2007 writes are safe. We re-enable rendering
// via `gen_enable_rendering` once all initial VRAM loads
// complete — writing to $2007 with either the sprite or the
// background layer active corrupts the PPU's internal
// address register, which used to clobber everything past
// about the first 72 bytes of a 1024-byte nametable load.
if let Some(first_palette) = palettes.first() {
all_instructions.extend(runtime::gen_initial_palette_load(&first_palette.label()));
} else {
@ -270,18 +278,21 @@ impl Linker {
// Load the initial background if the program declared any.
// Most programs don't, so the common case emits nothing
// here and leaves nametable 0 zero-filled.
let has_user_background = !backgrounds.is_empty();
if let Some(first_bg) = backgrounds.first() {
all_instructions.extend(runtime::gen_initial_background_load(
&first_bg.tiles_label(),
&first_bg.attrs_label(),
));
// Enable background rendering. Default init only turns
// on sprites (`$10`), so OR in the background bit
// (`$08`) when a user background is present.
all_instructions.push(Instruction::new(LDA, AM::Immediate(0x1E)));
all_instructions.push(Instruction::new(STA, AM::Absolute(0x2001)));
}
// Now that all palette and nametable writes are done, turn
// rendering on. Programs with a declared background get
// bg+sprites ($1E); programs without get sprites only ($10)
// to preserve the pre-fix behaviour of example ROMs that
// rely on a hidden nametable.
all_instructions.extend(runtime::gen_enable_rendering(has_user_background));
// User code (var init + main loop)
all_instructions.extend(user_code.iter().cloned());
@ -400,15 +411,10 @@ impl Linker {
if has_label(user_code, "__ir_mmc3_reload") {
all_instructions.push(Instruction::new(JSR, AM::Label("__ir_mmc3_reload".into())));
}
// Audio tick: if audio is in use, JSR into the per-frame
// driver tick before the normal NMI body. The tick walks
// both the sfx envelope and the music note stream, writing
// APU registers as needed. Programs that never use audio
// skip this splice entirely — no ROM cost.
if has_audio {
all_instructions.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
}
all_instructions.extend(runtime::gen_nmi(has_ppu_updates));
// The audio tick JSR is emitted by `gen_nmi` itself, after
// the register and scratch-slot saves, so it can freely
// clobber A/X/Y and $02/$03 without corrupting user state.
all_instructions.extend(runtime::gen_nmi(has_ppu_updates, has_audio));
// IRQ handler
all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into())));

View file

@ -160,15 +160,39 @@ pub fn gen_init() -> Vec<Instruction> {
AM::LabelRelative("__vblankwait2".into()),
));
// Enable PPU (sprites from pattern table 0, enable NMI)
// Enable NMI so the frame handshake fires every vblank. We
// deliberately leave PPU_MASK at 0 (rendering fully disabled)
// here — the linker splices in palette and background loads
// after this init, and $2007 writes during active rendering
// corrupt their target addresses via the PPU's v-register
// auto-increment glitch. Rendering is enabled by the linker
// *after* all initial VRAM loads complete, via `gen_enable_rendering`.
out.push(Instruction::new(LDA, AM::Immediate(0x80))); // enable NMI
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
out.push(Instruction::new(LDA, AM::Immediate(0x10))); // show sprites
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
out
}
/// Emit the `PPU_MASK` write that turns on rendering. Called by
/// the linker at the very end of the reset path, after all
/// initial palette / background loads are done, so the initial
/// VRAM writes are never corrupted by a mid-frame `$2007` glitch.
///
/// `show_background` controls whether the background layer is
/// enabled alongside the sprite layer — programs that declare a
/// `background` block want both, programs that don't can skip
/// the background bit to match the pre-fix behaviour.
#[must_use]
pub fn gen_enable_rendering(show_background: bool) -> Vec<Instruction> {
// $1E = show bg + sprites + left-8-px for both
// $10 = show sprites only (no bg)
let mask = if show_background { 0x1E } else { 0x10 };
vec![
Instruction::new(LDA, AM::Immediate(mask)),
Instruction::new(STA, AM::Absolute(PPU_MASK)),
]
}
/// Generate the NMI handler.
/// Called every vblank by the NES hardware.
///
@ -176,8 +200,17 @@ pub fn gen_init() -> Vec<Instruction> {
/// palette / nametable update helper. When false, the handler skips
/// that block entirely so programs that never call `set_palette` /
/// `load_background` pay zero cycles or bytes for the feature.
///
/// `has_audio` controls whether the handler calls the audio tick.
/// When true, the JSR to `__audio_tick` is emitted *after* the
/// register and scratch-slot saves, so the tick is free to trash
/// A/X/Y and the mul/state ZP scratch ($02/$03) without corrupting
/// the user's main-loop state. Placing the JSR outside the
/// save/restore window used to silently clobber `ZP_CURRENT_STATE`
/// whenever a music note was played (the tick's period-table
/// lookup stashes the table's high byte into $03).
#[must_use]
pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool) -> Vec<Instruction> {
let mut out = Vec::new();
// Save registers
@ -187,6 +220,24 @@ pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
out.push(Instruction::implied(TYA));
out.push(Instruction::implied(PHA));
// Save the multiply/divide scratch slots ($02/$03). $03 doubles
// as `ZP_CURRENT_STATE` for the state dispatch, and user code
// mid-multiply/divide has both slots live; preserving them here
// keeps the invariant that NMI never clobbers user-visible ZP
// state.
out.push(Instruction::new(LDA, AM::ZeroPage(0x02)));
out.push(Instruction::implied(PHA));
out.push(Instruction::new(LDA, AM::ZeroPage(0x03)));
out.push(Instruction::implied(PHA));
// Run the audio driver's per-frame tick *after* the saves so it
// can freely reuse A/X/Y and the $02/$03 scratch slots without
// corrupting anything the main loop cares about. Programs that
// never touch audio skip this splice entirely — no ROM cost.
if has_audio {
out.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
}
// OAM DMA — transfer sprite data from $0200
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR)));
@ -228,6 +279,13 @@ pub fn gen_nmi(has_ppu_updates: bool) -> Vec<Instruction> {
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
out.push(Instruction::new(STA, AM::ZeroPage(ZP_FRAME_FLAG)));
// Restore the mul/state scratch slots ($03 then $02, reverse
// order of the PHA pushes above).
out.push(Instruction::implied(PLA));
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
out.push(Instruction::implied(PLA));
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
// Restore registers
out.push(Instruction::implied(PLA));
out.push(Instruction::implied(TAY));

View file

@ -70,7 +70,7 @@ fn init_assembles_without_error() {
#[test]
fn nmi_saves_and_restores_registers() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
@ -86,7 +86,7 @@ fn nmi_saves_and_restores_registers() {
#[test]
fn nmi_triggers_oam_dma() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
@ -95,7 +95,7 @@ fn nmi_triggers_oam_dma() {
#[test]
fn nmi_reads_controller() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
// Should write strobe to $4016
let has_strobe = nmi
.iter()
@ -105,7 +105,7 @@ fn nmi_reads_controller() {
#[test]
fn nmi_sets_frame_flag() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
@ -114,7 +114,7 @@ fn nmi_sets_frame_flag() {
#[test]
fn nmi_assembles_without_error() {
let nmi = gen_nmi(false);
let nmi = gen_nmi(false, false);
let result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(

View file

@ -1 +1 @@
6a3efe63 132084
95b6d6d4 132084

View file

@ -0,0 +1 @@
7c8e60e4 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -11,6 +11,9 @@
"jsnes": "^2.1.0",
"pngjs": "^7.0.0",
"puppeteer": "^24.40.0"
},
"devDependencies": {
"gifenc": "^1.0.3"
}
},
"node_modules/@babel/code-frame": {
@ -559,6 +562,13 @@
"node": ">= 14"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",

View file

@ -11,5 +11,8 @@
"jsnes": "^2.1.0",
"pngjs": "^7.0.0",
"puppeteer": "^24.40.0"
},
"devDependencies": {
"gifenc": "^1.0.3"
}
}

View file

@ -0,0 +1,105 @@
// Record a GIF of a .nes ROM running in jsnes.
//
// Usage:
// node record_gif.mjs <rom-name> [frames] [stride] [output.gif]
//
// Example:
// node record_gif.mjs platformer 360 2 docs/platformer.gif
//
// The recorder drives `harness.html` via puppeteer, collects one
// canvas frame every `stride` NES frames for `frames` total, and
// encodes the sequence as a paletted GIF via the `gifenc` library.
// At stride=2 we end up with a 30 fps GIF that maps 1:1 to every
// other NES frame (NES runs at ~60 fps), which is the right
// tradeoff between smoothness and file size for a README demo.
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import puppeteer from "puppeteer";
import gifenc from "gifenc";
const { GIFEncoder, quantize, applyPalette } = gifenc;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString();
const WIDTH = 256;
const HEIGHT = 240;
const romName = process.argv[2] ?? "platformer";
const totalFrames = parseInt(process.argv[3] ?? "360", 10);
const stride = parseInt(process.argv[4] ?? "2", 10); // captured every Nth NES frame
const outputPath = path.resolve(repoRoot, process.argv[5] ?? `docs/${romName}.gif`);
const romPath = path.join(repoRoot, "examples", `${romName}.nes`);
const romBytes = await fs.readFile(romPath);
const romB64 = romBytes.toString("base64");
const browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--allow-file-access-from-files",
],
});
const page = await browser.newPage();
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
await page.goto(harnessUrl, { waitUntil: "load" });
await page.waitForFunction(
"window.nesHarness && document.getElementById('info').textContent === 'ready'",
);
await page.evaluate((b) => window.nesHarness.loadRomBase64(b), romB64);
// Warm-up: skip past the reset stall and any title screen so the
// first captured frame shows real gameplay. 30 frames at 60 fps
// covers ~0.5 s which is enough for the platformer example's
// Title → Playing auto-transition at frame 20.
const warmupFrames = parseInt(process.env.WARMUP ?? "30", 10);
await page.evaluate((n) => window.nesHarness.runFrames(n), warmupFrames);
console.log(
`recording ${romName}.nes: ${totalFrames} frames, stride ${stride}, ` +
`~${Math.round((totalFrames / 60) * 100) / 100}s of gameplay`,
);
const frames = [];
for (let i = 0; i < totalFrames; i += stride) {
await page.evaluate((n) => window.nesHarness.runFrames(n), stride);
const pixelsB64 = await page.evaluate(() => window.nesHarness.rawPixelsBase64());
const rgba = Buffer.from(pixelsB64, "base64");
// gifenc wants a Uint8Array or Uint8ClampedArray of RGBA pixels.
frames.push(new Uint8Array(rgba));
if (i % 20 === 0) process.stdout.write(".");
}
process.stdout.write("\n");
await browser.close();
// Encode. We quantize a representative middle frame to build a
// shared palette — this avoids the dithering / palette-drift
// artifacts you get with per-frame palettes and keeps the file
// size down. The NES only renders out of a fixed master palette
// anyway, so a single shared palette is the right answer.
console.log(`encoding ${frames.length} frames as GIF → ${path.relative(repoRoot, outputPath)}`);
const paletteSource = frames[Math.floor(frames.length / 2)];
const palette = quantize(paletteSource, 256, { format: "rgba4444" });
const gif = GIFEncoder();
for (let i = 0; i < frames.length; i++) {
const indexed = applyPalette(frames[i], palette, "rgba4444");
// delay is in milliseconds. stride NES frames at ~60 fps =
// stride * 16.67 ms per captured frame.
gif.writeFrame(indexed, WIDTH, HEIGHT, {
palette,
delay: Math.round((stride * 1000) / 60),
transparent: false,
});
}
gif.finish();
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from(gif.bytes()));
console.log(`wrote ${outputPath} (${(gif.bytes().length / 1024).toFixed(1)} KB)`);