From e0b268eea921d0e15567182960a5ffa60e5903ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 19:31:55 +0000 Subject: [PATCH] compiler: GNROM / debug port / sprite flicker / fade / sprite-0 split + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 9 +- docs/future-work.md | 97 ++-- docs/language-guide.md | 52 ++ examples/README.md | 4 + examples/auto_sprite_flicker.ne | 33 ++ examples/auto_sprite_flicker.nes | Bin 0 -> 24592 bytes examples/fade_demo.ne | 28 + examples/fade_demo.nes | Bin 0 -> 24592 bytes examples/gnrom_simple.ne | 25 + examples/gnrom_simple.nes | Bin 0 -> 40976 bytes examples/sprite_0_split_demo.ne | 73 +++ examples/sprite_0_split_demo.nes | Bin 0 -> 24592 bytes src/analyzer/mod.rs | 35 +- src/assets/audio.rs | 2 + src/assets/resolve.rs | 4 + src/codegen/ir_codegen.rs | 108 +++- src/ir/lowering.rs | 49 ++ src/ir/mod.rs | 20 + src/linker/mod.rs | 45 +- src/optimizer/mod.rs | 10 + src/parser/ast.rs | 25 + src/parser/mod.rs | 70 ++- src/pipeline.rs | 3 +- src/rom/mod.rs | 1 + src/rom/tests.rs | 1 + src/runtime/mod.rs | 172 ++++++ .../goldens/auto_sprite_flicker.audio.hash | 1 + .../emulator/goldens/auto_sprite_flicker.png | Bin 0 -> 1422 bytes tests/emulator/goldens/fade_demo.audio.hash | 1 + tests/emulator/goldens/fade_demo.png | Bin 0 -> 44915 bytes .../emulator/goldens/gnrom_simple.audio.hash | 1 + tests/emulator/goldens/gnrom_simple.png | Bin 0 -> 836 bytes .../goldens/sprite_0_split_demo.audio.hash | 1 + .../emulator/goldens/sprite_0_split_demo.png | Bin 0 -> 37366 bytes tests/integration_test.rs | 488 +++++++++++++++++- 35 files changed, 1269 insertions(+), 89 deletions(-) create mode 100644 examples/auto_sprite_flicker.ne create mode 100644 examples/auto_sprite_flicker.nes create mode 100644 examples/fade_demo.ne create mode 100644 examples/fade_demo.nes create mode 100644 examples/gnrom_simple.ne create mode 100644 examples/gnrom_simple.nes create mode 100644 examples/sprite_0_split_demo.ne create mode 100644 examples/sprite_0_split_demo.nes create mode 100644 tests/emulator/goldens/auto_sprite_flicker.audio.hash create mode 100644 tests/emulator/goldens/auto_sprite_flicker.png create mode 100644 tests/emulator/goldens/fade_demo.audio.hash create mode 100644 tests/emulator/goldens/fade_demo.png create mode 100644 tests/emulator/goldens/gnrom_simple.audio.hash create mode 100644 tests/emulator/goldens/gnrom_simple.png create mode 100644 tests/emulator/goldens/sprite_0_split_demo.audio.hash create mode 100644 tests/emulator/goldens/sprite_0_split_demo.png diff --git a/README.md b/README.md index ec95ba7..317cb8b 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,13 @@ start Main - **Compile-time safety** -- call depth limits, recursion detection, type checking, unused-var warnings - **IR-based optimizer** -- constant folding, dead code elimination, strength reduction (incl. div/mod by power-of-two), copy propagation, peephole passes including INC/DEC fold and live-range slot recycling - **Full 16-bit arithmetic** -- u16 add/sub/compare lower to carry-propagating paired operations -- **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state), AxROM (mapper 7), CNROM (mapper 3) -- **Runtime PRNG** -- `rand8()`, `rand16()`, `seed_rand(s)` backed by a zero-cost-when-unused xorshift LFSR +- **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state), AxROM (mapper 7), CNROM (mapper 3), GNROM / MHROM (mapper 66) +- **Runtime PRNG** -- `rand8()`, `rand16()`, `seed_rand(s)` backed by a zero-cost-when-unused Galois LFSR - **Edge-triggered input** -- `p1.button.a.pressed` / `.released` for menu / one-shot input handling -- **Palette brightness fades** -- `set_palette_brightness(level)` for cheap neslib-style screen fades +- **Palette brightness fades** -- `set_palette_brightness(level)` + blocking `fade_out(n)` / `fade_in(n)` helpers +- **Sprite-0 split** -- `sprite_0_split(scroll_x, scroll_y)` for mid-frame scroll changes on any mapper +- **Auto sprite cycling** -- `game { sprite_flicker: true }` to mitigate the NES's 8-sprites-per-scanline limit with no per-frame boilerplate +- **Configurable debug port** -- `game { debug_port: fceux \| mesen \| 0xXXXX }` targets either debugger convention - **Audio subsystem** -- frame-walking pulse driver with user-declared `sfx`/`music` blocks, builtin effects and tracks, period table, and zero-cost elision when unused - **Palette & background pipeline** -- `palette` and `background` blocks, initial values loaded at reset, vblank-safe `set_palette` / `load_background` runtime swaps - **Asset pipeline** -- PNG-to-CHR conversion, inline tile data, sfx envelopes, music note streams diff --git a/docs/future-work.md b/docs/future-work.md index d030d79..ef488bd 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -394,43 +394,27 @@ NMI-time write budget (~2273 cycles) is too tight for a full nametable. RLE is the smaller first step — emit a `nametable` that can declare `compression: rle` and decompress at swap time. -### J. Palette-fade follow-ups +### J. Palette-fade brightness LUT follow-up -`set_palette_brightness(level: u8)` ships today (levels 0..8 mapped -onto `$2001` emphasis bits); `examples/palette_brightness_demo.ne` -exercises it. Two follow-ups are still worth doing: +`set_palette_brightness(level: u8)` and the blocking +`fade_out(step_frames)` / `fade_in(step_frames)` builtins all ship +today — see `examples/palette_brightness_demo.ne` and +`examples/fade_demo.ne`. One follow-up still worth doing: a +brightness-LUT path that recolours the active palette in addition +to the emphasis bits, for non-NTSC-assumption fades. The current +implementation only manipulates `$2001` emphasis bits, so the +"dimmed" end of the fade still shows colour tint rather than a +true colour-space darken. -- Blocking `fade_out(frames)` / `fade_in(frames)` helpers — today - users write them in user-space with a for-loop + `wait_frame`. - Making them builtin would elide the frame-counting boilerplate. -- A brightness-LUT path that recolours the active palette in - addition to the emphasis bits, for non-NTSC-assumption fades. +### M. Sprite cycling follow-ups -### L. Sprite 0 hit split-screen - -`split(x, y)` is the neslib primitive for a fixed status bar above -a scrolling playfield without MMC3. NEScript only offers -`on_scanline(N)` on MMC3. A sprite-0-hit-based split that works on -NROM/UxROM/MMC1 unlocks most of the tutorial games. API: - -``` -sprite_0_split scanline: 32, { - scroll_x: 0, - scroll_y: 0, -} -``` - -…emits a busy-wait on `$2002` bit 6 followed by the requested -scroll write. - -### M. Automatic sprite cycling - -The existing `cycle_sprites` opt-in keyword rotates the DMA offset -each frame. A `game { sprite_flicker: true }` attribute that emits -the rotation automatically — plus a `draw ... priority: pinned` -modifier for HUD sprites that must stay at low OAM slots — is the -cleaner user-facing API. Mentioned already under Open Design -Questions; bumping it into the active roadmap. +Auto sprite cycling ships today via `game { sprite_flicker: true }` +— the IR lowerer injects an `IrOp::CycleSprites` at the top of +every `on frame` handler when the flag is set. See +`examples/auto_sprite_flicker.ne`. A companion `draw ... priority: +pinned` modifier for HUD sprites that must stay at low OAM slots +is still missing — today pinning has to be manual (draw the HUD +sprites first). ### O. DPCM / DMC sample playback @@ -494,19 +478,19 @@ three reads every program needs. ### V. Additional mappers -AxROM (mapper 7) and CNROM (mapper 3) both ship today; see -`examples/axrom_simple.ne` and `examples/cnrom_simple.ne`. CNROM's -user-visible CHR bankswitching is still TODO — the reset-time init -writes bank 0 and nothing else is exposed yet, so CHR swaps -mid-frame aren't reachable from user source. The next set: +AxROM (mapper 7), CNROM (mapper 3), and GNROM (mapper 66) all +ship today; see `examples/axrom_simple.ne`, `examples/cnrom_simple.ne`, +and `examples/gnrom_simple.ne`. CNROM and GNROM both have CHR +bankswitching but user-visible CHR swaps aren't reachable from +user source yet — the reset-time init writes bank 0 and the +`__bank_select` routine exists but has no user-exposed API. The +next set: -1. **GNROM / MHROM** (mapper 66). Combines AxROM-style PRG with - CNROM-style CHR banking. Another single-register mapper. -2. **MMC2** (mapper 9, Punch-Out only realistically). Medium. -3. **UNROM-512** (mapper 30). The modern homebrew sweet spot — +1. **MMC2** (mapper 9, Punch-Out only realistically). Medium. +2. **UNROM-512** (mapper 30). The modern homebrew sweet spot — 512 KB PRG + CHR-RAM + self-flashing. Mapping is UxROM-like plus a one-screen bit. -4. **MMC5** (mapper 5). Big. Driven by FamiStudio's expansion +3. **MMC5** (mapper 5). Big. Driven by FamiStudio's expansion audio more than by the extra PRG/CHR modes. Probably last. Each new mapper needs a `Mapper::X` variant, a reset-time @@ -522,12 +506,14 @@ target (`--target nsf`) would wrap the existing music/sfx blocks in the NSF header and expose `init`/`play` entry points. Nearly free, gets the chiptune audience for ~a day of work. -### X. Configurable / Mesen-native debug output +### X. Mesen trace-log documentation follow-up -Today the debug port is hardcoded to `$4800`. Expose -`debug.port: $4800 | mesen` on the `game { }` block. For -`mesen`, emit writes to `$4018` (Mesen's documented debug port) -and document the trace-log tool invocation in the debug docs. +`game { debug_port: fceux | mesen | 0xXXXX }` ships today — see +the integration test `debug_log_targets_configured_port`. Mesen's +trace-log tool invocation still isn't documented anywhere in the +NEScript docs; a short section under `docs/nes-reference.md` +walking through how to hook a Mesen trace-log against a ROM built +with `debug_port: mesen` would close the gap. ### Y. FCEUX `.ld` line-info follow-up @@ -558,14 +544,13 @@ Remaining gap items in order of user value: 1. `i16` (§A) — unblocks signed physics, metasprite offsets. 2. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming. -3. Sprite-0 split (§L) + auto sprite cycling (§M) — cheap polish. -4. Register allocator (existing section) — compounding size win. -5. Metatiles + collision (§H) — closes several items at once. -6. Inline-asm completeness (§D) — escape hatch for power users. -7. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — +3. Register allocator (existing section) — compounding size win. +4. Metatiles + collision (§H) — closes several items at once. +5. Inline-asm completeness (§D) — escape hatch for power users. +6. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — turns NEScript into a general-purpose NES language. -8. SRAM (§S) + UNROM-512 + GNROM + MMC5 (§V) — ecosystem fit. -9. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P). +7. SRAM (§S) + UNROM-512 + MMC5 (§V) — ecosystem fit. +8. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P). --- diff --git a/docs/language-guide.md b/docs/language-guide.md index 6e336c3..ab79922 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -306,6 +306,58 @@ reset_score() - **Call depth limit.** The default maximum call depth is 8. Exceeding it produces error `E0401`. - **Maximum 8 parameters per function.** The calling convention is hybrid: **leaf** functions (no nested `JSR` in their body) receive up to four parameters through fixed zero-page transport slots `$04`-`$07`, while **non-leaf** functions receive up to eight parameters via direct caller writes into per-function RAM spill slots (no transport, no prologue copy). Declaring a function with 9+ parameters produces error `E0506`. Declaring a leaf with 5+ parameters silently promotes it to the non-leaf convention — you pay the direct-write cost rather than the prologue-copy cost, which is still cheaper than the old transport-plus-spill path. +#### Why no recursion? + +This is a deliberate design choice, not a bug. NEScript uses a hybrid +direct-write calling convention that lands each function's +parameters and locals at a fixed RAM address the analyzer reserves +at compile time. Recursion would require each activation to have +its own stack frame, which means either: + +1. A software stack pointer managed by a prologue/epilogue at every + call site (costs cycles on a platform that only has 2 KB of RAM + and a 256-byte hardware stack), or +2. The hardware stack carrying frames directly (the 6502's 256-byte + `$0100-$01FF` stack overflows fast — a single recursive call + with any meaningful locals blows it within a handful of levels). + +Neither is a good fit for the NES's constraints, and NEScript +already surfaces the tradeoff at compile time via the call-depth +limit (`E0401`) and the parameter cap (`E0506`). The direct-write +convention is what makes those limits enforceable. + +If you actually need recursion-shaped logic — flood fill, tree +walking, tile-spread simulations — the idiomatic pattern is an +explicit stack held in a small `u8` array: + +``` +const MAX_STACK: u8 = 32 +var stack: u8[MAX_STACK] = [0; 32] +var top: u8 = 0 + +fun flood_push(x: u8) { + stack[top] = x + top += 1 +} + +fun flood_pop() -> u8 { + top -= 1 + return stack[top] +} + +fun flood_fill(start: u8) { + flood_push(start) + while top > 0 { + var here: u8 = flood_pop() + // ...process `here`, push neighbours that need visiting... + } +} +``` + +This gives the compiler full visibility into the worst-case stack +depth (`MAX_STACK`), uses flat RAM instead of the hardware stack, +and composes cleanly with the call-graph validator. + --- ## States diff --git a/examples/README.md b/examples/README.md index 68b8251..a41ec00 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/auto_sprite_flicker.ne b/examples/auto_sprite_flicker.ne new file mode 100644 index 0000000..0824a32 --- /dev/null +++ b/examples/auto_sprite_flicker.ne @@ -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 diff --git a/examples/auto_sprite_flicker.nes b/examples/auto_sprite_flicker.nes new file mode 100644 index 0000000000000000000000000000000000000000..3f439b6bb48c2639bf31af57db67790794846085 GIT binary patch literal 24592 zcmeI)KWh|07{~EvXD)YMwP%R`QjI|Zh7=*Bk^_f8WDAjiZ@?nO3MnkDLM#?Um|*D! z7LFqeju6mya9m}&D{XD$x=UpW#WI0#HHd}o)LS$@Ma@Ok=uw)K25N#tt0{?@k# zcWnRs(2En}660=JucyIx&2V?SQ-D=HBG1;rsYEUVWE5SMY)#wIxwT3z7uZrQs`Pve}`3SeIlp&>DxhH zF6xiN7@dWreLQL1ec5o6mftyQ`<-%ncbTYCDc>oz`q938uBRrNu0>NDO}C@zi)i{P zn(jx__p@mpH1b)EUNrq2O$RgSzm|k%q|z~I^Y(JuzV|RqZr;E6UU;yPF0MRE=a<&A zciD%mR#~mH&?cviS8l=jq>#MOd7<);`KR34a|QwkAbt`_ogTIx>r92V1obx2q1s}0tg_000IagfB*srAbu6~`zdC>A1C5_CBbQivdS7LwwM{Df3Nk!=oEER7J6 z;DQrOVK0*HjL5O2<=KQ>qi3U_a`DRs4-0>Q!aE7eJK14&_W1$1dHwdf=gHydei+6N zPsGE;6DO(I)U)Za*!j!L#Z=<761#fp#c4Hma>3Zf4<|zzh<-hHFc9Ok{DD*kQXNQb zAi=!&xLS8lw;LPluBA1rd+X(8VqBj1&*YU!1N(Mji?6PsPjr$r5-K#! z3s)cAa~eJ}u0Gn8qNZo^YrGj8t%GH? zf4@dMl)~uF^T1Woy= 240 { dx = 0 } + } else { + px -= 1 + if px == 0 { dx = 1 } + } + draw Ball at: (px, 100) +} + +start Main diff --git a/examples/gnrom_simple.nes b/examples/gnrom_simple.nes new file mode 100644 index 0000000000000000000000000000000000000000..e9912eadb9af67c1006d0106f8653af885a719ed GIT binary patch literal 40976 zcmeIvF>4e-6u|MfGsz`a?Vdy>sm2gQ#X`hN4jcl3EzUH4ffOnBBcuwk*hUyT!4($d zaKI6SkAq4(!m)V_}-8XN3v%U3hO(n+4|F|L$KmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0;MN89PKNg5Lp!|s62ysdiE;OB&~5X=iFFG$EW5byIh4`jxS*T^PmQF28XOrp8cLyXTQa{JGx-~`xNO)fvyha%Af@&)yNplqdMq&$s)n8+NO<(kfAvQa-D+I_=(EF?X588h293Oxk(y zIPI=JO_ScEd4FZ&Rl4@6O>!AF~1b0E=pB=FMbr(-uiJ; z00IagfB*srAb8;XOg=M7JHE-?kOB=qXsR~*+g9qgcM?9pE~&lR);y5Mp$bjEJ(Nk zN3if4#3GmdDc9wm2c=uO4-9Ehk=yDLqdOZF-OP*ay zW~VQlq!On=oVF9E%c+-2lGYNpl?F*#Pn_IR@%9fVGs(oCf%}z-a(<;Vsbx~nWFwQ! z&x?s2}nAO%LMZUBu8W%=c zJr<)s=3m{euUh=IO_DF&u5a9liHx>V{PZia`hyhu!Ke95sz#6f$iMq1jibm#hYc4^ z%kHOvnkwbCr&=8~R)?>dm;>KgH_1<`OL1Z+X+5?F82F1>qaP*0H z@lPn)W>*!r&qQMtiLI)jjKo$Mi7ne6i)s5v^e=a+XJr(MX}ZA3$mo$#qgSKXqa-g#Ozb*QEc4DV{{r-O0@P7Z?{i4eUBY*$`2q1s} k0tg_000IagfB*srAb bool { fn is_intrinsic(name: &str) -> bool { matches!( name, - "poke" | "peek" | "rand8" | "rand16" | "seed_rand" | "set_palette_brightness" + "poke" + | "peek" + | "rand8" + | "rand16" + | "seed_rand" + | "set_palette_brightness" + | "fade_out" + | "fade_in" + | "sprite_0_split" ) } @@ -2601,7 +2609,10 @@ fn is_intrinsic(name: &str) -> bool { /// analyzer so the codegen / linker never sees a stray /// `Expr::Call` for one of them. fn is_void_intrinsic(name: &str) -> bool { - matches!(name, "poke" | "seed_rand" | "set_palette_brightness") + matches!( + name, + "poke" | "seed_rand" | "set_palette_brightness" | "fade_out" | "fade_in" | "sprite_0_split" + ) } /// True if `name` is one of the NES controller's eight buttons. @@ -2662,6 +2673,26 @@ impl Analyzer { span, )); } + "fade_out" | "fade_in" if args.len() != 1 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`{name}` takes exactly 1 argument (step_frames: u8), got {}", + args.len() + ), + span, + )); + } + "sprite_0_split" if args.len() != 2 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`sprite_0_split` takes exactly 2 arguments (scroll_x: u8, scroll_y: u8), got {}", + args.len() + ), + span, + )); + } _ => {} } } diff --git a/src/assets/audio.rs b/src/assets/audio.rs index 8cd1381..4408019 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -763,6 +763,8 @@ mod tests { mapper: Mapper::NROM, mirroring: Mirroring::Horizontal, header: HeaderFormat::Ines1, + debug_port: 0x4800, + sprite_flicker: false, span: Span::dummy(), }, globals: Vec::new(), diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 67fc834..4af65bb 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -238,6 +238,8 @@ mod tests { mapper: Mapper::NROM, mirroring: Mirroring::Horizontal, header: HeaderFormat::Ines1, + debug_port: 0x4800, + sprite_flicker: false, span: Span::dummy(), }, globals: Vec::new(), @@ -317,6 +319,8 @@ mod tests { mapper: Mapper::NROM, mirroring: Mirroring::Horizontal, header: HeaderFormat::Ines1, + debug_port: 0x4800, + sprite_flicker: false, span: Span::dummy(), }, globals: Vec::new(), diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 31a188d..705949a 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -51,10 +51,14 @@ const ZP_CURRENT_STATE: u8 = 0x03; /// with only a single scanline handler ignore the counter. const ZP_SCANLINE_STEP: u8 = 0x0C; -/// Emulator debug output port. Writes to this address are logged by -/// Mesen / fceux when the debugger is attached. Used by `debug.log` -/// and `debug.assert` when compiled with `--debug`. -const DEBUG_PORT: u16 = 0x4800; +/// Default emulator debug output port. Writes to this address are +/// logged by FCEUX (which defaults to `$4800`) when the debugger is +/// attached. Programs can override via `game { debug_port: mesen }` +/// (which selects `$4018`, Mesen's documented tracing port) or +/// `debug_port: 0xXXXX` for a custom address. Used by `debug.log`, +/// `debug.assert`, and the `__debug_halt` bounds-check sentinel +/// when compiled with `--debug`. +const DEFAULT_DEBUG_PORT: u16 = 0x4800; /// IR codegen that produces 6502 instructions from an `IrProgram`. #[allow(clippy::struct_excessive_bools)] @@ -218,6 +222,13 @@ pub struct IrCodeGen<'a> { /// linker uses it to splice `runtime::gen_palette_brightness` /// into PRG ROM. palette_bright_used: bool, + /// Set to true the first time we lower `fade_out` / `fade_in`. + /// Drives the `__fade_used` marker label — the linker uses it + /// to splice `runtime::gen_fade` into PRG ROM. Fade routines + /// call `__set_palette_brightness`, so this flag also forces + /// `palette_bright_used` so the brightness routine is always + /// linked in whenever fade is. + fade_used: bool, /// Set to true the first time we lower `IrOp::ReadInputEdge` /// (`p1.a.pressed` / `p1.a.released`). Drives the /// `__edge_input_used` marker label — the linker uses it to @@ -244,6 +255,14 @@ pub struct IrCodeGen<'a> { /// the final ROM. Enabled by the CLI when `--source-map` is /// passed. emit_source_locs: bool, + /// Absolute address the runtime writes to for `debug.log` + /// output, `debug.assert` failures, and the `__debug_halt` + /// bounds-check sentinel. Defaults to [`DEFAULT_DEBUG_PORT`] + /// (`$4800`, the FCEUX convention); programs can override via + /// `game { debug_port: mesen }` (selects `$4018`) or + /// `debug_port: 0xXXXX` for an arbitrary address. Threaded in + /// from the analyzer via [`Self::with_debug_port`]. + debug_port: u16, /// Byte size of each named global / local variable. Keyed by /// IR `VarId`, mirrors [`Self::var_addrs`]. Used by the /// debug-mode array bounds checker to emit an `idx >= size` @@ -444,10 +463,12 @@ impl<'a> IrCodeGen<'a> { p2_input_used: false, rand_used: false, palette_bright_used: false, + fade_used: false, edge_input_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, + debug_port: DEFAULT_DEBUG_PORT, var_sizes, bounds_halt_used: false, banked_streams: HashMap::new(), @@ -500,6 +521,19 @@ impl<'a> IrCodeGen<'a> { self } + /// Install the absolute address that `debug.log`, + /// `debug.assert`, and the `__debug_halt` sentinel should + /// target. The analyzer threads the program's + /// `game { debug_port: ... }` value through; callers that + /// build an `IrCodeGen` directly (unit tests, integration + /// tests) can skip this and inherit the FCEUX-convention + /// default `$4800`. + #[must_use] + pub fn with_debug_port(mut self, port: u16) -> Self { + self.debug_port = port; + self + } + /// Source-location markers emitted during codegen. Populated /// once [`Self::generate`] has run; each entry pairs a /// `__src_` label name with the span it came from. The CLI @@ -954,7 +988,8 @@ impl<'a> IrCodeGen<'a> { // port before wedging, so the log shows a bounds-check // failure as a distinct event from a plain halt. self.emit(LDA, AM::Immediate(0xBC)); - self.emit(STA, AM::Absolute(DEBUG_PORT)); + let port = self.debug_port; + self.emit(STA, AM::Absolute(port)); self.emit(JMP, AM::Label("__debug_halt".to_string())); } @@ -1734,9 +1769,10 @@ impl<'a> IrCodeGen<'a> { } IrOp::DebugLog(args) => { if self.debug_mode { + let port = self.debug_port; for arg in args { self.load_temp(*arg); - self.emit(STA, AM::Absolute(DEBUG_PORT)); + self.emit(STA, AM::Absolute(port)); } } // In release mode, stripped entirely @@ -1749,7 +1785,8 @@ impl<'a> IrCodeGen<'a> { self.emit(BNE, AM::LabelRelative(pass_label.clone())); // Assertion failed: write marker to debug port and BRK self.emit(LDA, AM::Immediate(0xFF)); - self.emit(STA, AM::Absolute(DEBUG_PORT)); + let port = self.debug_port; + self.emit(STA, AM::Absolute(port)); self.emit(BRK, AM::Implied); self.emit_label(&pass_label); } @@ -1956,6 +1993,45 @@ impl<'a> IrCodeGen<'a> { self.load_temp(*level); self.emit(JSR, AM::Label("__set_palette_brightness".into())); } + IrOp::FadeOut(step_frames) | IrOp::FadeIn(step_frames) => { + self.emit_fade_marker(); + self.load_temp(*step_frames); + let target = if matches!(op, IrOp::FadeOut(_)) { + "__fade_out" + } else { + "__fade_in" + }; + self.emit(JSR, AM::Label(target.into())); + } + IrOp::Sprite0Split { scroll_x, scroll_y } => { + // Two-phase busy-wait on PPU `$2002` bit 6 (sprite-0 + // hit flag), then write the requested scroll values + // to `$2005`. Phase 1 waits for the flag to clear + // (the previous frame's hit is still latched at + // NMI time and doesn't clear until the pre-render + // scanline); phase 2 waits for it to set again on + // the current frame's sprite-0 overlap. Emitted + // inline — no runtime routine — so each call site + // gets its own pair of local labels. + let suffix = self.local_label_suffix(); + let clear_label = format!("__ir_spr0_clear_{suffix}"); + let set_label = format!("__ir_spr0_set_{suffix}"); + // Phase 1: wait for flag clear. + self.emit_label(&clear_label); + self.emit(LDA, AM::Absolute(0x2002)); + self.emit(AND, AM::Immediate(0x40)); + self.emit(BNE, AM::LabelRelative(clear_label)); + // Phase 2: wait for flag set. + self.emit_label(&set_label); + self.emit(LDA, AM::Absolute(0x2002)); + self.emit(AND, AM::Immediate(0x40)); + self.emit(BEQ, AM::LabelRelative(set_label)); + // Write new scroll: `$2005` takes two writes, X then Y. + self.load_temp(*scroll_x); + self.emit(STA, AM::Absolute(0x2005)); + self.load_temp(*scroll_y); + self.emit(STA, AM::Absolute(0x2005)); + } IrOp::ReadInputEdge { dest, player, @@ -2368,6 +2444,9 @@ impl<'a> IrCodeGen<'a> { if self.palette_bright_used { self.emit_label("__palette_bright_used"); } + if self.fade_used { + self.emit_label("__fade_used"); + } if self.edge_input_used { self.emit_label("__edge_input_used"); } @@ -2388,6 +2467,14 @@ impl<'a> IrCodeGen<'a> { self.palette_bright_used = true; } + /// Emit the `__fade_used` marker at most once per program. + /// `gen_fade` also calls `__set_palette_brightness`, so fade + /// use forces the palette-brightness marker too. + fn emit_fade_marker(&mut self) { + self.fade_used = true; + self.palette_bright_used = true; + } + /// Emit the MMC3 `__irq_user` handler that dispatches on the /// `(current_state, scanline_step)` pair. Supports multiple /// `on scanline(N)` handlers per state — they fire in ascending @@ -2780,7 +2867,9 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::Rand8(..) | IrOp::Rand16(..) | IrOp::SeedRand(..) - | IrOp::SetPaletteBrightness(..) => true, + | IrOp::SetPaletteBrightness(..) + | IrOp::FadeOut(..) + | IrOp::FadeIn(..) => true, IrOp::InlineAsm(body) => { // Strip the raw-asm magic prefix if present so // we don't false-match the marker characters. @@ -2843,6 +2932,7 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::StartMusic(..) | IrOp::StopMusic | IrOp::ReadInputEdge { .. } + | IrOp::Sprite0Split { .. } | IrOp::SourceLoc(..) => false, }; if is_jsr_emitting { @@ -3051,6 +3141,8 @@ fn op_source_temps(op: &IrOp) -> Vec { | IrOp::SourceLoc(_) => Vec::new(), IrOp::SeedRand(lo, hi) => vec![*lo, *hi], IrOp::SetPaletteBrightness(level) => vec![*level], + IrOp::FadeOut(n) | IrOp::FadeIn(n) => vec![*n], + IrOp::Sprite0Split { scroll_x, scroll_y } => vec![*scroll_x, *scroll_y], } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 16b7089..8cbedf8 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -95,6 +95,12 @@ struct LoweringContext { /// here keeps the per-statement lowering simple and avoids /// having to thread the program through every helper. metasprites: HashMap, + /// When true (driven by `game { sprite_flicker: true }`), the + /// lowerer injects an `IrOp::CycleSprites` op at the top of + /// every `on frame` handler, giving the runtime the same + /// rotating-OAM effect as an explicit `cycle_sprites` call + /// without requiring user code to opt in at every site. + auto_sprite_flicker: bool, } /// A captured `inline fun` body that the lowerer can splice in @@ -186,6 +192,7 @@ impl LoweringContext { start_state: String::new(), wide_hi: HashMap::new(), metasprites: HashMap::new(), + auto_sprite_flicker: false, } } @@ -542,6 +549,10 @@ impl LoweringContext { // Capture state metadata before lowering self.state_names = program.states.iter().map(|s| s.name.clone()).collect(); program.start_state.clone_into(&mut self.start_state); + // Pick up the `sprite_flicker` game attribute so each + // on-frame handler's prologue can inject a CycleSprites + // op without threading the flag through per-handler calls. + self.auto_sprite_flicker = program.game.sprite_flicker; // Capture metasprite declarations so the per-statement // Draw lowering can expand `draw Hero` into one @@ -909,6 +920,18 @@ impl LoweringContext { } } + // When `game { sprite_flicker: true }` is set, implicitly + // call `cycle_sprites` at the top of every `on frame` + // handler. Detected via the handler name suffix — + // `{state}_frame` — so `on_enter` / `on_exit` / scanline + // handlers don't pay the extra ~10 bytes. The existing + // `IrOp::CycleSprites` lowering emits the `__sprite_cycle_used` + // marker on its first hit, which is all the linker needs to + // switch the NMI over to the rotating-OAM variant. + if self.auto_sprite_flicker && name.ends_with("_frame") { + self.emit(IrOp::CycleSprites); + } + self.lower_block(block); self.end_block(IrTerminator::Return(None)); @@ -1133,6 +1156,32 @@ impl LoweringContext { let level = self.lower_expr(&args[0]); self.emit(IrOp::SetPaletteBrightness(level)); } + // `fade_out(step_frames)` / `fade_in(step_frames)` — + // blocking fades that walk brightness 4 → 0 and + // 0 → 4 respectively, calling + // `__set_palette_brightness` per step with + // `step_frames` frames of wait between steps. + "fade_out" if args.len() == 1 => { + let n = self.lower_expr(&args[0]); + self.emit(IrOp::FadeOut(n)); + } + "fade_in" if args.len() == 1 => { + let n = self.lower_expr(&args[0]); + self.emit(IrOp::FadeIn(n)); + } + // `sprite_0_split(scroll_x, scroll_y)` — busy-wait + // for the PPU's sprite-0 hit flag, then write + // the new scroll values to `$2005`. Works on + // any mapper (NROM/UxROM/MMC1 included), unlike + // `on_scanline(N)` which requires MMC3's IRQ. + "sprite_0_split" if args.len() == 2 => { + let x = self.lower_expr(&args[0]); + let y = self.lower_expr(&args[1]); + self.emit(IrOp::Sprite0Split { + scroll_x: x, + scroll_y: y, + }); + } // `rand8()` / `rand16()` at statement position — // valid because they have side effects (advancing // the PRNG state). The returned value is discarded diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 1557756..c54f47d 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -325,6 +325,26 @@ pub enum IrOp { /// and emits the `__palette_bright_used` marker. SetPaletteBrightness(IrTemp), + /// `fade_out(step_frames)` — block for + /// `step_frames × 5` frames while walking brightness 4 → 0 + /// via `__set_palette_brightness`. Codegen lowers to a JSR + /// to `__fade_out` and emits both `__fade_used` and + /// `__palette_bright_used` markers. + FadeOut(IrTemp), + /// `fade_in(step_frames)` — block for + /// `step_frames × 5` frames while walking brightness 0 → 4. + FadeIn(IrTemp), + + /// `sprite_0_split(scroll_x, scroll_y)` — busy-wait for the + /// PPU's sprite-0 hit flag (`$2002` bit 6) and then write + /// the given scroll values to `$2005`. Used to implement + /// fixed status-bar / scrolling-playfield splits on any + /// mapper (unlike `on_scanline(N)`, which requires MMC3). + Sprite0Split { + scroll_x: IrTemp, + scroll_y: IrTemp, + }, + /// Edge-triggered input read: `p1.a.pressed` / `p1.a.released`. /// `dest` receives a boolean (0 or the button mask) — set when /// the button is pressed-but-was-not this frame (for diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 65177a7..ee88866 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -321,12 +321,14 @@ impl Linker { switchable_banks.len() ); // CNROM has a fixed 32 KB PRG — user-declared switchable PRG - // banks are meaningless. AxROM switches 32 KB pages as a - // unit, which the current 16 KB-per-bank trampoline model - // doesn't support cleanly. Reject both up front so a silent - // layout mismatch doesn't produce a subtly broken ROM. + // banks are meaningless. AxROM and GNROM switch 32 KB pages + // as a unit, which the current 16 KB-per-bank trampoline + // model doesn't support cleanly. Reject all three up front + // so a silent layout mismatch doesn't produce a subtly + // broken ROM. assert!( - switchable_banks.is_empty() || !matches!(self.mapper, Mapper::CNROM | Mapper::AxROM), + switchable_banks.is_empty() + || !matches!(self.mapper, Mapper::CNROM | Mapper::AxROM | Mapper::GNROM), "{:?} does not yet support switchable PRG banks (got {} banks); \ use MMC1/UxROM/MMC3 for per-function banking", self.mapper, @@ -576,11 +578,23 @@ impl Linker { // Palette brightness: splice `__set_palette_brightness` // whenever `set_palette_brightness(level)` was called. + // Fade builtins (`fade_out` / `fade_in`) also require the + // brightness routine — the codegen's `emit_fade_marker` + // forces `__palette_bright_used` whenever fade is used, so + // this path picks up fade as a side effect. let has_palette_bright = has_label(user_code, "__palette_bright_used"); if has_palette_bright { all_instructions.extend(runtime::gen_palette_brightness()); } + // Fade helpers (`__fade_out` / `__fade_in` plus the shared + // `__wait_frame_rt` subroutine). Splices when user code + // called `fade_out(n)` or `fade_in(n)`. + let has_fade = has_label(user_code, "__fade_used"); + if has_fade { + all_instructions.extend(runtime::gen_fade()); + } + // Audio subsystem — linked in whenever user code touched // audio (detected via the `__audio_used` marker emitted by // the IR codegen). The driver body, period table, and @@ -789,16 +803,17 @@ impl Linker { // the fixed bank — math runtime, audio tick, other state // handlers, etc. if switchable_banks.is_empty() { - // AxROM (mapper 7) maps a single 32 KB PRG page at - // $8000-$FFFF, so emulators expect PRG size in multiples - // of 32 KB. For single-bank AxROM we emit two 16 KB iNES - // banks: the first is 0xFF fill (maps at $8000-$BFFF when - // bank 0 is selected), and the second is our assembled - // fixed-bank code (maps at $C000-$FFFF). Bank-select - // writes still work — mapper 7's register picks the 32 KB - // page — but with a single PRG page the upper-half code - // is always visible. - if self.mapper == Mapper::AxROM { + // AxROM (mapper 7) and GNROM (mapper 66) both map a + // single 32 KB PRG page at $8000-$FFFF, so emulators + // expect PRG size in multiples of 32 KB. For single-page + // AxROM/GNROM we emit two 16 KB iNES banks: the first + // is 0xFF fill (maps at $8000-$BFFF when bank 0 is + // selected), and the second is our assembled fixed-bank + // code (maps at $C000-$FFFF). Bank-select writes still + // work — the mapper's register picks the 32 KB page — + // but with a single PRG page the upper-half code is + // always visible. + if matches!(self.mapper, Mapper::AxROM | Mapper::GNROM) { let filler = vec![0xFF_u8; 16384]; builder.set_prg_banks(vec![filler, prg]); } else { diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 004d79c..e5dbb67 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -595,6 +595,13 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { IrOp::SetPaletteBrightness(level) => { used.insert(*level); } + IrOp::FadeOut(step_frames) | IrOp::FadeIn(step_frames) => { + used.insert(*step_frames); + } + IrOp::Sprite0Split { scroll_x, scroll_y } => { + used.insert(*scroll_x); + used.insert(*scroll_y); + } } } @@ -653,6 +660,9 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::StopMusic | IrOp::SeedRand(_, _) | IrOp::SetPaletteBrightness(_) + | IrOp::FadeOut(_) + | IrOp::FadeIn(_) + | IrOp::Sprite0Split { .. } | IrOp::SetPalette(_) | IrOp::LoadBackground(_) | IrOp::SourceLoc(_) => None, diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 175d3c6..a704f9a 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -226,6 +226,24 @@ pub struct GameDecl { /// iNES header flavor to emit. Defaults to [`HeaderFormat::Ines1`]; /// programs can opt into NES 2.0 via `game Foo { header: nes2 }`. pub header: HeaderFormat, + /// Absolute address the runtime should write to for `debug.log` + /// output and `__debug_halt` sentinels. Defaults to `$4800` + /// (the FCEUX convention). Programs targeting Mesen/Mesen2 can + /// set `debug_port: mesen` in the `game { }` block, which + /// selects `$4018` — Mesen's documented tracing port. Custom + /// addresses (`debug_port: 0x2FFF`) are also accepted so ROMs + /// for unusual debuggers can retarget the port. + pub debug_port: u16, + /// When true, every `on frame { }` handler automatically bumps + /// the OAM cycle offset by 4 at the top — the same effect as + /// calling `cycle_sprites` as the first statement. Paired with + /// the `__sprite_cycle_used` runtime path this turns the NES's + /// 8-sprites-per-scanline hardware dropout into per-frame + /// flicker, which the eye reconstructs into a full sprite + /// count across frames. Opt-in because it adds ~10 bytes per + /// handler and the flicker only looks correct if user code + /// isn't already managing priorities manually. + pub sprite_flicker: bool, pub span: Span, } @@ -248,6 +266,13 @@ pub enum Mapper { /// for games that want static PRG but swap entire tile sheets /// per screen / level. CNROM, + /// `GNROM` / `MHROM` (mapper 66). Combines `AxROM`-style 32 KB + /// PRG pages with `CNROM`-style 8 KB CHR bankswitching in one + /// register at `$8000-$FFFF`. Bits 4-5 select the PRG page, + /// bits 0-1 select the CHR bank. Useful for small-to-medium + /// homebrew games that outgrow NROM but don't need MMC1's + /// mirroring control or MMC3's scanline IRQ. + GNROM, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e7076f1..551e8ff 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -357,6 +357,11 @@ impl Parser { let mut mapper = Mapper::NROM; let mut mirroring = Mirroring::Horizontal; let mut header = HeaderFormat::Ines1; + // Default to the FCEUX `$4800` convention; programs + // targeting Mesen can override to `$4018` via + // `debug_port: mesen`. + let mut debug_port: u16 = 0x4800; + let mut sprite_flicker: bool = false; while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { let (key, _) = self.expect_ident()?; @@ -371,6 +376,7 @@ impl Parser { "MMC3" => Mapper::MMC3, "AxROM" => Mapper::AxROM, "CNROM" => Mapper::CNROM, + "GNROM" => Mapper::GNROM, _ => { return Err(Diagnostic::error( ErrorCode::E0201, @@ -378,7 +384,7 @@ impl Parser { self.current_span(), ) .with_help( - "supported mappers: NROM, MMC1, UxROM, MMC3, AxROM, CNROM", + "supported mappers: NROM, MMC1, UxROM, MMC3, AxROM, CNROM, GNROM", )); } }; @@ -412,6 +418,66 @@ impl Parser { } }; } + "sprite_flicker" => { + // The lexer emits `true` / `false` as + // `TokenKind::BoolLiteral` (not `Ident`), so + // match on the token rather than going through + // `expect_ident`. + match self.peek().clone() { + TokenKind::BoolLiteral(b) => { + self.advance(); + sprite_flicker = b; + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + "expected `true` or `false` for sprite_flicker", + self.current_span(), + )); + } + } + } + "debug_port" => { + // Accept either a named alias (`fceux`, `mesen`) + // or an integer literal for custom ports. The + // numeric path routes through the normal int + // literal parser so both decimal and `0x…` + // hex work. + match self.peek().clone() { + TokenKind::Ident(alias) => { + self.advance(); + debug_port = match alias.as_str() { + "fceux" => 0x4800, + "mesen" => 0x4018, + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown debug port alias '{alias}'"), + self.current_span(), + ) + .with_help( + "supported aliases: fceux ($4800), mesen ($4018); \ + or pass a numeric literal (e.g. `0x2FFF`)", + )); + } + }; + } + TokenKind::IntLiteral(n) => { + // IntLiteral is already a u16, so it + // can't exceed the 6502 address space — + // no range check needed. + self.advance(); + debug_port = n; + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + "expected an alias (`fceux`, `mesen`) or a numeric address", + self.current_span(), + )); + } + } + } _ => { return Err(Diagnostic::error( ErrorCode::E0201, @@ -428,6 +494,8 @@ impl Parser { mapper, mirroring, header, + debug_port, + sprite_flicker, span: Span::new( start_span.file_id, start_span.start, diff --git a/src/pipeline.rs b/src/pipeline.rs index 39fb520..b1fc223 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -200,7 +200,8 @@ pub fn compile_source( .with_sprites(&sprites) .with_audio(&sfx, &music) .with_debug(opts.debug) - .with_source_map(opts.emit_source_map); + .with_source_map(opts.emit_source_map) + .with_debug_port(program.game.debug_port); let mut instructions = codegen.generate(&ir_program); peephole::optimize(&mut instructions); diff --git a/src/rom/mod.rs b/src/rom/mod.rs index 8d3520a..915a4f1 100644 --- a/src/rom/mod.rs +++ b/src/rom/mod.rs @@ -277,5 +277,6 @@ pub fn mapper_number(mapper: Mapper) -> u8 { Mapper::CNROM => 3, Mapper::MMC3 => 4, Mapper::AxROM => 7, + Mapper::GNROM => 66, } } diff --git a/src/rom/tests.rs b/src/rom/tests.rs index 8d1aad8..30798b4 100644 --- a/src/rom/tests.rs +++ b/src/rom/tests.rs @@ -309,6 +309,7 @@ fn nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers() { crate::parser::ast::Mapper::MMC3, crate::parser::ast::Mapper::AxROM, crate::parser::ast::Mapper::CNROM, + crate::parser::ast::Mapper::GNROM, ] { let mut builder = RomBuilder::new(Mirroring::Horizontal); builder.set_mapper(crate::rom::mapper_number(mapper)); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 4f4d6dd..6cd729c 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -209,6 +209,15 @@ pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7; pub const PREV_INPUT_P1: u16 = 0x07E6; pub const PREV_INPUT_P2: u16 = 0x07E7; +/// Fade-helper scratch bytes. `FADE_STEP_FRAMES` holds the +/// per-step frame count saved at the top of `__fade_out` / +/// `__fade_in`; `FADE_LEVEL` tracks the current brightness level +/// as the fade walks 4 → 0 or 0 → 4. Programs that never call the +/// builtins (detected via the absence of `__fade_used`) leave +/// these bytes free for analyzer allocation. +pub const FADE_STEP_FRAMES: u16 = 0x07E8; +pub const FADE_LEVEL: u16 = 0x07E9; + /// PRNG state — two bytes holding a 16-bit xorshift state. /// Only touched by the `__rand8` / `__rand16` / `__rand_seed` /// runtime routines, which the linker splices in whenever user @@ -1782,6 +1791,143 @@ pub fn gen_palette_brightness() -> Vec { out } +/// Generate the blocking fade helpers `__fade_out` and `__fade_in`, +/// plus the shared callable `__wait_frame_rt` they use internally. +/// Spliced by the linker whenever user code contains the +/// `__fade_used` marker label (emitted by the IR codegen's +/// `FadeOut` / `FadeIn` ops). +/// +/// Both fade entry points take `step_frames` in `A` — the number +/// of frames to hold each brightness level before advancing. A +/// full fade-out runs through levels 4 → 3 → 2 → 1 → 0 (five +/// steps) and a fade-in runs 0 → 1 → 2 → 3 → 4, so the total +/// blocking time is `step_frames × 5` frames. +/// +/// Internally the fade JSRs `__set_palette_brightness` for each +/// step and `__wait_frame_rt` between steps. `__set_palette_brightness` +/// is always linked in alongside fade (we can't emit it conditionally +/// without also forcing its marker). `__wait_frame_rt` spins on +/// `ZP_FRAME_FLAG` exactly the way an inline `wait_frame` does. +/// +/// Main-RAM scratch bytes the routines use: +/// `FADE_STEP_FRAMES` ($07E8) — saved copy of `step_frames` +/// `FADE_LEVEL` ($07E9) — current brightness level +/// +/// Both slots are free for analyzer allocation in programs that +/// don't use fade (they never trigger the marker). +#[must_use] +pub fn gen_fade() -> Vec { + let mut out = Vec::new(); + + // ── __wait_frame_rt ────────────────────────────────────── + // Callable wait-for-NMI: spin until `ZP_FRAME_FLAG` is + // nonzero, then clear it and RTS. Mirrors the inline code + // the IR codegen emits for `IrOp::WaitFrame`, but callable + // with a JSR from inside other runtime routines. + out.push(Instruction::new(NOP, AM::Label("__wait_frame_rt".into()))); + out.push(Instruction::new( + NOP, + AM::Label("__wait_frame_rt_loop".into()), + )); + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_FRAME_FLAG))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__wait_frame_rt_loop".into()), + )); + out.push(Instruction::new(LDA, AM::Immediate(0))); + out.push(Instruction::new(STA, AM::ZeroPage(ZP_FRAME_FLAG))); + out.push(Instruction::implied(RTS)); + + // ── __fade_out ─────────────────────────────────────────── + // A = step_frames on entry. Loops 4 → 3 → 2 → 1 → 0, calling + // __set_palette_brightness each iteration and waiting + // step_frames frames between steps. + // + // Zero-safety: if the caller passes A=0, the inner wait loop + // would DEX-underflow from 0 to $FF and burn 256 frames per + // step (~21 seconds on NTSC). Force a minimum of 1 so the + // pathological "pass 0" case behaves as "1 frame per step" + // instead of "hold forever". + out.push(Instruction::new(NOP, AM::Label("__fade_out".into()))); + out.push(Instruction::new(ORA, AM::Immediate(0x00))); + out.push(Instruction::new(BNE, AM::LabelRelative("__fade_out_ok".into()))); + out.push(Instruction::new(LDA, AM::Immediate(1))); + out.push(Instruction::new(NOP, AM::Label("__fade_out_ok".into()))); + out.push(Instruction::new(STA, AM::Absolute(FADE_STEP_FRAMES))); + out.push(Instruction::new(LDA, AM::Immediate(4))); + out.push(Instruction::new(STA, AM::Absolute(FADE_LEVEL))); + // Fall through into the shared loop that runs until FADE_LEVEL + // underflows past 0 (BMI catches the $FF that DEC produces). + out.push(Instruction::new(NOP, AM::Label("__fade_step".into()))); + out.push(Instruction::new(LDA, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new( + JSR, + AM::Label("__set_palette_brightness".into()), + )); + // Wait step_frames frames. + out.push(Instruction::new(LDA, AM::Absolute(FADE_STEP_FRAMES))); + out.push(Instruction::implied(TAX)); + out.push(Instruction::new(NOP, AM::Label("__fade_wait".into()))); + out.push(Instruction::new(JSR, AM::Label("__wait_frame_rt".into()))); + out.push(Instruction::implied(DEX)); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_wait".into()), + )); + // Decrement the level and loop if it's still a valid level. + // `__fade_out` is at entry point A; `__fade_in` jumps into a + // sibling block below that decrements upward. Here, decrement + // and bail out when we hit $FF (underflow past 0). + out.push(Instruction::new(DEC, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new(LDA, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new(CMP, AM::Immediate(0xFF))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_step".into()), + )); + out.push(Instruction::implied(RTS)); + + // ── __fade_in ──────────────────────────────────────────── + // A = step_frames on entry. Loops 0 → 1 → 2 → 3 → 4. + // Uses its own top-of-loop label and increments rather than + // decrementing; shares the step-wait body via a small duplicate + // loop (cheaper than threading direction through the shared + // body for a one-off five-step loop). + out.push(Instruction::new(NOP, AM::Label("__fade_in".into()))); + out.push(Instruction::new(ORA, AM::Immediate(0x00))); + out.push(Instruction::new(BNE, AM::LabelRelative("__fade_in_ok".into()))); + out.push(Instruction::new(LDA, AM::Immediate(1))); + out.push(Instruction::new(NOP, AM::Label("__fade_in_ok".into()))); + out.push(Instruction::new(STA, AM::Absolute(FADE_STEP_FRAMES))); + out.push(Instruction::new(LDA, AM::Immediate(0))); + out.push(Instruction::new(STA, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new(NOP, AM::Label("__fade_in_step".into()))); + out.push(Instruction::new(LDA, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new( + JSR, + AM::Label("__set_palette_brightness".into()), + )); + out.push(Instruction::new(LDA, AM::Absolute(FADE_STEP_FRAMES))); + out.push(Instruction::implied(TAX)); + out.push(Instruction::new(NOP, AM::Label("__fade_in_wait".into()))); + out.push(Instruction::new(JSR, AM::Label("__wait_frame_rt".into()))); + out.push(Instruction::implied(DEX)); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_in_wait".into()), + )); + out.push(Instruction::new(INC, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new(LDA, AM::Absolute(FADE_LEVEL))); + out.push(Instruction::new(CMP, AM::Immediate(5))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_in_step".into()), + )); + out.push(Instruction::implied(RTS)); + + out +} + /// Emit the reset-time PRNG state seed. Spliced into the reset /// path whenever `gen_prng` is linked in, so the first `rand8()` /// call returns a useful value even without an explicit @@ -1847,6 +1993,7 @@ pub fn gen_mapper_init( Mapper::MMC3 => gen_mmc3_init(mirroring), Mapper::AxROM => gen_axrom_init(mirroring), Mapper::CNROM => gen_cnrom_init(), + Mapper::GNROM => gen_gnrom_init(), }; // Initialize ZP_BANK_CURRENT to the fixed bank index for any // banked mapper. CNROM and AxROM don't use per-function bank @@ -1902,6 +2049,20 @@ fn gen_cnrom_init() -> Vec { out } +/// `GNROM` (mapper 66) reset. Single register at `$8000-$FFFF`: +/// bits 4-5: select 32 KB PRG page at `$8000-$FFFF` +/// bits 0-1: select 8 KB CHR bank +/// At power-on both fields are nominally zero but some boards +/// leave them undefined, so we explicitly write `$00` — PRG page 0 +/// with CHR bank 0. +fn gen_gnrom_init() -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__gnrom_init".into()))); + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out +} + /// MMC1 reset: pulse the reset bit, then write the control register. /// Control-register layout (5 bits, serialized LSB-first into any /// $8000-$FFFF address): @@ -2099,6 +2260,17 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec { out.push(Instruction::new(STA, AM::Absolute(0x8000))); out.push(Instruction::implied(RTS)); } + Mapper::GNROM => { + // GNROM: one register at `$8000-$FFFF` packs both a + // 32 KB PRG page (bits 4-5) and an 8 KB CHR bank + // (bits 0-1). The input A is the packed byte; user + // code is responsible for composing it. Like CNROM + // and AxROM, real boards have bus conflicts and a + // production API should use a bus-conflict-safe + // table. + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out.push(Instruction::implied(RTS)); + } } out } diff --git a/tests/emulator/goldens/auto_sprite_flicker.audio.hash b/tests/emulator/goldens/auto_sprite_flicker.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/auto_sprite_flicker.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/auto_sprite_flicker.png b/tests/emulator/goldens/auto_sprite_flicker.png new file mode 100644 index 0000000000000000000000000000000000000000..89a283fa43e7d3115f1c7021b46e0547a8457e26 GIT binary patch literal 1422 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ4fS+!45?szdvJHJ%1$xY zi?QOm0ldZyMD>?3fAO~{`2*^cltd>bjUpmL;O_(TmwEG! zN$gp?yHYCoL*Kh+nLP)scgd->~mJ%$thEqNqf z3RJVM`cu)}$(zORo;?rJLKSZB7j@D7CvSF7-z@&#s`_Kmw@0z*`f=yt_Pl;`^z_Z{ z^0|8}fUaM+SzJF|f64xZKYSs!mdAd7@^qK_d!S!Vm-^o;y*qjO>v?-#?^4&lZd-Z1 z)IV-rwbEZ+`yamN;$pr(ff%&o-ARZog?A?dO}kh6?a?mvc!*V{{`-I-bNy!b>v=Vn z)rx<4sT6Sh4<_GVfH#sLvDf%zc`d_ykN=b2J$rMs)c;&u&CYkgPyq%14|PyXyq-5_ z?+>oP6=)XoJBZvzfoA@6Hdrsu?+&GE`Bn1Ak+o=S}d1p3$0Ts8Nu6{1-oD!M<`&f9} literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/fade_demo.audio.hash b/tests/emulator/goldens/fade_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/fade_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/fade_demo.png b/tests/emulator/goldens/fade_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..6ade9b9e97847ddb514a1aadd5ee52d6dbcc56d0 GIT binary patch literal 44915 zcmeI5e^8T08pq*0$W_Gvj-rtx{+Nh!3?h{Xwj8fYgo+}hch{&~0ELP~3u38fW}Kuo zC&EBX2g*S~2MKqWJ1c1YaY!>0DMBs>C>~MJVFn~LSg2DV<6U0vCGYLN0lkjss5JCV zviyKEE%iZ*g&{ zeQOJE<8P0+e%El(V@J#1Hr)PTh*1>p=6X6l+jn0~ZzK7Zu6>ahrM^F81|?5BM$7eL}4E6MuC0(gePDhClMRF z&jj4#d0;nlmt=FwRS&d_vt%`?^SMOWij4N+1IiqB+6|BT!dU&Vp`dP~;LALgyPBI z*n9&=bd)DAp-G`Rfz&1qQlnCB9gwxuSk#@PM(wDb{UjY?4CiCcON&W&W%7skJYLse z)ZXiJFK4IaFF7DN6_{}W&$>#J!huYpAI=?b!AT_3FJt!g&LdEC#rMQ#>9ZNe-Y3oadqHuhcUWbNTwhI05i(5klbU@N7

I_QyNQX%CR=X4yQ>voOaTm!5!|AleGw#aL5Adn+q^ZuJqz@sV z3@0Fc2<0xN52bL%gOr3VH5L&@w#0z|3q5rPC4E551m8+ione(enIP)-SGyDzQ>h~2 zJo|c(WvR17qMo6}lfYU90r2TVQ=MUzKAMq<&@|GAFgj<|hfFZzK}y1w8Y_oAAjNec zz(P-*K}jF!kiT>b{qh-4Q!K=J(hy|N$&4Rr4$b3P<|ekI=PjO3i^=F5UIg*EiGKNv zrzsXw=w!AM=|h-Gqx2!U49s{?3d5G!nqdPG1m^O#$!83Z_hcyv7Y_^bl^xA+rDZrv1B08QRN7V&|B8OBdFr@AmWU=;H>q z%2IZztB;EhZp-haU8A!SSE)Jwe$(Z;&l%SWs}2VTW*9ft^s9v1^2^1a2CNtPboA}@ z>yJ6<1XVI&Uwm@EDNu0eoUr>4=kK)rij9=R7iP%wN!QsHrBgq&$&ree!@h`ov0CH2lthg zR0lKX#hU&VVr-~PPb_N=4-0AAO1nnC(R_>%n;&iJ=x&w$UdR8cJtWk$RB%YYI;5>F z{2#@>SLVcixR!Z{6Ycm_Mnhp=YYZJ?|!m>JTx+ixFbuf|WR*Mn-%Xq&^^Lr#Ed z&~ia5;~FMqzAVtn7gU2boZ`2ytPxa$hV0Zg|1PttWy=tJjpk)xL_^1D6xt*jR?Ip;kt-BF&B-R z$dc#tC$i*w^b?-P;j2*235)-#?wgp5EBOgE{qNECtG@)EyFh3j&oMV9=~p}PL-V~d zF;b-_&=*pbuz+Ps)`J!0l)SULyi4$|<7_W=4T5I%owf z7qnUrszLK@2Ccw~a>|B5E09H}4B5GF-XCLW$&~EVAB7%|W*>3m#uGE}rqt`_%;Uxt zY})z%*TNHm27I5)>U0q_tZ&uX=X?P{!}hpC(3r-1ya*cbm0kOEF42#kr=1Wq5Hyg| zc#!}@&_K`t(KDnpkkW9B$Rtu4NNJ$()QluT5Ht`p5HwIuoW z{?03_-ughj;f-_2*SGHod3*QuU%jVnWzSx>bX8ph{+~rQSimkB^cg;(Q3>~zO>Dhh zX0&d-e!Tjcb$9m4^oiZs9lnai2>ODpqW3oJ)CTdR^ELPYIFPny%#C;uB=T|Mhj#M* z#7NcPpf516D*{?kZnL%UhdAUnq1d0(Bj83FH2XLUc8RCou^s<+lE_($mTB*Mkyy7% zLC}DQlvtfEf`&cM7Z5aTzkv#Z#xxFZvG;qzod%O02@&50w zKTI2PD~fk>Jslr%+B4owmnvD_OK>2&rWVKKpiH? z$da`wE;V_q!)`7N?IOYwx|2?F1fz~8uSv~i z3G-CJ_1^rdfW^uv!E#YS4Nw0tCP#IOR2_AOj->2nip3gig2Ex8UQ(#aGBl|)z31PlPy;x}2U3B>Xz&q(R(dxR%w9$+z)4GB8k06|gg@<^@+T}pAh-OtMXGiIe_J+h=hbXVKK4JF CsTR`! literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/gnrom_simple.audio.hash b/tests/emulator/goldens/gnrom_simple.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/gnrom_simple.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/gnrom_simple.png b/tests/emulator/goldens/gnrom_simple.png new file mode 100644 index 0000000000000000000000000000000000000000..87d03a99f477fc96cb75a4bff005e2a0e8002706 GIT binary patch literal 836 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ=Js@P45?szd(g1=4g&+r zfxn?#+uI*%$kekeOW?`7TPxx^vFTxX+P+CO>@%(~Y?ew0V?LAB5Xoz>nla7vz#6t0 zS4W)SekSI5&rkmEd(7d$%;%L(mk#iMv+;lT`poOUC+*jN zR|iT}&Rdh6aG-YX&*%HjeF$e}j(hL_uX3;b!}alvj6mtH|DN>!wmKZo`zPUe;S&h2 zI6nPf#a?^MKIPY-ps(BUyzUIag0hFMUIeAP0Il?2d=w)zui4t0SyRye7C#K z_;IpxH!{mU-}652^Ss|a&B~|=-u~WhZf+A|qL;36b90B6Zf#?|VEtdgSO0QzJ8(W` zsX8&!{o&Ww_H8=r`@3(;BmV01hkqJ=AHR0`%$^{!K4sjx=&>um`;f}iELi3dY}P&L ziSM~op*2&LRNI4_74&g}?0NKnc%VR+>IS@|q{jH?uijxTRV_Fgz7pG))C!Ma;;?Gp z*|KTpZP!dy?<9V7*S;w(d2UE_`mV5Gm7h;(_#*#&lhM0=sxmjGHqx^-H?R8jmXhqw z>Zp{-?VH?^##Ppc`6h;(!#p$9`-dmo2C@lDI zoz*eezBG5U!0-QNx4Rhd@274Ihy5h%3;JSJx0s=CD0rSlU$++$q(T5Rd@jfm?KyOo z(3m&?;Od%zyrA7K5Vu8F>YlH>Dgl<_cuMcVz7T;edF0Vr>WUJm@S~zf3dAO-Z(MzH za~!oSWpYIAvE=Kfs)?25w}4GnrG?DPjTx^hG_1h>l9DBg4yJe7WHh`XO^S$0gK)jQ z7zp9yJ%$yYssiDtArA~w^!Dk%3K97fgbfaWe29q>T?%3flmjir4CyaGY(Ec=h4^N7 zjhTZ#_%bKtAe1Go6U0nQ(iz@WGyA+vznQH{NiDW`sB2PE%INe>aVuMMwIB`9>pg<$ zC9YT{JNBO;miY8B_k8pVD&l&1D5OyhVhPj_3X7oCG!HVNg4aD%5T!yD(9eNPRJ0`O#>U)(@4sS-6Rhj2fEv>kaLoi;GY2e;$?TWtJKBrbG2p zXXXOQ#PTI!%Ryu^Rf;zV_HBvxXP)9FH0D5o)C{v1LlIq#jeq{~t=N*rlFlp3!%rlC zo4=-USx@AHHy2MUefxvEK9h#h2qGR|%5!E5$H71=Y1eT!-2iI z1ltUy+Uf|JG~d`=qZM<9yYGgFHadlWzRP#2pCaM#+V7xdYN@s(PSG&AmUMU)8E+? zolX`IUzi5b$TD&8L^I5IsToS&Vr!10HXh!975!3=8)1ux^ zIV9WGL$mxg3Ns?7dKwBd`hv-59ttxWp=K{cVMYs>Gr_}Mb_?;-V-RLcLl*bd5N15> z`X9qO)sLEjS|+^@rRsi&E*IsYhbu|0}mfsF%yWsb>Tx^DbI+N=b?agugVDW6X zQR?8>i}AN<^U8tW5X&wx}Jymc2zDQ4@F1QB&K8C>@}QB&RJZEwGn~mGIf6N{XgKm-75Y2St1Ph+dQm>&cK-S=}ETd$j^4yJMc>tZEx@v-=c5e(bC&n^(f? zek63WL1RJFgYa4^q()P+U4un3G?%PaQr+JdF_yTZr z=&|4vf+1^KgP=m>vy_r-Q0iiwp+oWz5^133E~Od9XTa<@+?JaXqbWKy?ph>IEY(?A zN9E^`UFXJ8ibo{w!yz3%7zxv?xM>-pZO9A)9DAEp?6izf%gFa=o`ptUUr6_(!dxnJ zr=|}=qArp4ah?`D=P+|~xQku6bKAC{TL#aBwyXY?^%IVt=_>U(b3mucw!AvF{If1| z5bF6M*X9T<+ztPq2t_fx4`Q%qOe{y&JW;EId$Cq}l7Dux*WR@wy z&h{XH)WI^lq4T{6`rPr*@_w~ruh9QAcjahT*%EA;QTV%+wib*Ww8<#>av&cQmKB#7ueTlM6|$UTe~J)_dQHHgEdc z?H7OQ{LjD7{ZcdmNMM`Hv>|7@h|shlAybS4^u|$wlAAU(LM`iI>y%GFfIn>rV+t}d z|1~u33zD4JrgZwnhz6LYA^HR=8k(nP&)kt+L=brg7ZKf_qjai^s4GWVE>zC@IS7d| z=Bczb3b*t(6GzuUxY03VTM`c-BspCt0`A9#8&L$@apK4D)Cd=NH#+v4o5?1&nHPg_ z2K8Djd*^{TX0kbBLI&r)WR8juViLv+5E4z{V2_HxEB(#H(fKpkT;LHJp3aFLKuB_8 znDy?*BH4`k0E5t<aWK zK^eDcdjf9hZzhgj#5;*V5)UBE_H~6pLTq@)!iF1B1l)0+$ME(E{NHWm9jGG@UE@uz z9ND>Q{h^UJ%Z)iu$BDz%IEu*~sDoGfn~9_ICs*SC2sv`XFDFwn4`7xnMZgC*E<$vZ z@cQh{HgqoB80Pp!Nl&EgC7>{LEmUt$aOTjpRK5McXbj5eB6N60z*BkWwczpq_L#dG zP3zv9lHV>|Hr@Y9?9M}{jyyT5z2W)arsAYs9VO%LSRyLzEs=g_j5!-^ zSB!JF`6;e675cr~I5+T9OL*v47vEhNW=#LhAG6M7Zt}hNj~`z7z;<=ch3(f~@B#Gd zi|u1RopIJ!n)LCx=uv%PH@v}Q*E~h@3zu#J^3%G1cBKBMD g$CZ~ec6~$Gzbt9^Sps#!4gSPLu3Y-Xk`3Aa2k3hC`v3p{ literal 0 HcmV?d00001 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index aef4b7d..15dae4c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -3224,6 +3224,21 @@ start Main assert_eq!(info.prg_banks, 2, "AxROM should be 32 KB PRG"); } +#[test] +fn gnrom_rom_has_correct_header_and_size() { + let source = r#" +game "GnDemo" { mapper: GNROM } +var x: u8 = 0 +on frame { x += 1 } +start Main +"#; + let rom = compile_with_mapper(source); + let info = rom::validate_ines(&rom).expect("should be valid iNES"); + assert_eq!(info.mapper, 66, "GNROM is iNES mapper 66"); + // GNROM (like AxROM) ships in 32 KB PRG pages. + assert_eq!(info.prg_banks, 2, "GNROM should be 32 KB PRG"); +} + #[test] fn cnrom_rom_has_correct_header() { let source = r#" @@ -3263,15 +3278,482 @@ start Main ); } +#[test] +fn sprite_0_split_emits_two_phase_busy_wait_and_scroll() { + // `sprite_0_split(x, y)` should emit the classic two-phase + // wait on `$2002` bit 6, followed by two writes to `$2005` + // (the PPU scroll register takes X then Y). We verify the + // byte-level sequence appears in the fixed bank — the + // `LDA $2002 / AND #$40 / BEQ` pattern is the phase-2 wait, + // and the `STA $2005 / STA $2005` pair comes right after. + let source = r#" +game "Spr0" { mapper: NROM } +var sx: u8 = 5 +var sy: u8 = 9 +on frame { + sprite_0_split(sx, sy) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.unwrap(); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + // `LDA $2002` = `AD 02 20` (little-endian). + let lda_2002 = [0xAD_u8, 0x02, 0x20]; + // `AND #$40` = `29 40`. + let and_40 = [0x29_u8, 0x40]; + // `STA $2005` = `8D 05 20`. + let sta_2005 = [0x8D_u8, 0x05, 0x20]; + assert!( + linked.rom.windows(3).any(|w| w == lda_2002), + "sprite_0_split should read `$2002` to poll bit 6" + ); + assert!( + linked.rom.windows(2).any(|w| w == and_40), + "sprite_0_split should mask with `#$40` for bit 6" + ); + // Two `STA $2005` writes for the X/Y scroll pair. + let scroll_writes = linked.rom.windows(3).filter(|w| *w == sta_2005).count(); + assert!( + scroll_writes >= 2, + "sprite_0_split should emit at least two `STA $2005` writes (X, Y); got {scroll_writes}" + ); +} + +#[test] +fn sprite_0_split_arity_enforced() { + // Wrong arity must fail at compile time, not later. + let source = r#" +game "BadSpr0" { mapper: NROM } +on frame { + sprite_0_split(1) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis + .diagnostics + .iter() + .any(|d| d.is_error() && d.message.contains("sprite_0_split")), + "sprite_0_split with 1 arg should error; got {:?}", + analysis.diagnostics + ); +} + +#[test] +fn sprite_0_split_omitted_without_use() { + // Programs that never call sprite_0_split must not have the + // phase-2 BEQ busy-wait pattern anywhere in their ROM. + let source = r#" +game "NoSpr0" { mapper: NROM } +var x: u8 = 0 +on frame { x = x + 1 } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.unwrap(); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + // Programs without sprite_0_split shouldn't have the + // `LDA $2002 / AND #$40` pattern on a busy-wait. $2002 reads + // do happen elsewhere (sprite-overflow sampling in debug + // mode), but the AND #$40 mask is specific to sprite-0-hit. + let lda_2002 = [0xAD_u8, 0x02, 0x20]; + let and_40 = [0x29_u8, 0x40]; + // Find any place where `LDA $2002` is immediately followed + // by `AND #$40`. That's the sprite-0-hit poll signature. + let has_poll_pattern = linked + .rom + .windows(5) + .any(|w| w[0..3] == lda_2002 && w[3..5] == and_40); + assert!( + !has_poll_pattern, + "program without sprite_0_split should not emit the sprite-0 busy-wait" + ); +} + +#[test] +fn fade_out_emits_jsr_and_forces_palette_bright() { + // `fade_out(n)` should emit a JSR to `__fade_out` and also + // force the palette-brightness routine to be linked in, + // since the fade body JSRs into it. + let source = r#" +game "FadeOutProg" { mapper: NROM } +on frame { + fade_out(6) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + let fade_addr = *linked + .labels + .get("__fade_out") + .expect("fade_out should link in __fade_out"); + assert!( + contains_jsr_to(&linked.rom, fade_addr), + "fade_out(6) should emit JSR __fade_out" + ); + // The fade routine internally JSRs __set_palette_brightness, + // so that routine must also be present even though user code + // never called `set_palette_brightness` directly. + assert!( + linked.labels.contains_key("__set_palette_brightness"), + "fade_out forces __set_palette_brightness into the ROM" + ); + // And the __wait_frame_rt helper must be present, since + // gen_fade JSRs it between steps. + assert!( + linked.labels.contains_key("__wait_frame_rt"), + "fade_out must link in __wait_frame_rt" + ); +} + +#[test] +fn fade_omitted_without_use() { + // Programs that never touch fade should not pay for it. + let source = r#" +game "NoFade" { mapper: NROM } +var x: u8 = 0 +on frame { x = x + 1 } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.unwrap(); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + for sym in ["__fade_out", "__fade_in", "__wait_frame_rt"] { + assert!( + !linked.labels.contains_key(sym), + "{sym} should not be linked into a program that never uses fade" + ); + } +} + +#[test] +fn fade_void_in_expression_position_errors() { + // Like the other void intrinsics, fade must be rejected at + // expression position. + let source = r#" +game "BadFade" { mapper: NROM } +on frame { + var _x: u8 = fade_out(6) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis + .diagnostics + .iter() + .any(|d| d.is_error() && d.message.contains("does not return a value")), + "fade_out in expression position should error; got {:?}", + analysis.diagnostics + ); +} + +#[test] +fn sprite_flicker_attribute_emits_cycle_marker() { + // `game { sprite_flicker: true }` should cause the IR + // lowerer to inject an `IrOp::CycleSprites` at the top of + // every on_frame handler. The marker is what flips the NMI + // into the rotating-OAM variant, so we check that the + // linker's label table contains it. + let source = r#" +game "Flicker" { + mapper: NROM + sprite_flicker: true +} +on frame { + draw Ball at: (10, 20) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + assert!( + linked.labels.contains_key("__sprite_cycle_used"), + "sprite_flicker: true should emit the __sprite_cycle_used marker" + ); +} + +#[test] +fn sprite_flicker_attribute_defaults_off() { + // A program that doesn't opt in must produce byte-identical + // output to the same program on any pre-flicker-attribute + // codegen: no `__sprite_cycle_used` marker, no NMI rotation. + let source = r#" +game "NoFlicker" { mapper: NROM } +on frame { + draw Ball at: (10, 20) +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + assert!( + !linked.labels.contains_key("__sprite_cycle_used"), + "sprite_flicker defaults to false and must emit no marker" + ); +} + +#[test] +fn sprite_flicker_bad_value_errors() { + // Non-bool values on sprite_flicker are rejected by the + // parser, not silently coerced. + let source = r#" +game "BadFlicker" { + mapper: NROM + sprite_flicker: yes +} +start Main +"#; + let (_, diags) = nescript::parser::parse(source); + assert!( + diags + .iter() + .any(|d| d.is_error() && d.message.contains("true")), + "non-bool on sprite_flicker should error; got {diags:?}" + ); +} + +#[test] +fn debug_log_targets_configured_port() { + // `debug.log` should write to whatever port the `game { }` + // block selected. Covers the alias shortcuts (`fceux`, + // `mesen`) and an explicit hex override. `game { }` fields + // are newline-separated; commas are not a separator there. + for (source, expected_addr) in [ + ( + r#" +game "FceuxDefault" { + mapper: NROM + debug_port: fceux +} +on frame { debug.log(7) } +start Main +"#, + 0x4800_u16, + ), + ( + r#" +game "MesenDebug" { + mapper: NROM + debug_port: mesen +} +on frame { debug.log(7) } +start Main +"#, + 0x4018_u16, + ), + ( + r#" +game "CustomDebug" { + mapper: NROM + debug_port: 0x2FFF +} +on frame { debug.log(7) } +start Main +"#, + 0x2FFF_u16, + ), + ] { + let (program, diags) = nescript::parser::parse(source); + assert!( + diags.iter().all(|d| !d.is_error()), + "parse errors for `{source}`: {diags:?}" + ); + let program = program.expect("parse should succeed"); + + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music) + .with_debug(true) + .with_debug_port(program.game.debug_port); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + // Scan the ROM for `STA abs ` = opcode 0x8D + // followed by the little-endian address bytes. This is the + // same authoritative byte-level assertion as the edge-input + // test — byte-identical wording. + let lo = (expected_addr & 0xFF) as u8; + let hi = (expected_addr >> 8) as u8; + assert!( + linked + .rom + .windows(3) + .any(|w| w[0] == 0x8D && w[1] == lo && w[2] == hi), + "debug.log should target {expected_addr:#06X} (got a ROM without `STA ${expected_addr:04X}`)" + ); + } +} + +#[test] +fn debug_port_unknown_alias_errors() { + let source = r#" +game "BadPort" { + mapper: NROM + debug_port: notarealemulator +} +start Main +"#; + let (_, diags) = nescript::parser::parse(source); + assert!( + diags + .iter() + .any(|d| d.is_error() && d.message.contains("unknown debug port alias")), + "unknown alias should parse-error; got {diags:?}" + ); +} + #[test] fn void_intrinsic_in_expression_position_errors() { - // `seed_rand(...)`, `set_palette_brightness(...)`, and `poke(...)` - // don't produce values, so using them in expression position - // should be a compile-time error (not a linker panic later). + // Every void intrinsic — `seed_rand`, `set_palette_brightness`, + // `poke`, `fade_out`, `fade_in`, `sprite_0_split` — should + // refuse expression position at compile time rather than + // panicking the linker with an unresolved `__ir_fn_X` label. let cases = [ "var x: u8 = seed_rand(42)", "var x: u8 = set_palette_brightness(4)", "var x: u8 = poke(0x0200, 1)", + "var x: u8 = fade_out(6)", + "var x: u8 = fade_in(6)", + "var x: u8 = sprite_0_split(0, 0)", ]; for frag in cases { let source = format!(