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:
parent
958f41d340
commit
688d9afcec
16 changed files with 1171 additions and 25 deletions
11
Cargo.toml
11
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"] }
|
||||
|
|
|
|||
11
README.md
11
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.
|
||||
|
||||

|
||||
|
||||
*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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
BIN
docs/platformer.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
|
|
@ -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
505
examples/platformer.ne
Normal 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
|
||||
428
scripts/gen_platformer_tiles.rs
Normal file
428
scripts/gen_platformer_tiles.rs
Normal 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, " "));
|
||||
}
|
||||
|
|
@ -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())));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
6a3efe63 132084
|
||||
95b6d6d4 132084
|
||||
|
|
|
|||
1
tests/emulator/goldens/platformer.audio.hash
Normal file
1
tests/emulator/goldens/platformer.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
7c8e60e4 132084
|
||||
BIN
tests/emulator/goldens/platformer.png
Normal file
BIN
tests/emulator/goldens/platformer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
10
tests/emulator/package-lock.json
generated
10
tests/emulator/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,5 +11,8 @@
|
|||
"jsnes": "^2.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"puppeteer": "^24.40.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gifenc": "^1.0.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
tests/emulator/record_gif.mjs
Normal file
105
tests/emulator/record_gif.mjs
Normal 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)`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue