diff --git a/Cargo.toml b/Cargo.toml index fa20b9b..37bfef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index bf9c47f..4a8ca0d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/future-work.md b/docs/future-work.md index 28d5315..cf682bb 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -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. --- diff --git a/docs/platformer.gif b/docs/platformer.gif new file mode 100644 index 0000000..fb2bff5 Binary files /dev/null and b/docs/platformer.gif differ diff --git a/examples/README.md b/examples/README.md index a03d81c..9237dee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/platformer.ne b/examples/platformer.ne new file mode 100644 index 0000000..100f0c6 --- /dev/null +++ b/examples/platformer.ne @@ -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 diff --git a/scripts/gen_platformer_tiles.rs b/scripts/gen_platformer_tiles.rs new file mode 100644 index 0000000..04ba26b --- /dev/null +++ b/scripts/gen_platformer_tiles.rs @@ -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 = 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, " ")); +} diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 081c47a..5fd0751 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -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()))); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 574bf77..b8c42c4 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -160,15 +160,39 @@ pub fn gen_init() -> Vec { 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 { + // $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 { /// 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 { +pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool) -> Vec { let mut out = Vec::new(); // Save registers @@ -187,6 +220,24 @@ pub fn gen_nmi(has_ppu_updates: bool) -> Vec { 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 { 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)); diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 687fcf0..d2d64fc 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -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!( diff --git a/tests/emulator/goldens/audio_demo.audio.hash b/tests/emulator/goldens/audio_demo.audio.hash index fe0b8da..178a8d6 100644 --- a/tests/emulator/goldens/audio_demo.audio.hash +++ b/tests/emulator/goldens/audio_demo.audio.hash @@ -1 +1 @@ -6a3efe63 132084 +95b6d6d4 132084 diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash new file mode 100644 index 0000000..03072e1 --- /dev/null +++ b/tests/emulator/goldens/platformer.audio.hash @@ -0,0 +1 @@ +7c8e60e4 132084 diff --git a/tests/emulator/goldens/platformer.png b/tests/emulator/goldens/platformer.png new file mode 100644 index 0000000..1b975c9 Binary files /dev/null and b/tests/emulator/goldens/platformer.png differ diff --git a/tests/emulator/package-lock.json b/tests/emulator/package-lock.json index 2281062..7904bee 100644 --- a/tests/emulator/package-lock.json +++ b/tests/emulator/package-lock.json @@ -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", diff --git a/tests/emulator/package.json b/tests/emulator/package.json index 39bc4d6..2fbcd75 100644 --- a/tests/emulator/package.json +++ b/tests/emulator/package.json @@ -11,5 +11,8 @@ "jsnes": "^2.1.0", "pngjs": "^7.0.0", "puppeteer": "^24.40.0" + }, + "devDependencies": { + "gifenc": "^1.0.3" } } diff --git a/tests/emulator/record_gif.mjs b/tests/emulator/record_gif.mjs new file mode 100644 index 0000000..59a3161 --- /dev/null +++ b/tests/emulator/record_gif.mjs @@ -0,0 +1,105 @@ +// Record a GIF of a .nes ROM running in jsnes. +// +// Usage: +// node record_gif.mjs [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)`);