mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
Merge pull request #17 from imjasonh/claude/test-platformer-game-6S3TX
This commit is contained in:
commit
68184b7296
18 changed files with 358 additions and 55 deletions
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
|
|
@ -146,6 +146,21 @@ jobs:
|
|||
- name: Diff each ROM's framebuffer against its golden
|
||||
working-directory: tests/emulator
|
||||
run: node run_examples.mjs
|
||||
- name: Verify docs/platformer.gif is up to date
|
||||
# The README embeds docs/platformer.gif as the project demo.
|
||||
# gifenc + jsnes are deterministic, so rebuilding the gif from
|
||||
# the current compiler should byte-match the committed copy.
|
||||
# If it doesn't, the contributor changed the compiler / source
|
||||
# / harness in a way that affects the observable gameplay of
|
||||
# platformer.ne but forgot to regenerate the gif — the fix is
|
||||
# the exact command printed in the error.
|
||||
working-directory: tests/emulator
|
||||
run: |
|
||||
node record_gif.mjs platformer 360 2 /tmp/platformer.gif
|
||||
if ! cmp -s ../../docs/platformer.gif /tmp/platformer.gif; then
|
||||
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: Upload actual + diff PNGs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -22,6 +22,22 @@ re-derive the project conventions from scratch.
|
|||
tmp path and fails if the committed version differs, pointing at
|
||||
the exact `cargo run -- build examples/<name>.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:
|
||||
|
||||
```bash
|
||||
node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif
|
||||
```
|
||||
|
||||
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).
|
||||
- `docs/future-work.md` lists the remaining gaps. If you implement
|
||||
something from that file, update the doc in the same PR.
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,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 |
|
||||
| [`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, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
|
||||
|
||||
## Compiler Commands
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 446 KiB |
|
|
@ -28,7 +28,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `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 |
|
||||
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
|
||||
| `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`. |
|
||||
| `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, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
|
||||
## Emulator Controls
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -3,12 +3,13 @@
|
|||
// 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.
|
||||
// scrolling, moving enemies with stomp-or-die contact logic, a
|
||||
// coin HUD that tracks live score, collectible coins, user-declared
|
||||
// SFX envelopes, 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
|
||||
// the full gameplay loop inside the first ~6 seconds of demo.
|
||||
//
|
||||
// Controls (when played by a human):
|
||||
// D-pad left/right — walk
|
||||
|
|
@ -358,6 +359,12 @@ const ENEMY2_WORLD_X: u8 = 200
|
|||
const COIN1_WORLD_X: u8 = 50
|
||||
const COIN2_WORLD_X: u8 = 150
|
||||
|
||||
// How many auto-jumps the autopilot will pre-queue per life. Two
|
||||
// is exactly enough to stomp enemy 1 and enemy 2 once each; after
|
||||
// that the autopilot stops assisting so the headless harness gets
|
||||
// to see the player walk into the next enemy, die, and retry.
|
||||
const AUTOPILOT_JUMPS: u8 = 2
|
||||
|
||||
// ── Game state ──────────────────────────────────────────────
|
||||
|
||||
// Player physics
|
||||
|
|
@ -369,9 +376,13 @@ 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
|
||||
|
||||
// Gameplay
|
||||
var alive: u8 = 1 // 0 = dying/dead, 1 = playable
|
||||
var stomp_count: u8 = 0 // successful enemy stomps this life
|
||||
var auto_jumps: u8 = 0 // proximity pre-jumps used this life
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────
|
||||
|
||||
// Try to start a jump. Only valid when the player is on the
|
||||
|
|
@ -423,6 +434,54 @@ fun draw_player() {
|
|||
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR
|
||||
}
|
||||
|
||||
// Resolve contact with an enemy whose screen X is `e_sx`. Mutates
|
||||
// the player's physics / alive flag directly and returns 1 on a
|
||||
// successful stomp, 0 on no contact or a fatal hit. The caller
|
||||
// checks `alive` after invocation to decide whether to keep
|
||||
// running the frame.
|
||||
//
|
||||
// The player's 16×16 hitbox is at ([80, 96), [player_y, player_y+16)).
|
||||
// Each enemy's 8×8 hitbox is at ([e_sx, e_sx+8), [168, 176)). Those
|
||||
// overlap horizontally when e_sx ∈ (72, 96) and overlap vertically
|
||||
// when player_y ∈ (152, 176).
|
||||
//
|
||||
// Outcomes:
|
||||
// - No overlap → no-op
|
||||
// - Overlap while rising (rise_count > 0) → graceful
|
||||
// pass-through. rise_count > 0 only happens right after a
|
||||
// successful stomp bounce (try_jump() clears rise_count by
|
||||
// the end of the same frame) or on the stomp's own upward
|
||||
// leg, which we don't want to retrigger.
|
||||
// - Overlap while falling, feet near enemy head → STOMP: bounce
|
||||
// up, increment `stomp_count`, play Boing
|
||||
// - Any other overlap (walking, on ground) → DEATH: set
|
||||
// `alive = 0` and play the builtin `hit` sfx
|
||||
fun resolve_enemy_hit(e_sx: u8) -> u8 {
|
||||
if e_sx > 72 {
|
||||
if e_sx < 96 {
|
||||
if player_y > 152 {
|
||||
if player_y < 176 {
|
||||
// Real contact. Stomp if falling; otherwise, if
|
||||
// we're not in the post-bounce grace window,
|
||||
// die.
|
||||
if fall_vy > 0 {
|
||||
rise_count = 6
|
||||
fall_vy = 0
|
||||
stomp_count += 1
|
||||
play Boing
|
||||
return 1
|
||||
}
|
||||
if rise_count == 0 {
|
||||
alive = 0
|
||||
play hit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ── States ──────────────────────────────────────────────────
|
||||
|
||||
state Title {
|
||||
|
|
@ -468,8 +527,11 @@ state Playing {
|
|||
fall_vy = 0
|
||||
camera_x = 0
|
||||
frame_tick = 0
|
||||
jump_timer = 0
|
||||
anim_tick = 0
|
||||
alive = 1
|
||||
stomp_count = 0
|
||||
auto_jumps = 0
|
||||
start_music Theme
|
||||
}
|
||||
|
||||
on frame {
|
||||
|
|
@ -490,31 +552,40 @@ state Playing {
|
|||
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.
|
||||
// Always advance camera one px per frame (baseline scroll).
|
||||
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).
|
||||
// ── Compute screen positions of world entities ──────
|
||||
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
|
||||
|
||||
// ── Proximity autopilot ─────────────────────────────
|
||||
// Pre-jump when an enemy is 19 px ahead. The fall phase of
|
||||
// a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's
|
||||
// feet at enemy head height ~20 frames later, by which
|
||||
// point the autopilot camera has scrolled the enemy under
|
||||
// the player. Limited to AUTOPILOT_JUMPS hops per life so
|
||||
// the third enemy encounter becomes a scripted death,
|
||||
// giving the golden harness a visible game-over sequence.
|
||||
if auto_jumps < AUTOPILOT_JUMPS {
|
||||
if on_ground == 1 {
|
||||
if e1_sx == 99 {
|
||||
try_jump()
|
||||
auto_jumps += 1
|
||||
}
|
||||
if e2_sx == 99 {
|
||||
try_jump()
|
||||
auto_jumps += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Physics ──────────────────────────────────────────
|
||||
step_physics()
|
||||
|
||||
// ── Coin pickups ─────────────────────────────────────
|
||||
// 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).
|
||||
|
|
@ -529,24 +600,17 @@ state Playing {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Enemy collisions ────────────────────────────────
|
||||
// Both enemies get the full stomp-or-die check; the helper
|
||||
// mutates player state directly. If `alive` flips to 0
|
||||
// we fall through the rest of the frame (drawing is
|
||||
// guarded below) and transition to GameOver at the end.
|
||||
resolve_enemy_hit(e1_sx)
|
||||
resolve_enemy_hit(e2_sx)
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────
|
||||
draw_player()
|
||||
|
||||
// Enemies — walking on the grass line. anim_tick drives
|
||||
// a small vertical bob so they visibly move on the golden.
|
||||
// Enemies and coins are always drawn so the scene stays
|
||||
// coherent even on the fatal-contact frame.
|
||||
var ey: u8 = ENEMY_Y
|
||||
if (anim_tick & 16) != 0 {
|
||||
ey = ENEMY_Y - 2
|
||||
|
|
@ -554,9 +618,6 @@ state Playing {
|
|||
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
|
||||
|
|
@ -564,11 +625,85 @@ state Playing {
|
|||
draw Tileset at: (c1_sx, cy) frame: TILE_COIN
|
||||
draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN
|
||||
|
||||
// Live HUD: draw one coin per stomp in the top-left. The
|
||||
// background palette-1 region covers this strip so the
|
||||
// sprite coins read correctly against the brick band.
|
||||
if stomp_count >= 1 {
|
||||
draw Tileset at: (16, 24) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 2 {
|
||||
draw Tileset at: (28, 24) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 3 {
|
||||
draw Tileset at: (40, 24) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 4 {
|
||||
draw Tileset at: (52, 24) frame: TILE_COIN
|
||||
}
|
||||
|
||||
// Player is only drawn while alive — on the dying frame
|
||||
// we show the scene without the hero on top of the enemy
|
||||
// that killed them, which reads as "player got got".
|
||||
if alive == 1 {
|
||||
draw_player()
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
|
||||
// Fatal contact this frame → kick the state machine over
|
||||
// to GameOver on the next frame.
|
||||
if alive == 0 {
|
||||
transition GameOver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state GameOver {
|
||||
var linger: u8 = 0
|
||||
|
||||
on enter {
|
||||
linger = 0
|
||||
stop_music
|
||||
}
|
||||
|
||||
on frame {
|
||||
linger += 1
|
||||
|
||||
// "GAME OVER" marker: a row of enemy tiles across the
|
||||
// middle of the screen, plus a live readout of how many
|
||||
// stomps the player racked up before dying (drawn as
|
||||
// coins right underneath). The death-screen backdrop is
|
||||
// whatever Playing last scrolled to — we don't clear it.
|
||||
draw Tileset at: (96, 112) frame: TILE_ENEMY
|
||||
draw Tileset at: (112, 112) frame: TILE_ENEMY
|
||||
draw Tileset at: (128, 112) frame: TILE_ENEMY
|
||||
draw Tileset at: (144, 112) frame: TILE_ENEMY
|
||||
|
||||
if stomp_count >= 1 {
|
||||
draw Tileset at: (96, 128) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 2 {
|
||||
draw Tileset at: (112, 128) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 3 {
|
||||
draw Tileset at: (128, 128) frame: TILE_COIN
|
||||
}
|
||||
if stomp_count >= 4 {
|
||||
draw Tileset at: (144, 128) frame: TILE_COIN
|
||||
}
|
||||
|
||||
// Auto-retry after ~1 s so the headless harness sees the
|
||||
// full loop. A human can hit Start to retry immediately.
|
||||
if linger >= 60 {
|
||||
transition Playing
|
||||
}
|
||||
if button.start {
|
||||
transition Playing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -39,4 +39,26 @@ 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.
|
||||
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)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "All pre-commit checks passed."
|
||||
|
|
|
|||
|
|
@ -218,13 +218,22 @@ fn strength_reduce_block(block: &mut IrBasicBlock) {
|
|||
/// destination temps are no longer referenced anywhere in the block.
|
||||
fn const_fold(program: &mut IrProgram) {
|
||||
for func in &mut program.functions {
|
||||
// Pre-compute function-wide source-operand usage: a LoadImm's
|
||||
// destination may be read by an op in a sibling block or
|
||||
// consumed by a branch/jump terminator in another block, so
|
||||
// the per-block DCE below can't decide liveness by looking
|
||||
// at its own block alone. Cf. the `and` / `or` short-circuit
|
||||
// lowering: the false path writes `LoadImm(result, 0)` but
|
||||
// `result` is read by the merge block's branch, not in the
|
||||
// false block itself.
|
||||
let func_used = collect_used_temps(func);
|
||||
for block in &mut func.blocks {
|
||||
const_fold_block(block);
|
||||
const_fold_block(block, &func_used);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn const_fold_block(block: &mut IrBasicBlock) {
|
||||
fn const_fold_block(block: &mut IrBasicBlock, func_used: &HashSet<IrTemp>) {
|
||||
let mut constants: HashMap<IrTemp, u8> = HashMap::new();
|
||||
|
||||
// First pass: fold arithmetic / comparison ops into LoadImm where possible.
|
||||
|
|
@ -349,12 +358,21 @@ fn const_fold_block(block: &mut IrBasicBlock) {
|
|||
}
|
||||
}
|
||||
|
||||
// Second pass: remove LoadImm ops whose dest temps are no longer referenced
|
||||
// as source operands by anything else in the block (ops + terminator).
|
||||
let used = collect_used_temps_in_block(block);
|
||||
// Second pass: remove LoadImm ops whose dest temps are no longer
|
||||
// referenced locally AND aren't referenced function-wide. The
|
||||
// function-wide check is what makes this pass correct in the
|
||||
// presence of control-flow merges — a LoadImm written in one
|
||||
// block and consumed in another (for example the `and`/`or`
|
||||
// short-circuit false path, whose `LoadImm(result, 0)` is only
|
||||
// read by the downstream merge block's branch terminator) must
|
||||
// not be dropped. The previous implementation only consulted
|
||||
// block-local usage and silently dropped these cross-block
|
||||
// LoadImms, leaving the zero-page result slot to carry whatever
|
||||
// value the *previous* AND/OR had written into it.
|
||||
let used_local = collect_used_temps_in_block(block);
|
||||
block.ops.retain(|op| {
|
||||
if let IrOp::LoadImm(t, _) = op {
|
||||
used.contains(t)
|
||||
used_local.contains(t) || func_used.contains(t)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
|
@ -397,7 +415,10 @@ fn collect_used_temps(func: &IrFunction) -> HashSet<IrTemp> {
|
|||
used
|
||||
}
|
||||
|
||||
/// Collect temps used as source operands within a single block (ops + terminator).
|
||||
/// Collect temps used as source operands within a single block
|
||||
/// (ops + terminator). Used by the per-block `LoadImm` DCE so we
|
||||
/// can cheaply find local uses before falling back on the
|
||||
/// function-wide liveness set.
|
||||
fn collect_used_temps_in_block(block: &IrBasicBlock) -> HashSet<IrTemp> {
|
||||
let mut used = HashSet::new();
|
||||
collect_used_from_block(block, &mut used);
|
||||
|
|
|
|||
|
|
@ -436,3 +436,85 @@ fn inline_removes_trivial() {
|
|||
.any(|op| matches!(op, IrOp::Call(..)));
|
||||
assert!(!has_call, "call to trivial function should be removed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-block LoadImm liveness (regression for stale-and-result bug)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `LoadImm(result, 0)` in a predecessor block whose only consumer
|
||||
/// is a branch terminator in a *successor* block must not be dropped
|
||||
/// by the const-folding pass. This is the shape the logical-`and`
|
||||
/// short-circuit false path lowers to, and dropping the `LoadImm`
|
||||
/// leaves the zero-page slot holding whichever value the previous
|
||||
/// `and` evaluation wrote — which was observable in the platformer
|
||||
/// example as spurious enemy-stomp bounces firing whenever coin 2
|
||||
/// happened to be in its pickup window.
|
||||
#[test]
|
||||
fn const_fold_preserves_loadimm_used_by_sibling_branch() {
|
||||
let cond = IrTemp(0);
|
||||
let result = IrTemp(1);
|
||||
let zero = IrTemp(2);
|
||||
|
||||
// Shape mirrors `lower_logical_and`: an entry block that branches
|
||||
// on `cond`, a right-side block that writes `result` via an
|
||||
// `Or(result, <true-case>, zero)`, a false-side block that only
|
||||
// writes `LoadImm(result, 0)`, and a merge block whose terminator
|
||||
// reads `result`.
|
||||
let entry = IrBasicBlock {
|
||||
label: "entry".to_string(),
|
||||
ops: vec![IrOp::LoadImm(cond, 1)],
|
||||
terminator: IrTerminator::Branch(cond, "right".to_string(), "false_path".to_string()),
|
||||
};
|
||||
let right = IrBasicBlock {
|
||||
label: "right".to_string(),
|
||||
ops: vec![IrOp::LoadImm(zero, 0), IrOp::Or(result, cond, zero)],
|
||||
terminator: IrTerminator::Jump("end".to_string()),
|
||||
};
|
||||
// This is the block that previously tripped the bug: its
|
||||
// `LoadImm(result, 0)` is the only op, and `result` is not used
|
||||
// as a source operand anywhere *within* this block — but it is
|
||||
// read by the `end` block's branch terminator.
|
||||
let false_path = IrBasicBlock {
|
||||
label: "false_path".to_string(),
|
||||
ops: vec![IrOp::LoadImm(result, 0)],
|
||||
terminator: IrTerminator::Jump("end".to_string()),
|
||||
};
|
||||
let end = IrBasicBlock {
|
||||
label: "end".to_string(),
|
||||
ops: vec![],
|
||||
terminator: IrTerminator::Return(Some(result)),
|
||||
};
|
||||
|
||||
let mut prog = IrProgram {
|
||||
functions: vec![IrFunction {
|
||||
name: "and_like".to_string(),
|
||||
blocks: vec![entry, right, false_path, end],
|
||||
locals: vec![],
|
||||
param_count: 0,
|
||||
has_return: true,
|
||||
source_span: Span::new(0, 0, 0),
|
||||
}],
|
||||
globals: vec![],
|
||||
rom_data: vec![],
|
||||
states: vec![],
|
||||
start_state: String::new(),
|
||||
};
|
||||
|
||||
optimize(&mut prog);
|
||||
|
||||
// Find the false_path block post-optimization and verify that
|
||||
// its LoadImm(result, 0) is still there.
|
||||
let func = &prog.functions[0];
|
||||
let fp = func
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.label == "false_path")
|
||||
.expect("false_path block should survive as reachable");
|
||||
assert!(
|
||||
fp.ops
|
||||
.iter()
|
||||
.any(|op| matches!(op, IrOp::LoadImm(t, 0) if *t == result)),
|
||||
"false-path LoadImm(result, 0) must survive optimization; block ops were {:?}",
|
||||
fp.ops
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
7c8e60e4 132084
|
||||
e8b3ea0b 132084
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.6 KiB |
|
|
@ -12,6 +12,18 @@
|
|||
// 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.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif
|
||||
//
|
||||
// committed alongside the source change. The CI `emulator` job
|
||||
// regenerates the gif and fails if the committed copy is stale —
|
||||
// gifenc + jsnes are deterministic, so the freshly-rendered bytes
|
||||
// byte-match a valid commit. See `.github/workflows/ci.yml`.
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue