1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

compiler: GNROM / debug port / sprite flicker / fade / sprite-0 split + docs

Another batch from the cc65/nesdoug gap catalogue. All six items
gated on marker labels (or default-false attributes) so existing
programs produce byte-identical ROMs — every pre-existing .nes
file round-trips unchanged.

**Language / runtime additions:**

- `mapper: GNROM` (iNES 66). Combines AxROM's 32 KB PRG pages with
  CNROM's 8 KB CHR banks in a single `$8000` register. Linker
  pads single-page ROMs to 32 KB to match mapper-66 expectations.
- `game { debug_port: fceux | mesen | 0xXXXX }`. `debug.log`,
  `debug.assert`, and the `__debug_halt` sentinel now target a
  user-selected address. `fceux` (default, $4800) and `mesen`
  ($4018) are named aliases; custom hex addresses are accepted
  for unusual debuggers.
- `game { sprite_flicker: true }`. IR lowerer injects an
  `IrOp::CycleSprites` at the top of every `on frame` handler,
  which flips on the rotating-OAM NMI variant with no per-site
  boilerplate. Default false so existing ROMs keep their layout.
- `fade_out(step_frames)` / `fade_in(step_frames)` builtins.
  Blocking helpers that walk brightness 4 → 0 or 0 → 4 with
  `step_frames` frames between each step. Runtime splices
  `__fade_out`, `__fade_in`, and a callable `__wait_frame_rt`
  helper when the builtin is used. Zero-guard on step_frames
  prevents a pathological 256-frame spin when the caller
  accidentally passes 0.
- `sprite_0_split(scroll_x, scroll_y)` intrinsic. Emits a
  two-phase busy-wait on `$2002` bit 6 (wait-for-clear,
  wait-for-set) then writes the new scroll values to `$2005`.
  Works on any mapper — unlike `on_scanline(N)` which requires
  MMC3. Enables HUD-over-playfield scrolling on NROM/UxROM/MMC1.

**Docs:**

- New paragraph in the language guide explaining the no-recursion
  design choice and the explicit-stack workaround pattern.
- `future-work.md` updated to mark the shipped items out of the
  catalogue; remaining items reshuffled in the priority ranking.
- README + examples/README updated with the new mapper and
  builtins.

**Tests:**

- 12 new integration tests covering: GNROM header emission,
  debug-port targeting (fceux/mesen/custom), unknown-alias
  rejection, sprite_flicker on/off/bad-value, fade_out JSR + marker
  coupling, fade omitted-when-unused, fade-in-expression rejected,
  sprite_0_split byte-level busy-wait verification, sprite_0_split
  arity enforcement, sprite_0_split omitted-when-unused, and an
  extended void-intrinsic-in-expression-position test covering the
  three new void builtins.
- `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
  extended to include GNROM.
- Four new examples with committed .nes ROMs + pixel/audio
  goldens: `gnrom_simple`, `auto_sprite_flicker`, `fade_demo`,
  `sprite_0_split_demo`.

All 752 tests pass. Clippy clean. 44/44 emulator goldens match.
This commit is contained in:
Claude 2026-04-18 19:31:55 +00:00
parent f4968256f4
commit e0b268eea9
No known key found for this signature in database
35 changed files with 1269 additions and 89 deletions

View file

@ -46,6 +46,10 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `palette_brightness_demo.ne` | `set_palette_brightness(level)` | Cycles through the 9 brightness levels (0 = blank, 4 = normal, 8 = max emphasis) every 20 frames. Exercises the neslib-style `pal_bright` mapping onto `$2001` PPU mask emphasis bits. The runtime routine `__set_palette_brightness` is spliced in only when user code references the builtin. |
| `axrom_simple.ne` | `mapper: AxROM` (mapper 7) | Single-screen AxROM demo. The linker pads PRG to 32 KB (one blank 16 KB bank plus our 16 KB fixed bank) so emulators that enforce mapper-7's 32 KB page size boot cleanly. Register layout: bit 4 of `$8000` selects single-screen lower / upper nametable. |
| `cnrom_simple.ne` | `mapper: CNROM` (mapper 3) | CNROM demo. Fixed 32 KB PRG, switchable 8 KB CHR. Single-bank CNROM is functionally equivalent to NROM at the PRG level, but the iNES header reports mapper 3 and the runtime writes a CHR bank 0 select at reset. |
| `gnrom_simple.ne` | `mapper: GNROM` (mapper 66) | GNROM / MHROM demo. Combines AxROM-style 32 KB PRG pages with CNROM-style 8 KB CHR banks in a single `$8000` register (bits 4-5 select PRG, bits 0-1 select CHR). Like AxROM the linker pads single-page ROMs to 32 KB so emulators that enforce mapper-66's page size boot cleanly. |
| `auto_sprite_flicker.ne` | `game { sprite_flicker: true }` | The `game` attribute equivalent of calling `cycle_sprites` at the top of every `on frame` handler. Same 12-sprite layout as `sprite_flicker_demo.ne`, minus the explicit call — the IR lowerer injects the op automatically when the flag is set, so it's byte-identical to a hand-rolled version without the per-site boilerplate. |
| `fade_demo.ne` | `fade_out(n)`, `fade_in(n)` | Blocking fade helpers that walk brightness 4 → 0 and 0 → 4 with `n` frames per step. The runtime splices `__fade_out` / `__fade_in` plus a callable `__wait_frame_rt` helper when the builtin is used; fade use also forces `__set_palette_brightness` to be linked in since the fade body JSRs into it. |
| `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. |
## Emulator Controls

View file

@ -0,0 +1,33 @@
// Auto sprite-flicker demo — same 12-sprite layout as
// `sprite_flicker_demo.ne`, but the explicit `cycle_sprites` call
// is replaced by the `game { sprite_flicker: true }` opt-in. The
// IR lowerer injects a `CycleSprites` op at the top of every
// `on frame` handler, which flips on the rotating-OAM NMI variant
// without any per-site boilerplate.
game "Auto Sprite Flicker Demo" {
mapper: NROM
sprite_flicker: true
}
on frame {
// Twelve sprites on the same 8-pixel band, same as the
// explicit sprite_flicker_demo — the PPU can only render
// 8 per scanline.
draw Star at: (16, 100)
draw Star at: (32, 100)
draw Star at: (48, 100)
draw Star at: (64, 100)
draw Star at: (80, 100)
draw Star at: (96, 100)
draw Star at: (112, 100)
draw Star at: (128, 100)
draw Star at: (144, 100)
draw Star at: (160, 104)
draw Star at: (176, 104)
draw Star at: (192, 104)
wait_frame
}
start Main

Binary file not shown.

28
examples/fade_demo.ne Normal file
View file

@ -0,0 +1,28 @@
// Fade demo — blocking fade_out / fade_in builtins cycle between
// normal and black every few seconds. Exercises the runtime fade
// helper that walks 5 brightness levels with a user-controlled
// step delay between each.
game "Fade Demo" {
mapper: NROM
}
var frame: u8 = 0
on frame {
frame += 1
// Every 120 frames (~2 sec), fade out and back in. Each fade
// takes 5 steps × 6 frames = 30 frames.
if frame == 30 {
fade_out(6)
}
if frame == 120 {
fade_in(6)
frame = 0
}
draw Ball at: (120, 100)
}
start Main

BIN
examples/fade_demo.nes Normal file

Binary file not shown.

25
examples/gnrom_simple.ne Normal file
View file

@ -0,0 +1,25 @@
// GNROM (mapper 66) demo — 32 KB PRG page + 8 KB CHR bank in a
// single write to `$8000-$FFFF`. Bits 4-5 select the PRG page,
// bits 0-1 select the CHR bank. Single-page GNROM is functionally
// equivalent to AxROM at the PRG level; this example just exercises
// the reset-time init and iNES header emission.
game "GNROM Demo" {
mapper: GNROM
}
var px: u8 = 120
var dx: u8 = 1
on frame {
if dx == 1 {
px += 1
if px >= 240 { dx = 0 }
} else {
px -= 1
if px == 0 { dx = 1 }
}
draw Ball at: (px, 100)
}
start Main

BIN
examples/gnrom_simple.nes Normal file

Binary file not shown.

View file

@ -0,0 +1,73 @@
// Sprite-0 split demo — `sprite_0_split(x, y)` busy-waits for the
// PPU's sprite-0 hit flag (`$2002` bit 6) and then writes the
// requested scroll values to `$2005`. This produces a mid-frame
// scroll change without needing MMC3's scanline IRQ, so the split
// works on NROM / UxROM / MMC1 — any mapper.
//
// For sprite-0 hit to actually fire we need:
// 1. A sprite in OAM slot 0 (the first `draw` in on_frame)
// 2. An opaque sprite pixel overlapping an opaque background
// pixel at some visible scanline
// 3. Rendering enabled (automatic when we have a bg + palette)
//
// This demo draws the smiley as sprite 0 at (16, 24) so its bottom
// row falls on scanline 31, where it overlaps the background's
// smiley tiles. The split then resets the scroll so the second
// half of the frame scrolls independently from the first.
game "Sprite 0 Split Demo" {
mapper: NROM
mirroring: horizontal
}
palette Colors {
universal: black
bg0: [dk_blue, blue, sky_blue]
bg1: [dk_red, red, lt_red]
bg2: [dk_green, green, lt_green]
bg3: [black, lt_gray, white]
sp0: [dk_blue, blue, sky_blue]
sp1: [red, orange, white]
sp2: [dk_teal, teal, lt_teal]
sp3: [dk_olive, olive, yellow]
}
background Tiled {
// Every cell is tile 0 (the built-in smiley) so sprite 0's
// opaque pixels overlap opaque background pixels at any
// position on the screen — guaranteeing sprite-0 hit fires
// on every frame regardless of sprite position.
legend { ".": 0 }
map: [
"................................",
"................................",
"................................",
"................................"
]
}
var top_scroll: u8 = 0
on frame {
// Top half scrolls to the left each frame (wraps at 256).
top_scroll += 1
// Sprite 0 at row 24 → its bottom edge is at scanline 31,
// which is inside the top-half scroll region. The hit fires
// around scanline 31 of the current frame.
draw Smiley at: (16, 24)
// After sprite-0 fires we reset scroll_x to 0 and scroll_y
// to 0, so the bottom half of the screen stays put while
// the top half drifts. The effect is a "scrolling status
// bar" pattern — classic technique for HUD-over-playfield.
sprite_0_split(0, 0)
// Set the top-half scroll AFTER sprite_0_split so the NEXT
// frame's top half gets the drifting value. Writes to $2005
// between NMI and sprite 0 affect the whole frame up to the
// split point.
scroll(top_scroll, 0)
}
start Main

Binary file not shown.