mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +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
|
|
@ -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