@tmustier/pi-nes 0.2.21 → 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 +7 -0
- package/README.md +12 -0
- package/TODO_INVENTORY.md +116 -0
- package/extensions/nes/config.ts +19 -5
- package/extensions/nes/index.ts +10 -18
- package/extensions/nes/native/nes-core/index.d.ts +0 -3
- package/extensions/nes/native/nes-core/index.node +0 -0
- package/extensions/nes/native/nes-core/native.d.ts +0 -2
- package/extensions/nes/native/nes-core/src/lib.rs +0 -10
- 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/ppu.rs +14 -44
- package/extensions/nes/nes-component.ts +67 -32
- package/extensions/nes/nes-core.ts +3 -6
- package/extensions/nes/nes-session.ts +14 -1
- package/extensions/nes/renderer.ts +77 -47
- package/extensions/nes/roms.ts +0 -13
- package/extensions/nes/saves.ts +8 -1
- package/package.json +11 -1
- package/scripts/update-vendor-nes-rust.sh +30 -0
- package/spec.md +2 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { test, describe, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createNesCore, type NesCore } from "../extensions/nes/nes-core.js";
|
|
5
|
+
|
|
6
|
+
const TEST_ROM = process.env.NES_TEST_ROM;
|
|
7
|
+
const SKIP_REASON = "Set NES_TEST_ROM=/path/to/rom.nes to run core tests";
|
|
8
|
+
|
|
9
|
+
function hashFramebuffer(data: Uint8Array): number {
|
|
10
|
+
let hash = 0;
|
|
11
|
+
// Sample every 1000th byte for speed
|
|
12
|
+
for (let i = 0; i < data.length; i += 1000) {
|
|
13
|
+
hash = ((hash << 5) - hash + data[i]) | 0;
|
|
14
|
+
}
|
|
15
|
+
return hash;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("core-smoke", { skip: !TEST_ROM ? SKIP_REASON : undefined }, () => {
|
|
19
|
+
let core: NesCore;
|
|
20
|
+
let romData: Uint8Array;
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
if (!TEST_ROM) return;
|
|
24
|
+
romData = new Uint8Array(await readFile(TEST_ROM));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(() => {
|
|
28
|
+
core?.dispose();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("native core is available", () => {
|
|
32
|
+
// This will throw if native module not built
|
|
33
|
+
core = createNesCore();
|
|
34
|
+
assert.ok(core, "Core should be created");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("loads ROM without crashing", () => {
|
|
38
|
+
core.loadRom(romData);
|
|
39
|
+
// If we get here, it loaded
|
|
40
|
+
assert.ok(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("runs 60 frames without crashing", () => {
|
|
44
|
+
for (let i = 0; i < 60; i++) {
|
|
45
|
+
core.tick();
|
|
46
|
+
}
|
|
47
|
+
assert.ok(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("framebuffer contains data", () => {
|
|
51
|
+
const fb = core.getFrameBuffer();
|
|
52
|
+
assert.ok(fb.data.length > 0, "Framebuffer should have data");
|
|
53
|
+
// Check it's not all zeros (at least some pixels rendered)
|
|
54
|
+
const hasNonZero = fb.data.some(b => b !== 0);
|
|
55
|
+
assert.ok(hasNonZero, "Framebuffer should have non-zero pixels");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("frames change over time (not frozen)", () => {
|
|
59
|
+
const hashes: number[] = [];
|
|
60
|
+
for (let i = 0; i < 30; i++) {
|
|
61
|
+
core.tick();
|
|
62
|
+
const fb = core.getFrameBuffer();
|
|
63
|
+
hashes.push(hashFramebuffer(fb.data));
|
|
64
|
+
}
|
|
65
|
+
const uniqueHashes = new Set(hashes);
|
|
66
|
+
// Should have at least a few different frames
|
|
67
|
+
assert.ok(uniqueHashes.size > 1, `Expected frame changes, got ${uniqueHashes.size} unique frames out of 30`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("button input does not crash", () => {
|
|
71
|
+
core.setButton("a", true);
|
|
72
|
+
core.tick();
|
|
73
|
+
core.setButton("a", false);
|
|
74
|
+
core.setButton("start", true);
|
|
75
|
+
core.tick();
|
|
76
|
+
core.setButton("start", false);
|
|
77
|
+
core.tick();
|
|
78
|
+
assert.ok(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Note: reset() exists in native but isn't exposed via NesCore interface
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("sram-roundtrip", { skip: !TEST_ROM ? SKIP_REASON : undefined }, () => {
|
|
85
|
+
test("SRAM save and restore", async () => {
|
|
86
|
+
if (!TEST_ROM) return;
|
|
87
|
+
|
|
88
|
+
const romData = new Uint8Array(await readFile(TEST_ROM));
|
|
89
|
+
const core = createNesCore();
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
core.loadRom(romData);
|
|
93
|
+
|
|
94
|
+
// Run some frames
|
|
95
|
+
for (let i = 0; i < 60; i++) {
|
|
96
|
+
core.tick();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sram = core.getSram();
|
|
100
|
+
if (!sram) {
|
|
101
|
+
// ROM doesn't have battery-backed SRAM, skip
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Modify SRAM dirty state
|
|
106
|
+
assert.strictEqual(typeof core.isSramDirty(), "boolean");
|
|
107
|
+
|
|
108
|
+
// Create new core and restore SRAM
|
|
109
|
+
const core2 = createNesCore();
|
|
110
|
+
try {
|
|
111
|
+
core2.loadRom(romData);
|
|
112
|
+
core2.setSram(sram);
|
|
113
|
+
|
|
114
|
+
const restored = core2.getSram();
|
|
115
|
+
assert.ok(restored, "Should be able to get SRAM after setting");
|
|
116
|
+
assert.strictEqual(restored.length, sram.length, "SRAM length should match");
|
|
117
|
+
} finally {
|
|
118
|
+
core2.dispose();
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
core.dispose();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug script to visually inspect what a game looks like after scripted input
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx tests/debug-game.ts ~/roms/nes/Mario.nes
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { createNesCore, type NesCore, type NesButton } from "../extensions/nes/nes-core.js";
|
|
10
|
+
import { findScript, type GameScript } from "./game-scripts.js";
|
|
11
|
+
|
|
12
|
+
const FRAME_WIDTH = 256;
|
|
13
|
+
const FRAME_HEIGHT = 240;
|
|
14
|
+
|
|
15
|
+
function hashFramebuffer(data: Uint8Array): number {
|
|
16
|
+
let hash = 0;
|
|
17
|
+
for (let i = 0; i < data.length; i += 500) {
|
|
18
|
+
hash = ((hash << 5) - hash + data[i]) | 0;
|
|
19
|
+
}
|
|
20
|
+
return hash;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function analyzeFrame(data: Uint8Array): { nonZeroPixels: number; uniqueColors: number; brightness: number } {
|
|
24
|
+
let nonZeroPixels = 0;
|
|
25
|
+
let totalBrightness = 0;
|
|
26
|
+
const colors = new Set<number>();
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < data.length; i += 3) {
|
|
29
|
+
const r = data[i];
|
|
30
|
+
const g = data[i + 1];
|
|
31
|
+
const b = data[i + 2];
|
|
32
|
+
|
|
33
|
+
if (r !== 0 || g !== 0 || b !== 0) {
|
|
34
|
+
nonZeroPixels++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const color = (r << 16) | (g << 8) | b;
|
|
38
|
+
colors.add(color);
|
|
39
|
+
totalBrightness += (r + g + b) / 3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
nonZeroPixels,
|
|
44
|
+
uniqueColors: colors.size,
|
|
45
|
+
brightness: totalBrightness / (FRAME_WIDTH * FRAME_HEIGHT),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderFrameAscii(data: Uint8Array, width: number = 64, height: number = 24): string[] {
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
const scaleX = FRAME_WIDTH / width;
|
|
52
|
+
const scaleY = FRAME_HEIGHT / height;
|
|
53
|
+
const chars = " .:-=+*#%@";
|
|
54
|
+
|
|
55
|
+
for (let y = 0; y < height; y++) {
|
|
56
|
+
let line = "";
|
|
57
|
+
for (let x = 0; x < width; x++) {
|
|
58
|
+
const srcX = Math.floor(x * scaleX);
|
|
59
|
+
const srcY = Math.floor(y * scaleY);
|
|
60
|
+
const idx = (srcY * FRAME_WIDTH + srcX) * 3;
|
|
61
|
+
const r = data[idx] || 0;
|
|
62
|
+
const g = data[idx + 1] || 0;
|
|
63
|
+
const b = data[idx + 2] || 0;
|
|
64
|
+
const brightness = (r + g + b) / 3 / 255;
|
|
65
|
+
const charIdx = Math.min(chars.length - 1, Math.floor(brightness * chars.length));
|
|
66
|
+
line += chars[charIdx];
|
|
67
|
+
}
|
|
68
|
+
lines.push(line);
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function debugGame(romPath: string) {
|
|
74
|
+
const filename = path.basename(romPath);
|
|
75
|
+
console.log(`\n${"=".repeat(70)}`);
|
|
76
|
+
console.log(`Debugging: ${filename}`);
|
|
77
|
+
console.log(`${"=".repeat(70)}\n`);
|
|
78
|
+
|
|
79
|
+
const romData = new Uint8Array(await readFile(romPath));
|
|
80
|
+
const core = createNesCore();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
core.loadRom(romData);
|
|
84
|
+
console.log("✅ ROM loaded successfully\n");
|
|
85
|
+
|
|
86
|
+
const script = findScript(filename);
|
|
87
|
+
if (!script) {
|
|
88
|
+
console.log("⚠️ No script found for this ROM, running default frames\n");
|
|
89
|
+
for (let i = 0; i < 300; i++) {
|
|
90
|
+
core.tick();
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
console.log(`📜 Script: ${script.description}\n`);
|
|
94
|
+
console.log("Executing script...\n");
|
|
95
|
+
|
|
96
|
+
let totalFrames = 0;
|
|
97
|
+
for (const action of script.sequence) {
|
|
98
|
+
switch (action.type) {
|
|
99
|
+
case "wait":
|
|
100
|
+
console.log(` ⏳ Wait ${action.frames} frames (${(action.frames / 60).toFixed(1)}s)`);
|
|
101
|
+
for (let i = 0; i < action.frames; i++) {
|
|
102
|
+
core.tick();
|
|
103
|
+
totalFrames++;
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case "press":
|
|
107
|
+
console.log(` 🎮 Press ${action.button}`);
|
|
108
|
+
core.setButton(action.button, true);
|
|
109
|
+
for (let i = 0; i < (action.frames ?? 5); i++) {
|
|
110
|
+
core.tick();
|
|
111
|
+
totalFrames++;
|
|
112
|
+
}
|
|
113
|
+
core.setButton(action.button, false);
|
|
114
|
+
break;
|
|
115
|
+
case "hold":
|
|
116
|
+
console.log(` 🎮 Hold ${action.button} for ${action.frames} frames`);
|
|
117
|
+
core.setButton(action.button, true);
|
|
118
|
+
for (let i = 0; i < action.frames; i++) {
|
|
119
|
+
core.tick();
|
|
120
|
+
totalFrames++;
|
|
121
|
+
}
|
|
122
|
+
core.setButton(action.button, false);
|
|
123
|
+
break;
|
|
124
|
+
case "release":
|
|
125
|
+
console.log(` 🎮 Release ${action.button}`);
|
|
126
|
+
core.setButton(action.button, false);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
console.log(`\n✅ Script complete, ran ${totalFrames} frames\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Analyze current frame
|
|
134
|
+
const fb = core.getFrameBuffer();
|
|
135
|
+
const analysis = analyzeFrame(fb.data);
|
|
136
|
+
|
|
137
|
+
console.log("Frame Analysis (after script):");
|
|
138
|
+
console.log(` Non-zero pixels: ${analysis.nonZeroPixels.toLocaleString()} / ${(FRAME_WIDTH * FRAME_HEIGHT).toLocaleString()} (${(analysis.nonZeroPixels / (FRAME_WIDTH * FRAME_HEIGHT) * 100).toFixed(1)}%)`);
|
|
139
|
+
console.log(` Unique colors: ${analysis.uniqueColors}`);
|
|
140
|
+
console.log(` Average brightness: ${analysis.brightness.toFixed(1)}`);
|
|
141
|
+
console.log(` Frame hash: ${hashFramebuffer(fb.data)}`);
|
|
142
|
+
|
|
143
|
+
console.log("\nASCII Preview:");
|
|
144
|
+
console.log("┌" + "─".repeat(64) + "┐");
|
|
145
|
+
for (const line of renderFrameAscii(fb.data)) {
|
|
146
|
+
console.log("│" + line + "│");
|
|
147
|
+
}
|
|
148
|
+
console.log("└" + "─".repeat(64) + "┘");
|
|
149
|
+
|
|
150
|
+
// Run more frames and check for changes
|
|
151
|
+
console.log("\nRunning 60 more frames to check for animation...\n");
|
|
152
|
+
const hashes: number[] = [hashFramebuffer(fb.data)];
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < 60; i++) {
|
|
155
|
+
core.tick();
|
|
156
|
+
if (i % 10 === 0) {
|
|
157
|
+
const currentFb = core.getFrameBuffer();
|
|
158
|
+
hashes.push(hashFramebuffer(currentFb.data));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const uniqueHashes = new Set(hashes);
|
|
163
|
+
console.log(`Frame hashes sampled: ${hashes.length}`);
|
|
164
|
+
console.log(`Unique hashes: ${uniqueHashes.size}`);
|
|
165
|
+
console.log(`Hashes: ${hashes.join(", ")}`);
|
|
166
|
+
|
|
167
|
+
if (uniqueHashes.size <= 1) {
|
|
168
|
+
console.log("\n⚠️ FROZEN: No frame changes detected!");
|
|
169
|
+
} else {
|
|
170
|
+
console.log("\n✅ ANIMATED: Frames are changing");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Final frame
|
|
174
|
+
const finalFb = core.getFrameBuffer();
|
|
175
|
+
const finalAnalysis = analyzeFrame(finalFb.data);
|
|
176
|
+
|
|
177
|
+
console.log("\nFinal Frame Analysis:");
|
|
178
|
+
console.log(` Non-zero pixels: ${finalAnalysis.nonZeroPixels.toLocaleString()} (${(finalAnalysis.nonZeroPixels / (FRAME_WIDTH * FRAME_HEIGHT) * 100).toFixed(1)}%)`);
|
|
179
|
+
console.log(` Unique colors: ${finalAnalysis.uniqueColors}`);
|
|
180
|
+
|
|
181
|
+
console.log("\nFinal ASCII Preview:");
|
|
182
|
+
console.log("┌" + "─".repeat(64) + "┐");
|
|
183
|
+
for (const line of renderFrameAscii(finalFb.data)) {
|
|
184
|
+
console.log("│" + line + "│");
|
|
185
|
+
}
|
|
186
|
+
console.log("└" + "─".repeat(64) + "┘");
|
|
187
|
+
|
|
188
|
+
} finally {
|
|
189
|
+
core.dispose();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Main
|
|
194
|
+
const romPath = process.argv[2];
|
|
195
|
+
if (!romPath) {
|
|
196
|
+
console.error("Usage: npx tsx tests/debug-game.ts <rom-path>");
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
debugGame(romPath).catch(err => {
|
|
201
|
+
console.error("Error:", err);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { NesButton } from "../extensions/nes/nes-core.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input action in a game script
|
|
5
|
+
*/
|
|
6
|
+
export type ScriptAction =
|
|
7
|
+
| { type: "wait"; frames: number }
|
|
8
|
+
| { type: "press"; button: NesButton; frames?: number } // tap (default 5 frames)
|
|
9
|
+
| { type: "hold"; button: NesButton; frames: number } // hold for duration
|
|
10
|
+
| { type: "release"; button: NesButton };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Script for testing a specific game
|
|
14
|
+
*/
|
|
15
|
+
export interface GameScript {
|
|
16
|
+
/** Input sequence to execute */
|
|
17
|
+
sequence: ScriptAction[];
|
|
18
|
+
/** Description of what the script does */
|
|
19
|
+
description: string;
|
|
20
|
+
/** Frames to run after sequence to check for freeze (default 60) */
|
|
21
|
+
postSequenceFrames?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to convert seconds to frames (at 60fps)
|
|
25
|
+
const sec = (s: number) => Math.round(s * 60);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Game-specific test scripts
|
|
29
|
+
* Keys should match ROM filename (without extension, case-insensitive)
|
|
30
|
+
*/
|
|
31
|
+
export const GAME_SCRIPTS: Record<string, GameScript> = {
|
|
32
|
+
"dragon quest iii": {
|
|
33
|
+
description: "Wait for title, press Start, wait for game to load",
|
|
34
|
+
sequence: [
|
|
35
|
+
{ type: "wait", frames: sec(3) }, // Wait for title screen
|
|
36
|
+
{ type: "press", button: "start" }, // Press Start
|
|
37
|
+
{ type: "wait", frames: sec(3) }, // Wait for game to load
|
|
38
|
+
],
|
|
39
|
+
postSequenceFrames: 60,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
"super mario bros": {
|
|
43
|
+
description: "Start game, move Mario right to verify sprites render",
|
|
44
|
+
sequence: [
|
|
45
|
+
{ type: "wait", frames: sec(2) }, // Wait for title
|
|
46
|
+
{ type: "press", button: "start" }, // Press Start (1 player game)
|
|
47
|
+
{ type: "wait", frames: sec(3) }, // Wait for level to load
|
|
48
|
+
{ type: "hold", button: "right", frames: 30 }, // Move right
|
|
49
|
+
{ type: "wait", frames: 10 },
|
|
50
|
+
{ type: "hold", button: "right", frames: 30 }, // Move right again
|
|
51
|
+
{ type: "wait", frames: 10 },
|
|
52
|
+
{ type: "hold", button: "right", frames: 30 }, // Move right again
|
|
53
|
+
],
|
|
54
|
+
postSequenceFrames: 60,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Multi-cart version
|
|
58
|
+
"super mario bros. + duck hunt + world class track meet (usa) (rev 1)": {
|
|
59
|
+
description: "Select SMB from menu, start game, move Mario",
|
|
60
|
+
sequence: [
|
|
61
|
+
{ type: "wait", frames: sec(2) }, // Wait for menu
|
|
62
|
+
{ type: "press", button: "start" }, // Select first game (SMB)
|
|
63
|
+
{ type: "wait", frames: sec(2) }, // Wait for SMB title
|
|
64
|
+
{ type: "press", button: "start" }, // Start game
|
|
65
|
+
{ type: "wait", frames: sec(3) }, // Wait for level
|
|
66
|
+
{ type: "hold", button: "right", frames: 30 },
|
|
67
|
+
{ type: "wait", frames: 10 },
|
|
68
|
+
{ type: "hold", button: "right", frames: 30 },
|
|
69
|
+
],
|
|
70
|
+
postSequenceFrames: 60,
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
"legend of zelda": {
|
|
74
|
+
description: "Start game, select file, begin playing",
|
|
75
|
+
sequence: [
|
|
76
|
+
{ type: "wait", frames: sec(3) }, // Wait for title
|
|
77
|
+
{ type: "press", button: "start" }, // Press Start
|
|
78
|
+
{ type: "wait", frames: sec(1) }, // Wait for file select
|
|
79
|
+
{ type: "press", button: "start" }, // Select first file
|
|
80
|
+
{ type: "wait", frames: sec(2) }, // Wait for game
|
|
81
|
+
{ type: "hold", button: "up", frames: 20 }, // Move up
|
|
82
|
+
{ type: "wait", frames: 10 },
|
|
83
|
+
{ type: "hold", button: "down", frames: 20 }, // Move down
|
|
84
|
+
],
|
|
85
|
+
postSequenceFrames: 60,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
"metroid": {
|
|
89
|
+
description: "Start game, move Samus",
|
|
90
|
+
sequence: [
|
|
91
|
+
{ type: "wait", frames: sec(3) }, // Wait for title
|
|
92
|
+
{ type: "press", button: "start" }, // Press Start
|
|
93
|
+
{ type: "wait", frames: sec(2) }, // Wait for game
|
|
94
|
+
{ type: "hold", button: "right", frames: 30 }, // Move right
|
|
95
|
+
{ type: "wait", frames: 10 },
|
|
96
|
+
{ type: "hold", button: "left", frames: 30 }, // Move left
|
|
97
|
+
],
|
|
98
|
+
postSequenceFrames: 60,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find a script for a ROM by filename
|
|
104
|
+
*/
|
|
105
|
+
export function findScript(romFilename: string): GameScript | null {
|
|
106
|
+
const baseName = romFilename
|
|
107
|
+
.replace(/\.nes$/i, "")
|
|
108
|
+
.toLowerCase();
|
|
109
|
+
|
|
110
|
+
// Exact match first
|
|
111
|
+
if (GAME_SCRIPTS[baseName]) {
|
|
112
|
+
return GAME_SCRIPTS[baseName];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Partial match (for ROMs with extra suffixes)
|
|
116
|
+
for (const [key, script] of Object.entries(GAME_SCRIPTS)) {
|
|
117
|
+
if (baseName.includes(key) || key.includes(baseName)) {
|
|
118
|
+
return script;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { getMappedButtons, DEFAULT_INPUT_MAPPING } from "../extensions/nes/input-map.js";
|
|
4
|
+
|
|
5
|
+
describe("input-map", () => {
|
|
6
|
+
describe("DEFAULT_INPUT_MAPPING", () => {
|
|
7
|
+
test("has all required buttons", () => {
|
|
8
|
+
const buttons = ["up", "down", "left", "right", "a", "b", "start", "select"];
|
|
9
|
+
for (const button of buttons) {
|
|
10
|
+
assert.ok(button in DEFAULT_INPUT_MAPPING, `Missing button: ${button}`);
|
|
11
|
+
assert.ok(Array.isArray(DEFAULT_INPUT_MAPPING[button as keyof typeof DEFAULT_INPUT_MAPPING]));
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("getMappedButtons", () => {
|
|
17
|
+
test("maps 'z' to 'a' button", () => {
|
|
18
|
+
const buttons = getMappedButtons("z");
|
|
19
|
+
assert.ok(buttons.includes("a"), `Expected 'a' in ${JSON.stringify(buttons)}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("maps 'x' to 'b' button", () => {
|
|
23
|
+
const buttons = getMappedButtons("x");
|
|
24
|
+
assert.ok(buttons.includes("b"), `Expected 'b' in ${JSON.stringify(buttons)}`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("maps 'w' to 'up' button", () => {
|
|
28
|
+
const buttons = getMappedButtons("w");
|
|
29
|
+
assert.ok(buttons.includes("up"), `Expected 'up' in ${JSON.stringify(buttons)}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns empty for unmapped key", () => {
|
|
33
|
+
const buttons = getMappedButtons("q");
|
|
34
|
+
assert.strictEqual(buttons.length, 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uses custom mapping when provided", () => {
|
|
38
|
+
const customMapping = {
|
|
39
|
+
...DEFAULT_INPUT_MAPPING,
|
|
40
|
+
a: ["k"],
|
|
41
|
+
};
|
|
42
|
+
const buttons = getMappedButtons("k", customMapping);
|
|
43
|
+
assert.ok(buttons.includes("a"));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { displayPath, expandHomePath, normalizePath, resolvePathInput } from "../extensions/nes/paths.js";
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
|
|
9
|
+
describe("paths", () => {
|
|
10
|
+
describe("displayPath", () => {
|
|
11
|
+
test("replaces home directory with ~", () => {
|
|
12
|
+
assert.strictEqual(displayPath(`${HOME}/roms/nes`), "~/roms/nes");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("leaves non-home paths unchanged", () => {
|
|
16
|
+
assert.strictEqual(displayPath("/usr/local/bin"), "/usr/local/bin");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("handles home directory exactly", () => {
|
|
20
|
+
assert.strictEqual(displayPath(HOME), "~");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("expandHomePath", () => {
|
|
25
|
+
test("expands ~ to home directory", () => {
|
|
26
|
+
assert.strictEqual(expandHomePath("~"), HOME);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("expands ~/path to home + path", () => {
|
|
30
|
+
assert.strictEqual(expandHomePath("~/roms/nes"), path.join(HOME, "roms/nes"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("leaves absolute paths unchanged", () => {
|
|
34
|
+
assert.strictEqual(expandHomePath("/usr/local"), "/usr/local");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("leaves relative paths unchanged", () => {
|
|
38
|
+
assert.strictEqual(expandHomePath("roms/nes"), "roms/nes");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("normalizePath", () => {
|
|
43
|
+
test("returns fallback for empty string", () => {
|
|
44
|
+
assert.strictEqual(normalizePath("", "/default"), "/default");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns fallback for whitespace-only", () => {
|
|
48
|
+
assert.strictEqual(normalizePath(" ", "/default"), "/default");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("expands ~ and returns path", () => {
|
|
52
|
+
assert.strictEqual(normalizePath("~/roms", "/default"), path.join(HOME, "roms"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("trims whitespace", () => {
|
|
56
|
+
assert.strictEqual(normalizePath(" /roms ", "/default"), "/roms");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("resolvePathInput", () => {
|
|
61
|
+
test("returns cwd for empty input", () => {
|
|
62
|
+
assert.strictEqual(resolvePathInput("", "/home/user"), "/home/user");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("expands ~ paths", () => {
|
|
66
|
+
assert.strictEqual(resolvePathInput("~/roms", "/cwd"), path.join(HOME, "roms"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("resolves relative paths against cwd", () => {
|
|
70
|
+
const result = resolvePathInput("roms/nes", "/home/user");
|
|
71
|
+
assert.strictEqual(result, "/home/user/roms/nes");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("keeps absolute paths as-is", () => {
|
|
75
|
+
assert.strictEqual(resolvePathInput("/absolute/path", "/cwd"), "/absolute/path");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|