The compiler is deterministic: rebuilding any example produces a byte-identical ROM, verified across all 22 examples and all four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes files are reproducible artefacts and can live next to their sources without drift. Benefits: - Users can clone the repo and open any example in an emulator without installing a Rust toolchain or running the compiler. - The emulator harness can trust examples/*.nes directly, so its CI job no longer needs a compiler build or a "compile all examples" loop — it just boots jsnes against the committed ROMs and diffs each against its golden. - ROM diffs in PRs are now meaningful: "this compiler change flipped 17 bytes in hello_sprite.nes" is visible review signal, not hidden behind the emulator golden. Guard rails so the ROMs don't drift from their sources: - .gitignore no longer excludes *.nes. - The `examples` CI job rebuilds every .ne into /tmp and fails loudly (with a GitHub error annotation pointing at the exact cargo command to rerun) if any committed ROM differs. - scripts/pre-commit does the same check locally. - CLAUDE.md now states that editing a .ne file requires rebuilding its .nes in the same commit, so future agents won't miss the invariant. Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are NROM 24 KB; two banked examples are larger). https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
7.7 KiB
CLAUDE.md
Guidance for Claude Code (and any other AI agents) working in this repo. Keep it short and practical — it's here so the next agent doesn't have to re-derive the project conventions from scratch.
Project shape
- NEScript is a Rust-based compiler that turns
.nesource files into iNES ROMs. Single binary, no external assemblers, no external linkers. src/is a flat module layout: each compiler phase is its own directory withmod.rs+tests.rs. Seedocs/architecture.mdfor the phase pipeline.- Examples live in
examples/*.ne. Every example is expected to compile cleanly and has a pinned emulator golden — see below. examples/*.nesis committed. The compiler is deterministic (same source → byte-identical ROM), so the ROMs travel with the repo. If you edit any.nefile you must rebuild its.nesin the same commit — CI'sexamplesjob rebuilds each ROM into a tmp path and fails if the committed version differs, pointing at the exactcargo run -- build examples/<name>.neto run. The pre-commit hook underscripts/pre-commitcatches this locally.docs/future-work.mdlists the remaining gaps. If you implement something from that file, update the doc in the same PR.
Running the basics
cargo build --release # build the compiler
cargo test # all Rust tests (lib + integration)
cargo fmt # mandatory before committing
cargo clippy --all-targets # mandatory before committing; fix or #[allow]
./target/release/nescript build examples/hello_sprite.ne # build one ROM
Compile every example at once:
for f in examples/*.ne; do cargo run --release -- build "$f"; done
The jsnes emulator harness
This is the most important piece of project-specific tooling. Every .ne
example has a pixel-exact PNG golden and a sample-exact audio hash
committed under tests/emulator/goldens/. Any compiler change that alters
observable behaviour — codegen, optimizer, runtime, linker, asset pipeline
— will flip at least one golden, and CI will fail loudly with a visible
diff. Do not skip or weaken this check.
Layout
tests/emulator/
harness.html # thin wrapper around jsnes; exposes window.nesHarness
# with loadRomBase64, runFrames, rawPixelsBase64,
# audioHash, audioWavBase64
run_examples.mjs # puppeteer-driven runner (headless Chrome)
package.json # depends on jsnes, pngjs, puppeteer
goldens/
<name>.png # 256×240 RGBA framebuffer at frame 180 (~3s at 60fps)
<name>.audio.hash # one line: "<fnv1a-hex> <sample-count>"
actual/ # gitignored; written on every run for diff artifacts
Running it locally
The harness is separate from cargo test. You have to run it by hand:
# 1. Install node deps (once per worktree; node_modules/ is gitignored).
cd tests/emulator
npm install # or `npm ci` in CI
# 2. Verify every committed ROM still matches its golden.
node run_examples.mjs
# → "22/22 ROMs match their goldens" on success
# → FAIL / MISS lines + `actual/<name>.png`, `actual/<name>.diff.png`,
# `actual/<name>.wav` written for any ROM that mismatched
The harness reads the committed examples/*.nes files directly —
since those are rebuilt and diffed by the examples CI job, the
emulator job doesn't need a compiler toolchain at all. If you're
iterating on the compiler locally, rebuild the example ROM(s) you
care about before re-running the harness:
cargo build --release
./target/release/nescript build examples/hello_sprite.ne
# then:
(cd tests/emulator && node run_examples.mjs)
Updating goldens
If a change is supposed to flip goldens (you added a new example, changed a rendering path, fixed a bug that was baked into the old output), update them with:
cd tests/emulator
UPDATE_GOLDENS=1 node run_examples.mjs # rewrites every mismatched golden
# or
node run_examples.mjs --update-goldens
Then git diff tests/emulator/goldens/ the result, eyeball each change,
and include the updated PNG+hash files in the same commit as the code
change. Goldens are the contract; the commit message should explain why
each diff is legitimate. Never UPDATE_GOLDENS=1 just to silence a
failing CI — that defeats the entire purpose of the harness.
Adding a new example
- Write
examples/<name>.ne. - Build it with the release compiler so a
.nesfile lands next to it. - Run
UPDATE_GOLDENS=1 node run_examples.mjsto generategoldens/<name>.pngandgoldens/<name>.audio.hash. Both files must be committed — the runner treats missing goldens as a hard failure. - Verify visually that the generated PNG is what you actually intended (open it; you can use Read on the PNG file to have Claude display it).
- Add the example to the tables in
README.mdandexamples/README.md.
What the harness tests (and doesn't)
- Tests: final rendered framebuffer at frame 180, full audio sample stream over the same window. Catches codegen miscompiles, runtime bugs, linker layout changes, PPU timing regressions, APU regressions, asset pipeline bugs — essentially anything that affects the observable behaviour of a whole program.
- Does not test: input handling (no buttons pressed during the run),
anything past frame 180 (~3 seconds), state transitions that require
user input. Examples that need input to look non-trivial should
structure themselves so a good demo happens on autopilot — e.g. a
frame counter that drives the interesting state (
examples/palette_and_background.neis a working pattern).
CI integration
The emulator job in .github/workflows/ci.yml installs Chrome deps,
builds all examples, then runs the harness. On failure it uploads the
actual/ directory and report.json as an artifact named emulator-diff
so reviewers can download and inspect the pixel diffs without cloning the
repo. The CI job does not pass UPDATE_GOLDENS; if it flips, the
change needs a manual update + review.
Conventions worth knowing
- Every
src/**/mod.rshas a co-locatedtests.rs. Add unit tests there, not in a separate file. - Big cross-phase tests go in
tests/integration_test.rs. Use thecompile/compile_bankedhelpers at the top of that file instead of re-building the pipeline by hand. - Error codes live in
src/errors/diagnostic.rs. Don't add a new code without emitting it from somewhere — clippy will catch unused variants, but past agents have also let them sit as dead code. - Zero page is tight.
$00-$0Fis reserved for the runtime (frame flag, input, OAM cursor, sfx/music pointers).$11-$17is reserved for PPU palette/background updates when the program declares them (the analyzer bumps the user ZP start from$10to$18in that case — programs without palette/bg keep the old$10layout to preserve their goldens). User vars go at$10+or$18+; IR temps land at$80+. docs/future-work.mdis the authoritative roadmap. If you finish an item, delete its section; if you add a new gap, write one.
Things to avoid
- Don't add backwards-compat shims. The repo is pre-1.0; breaking
changes are fine if they improve the code. Delete dead code outright
rather than
#[allow(dead_code)]-ing it. - Don't skip
cargo fmt/cargo clippy. CI runs both and they are cheap. - Don't
UPDATE_GOLDENS=1without reading the diff. If you can't explain why a golden flipped, the change is probably wrong. - Don't commit
tests/emulator/actual/ortests/emulator/node_modules/. Both are gitignored, but it's worth double-checking before a commit that touches the emulator directory.