diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c0ab23..59355c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,21 @@ jobs: echo "::error file=docs/platformer.gif::committed docs/platformer.gif is stale; rerun \`node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif\` and commit the new gif" exit 1 fi + - name: Verify docs/war.gif is up to date + # Same contract as docs/platformer.gif: the README embeds + # docs/war.gif as the second project demo, gifenc + jsnes are + # deterministic, so any change to the compiler / runtime / + # harness / war source that affects the first ~6 seconds of + # the war.ne gameplay must be followed by regenerating the + # gif. The 5th positional arg (`4`) is the warmup; war's + # title menu is the gif thumbnail so we don't skip past it. + working-directory: tests/emulator + run: | + node record_gif.mjs war 360 2 /tmp/war.gif 4 + if ! cmp -s ../../docs/war.gif /tmp/war.gif; then + echo "::error file=docs/war.gif::committed docs/war.gif is stale; rerun \`node tests/emulator/record_gif.mjs war 360 2 docs/war.gif 4\` and commit the new gif" + exit 1 + fi - name: Upload actual + diff PNGs on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 8319a54..40ef459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,22 +22,28 @@ re-derive the project conventions from scratch. tmp path and fails if the committed version differs, pointing at the exact `cargo run -- build examples/.ne` to run. The pre-commit hook under `scripts/pre-commit` catches this locally. -- **`docs/platformer.gif` is committed** and embedded in the - top-level README as the project demo. `gifenc` + `jsnes` are - deterministic, so the gif's bytes are a function of the compiler, - the runtime, the harness, and `examples/platformer.ne`. Any change - to those that affects the first ~6 seconds of observable platformer - gameplay must be followed by regenerating the gif: +- **`docs/platformer.gif` and `docs/war.gif` are committed** and + embedded in the top-level README as the project demos. `gifenc` + + `jsnes` are deterministic, so each gif's bytes are a function of + the compiler, the runtime, the harness, and the underlying `.ne` + source(s). Any change to those that affects the first ~6 seconds + of observable gameplay must be followed by regenerating the + affected gif: ```bash node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif + node tests/emulator/record_gif.mjs war 360 2 docs/war.gif 4 ``` - and committing it in the same change. CI's `emulator` job renders - a fresh gif and fails if the committed one doesn't byte-match. The - pre-commit hook rebuilds the gif when `platformer.ne`, `platformer.nes`, - `record_gif.mjs`, or `harness.html` is staged (and `tests/emulator/node_modules` - is installed). + (The trailing `4` on the war command is the warmup-frames + override — war's title menu is the gif thumbnail, so we don't + skip past it the way the platformer recording does.) Commit the + regenerated gif in the same change. CI's `emulator` job renders + fresh gifs and fails if either committed copy doesn't byte-match. + The pre-commit hook rebuilds whichever gif is affected when + `platformer.ne`, `platformer.nes`, any file under `examples/war/`, + `war.ne`, `war.nes`, `record_gif.mjs`, or `harness.html` is + staged (and `tests/emulator/node_modules` is installed). - `docs/future-work.md` lists the remaining gaps. If you implement something from that file, update the doc in the same PR. diff --git a/README.md b/README.md index 6d5905b..4ce0c97 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ NEScript compiles `.ne` source files directly into playable iNES ROM files, with _Source: [`examples/platformer.ne`](examples/platformer.ne)_ +![War demo](docs/war.gif) + +_Source: [`examples/war.ne`](examples/war.ne)_ + ## Quick Start ```bash diff --git a/docs/war.gif b/docs/war.gif new file mode 100644 index 0000000..7c71288 Binary files /dev/null and b/docs/war.gif differ diff --git a/scripts/pre-commit b/scripts/pre-commit index 8bfb841..2b73842 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -39,26 +39,51 @@ if [ $stale -ne 0 ]; then exit 1 fi -# Only rebuild docs/platformer.gif when platformer.nes or the gif -# recorder itself changed — the gif regeneration takes ~20s due to -# puppeteer's cold start, so we don't want to pay it on every commit. -# Skipped entirely if node_modules isn't installed in the emulator -# harness; the CI `emulator` job is the authoritative check. +# Only rebuild docs/{platformer,war}.gif when the underlying +# example sources, the gif recorder, or the harness changed — gif +# regeneration takes ~20s per gif due to puppeteer's cold start, +# so we don't want to pay it on every commit. Skipped entirely if +# node_modules isn't installed in the emulator harness; the CI +# `emulator` job is the authoritative check. +# +# `examples/war.ne` pulls in `examples/war/*.ne` via include +# directives, so any file under `examples/war/` is treated as a +# trigger for rebuilding the war gif — the parser's preprocess +# pass sees them as part of the same translation unit. changed_files=$(git diff --cached --name-only) -if echo "$changed_files" | grep -qE '^(examples/platformer\.(ne|nes)|tests/emulator/record_gif\.mjs|tests/emulator/harness\.html)$'; then - if [ -d tests/emulator/node_modules ]; then - echo " Rebuilding docs/platformer.gif (platformer.nes or recorder changed)..." - (cd tests/emulator && node record_gif.mjs platformer 360 2 /tmp/platformer-precheck.gif >/dev/null) - if ! cmp -s docs/platformer.gif /tmp/platformer-precheck.gif; then - echo " STALE: docs/platformer.gif" - echo " rerun: node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif" - rm -f /tmp/platformer-precheck.gif - exit 1 - fi - rm -f /tmp/platformer-precheck.gif - else - echo " (skipping gif freshness check — tests/emulator/node_modules not installed)" +recorder_or_harness_changed=0 +if echo "$changed_files" | grep -qE '^tests/emulator/(record_gif\.mjs|harness\.html)$'; then + recorder_or_harness_changed=1 +fi + +check_gif() { + name=$1 + args=$2 # extra args to record_gif.mjs (e.g. "4" for warmup) + if [ ! -d tests/emulator/node_modules ]; then + echo " (skipping $name gif freshness check — tests/emulator/node_modules not installed)" + return 0 fi + echo " Rebuilding docs/$name.gif..." + # shellcheck disable=SC2086 + (cd tests/emulator && node record_gif.mjs "$name" 360 2 /tmp/${name}-precheck.gif $args >/dev/null) + if ! cmp -s "docs/$name.gif" "/tmp/${name}-precheck.gif"; then + echo " STALE: docs/$name.gif" + echo " rerun: node tests/emulator/record_gif.mjs $name 360 2 docs/$name.gif $args" + rm -f "/tmp/${name}-precheck.gif" + return 1 + fi + rm -f "/tmp/${name}-precheck.gif" + return 0 +} + +if [ $recorder_or_harness_changed -eq 1 ] || \ + echo "$changed_files" | grep -qE '^examples/platformer\.(ne|nes)$'; then + check_gif platformer "" || exit 1 +fi + +if [ $recorder_or_harness_changed -eq 1 ] || \ + echo "$changed_files" | grep -qE '^examples/war(\.(ne|nes)|/[^/]+\.ne)$'; then + check_gif war 4 || exit 1 fi echo "All pre-commit checks passed." diff --git a/tests/emulator/record_gif.mjs b/tests/emulator/record_gif.mjs index dea9ca9..bc38a72 100644 --- a/tests/emulator/record_gif.mjs +++ b/tests/emulator/record_gif.mjs @@ -1,10 +1,11 @@ // Record a GIF of a .nes ROM running in jsnes. // // Usage: -// node record_gif.mjs [frames] [stride] [output.gif] +// node record_gif.mjs [frames] [stride] [output.gif] [warmup] // -// Example: +// Examples: // node record_gif.mjs platformer 360 2 docs/platformer.gif +// node record_gif.mjs war 360 2 docs/war.gif 4 // // The recorder drives `harness.html` via puppeteer, collects one // canvas frame every `stride` NES frames for `frames` total, and @@ -13,15 +14,23 @@ // other NES frame (NES runs at ~60 fps), which is the right // tradeoff between smoothness and file size for a README demo. // -// IMPORTANT: `docs/platformer.gif` is committed and embedded in the -// README. Any change to the compiler, the runtime, the harness, or -// `examples/platformer.ne` that alters the gameplay you see in the -// first ~6 seconds of the demo must be followed by +// `warmup` is the number of NES frames to advance before the first +// captured frame. The default of 30 skips past the reset stall and +// the platformer's auto-Title→Play handoff at frame 20; the war +// recording uses 4 instead because that demo opens on its menu and +// we want the title screen to be the gif's thumbnail. +// +// IMPORTANT: `docs/platformer.gif` and `docs/war.gif` are committed +// and embedded in the README. Any change to the compiler, the +// runtime, the harness, or the underlying `.ne` source that alters +// the gameplay you see in the first ~6 seconds of either demo must +// be followed by // // node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif +// node tests/emulator/record_gif.mjs war 360 2 docs/war.gif 4 // // committed alongside the source change. The CI `emulator` job -// regenerates the gif and fails if the committed copy is stale — +// regenerates both gifs and fails if the committed copies are stale — // gifenc + jsnes are deterministic, so the freshly-rendered bytes // byte-match a valid commit. See `.github/workflows/ci.yml`. @@ -66,11 +75,18 @@ await page.waitForFunction( 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); +// Warm-up: skip past the reset stall and (optionally) any title +// screen so the first captured frame shows what we want as the +// gif's thumbnail. 30 frames at 60 fps covers ~0.5 s which is +// enough for the platformer example's Title → Playing auto- +// transition at frame 20. The war recording overrides this with +// `4` (positional arg below) so the title menu is the first frame. +// Positional arg wins; `WARMUP=…` env var is honoured for ad-hoc +// experimentation. +const warmupFrames = parseInt( + process.argv[6] ?? process.env.WARMUP ?? "30", + 10, +); await page.evaluate((n) => window.nesHarness.runFrames(n), warmupFrames); console.log(