@tmustier/pi-nes 0.2.22 → 0.2.24
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 +7 -0
- package/README.md +6 -0
- package/extensions/nes/native/nes-core/index.node +0 -0
- package/extensions/nes/native/nes-core/vendor/nes_rust/TODO_INVENTORY.md +60 -0
- package/extensions/nes/native/nes-core/vendor/nes_rust/VENDOR.md +24 -0
- package/extensions/nes/native/nes-core/vendor/nes_rust/src/apu.rs +4 -5
- package/extensions/nes/native/nes-core/vendor/nes_rust/src/cpu.rs +3 -6
- package/extensions/nes/native/nes-core/vendor/nes_rust/src/lib.rs +1 -1
- package/extensions/nes/native/nes-core/vendor/nes_rust/src/ppu.rs +16 -49
- package/package.json +11 -1
- package/scripts/update-vendor-nes-rust.sh +30 -0
- package/tests/README.md +122 -0
- package/tests/config.test.ts +96 -0
- package/tests/core-smoke.test.ts +124 -0
- package/tests/debug-game.ts +203 -0
- package/tests/game-scripts.ts +123 -0
- package/tests/input-map.test.ts +46 -0
- package/tests/paths.test.ts +78 -0
- package/tests/regression.test.ts +243 -0
- package/tests/roms.test.ts +27 -0
- package/tests/saves.test.ts +32 -0
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
|
|
Binary file
|
|
@@ -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). Change: replace TODO with note that audio output is omitted.
|
|
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) — Delete: remove TODO unless a concrete note is added.
|
|
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 — Change: document no-op and remove TODOs.
|
|
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 — Delete: masking handled in load_palette (unless register-read behavior needed).
|
|
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 — Change: document as ignored on NES, remove TODO.
|
|
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 — Change: replace with unreachable! (exhaustive match).
|
|
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 — Delete: vague cleanup notes.
|
|
53
|
+
- L1417/1552/1557/1709/1716/1732/1738/1994: CPU logic correctness checks.
|
|
54
|
+
- L1531/L2116: Invalid instruction/addressing mode handling — Change: treat illegal opcodes as NOP placeholder and document unknown addressing mode fallback.
|
|
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: `28b6e9b` (fork master)
|
|
13
|
+
- Vendored on: 2026-02-02
|
|
14
|
+
- Local patch set: SRAM helpers, CHR RAM support, mapper fixes, PPU timing tweaks, debug hooks, palette index clamp.
|
|
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.
|
|
@@ -90,7 +90,6 @@ impl Apu {
|
|
|
90
90
|
self.pulse1.drive_timer();
|
|
91
91
|
self.pulse2.drive_timer();
|
|
92
92
|
self.noise.drive_timer();
|
|
93
|
-
// @TODO: Add note
|
|
94
93
|
if self.dmc.drive_timer(dmc_sample_data) {
|
|
95
94
|
self.dmc_irq_active = true;
|
|
96
95
|
}
|
|
@@ -397,7 +396,7 @@ impl ApuPulse {
|
|
|
397
396
|
self.timer_sequence = 0;
|
|
398
397
|
self.envelope_start_flag = true;
|
|
399
398
|
},
|
|
400
|
-
_ => {} //
|
|
399
|
+
_ => {} // Invalid register: no-op.
|
|
401
400
|
};
|
|
402
401
|
}
|
|
403
402
|
|
|
@@ -609,7 +608,7 @@ impl ApuTriangle {
|
|
|
609
608
|
|
|
610
609
|
self.linear_reload_flag = true;
|
|
611
610
|
},
|
|
612
|
-
_ => {} //
|
|
611
|
+
_ => {} // Invalid register: no-op.
|
|
613
612
|
};
|
|
614
613
|
}
|
|
615
614
|
|
|
@@ -772,7 +771,7 @@ impl ApuNoise {
|
|
|
772
771
|
|
|
773
772
|
self.envelope_start_flag = true;
|
|
774
773
|
},
|
|
775
|
-
_ => {} //
|
|
774
|
+
_ => {} // Invalid register: no-op.
|
|
776
775
|
};
|
|
777
776
|
}
|
|
778
777
|
|
|
@@ -946,7 +945,7 @@ impl ApuDmc {
|
|
|
946
945
|
self.register3.store(value);
|
|
947
946
|
self.remaining_bytes_counter = ((self.sample_length() as u16) << 4) | 1;
|
|
948
947
|
},
|
|
949
|
-
_ => {} //
|
|
948
|
+
_ => {} // Invalid register: no-op.
|
|
950
949
|
}
|
|
951
950
|
}
|
|
952
951
|
|
|
@@ -41,7 +41,7 @@ fn to_joypad_button(button: button::Button) -> joypad::Button {
|
|
|
41
41
|
button::Button::Joypad2Right => joypad::Button::Right,
|
|
42
42
|
button::Button::Start => joypad::Button::Start,
|
|
43
43
|
button::Button::Select => joypad::Button::Select,
|
|
44
|
-
_ =>
|
|
44
|
+
_ => unreachable!("Unhandled button mapping")
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -1365,7 +1365,6 @@ impl Cpu {
|
|
|
1365
1365
|
}
|
|
1366
1366
|
}
|
|
1367
1367
|
|
|
1368
|
-
// @TODO: Clean up if needed
|
|
1369
1368
|
fn operate(&mut self, op: &Operation) {
|
|
1370
1369
|
match op.instruction_type {
|
|
1371
1370
|
InstructionTypes::ADC => {
|
|
@@ -1528,7 +1527,7 @@ impl Cpu {
|
|
|
1528
1527
|
self.update_z(result);
|
|
1529
1528
|
},
|
|
1530
1529
|
InstructionTypes::INV => {
|
|
1531
|
-
//
|
|
1530
|
+
// Illegal opcode placeholder (treated as NOP for now).
|
|
1532
1531
|
println!("INV operation");
|
|
1533
1532
|
},
|
|
1534
1533
|
InstructionTypes::INX | InstructionTypes::INY => {
|
|
@@ -1892,8 +1891,6 @@ impl Cpu {
|
|
|
1892
1891
|
self.apu.store_register(address, value);
|
|
1893
1892
|
}
|
|
1894
1893
|
|
|
1895
|
-
// @TODO: clean up
|
|
1896
|
-
|
|
1897
1894
|
if address == 0x4014 {
|
|
1898
1895
|
self.ppu.store_register(address, value, &mut self.rom);
|
|
1899
1896
|
|
|
@@ -2113,7 +2110,7 @@ impl Cpu {
|
|
|
2113
2110
|
effective_address
|
|
2114
2111
|
},
|
|
2115
2112
|
_ => {
|
|
2116
|
-
//
|
|
2113
|
+
// Unknown addressing mode; returning 0 as a fallback.
|
|
2117
2114
|
println!("Unknown addressing mode.");
|
|
2118
2115
|
0
|
|
2119
2116
|
}
|
|
@@ -54,7 +54,7 @@ use audio::Audio;
|
|
|
54
54
|
/// nes.step_frame();
|
|
55
55
|
/// nes.copy_pixels(rgba_pixels);
|
|
56
56
|
/// // Render rgba_pixels
|
|
57
|
-
/// //
|
|
57
|
+
/// // Audio output omitted in this example.
|
|
58
58
|
/// // Adjust sleep time for your platform
|
|
59
59
|
/// std::thread::sleep(Duration::from_millis(1));
|
|
60
60
|
/// }
|
|
@@ -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
|
|
291
|
-
//
|
|
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
|
|
|
@@ -319,8 +319,6 @@ impl Ppu {
|
|
|
319
319
|
};
|
|
320
320
|
self.vram_read_buffer = value;
|
|
321
321
|
|
|
322
|
-
// @TODO: Support greyscale if needed
|
|
323
|
-
|
|
324
322
|
// Accessing ppudata increments vram_address
|
|
325
323
|
self.increment_vram_address();
|
|
326
324
|
self.data_bus = return_value;
|
|
@@ -735,9 +733,13 @@ impl Ppu {
|
|
|
735
733
|
fn update_flags(&mut self, rom: &mut Rom) {
|
|
736
734
|
if self.cycle == 1 {
|
|
737
735
|
if self.scanline == 241 {
|
|
738
|
-
//
|
|
736
|
+
// Set vblank and latch NMI at cycle 1 in scanline 241.
|
|
737
|
+
// NMI delivery is still delayed by CPU instruction timing.
|
|
739
738
|
if !self.suppress_vblank {
|
|
740
739
|
self.ppustatus.set_vblank();
|
|
740
|
+
if self.ppuctrl.is_nmi_enabled() {
|
|
741
|
+
self.nmi_interrupted = true;
|
|
742
|
+
}
|
|
741
743
|
}
|
|
742
744
|
self.suppress_vblank = false;
|
|
743
745
|
// Pixels for this frame should be ready so update the display
|
|
@@ -751,41 +753,6 @@ impl Ppu {
|
|
|
751
753
|
}
|
|
752
754
|
}
|
|
753
755
|
|
|
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
756
|
// @TODO: check this driving IRQ counter for MMC3Mapper timing is correct
|
|
790
757
|
// @TODO: This is MMC3Mapper specific. Should this be here?
|
|
791
758
|
|
|
@@ -901,7 +868,7 @@ impl Ppu {
|
|
|
901
868
|
// the PPU scans through OAM to determine which sprites
|
|
902
869
|
// to render on the next scanline
|
|
903
870
|
|
|
904
|
-
if self.scanline >= 240 {
|
|
871
|
+
if self.scanline >= 240 && self.scanline != 261 {
|
|
905
872
|
return;
|
|
906
873
|
}
|
|
907
874
|
|
|
@@ -917,16 +884,17 @@ impl Ppu {
|
|
|
917
884
|
} else if self.cycle == 257 {
|
|
918
885
|
// Evaluate at a time at cycle 257 due to performance
|
|
919
886
|
// and simplicity so far
|
|
920
|
-
self.
|
|
887
|
+
let next_scanline = if self.scanline == 261 { 0 } else { self.scanline + 1 };
|
|
888
|
+
self.process_sprite_pixels(next_scanline, rom);
|
|
921
889
|
}
|
|
922
890
|
}
|
|
923
891
|
|
|
924
|
-
fn process_sprite_pixels(&mut self, rom: &Rom) {
|
|
892
|
+
fn process_sprite_pixels(&mut self, scanline: u16, rom: &Rom) {
|
|
925
893
|
for i in 0..self.sprite_availables.len() {
|
|
926
894
|
self.sprite_availables[i] = false;
|
|
927
895
|
}
|
|
928
896
|
|
|
929
|
-
let y =
|
|
897
|
+
let y = scanline as u8;
|
|
930
898
|
let height = self.ppuctrl.sprite_height();
|
|
931
899
|
let mut n = 0;
|
|
932
900
|
|
|
@@ -1041,7 +1009,7 @@ impl Ppu {
|
|
|
1041
1009
|
// read from the grey column 0x00, 0x10, 0x20, or 0x30
|
|
1042
1010
|
let mask = match self.ppumask.is_greyscale() {
|
|
1043
1011
|
true => 0x30,
|
|
1044
|
-
false =>
|
|
1012
|
+
false => 0x3F
|
|
1045
1013
|
};
|
|
1046
1014
|
PALETTES[(address & mask) as usize] & 0xFFFFFF
|
|
1047
1015
|
}
|
|
@@ -1096,8 +1064,7 @@ impl PpuControlRegister {
|
|
|
1096
1064
|
self.register.is_bit_set(7)
|
|
1097
1065
|
}
|
|
1098
1066
|
|
|
1099
|
-
// Bit 6. PPU master/slave select
|
|
1100
|
-
// @TODO: Implement
|
|
1067
|
+
// Bit 6. PPU master/slave select (unused on NES; ignored)
|
|
1101
1068
|
|
|
1102
1069
|
// Bit 5. Sprite height
|
|
1103
1070
|
// -- 0: 8 (8x8 pixels), 1: 16 (8x16 pixels)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmustier/pi-nes",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
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."
|
package/tests/README.md
ADDED
|
@@ -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
|
+
});
|