@tmustier/pi-nes 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -36,6 +36,13 @@ pi-nes/
36
36
 
37
37
  The emulator uses the [`nes_rust`](https://crates.io/crates/nes_rust) crate (vendored + patched in `native/nes-core/vendor/nes_rust` for SRAM helpers) with [napi-rs](https://napi.rs) bindings.
38
38
 
39
+ ### Vendored `nes_rust` workflow
40
+
41
+ - Source of truth is the fork (intended): `https://github.com/tmustier/nes-rust`.
42
+ - Make changes in the fork first, then re-vendor via `scripts/update-vendor-nes-rust.sh`.
43
+ - Update `extensions/nes/native/nes-core/vendor/nes_rust/VENDOR.md` with the fork commit + date + patch summary.
44
+ - Keep TODO inventory in `extensions/nes/native/nes-core/vendor/nes_rust/TODO_INVENTORY.md`.
45
+
39
46
  **API exposed to JavaScript:**
40
47
  - `new NativeNes()` - Create emulator instance
41
48
  - `setRom(Uint8Array)` - Load ROM data
package/README.md CHANGED
@@ -106,6 +106,12 @@ Set `"renderer": "text"` if you prefer the ANSI renderer or have display issues.
106
106
  - **No audio** — Sound is not currently supported
107
107
  - **No save states** — Only battery-backed SRAM saves work
108
108
 
109
+ ## Vendored Dependencies
110
+
111
+ - `nes_rust` is vendored under `extensions/nes/native/nes-core/vendor/nes_rust`.
112
+ - Fork: https://github.com/tmustier/nes-rust (upstream: https://github.com/takahirox/nes-rust)
113
+ - Update helper: `scripts/update-vendor-nes-rust.sh`
114
+
109
115
  ---
110
116
 
111
117
  ## Building from Source
@@ -0,0 +1,116 @@
1
+ # TODO Inventory Review
2
+
3
+ Scope: `extensions/nes/native/nes-core/vendor/nes_rust` (vendored emulator core). Each TODO reviewed for necessity.
4
+
5
+ ## Action Plan (prioritized)
6
+
7
+ ### P0 — Correctness (act)
8
+ - [ ] APU timing fixes (sample_period, sampling timing, frame sequencer timing, sweep negation, DMC stall timing)
9
+ - [ ] PPU fetch timing + scroll updates (cycles 257–340, subcycle fetch, attribute fetch, pixel alignment)
10
+ - [ ] Mapper IRQ/MMC1 correctness (MMC3 IRQ timing/placement, MMC1 32KB banking fix)
11
+ - [ ] CPU correctness/timing (ADC 0x71 page-cross, NMI vs IRQ priority, BIT/JMP/JSR/RTI/RTS/SBC logic, relative addressing sign extension, DMA stall timing)
12
+
13
+ ### P1 — Architecture cleanup
14
+ - [ ] Move MMC3 IRQ handling out of ROM/PPU into mapper layer
15
+ - [ ] Replace invalid-register/addressing TODOs with explicit no-op or `unreachable!()`
16
+ - [ ] Remove/clarify doc-only TODOs (audio example note, greyscale comment, PPU master/slave select if intentionally ignored)
17
+
18
+ ### P2 — Optional refactors/optimizations
19
+ - [ ] Opcode table refactor
20
+ - [ ] Register<u8>/Register<u16> merge
21
+ - [ ] Audio buffer cleanup + constant for 4096
22
+ - [ ] Header caching + sprite eval/pos calculation optimizations
23
+
24
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/lib.rs
25
+ | Line | TODO | Assessment |
26
+ | --- | --- | --- |
27
+ | 57 | Audio buffer sample code is T.B.D. (doc example) | Not needed for runtime; either remove the TODO or replace with a short note that audio output is omitted in the example. |
28
+
29
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/apu.rs
30
+ | Line | TODO | Assessment |
31
+ | --- | --- | --- |
32
+ | 53 | Fix sample_period (1764000 / 44100) | Needed for accurate audio timing; keep until audio timing is corrected. |
33
+ | 67 | Implement reset properly | Needed for accurate APU reset behavior; keep. |
34
+ | 79 | More precise sampling timing | Needed for correct audio output timing; keep. |
35
+ | 93 | Add note (DMC timer) | Not needed; either add a clarifying comment or remove the TODO. |
36
+ | 102 | More precise frame sequencer timing | Needed for correct APU frame sequencing; keep. |
37
+ | 168 | Check IRQ timing when sending | Needed for correct IRQ behavior; keep. |
38
+ | 276 | DMC CPU memory workaround is hacky; simplify | Optional refactor; keep if you want cleanup, otherwise can remove. |
39
+ | 400 | Throw an error on invalid pulse register | Not needed; invalid register writes are typically ignored. Consider removing TODO or clarifying no-op behavior. |
40
+ | 467 | Fix negated sweep behavior | Needed for accurate sweep; keep. |
41
+ | 612 | Throw an error on invalid triangle register | Not needed; invalid writes can be ignored. Consider removing TODO. |
42
+ | 775 | Throw an error on invalid noise register | Not needed; invalid writes can be ignored. Consider removing TODO. |
43
+ | 949 | DMC invalid register case | Not needed; either document as ignored or remove TODO. |
44
+ | 975 | Remove DMC CPU memory workaround | Optional refactor; keep if you want to eliminate the workaround. |
45
+
46
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/default_audio.rs
47
+ | Line | TODO | Assessment |
48
+ | --- | --- | --- |
49
+ | 29 | Remove side effect in copy_sample_buffer | Optional cleanup; keep if you plan to revisit buffer semantics. |
50
+ | 31 | Remove magic number (4096) | Needed for maintainability; replace with a named constant if audio is used. |
51
+
52
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/ppu.rs
53
+ | Line | TODO | Assessment |
54
+ | --- | --- | --- |
55
+ | 119 | Support data bus decay | Optional accuracy improvement; keep if fidelity matters. |
56
+ | 322 | Support greyscale if needed | Likely optional; greyscale masking is already applied in `load_palette`. Consider removing or clarifying if register-read greyscale behavior is desired. |
57
+ | 495 | Investigate `cycle - 1` vs `cycle - 2` pixel alignment | Needed for render correctness; keep. |
58
+ | 594 | Cycle 257-320 behavior | Needed for correct PPU fetch timing; keep. |
59
+ | 595 | Cycle 321-336 behavior | Needed for correct PPU fetch timing; keep. |
60
+ | 596 | Cycle 337-340 behavior | Needed for correct PPU fetch timing; keep. |
61
+ | 615 | 0-1 subcycle fetch details | Needed for correctness; keep. |
62
+ | 616 | 2-3 subcycle fetch details | Needed for correctness; keep. |
63
+ | 617 | 4-5 subcycle fetch details | Needed for correctness; keep. |
64
+ | 618 | 6-7 subcycle fetch details | Needed for correctness; keep. |
65
+ | 660 | Implement attribute fetch properly | Needed for PPU accuracy; keep. |
66
+ | 690 | Optimize pos calculation | Optional performance cleanup; not required. |
67
+ | 758 | Check MMC3 IRQ timing | Needed for mapper IRQ accuracy; keep. |
68
+ | 759 | MMC3-specific IRQ hook location | Optional refactor; keep if you plan to move this into mapper layer. |
69
+ | 804 | Only increment scroll if rendering enabled? | Needed for correctness; keep. |
70
+ | 830 | Only copy scroll if rendering enabled? | Needed for correctness; keep. |
71
+ | 863 | Optimize sprite evaluation | Optional performance cleanup; not required. |
72
+ | 1020 | Implement color emphasis properly | Needed if emphasis bits should affect output; keep. |
73
+ | 1069 | Implement PPU master/slave select | Likely not needed (unused on NES). Consider removing TODO or documenting it as intentionally ignored. |
74
+
75
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/register.rs
76
+ | Line | TODO | Assessment |
77
+ | --- | --- | --- |
78
+ | 5 | Combine Register<u8> with Register<u16> | Optional refactor; not required. Consider removing if you don’t plan to refactor. |
79
+
80
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/mapper.rs
81
+ | Line | TODO | Assessment |
82
+ | --- | --- | --- |
83
+ | 45 | MMC3-specific `drive_irq_counter` in trait | Optional architecture cleanup; keep if you want a mapper-specific IRQ interface. |
84
+ | 149 | MMC1 32KB banking fix | Needed for correct MMC1 behavior; keep. |
85
+
86
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/cpu.rs
87
+ | Line | TODO | Assessment |
88
+ | --- | --- | --- |
89
+ | 44 | Throw error for unknown button mapping | Not needed; enum should be exhaustive. Replace with `unreachable!()` or remove TODO. |
90
+ | 242 | Replace opcode match with static array | Optional refactor; not required. |
91
+ | 620 | Add +1 cycle if page crossed (ADC 0x71) | Needed for accurate timing; keep. |
92
+ | 1254 | Simplify DMC sample handling | Optional refactor; keep if you want cleanup. |
93
+ | 1258 | Fix DMC stall timing (+4 cycles) | Needed for accuracy; keep. |
94
+ | 1271 | More precise frame update detection | Optional; keep if timing fidelity matters. |
95
+ | 1285 | Implement Poweroff input | Optional feature; keep if you plan to support it. |
96
+ | 1313 | Handle NMI vs IRQ priority | Needed for correctness; keep. |
97
+ | 1368 | Clean up operate() if needed | Not needed; remove TODO unless you plan a refactor. |
98
+ | 1417 | Check BIT instruction logic | Needed for correctness; keep. |
99
+ | 1531 | Throw on INV instruction | Not needed for runtime; prefer `unreachable!()` or remove TODO. |
100
+ | 1552 | Check JMP logic | Needed for correctness; keep. |
101
+ | 1557 | Check JSR logic | Needed for correctness; keep. |
102
+ | 1709 | Check RTI logic | Needed for correctness; keep. |
103
+ | 1716 | Check RTS logic | Needed for correctness; keep. |
104
+ | 1732 | Confirm SBC carry/borrow logic | Needed for correctness; keep. |
105
+ | 1738 | Implement correct SBC overflow logic | Needed for correctness; keep. |
106
+ | 1895 | Clean up store() control flow | Not needed; remove TODO unless refactoring. |
107
+ | 1909 | DMA stall cycle timing | Needed for accuracy (513/514 cycle detail); keep. |
108
+ | 1952 | Optimize interrupt handling | Optional; not required. |
109
+ | 1994 | Confirm relative addressing sign extension | Needed for correctness; keep. |
110
+ | 2116 | Throw on unknown addressing mode | Not needed; prefer `unreachable!()` or remove TODO. |
111
+
112
+ ## extensions/nes/native/nes-core/vendor/nes_rust/src/rom.rs
113
+ | Line | TODO | Assessment |
114
+ | --- | --- | --- |
115
+ | 139 | MMC3-specific `irq_interrupted` in ROM | Optional architecture cleanup; keep if you plan to move IRQ handling into mapper layer. |
116
+ | 145 | Cache RomHeader fields | Optional optimization; not required. |
@@ -0,0 +1,60 @@
1
+ # nes_rust TODO Inventory (vendored)
2
+
3
+ This inventory tracks TODOs in the vendored `nes_rust` snapshot.
4
+
5
+ ## src/lib.rs
6
+ - L57: Audio buffer sample code T.B.D. (doc example). Likely remove or replace with note.
7
+
8
+ ## src/apu.rs
9
+ - L53: sample_period timing fix needed for audio accuracy.
10
+ - L67: APU reset behavior incomplete.
11
+ - L79: Sampling timing not precise.
12
+ - L93: Add note (DMC timer) — probably remove or add clarification.
13
+ - L102: Frame sequencer timing not precise.
14
+ - L168: IRQ timing needs verification.
15
+ - L276: DMC CPU memory workaround is hacky; optional refactor.
16
+ - L400/612/775/949: Invalid register write handling — probably ignore or document no-op.
17
+ - L467: Sweep negation logic fix needed.
18
+ - L975: Remove DMC memory workaround; optional refactor.
19
+
20
+ ## src/default_audio.rs
21
+ - L29: Remove side effect in copy_sample_buffer (optional).
22
+ - L31: Replace magic number 4096 with constant.
23
+
24
+ ## src/ppu.rs
25
+ - L119: Data bus decay support (accuracy improvement).
26
+ - L322: Greyscale support comment; likely redundant.
27
+ - L495: Pixel alignment off-by-one investigation.
28
+ - L594-L618: Missing cycle/subcycle fetch behavior.
29
+ - L660: Attribute fetch correctness.
30
+ - L690: Optional optimization.
31
+ - L758-L759: MMC3 IRQ timing/placement.
32
+ - L804/L830: Scroll updates conditional on rendering.
33
+ - L863: Optional optimization.
34
+ - L1020: Color emphasis implementation.
35
+ - L1069: PPU master/slave select; likely ignorable on NES.
36
+
37
+ ## src/register.rs
38
+ - L5: Combine Register<u8>/Register<u16> (optional refactor).
39
+
40
+ ## src/mapper.rs
41
+ - L45: MMC3 IRQ hook in trait (optional architecture cleanup).
42
+ - L149: MMC1 32KB banking fix needed.
43
+
44
+ ## src/cpu.rs
45
+ - L44: Unknown button mapping; replace with unreachable or ignore.
46
+ - L242: Opcode table refactor (optional).
47
+ - L620: Page-cross cycle for ADC 0x71 needed.
48
+ - L1254/L1258: DMC sample handling simplification + stall timing fix.
49
+ - L1271: Frame update detection precision.
50
+ - L1285: Poweroff input handling.
51
+ - L1313: NMI vs IRQ ordering.
52
+ - L1368/1895: Cleanup notes (optional).
53
+ - L1417/1552/1557/1709/1716/1732/1738/1994: CPU logic correctness checks.
54
+ - L1531/L2116: Invalid instruction/addressing mode handling.
55
+ - L1909: DMA stall timing detail.
56
+ - L1952: Interrupt handling optimization (optional).
57
+
58
+ ## src/rom.rs
59
+ - L139: MMC3 IRQ in ROM (optional architecture cleanup).
60
+ - L145: Cache header fields (optional).
@@ -0,0 +1,24 @@
1
+ # Vendored Dependency: nes_rust
2
+
3
+ ## Upstream
4
+ - Repository: https://github.com/takahirox/nes-rust
5
+ - Upstream state: last known push (per GitHub API) 2020-08-28
6
+
7
+ ## Fork
8
+ - Repository: https://github.com/tmustier/nes-rust
9
+ - Purpose: carry project-specific fixes and act as upstream-of-record for this vendor copy.
10
+
11
+ ## Current Vendor Snapshot
12
+ - Source commit/tag: `fd5cf3b` (fork master)
13
+ - Vendored on: 2026-02-02
14
+ - Local patch set: SRAM helpers, CHR RAM support, mapper fixes, PPU timing tweaks, debug hooks.
15
+
16
+ ## Update Process
17
+ 1. Sync fork with upstream if needed.
18
+ 2. Apply/maintain project patches on the fork.
19
+ 3. Re-vendor from fork at a pinned commit/tag.
20
+ 4. Update this file with the new commit/tag + patch notes.
21
+
22
+ ## Notes
23
+ - This repo uses a vendored copy under `extensions/nes/native/nes-core/vendor/nes_rust`.
24
+ - Keep patch history in the fork so changes are auditable.
@@ -271,6 +271,7 @@ impl Ppu {
271
271
  // ppustatus load
272
272
  0x2002 => {
273
273
  let value = self.ppustatus.load();
274
+ let was_vblank = (value & 0x80) != 0;
274
275
 
275
276
  // clear vblank after reading 0x2002
276
277
  self.ppustatus.clear_vblank();
@@ -287,10 +288,9 @@ impl Ppu {
287
288
  // clears the flag, and won't fire NMI
288
289
 
289
290
  // Note: update_flags() which can set vblank is called
290
- // after this method in the same cycle, so set supress_vblank true
291
- // even at cycle=1 not only cycle=0
292
-
293
- if self.scanline == 241 && (self.cycle == 0 || self.cycle == 1) {
291
+ // after this method in the same cycle. Only suppress if
292
+ // vblank was already set when read.
293
+ if was_vblank && self.scanline == 241 && self.cycle == 1 {
294
294
  self.suppress_vblank = true;
295
295
  }
296
296
 
@@ -735,9 +735,13 @@ impl Ppu {
735
735
  fn update_flags(&mut self, rom: &mut Rom) {
736
736
  if self.cycle == 1 {
737
737
  if self.scanline == 241 {
738
- // set vblank and occur NMI at cycle 1 in scanline 241
738
+ // Set vblank and latch NMI at cycle 1 in scanline 241.
739
+ // NMI delivery is still delayed by CPU instruction timing.
739
740
  if !self.suppress_vblank {
740
741
  self.ppustatus.set_vblank();
742
+ if self.ppuctrl.is_nmi_enabled() {
743
+ self.nmi_interrupted = true;
744
+ }
741
745
  }
742
746
  self.suppress_vblank = false;
743
747
  // Pixels for this frame should be ready so update the display
@@ -751,41 +755,6 @@ impl Ppu {
751
755
  }
752
756
  }
753
757
 
754
- // According to http://wiki.nesdev.com/w/index.php/PPU_frame_timing#VBL_Flag_Timing
755
- // reading 0x2002 at cycle=2 and scanline=241 can suppress NMI
756
- // so firing NMI at some cycles away not at cycle=1 so far
757
-
758
- // There is a chance that CPU 0x2002 read gets the data vblank flag set
759
- // before CPU starts NMI interrupt routine.
760
- // CPU instructions take multiple CPU clocks to complete.
761
- // If CPU starts an operation of an istruction including 0x2002 read right before
762
- // PPU sets vblank flag and fires NMI,
763
- // the 0x2002 read gets the data with vblank flag set even before
764
- // CPU starts NMI routine.
765
- //
766
- // CPU PPU
767
- // 1. instruction operation start
768
- // 2. - doing something vblank start and fire NMI
769
- // 3. - read 0x2002 with
770
- // vblank flag set
771
- // 4. - doing something
772
- // 5. Notice NMI and start
773
- // NMI routine
774
- //
775
- // It seems some games rely on this behavior.
776
- // To simulate this behavior we fire NMI at cycle=20 so far.
777
- // If CPU reads 0x2002 between PPU cycle 3~20 it gets data
778
- // vblank flag set before NMI routine.
779
- // (reading at cycle 1~2 suppresses NMI, see load_register())
780
- // @TODO: Safer and more appropriate approach.
781
-
782
- if self.cycle == 20 && self.scanline == 241 {
783
- if self.ppustatus.is_vblank() &&
784
- self.ppuctrl.is_nmi_enabled() {
785
- self.nmi_interrupted = true;
786
- }
787
- }
788
-
789
758
  // @TODO: check this driving IRQ counter for MMC3Mapper timing is correct
790
759
  // @TODO: This is MMC3Mapper specific. Should this be here?
791
760
 
@@ -901,7 +870,7 @@ impl Ppu {
901
870
  // the PPU scans through OAM to determine which sprites
902
871
  // to render on the next scanline
903
872
 
904
- if self.scanline >= 240 {
873
+ if self.scanline >= 240 && self.scanline != 261 {
905
874
  return;
906
875
  }
907
876
 
@@ -917,16 +886,17 @@ impl Ppu {
917
886
  } else if self.cycle == 257 {
918
887
  // Evaluate at a time at cycle 257 due to performance
919
888
  // and simplicity so far
920
- self.process_sprite_pixels(rom);
889
+ let next_scanline = if self.scanline == 261 { 0 } else { self.scanline + 1 };
890
+ self.process_sprite_pixels(next_scanline, rom);
921
891
  }
922
892
  }
923
893
 
924
- fn process_sprite_pixels(&mut self, rom: &Rom) {
894
+ fn process_sprite_pixels(&mut self, scanline: u16, rom: &Rom) {
925
895
  for i in 0..self.sprite_availables.len() {
926
896
  self.sprite_availables[i] = false;
927
897
  }
928
898
 
929
- let y = self.scanline as u8;
899
+ let y = scanline as u8;
930
900
  let height = self.ppuctrl.sprite_height();
931
901
  let mut n = 0;
932
902
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,9 +13,19 @@
13
13
  "type": "git",
14
14
  "url": "https://github.com/tmustier/pi-nes.git"
15
15
  },
16
+ "scripts": {
17
+ "test": "node --import tsx --test tests/paths.test.ts tests/roms.test.ts tests/saves.test.ts tests/config.test.ts tests/input-map.test.ts",
18
+ "test:unit": "node --import tsx --test tests/paths.test.ts tests/roms.test.ts tests/saves.test.ts tests/config.test.ts tests/input-map.test.ts",
19
+ "test:core": "node --import tsx --test tests/core-smoke.test.ts",
20
+ "test:regression": "node --import tsx --test tests/regression.test.ts"
21
+ },
16
22
  "dependencies": {
17
23
  "pngjs": "^7.0.0"
18
24
  },
25
+ "devDependencies": {
26
+ "tsx": "^4.19.0",
27
+ "typescript": "^5.5.0"
28
+ },
19
29
  "peerDependencies": {
20
30
  "@mariozechner/pi-coding-agent": "*",
21
31
  "@mariozechner/pi-tui": "*"
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FORK_URL="https://github.com/tmustier/nes-rust.git"
5
+ VENDOR_DIR="extensions/nes/native/nes-core/vendor/nes_rust"
6
+
7
+ if [[ -n "$(git status --porcelain)" ]]; then
8
+ echo "Working tree not clean. Commit or stash changes before updating vendor." >&2
9
+ exit 1
10
+ fi
11
+
12
+ TMP_DIR="$(mktemp -d -t nes-rust-vendor-XXXX)"
13
+ cleanup() {
14
+ rm -rf "$TMP_DIR"
15
+ }
16
+ trap cleanup EXIT
17
+
18
+ git clone --depth 1 "$FORK_URL" "$TMP_DIR"
19
+
20
+ echo "This will replace contents of $VENDOR_DIR with $FORK_URL"
21
+ read -r -p "Continue? [y/N] " confirm
22
+ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
23
+ echo "Aborted."
24
+ exit 1
25
+ fi
26
+
27
+ rsync -a --delete --exclude ".git" "$TMP_DIR/" "$VENDOR_DIR/"
28
+
29
+ echo "Vendored from commit: $(git -C "$TMP_DIR" rev-parse HEAD)"
30
+ echo "Update $VENDOR_DIR/VENDOR.md with commit/tag + date + patch summary."
@@ -0,0 +1,122 @@
1
+ # Tests
2
+
3
+ ## Quick Start
4
+
5
+ ```bash
6
+ # Run unit tests (no ROMs needed)
7
+ npm test
8
+
9
+ # Run with a specific ROM
10
+ NES_TEST_ROM=~/roms/nes/Zelda.nes npm run test:core
11
+
12
+ # Run regression against all ROMs in a directory
13
+ NES_ROM_DIR=~/roms/nes npm run test:regression
14
+
15
+ # Debug a specific game (visual ASCII output)
16
+ npx tsx tests/debug-game.ts ~/roms/nes/Mario.nes
17
+ ```
18
+
19
+ ## Test Structure
20
+
21
+ ### Unit Tests (always run)
22
+
23
+ | File | What it tests |
24
+ |------|---------------|
25
+ | `paths.test.ts` | Path utilities (displayPath, expandHomePath, etc.) |
26
+ | `roms.test.ts` | ROM name parsing |
27
+ | `saves.test.ts` | Save path generation |
28
+ | `config.test.ts` | Config normalization and validation |
29
+ | `input-map.test.ts` | Keyboard-to-button mapping |
30
+
31
+ ### Core Smoke Tests (require ROM)
32
+
33
+ Set `NES_TEST_ROM=/path/to/rom.nes` to enable.
34
+
35
+ | File | What it tests |
36
+ |------|---------------|
37
+ | `core-smoke.test.ts` | ROM loading, frame execution, freeze detection, SRAM round-trip |
38
+
39
+ ### Regression Tests (require ROM directory)
40
+
41
+ Set `NES_ROM_DIR=/path/to/roms` to enable.
42
+
43
+ | File | What it tests |
44
+ |------|---------------|
45
+ | `regression.test.ts` | Scripted game tests with input sequences |
46
+ | `game-scripts.ts` | Game-specific input sequences (Start, move, etc.) |
47
+
48
+ ### Debug Tool
49
+
50
+ ```bash
51
+ npx tsx tests/debug-game.ts <rom-path>
52
+ ```
53
+
54
+ Shows ASCII rendering of the game after running the script, useful for diagnosing rendering issues.
55
+
56
+ ## CI Usage
57
+
58
+ For CI without commercial ROMs:
59
+
60
+ ```bash
61
+ npm run test:unit # Only pure function tests
62
+ ```
63
+
64
+ For local development with ROMs:
65
+
66
+ ```bash
67
+ NES_ROM_DIR=~/roms/nes npm run test:regression
68
+ ```
69
+
70
+ ## Interpreting Results
71
+
72
+ ### Regression Output
73
+
74
+ ```
75
+ ✅ OK Dragon Quest III [scripted] (425 frames) # Loaded, ran script, frames animated
76
+ ⚠️ FROZE Super Mario Bros [scripted] (475 frames) # Ran script but frames frozen
77
+ ❌ ERROR Broken.nes (0 frames) # Failed to load or crashed
78
+ ```
79
+
80
+ ### What "FROZE" Means
81
+
82
+ For **scripted** tests, "FROZE" indicates a likely bug:
83
+ - Background renders but sprites missing
84
+ - Game stuck after input sequence
85
+ - No animation after reaching gameplay
86
+
87
+ The scripted tests simulate actual gameplay (press Start, move character) and verify the screen animates. A frozen screen after pressing right in Mario means the sprite isn't rendering.
88
+
89
+ ### Debug Output Example
90
+
91
+ ```
92
+ Frame Analysis (after script):
93
+ Non-zero pixels: 59,440 / 61,440 (96.7%) ← Background renders
94
+ Unique colors: 9 ← Limited palette (no sprites)
95
+
96
+ ASCII Preview:
97
+ [Shows level but no Mario sprite visible]
98
+ ```
99
+
100
+ ## Adding Game Scripts
101
+
102
+ Edit `tests/game-scripts.ts` to add scripts for new ROMs:
103
+
104
+ ```typescript
105
+ "my game": {
106
+ description: "Start game, verify gameplay",
107
+ sequence: [
108
+ { type: "wait", frames: 180 }, // 3 seconds
109
+ { type: "press", button: "start" }, // tap Start
110
+ { type: "wait", frames: 120 }, // 2 seconds
111
+ { type: "hold", button: "right", frames: 30 }, // move
112
+ ],
113
+ postSequenceFrames: 60, // frames to check for animation
114
+ }
115
+ ```
116
+
117
+ ## Environment Variables
118
+
119
+ | Variable | Description |
120
+ |----------|-------------|
121
+ | `NES_TEST_ROM` | Path to a single ROM for core smoke tests |
122
+ | `NES_ROM_DIR` | Directory containing .nes files for regression tests |
@@ -0,0 +1,96 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import { normalizeConfig, DEFAULT_CONFIG, formatConfig } from "../extensions/nes/config.js";
4
+
5
+ describe("config", () => {
6
+ describe("normalizeConfig", () => {
7
+ test("returns defaults for empty object", () => {
8
+ const config = normalizeConfig({});
9
+ assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
10
+ assert.strictEqual(config.renderer, DEFAULT_CONFIG.renderer);
11
+ assert.strictEqual(config.imageQuality, DEFAULT_CONFIG.imageQuality);
12
+ assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
13
+ });
14
+
15
+ test("returns defaults for null", () => {
16
+ const config = normalizeConfig(null);
17
+ assert.strictEqual(config.renderer, "image");
18
+ });
19
+
20
+ test("returns defaults for non-object", () => {
21
+ const config = normalizeConfig("invalid");
22
+ assert.strictEqual(config.renderer, "image");
23
+ });
24
+
25
+ test("accepts valid renderer value", () => {
26
+ const config = normalizeConfig({ renderer: "text" });
27
+ assert.strictEqual(config.renderer, "text");
28
+ });
29
+
30
+ test("defaults invalid renderer to image", () => {
31
+ const config = normalizeConfig({ renderer: "invalid" });
32
+ assert.strictEqual(config.renderer, "image");
33
+ });
34
+
35
+ test("accepts valid imageQuality", () => {
36
+ const config = normalizeConfig({ imageQuality: "high" });
37
+ assert.strictEqual(config.imageQuality, "high");
38
+ });
39
+
40
+ test("defaults invalid imageQuality to balanced", () => {
41
+ const config = normalizeConfig({ imageQuality: "ultra" });
42
+ assert.strictEqual(config.imageQuality, "balanced");
43
+ });
44
+
45
+ test("clamps pixelScale to valid range", () => {
46
+ assert.strictEqual(normalizeConfig({ pixelScale: 0.1 }).pixelScale, 0.5);
47
+ assert.strictEqual(normalizeConfig({ pixelScale: 10 }).pixelScale, 4);
48
+ assert.strictEqual(normalizeConfig({ pixelScale: 2 }).pixelScale, 2);
49
+ });
50
+
51
+ test("handles NaN pixelScale", () => {
52
+ const config = normalizeConfig({ pixelScale: NaN });
53
+ assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
54
+ });
55
+
56
+ test("preserves valid keybindings", () => {
57
+ const config = normalizeConfig({
58
+ keybindings: { a: ["k", "l"] }
59
+ });
60
+ assert.deepStrictEqual(config.keybindings.a, ["k", "l"]);
61
+ // Other keys should have defaults
62
+ assert.ok(config.keybindings.up.length > 0);
63
+ });
64
+
65
+ test("ignores invalid keybinding values", () => {
66
+ const config = normalizeConfig({
67
+ keybindings: { a: "not-an-array" }
68
+ });
69
+ // Should fall back to default
70
+ assert.deepStrictEqual(config.keybindings.a, DEFAULT_CONFIG.keybindings.a);
71
+ });
72
+
73
+ test("accepts boolean enableAudio", () => {
74
+ assert.strictEqual(normalizeConfig({ enableAudio: true }).enableAudio, true);
75
+ assert.strictEqual(normalizeConfig({ enableAudio: false }).enableAudio, false);
76
+ });
77
+
78
+ test("defaults non-boolean enableAudio", () => {
79
+ const config = normalizeConfig({ enableAudio: "yes" });
80
+ assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
81
+ });
82
+ });
83
+
84
+ describe("formatConfig", () => {
85
+ test("produces valid JSON", () => {
86
+ const json = formatConfig(DEFAULT_CONFIG);
87
+ const parsed = JSON.parse(json);
88
+ assert.strictEqual(parsed.renderer, DEFAULT_CONFIG.renderer);
89
+ });
90
+
91
+ test("is pretty-printed", () => {
92
+ const json = formatConfig(DEFAULT_CONFIG);
93
+ assert.ok(json.includes("\n"), "Should be multi-line");
94
+ });
95
+ });
96
+ });