@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.
@@ -2,13 +2,10 @@ import type { Component, TUI } from "@mariozechner/pi-tui";
2
2
  import { isKeyRelease, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
3
  import type { InputMapping } from "./input-map.js";
4
4
  import { DEFAULT_INPUT_MAPPING, getMappedButtons } from "./input-map.js";
5
- import type { FrameBuffer, NesCore } from "./nes-core.js";
5
+ import type { NesButton, FrameBuffer, NesCore } from "./nes-core.js";
6
6
  import type { NesSessionStats } from "./nes-session.js";
7
- import type { RendererMode } from "./renderer.js";
8
- import { NesImageRenderer } from "./renderer.js";
9
-
10
- const FRAME_WIDTH = 256;
11
- const FRAME_HEIGHT = 240;
7
+ import type { RendererMode } from "./config.js";
8
+ import { FRAME_HEIGHT, FRAME_WIDTH, NesImageRenderer } from "./renderer.js";
12
9
 
13
10
  function readRgb(frameBuffer: FrameBuffer, index: number): [number, number, number] {
14
11
  const data = frameBuffer.data;
@@ -80,7 +77,8 @@ function averageBlock(
80
77
  export class NesOverlayComponent implements Component {
81
78
  wantsKeyRelease = true;
82
79
  private readonly inputMapping: InputMapping;
83
- private readonly tapTimers = new Map<string, ReturnType<typeof setTimeout>>();
80
+ private readonly tapTimers = new Map<"start" | "select", ReturnType<typeof setTimeout>>();
81
+ private readonly heldButtons = new Set<NesButton>();
84
82
  private readonly imageRenderer = new NesImageRenderer();
85
83
  private readonly rendererMode: RendererMode;
86
84
  private readonly pixelScale: number;
@@ -115,11 +113,13 @@ export class NesOverlayComponent implements Component {
115
113
  handleInput(data: string): void {
116
114
  const released = isKeyRelease(data);
117
115
  if (!released && matchesKey(data, "ctrl+q")) {
116
+ this.releaseAllButtons();
118
117
  this.cleanupImage();
119
118
  this.onDetach();
120
119
  return;
121
120
  }
122
121
  if (!released && (matchesKey(data, "q") || matchesKey(data, "shift+q"))) {
122
+ this.releaseAllButtons();
123
123
  this.cleanupImage();
124
124
  this.onQuit();
125
125
  return;
@@ -137,6 +137,11 @@ export class NesOverlayComponent implements Component {
137
137
  }
138
138
  continue;
139
139
  }
140
+ if (released) {
141
+ this.heldButtons.delete(button);
142
+ } else {
143
+ this.heldButtons.add(button);
144
+ }
140
145
  this.core.setButton(button, !released);
141
146
  }
142
147
  }
@@ -150,22 +155,39 @@ export class NesOverlayComponent implements Component {
150
155
  const frameBuffer = this.core.getFrameBuffer();
151
156
  const debugLines = this.debug ? this.buildDebugLines() : [];
152
157
  const footerRows = this.debug ? 1 + debugLines.length : 1;
158
+
153
159
  if (this.rendererMode === "image") {
154
- const lines = this.imageRenderer.render(
155
- frameBuffer,
156
- this.tui,
157
- width,
158
- footerRows,
159
- this.pixelScale,
160
- !this.windowed,
161
- );
162
- for (const line of debugLines) {
163
- lines.push(truncateToWidth(line, width));
164
- }
165
- lines.push(truncateToWidth(`\x1b[2m${footer}\x1b[0m`, width));
166
- return lines;
160
+ const lines = this.renderImage(frameBuffer, width, footerRows);
161
+ return this.appendFooter(lines, width, debugLines, footer, "");
167
162
  }
168
163
 
164
+ const { lines, padPrefix } = this.renderText(frameBuffer, width, footerRows);
165
+ return this.appendFooter(lines, width, debugLines, footer, padPrefix);
166
+ }
167
+
168
+ invalidate(): void {}
169
+
170
+ dispose(): void {
171
+ this.releaseAllButtons();
172
+ this.cleanupImage();
173
+ }
174
+
175
+ private renderImage(frameBuffer: FrameBuffer, width: number, footerRows: number): string[] {
176
+ return this.imageRenderer.render(
177
+ frameBuffer,
178
+ this.tui,
179
+ width,
180
+ footerRows,
181
+ this.pixelScale,
182
+ !this.windowed,
183
+ );
184
+ }
185
+
186
+ private renderText(
187
+ frameBuffer: FrameBuffer,
188
+ width: number,
189
+ footerRows: number,
190
+ ): { lines: string[]; padPrefix: string } {
169
191
  const maxFrameRows = Math.max(1, this.tui.terminal.rows - footerRows);
170
192
  const scaleX = Math.max(1, Math.ceil(FRAME_WIDTH / width));
171
193
  const scaleY = Math.max(1, Math.ceil(FRAME_HEIGHT / (maxFrameRows * 2)));
@@ -176,21 +198,22 @@ export class NesOverlayComponent implements Component {
176
198
 
177
199
  const rawLines = renderHalfBlock(frameBuffer, targetCols, targetRows, scaleX, scaleY);
178
200
  const lines = rawLines.map((line) => truncateToWidth(`${padPrefix}${line}`, width));
179
- for (const line of debugLines) {
180
- lines.push(truncateToWidth(`${padPrefix}${line}`, width));
181
- }
182
- lines.push(truncateToWidth(`\x1b[2m${padPrefix}${footer}\x1b[0m`, width));
183
- return lines;
201
+ return { lines, padPrefix };
184
202
  }
185
203
 
186
- invalidate(): void {}
187
-
188
- dispose(): void {
189
- this.cleanupImage();
190
- for (const timer of this.tapTimers.values()) {
191
- clearTimeout(timer);
204
+ private appendFooter(
205
+ lines: string[],
206
+ width: number,
207
+ debugLines: string[],
208
+ footer: string,
209
+ padPrefix: string,
210
+ ): string[] {
211
+ const output = [...lines];
212
+ for (const line of debugLines) {
213
+ output.push(truncateToWidth(`${padPrefix}${line}`, width));
192
214
  }
193
- this.tapTimers.clear();
215
+ output.push(truncateToWidth(`\x1b[2m${padPrefix}${footer}\x1b[0m`, width));
216
+ return output;
194
217
  }
195
218
 
196
219
  private cleanupImage(): void {
@@ -201,6 +224,18 @@ export class NesOverlayComponent implements Component {
201
224
  this.imageCleared = true;
202
225
  }
203
226
 
227
+ private releaseAllButtons(): void {
228
+ for (const button of this.heldButtons) {
229
+ this.core.setButton(button, false);
230
+ }
231
+ this.heldButtons.clear();
232
+ for (const [button, timer] of this.tapTimers.entries()) {
233
+ clearTimeout(timer);
234
+ this.core.setButton(button, false);
235
+ }
236
+ this.tapTimers.clear();
237
+ }
238
+
204
239
  private buildDebugLines(): string[] {
205
240
  const stats = this.statsProvider?.();
206
241
  if (!stats) {
@@ -46,7 +46,6 @@ export interface NesCore {
46
46
  markSramSaved(): void;
47
47
  getAudioWarning(): string | null;
48
48
  getDebugState(): NesDebugState | null;
49
- reset(): void;
50
49
  dispose(): void;
51
50
  }
52
51
 
@@ -59,7 +58,6 @@ interface NativeNesInstance {
59
58
  bootup(): void;
60
59
  stepFrame(): void;
61
60
  refreshFramebuffer(): void;
62
- reset(): void;
63
61
  pressButton(button: number): void;
64
62
  releaseButton(button: number): void;
65
63
  hasBatteryBackedRam(): boolean;
@@ -181,11 +179,10 @@ class NativeNesCore implements NesCore {
181
179
  return this.nes.getDebugState();
182
180
  }
183
181
 
184
- reset(): void {
185
- this.nes.reset();
182
+ dispose(): void {
183
+ // No explicit native teardown required; napi instance is GC-managed.
184
+ this.hasSram = false;
186
185
  }
187
-
188
- dispose(): void {}
189
186
  }
190
187
 
191
188
  export function createNesCore(options: CreateNesCoreOptions = {}): NesCore {
@@ -61,6 +61,8 @@ export class NesSession {
61
61
  dropped: 0,
62
62
  };
63
63
  private readonly loopDelay = monitorEventLoopDelay({ resolution: 10 });
64
+ private fatalErrorLogged = false;
65
+ private saveErrorLogged = false;
64
66
 
65
67
  constructor(options: NesSessionOptions) {
66
68
  this.core = options.core;
@@ -210,7 +212,12 @@ export class NesSession {
210
212
  this.statsWindow.ticks = 0;
211
213
  this.statsWindow.dropped = 0;
212
214
  }
213
- } catch {
215
+ } catch (error) {
216
+ if (!this.fatalErrorLogged) {
217
+ this.fatalErrorLogged = true;
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ console.error(`NES session crashed: ${message}`);
220
+ }
214
221
  void this.stop();
215
222
  }
216
223
  }
@@ -230,6 +237,12 @@ export class NesSession {
230
237
  try {
231
238
  await saveSram(this.saveDir, this.romPath, sram);
232
239
  this.core.markSramSaved();
240
+ } catch (error) {
241
+ if (!this.saveErrorLogged) {
242
+ this.saveErrorLogged = true;
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ console.warn(`NES save failed: ${message}`);
245
+ }
233
246
  } finally {
234
247
  this.saveInFlight = false;
235
248
  }
@@ -8,13 +8,14 @@ import { Image } from "@mariozechner/pi-tui";
8
8
  import { allocateImageId, deleteKittyImage, getCapabilities, getCellDimensions } from "@mariozechner/pi-tui";
9
9
  import type { FrameBuffer } from "./nes-core.js";
10
10
 
11
- const FRAME_WIDTH = 256;
12
- const FRAME_HEIGHT = 240;
11
+ export const FRAME_WIDTH = 256;
12
+ export const FRAME_HEIGHT = 240;
13
13
  const RAW_FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 3;
14
14
  const FALLBACK_TMP_DIR = "/tmp";
15
15
  const SHM_DIR = "/dev/shm";
16
16
  const IMAGE_HEIGHT_RATIO = 0.9;
17
17
 
18
+ // Renderer state + native addon loading.
18
19
  const require = createRequire(import.meta.url);
19
20
 
20
21
  interface SharedMemoryHandle {
@@ -45,8 +46,6 @@ function getKittyShmModule(): KittyShmModule | null {
45
46
  return kittyShmModule;
46
47
  }
47
48
 
48
- export type RendererMode = "image" | "text";
49
-
50
49
  export class NesImageRenderer {
51
50
  private readonly imageId = allocateImageId();
52
51
  private cachedImage?: { base64: string; width: number; height: number };
@@ -61,6 +60,8 @@ export class NesImageRenderer {
61
60
  private sharedMemoryModule: KittyShmModule | null = null;
62
61
  private lastFrameHash = 0;
63
62
  private rawVersion = 0;
63
+ private lastLines: string[] = [];
64
+ private readonly renderErrors = new Set<string>();
64
65
 
65
66
  render(
66
67
  frameBuffer: FrameBuffer,
@@ -74,12 +75,17 @@ export class NesImageRenderer {
74
75
  if (caps.images === "kitty") {
75
76
  const shared = this.renderKittySharedMemory(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
76
77
  if (shared) {
78
+ this.lastLines = [...shared];
77
79
  return shared;
78
80
  }
79
- return this.renderKittyRaw(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
81
+ const raw = this.renderKittyRaw(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
82
+ this.lastLines = [...raw];
83
+ return raw;
80
84
  }
81
85
 
82
- return this.renderPng(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
86
+ const png = this.renderPng(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
87
+ this.lastLines = [...png];
88
+ return png;
83
89
  }
84
90
 
85
91
  dispose(tui: TUI): void {
@@ -89,6 +95,8 @@ export class NesImageRenderer {
89
95
  this.cachedImage = undefined;
90
96
  this.cachedRaw = undefined;
91
97
  this.lastFrameHash = 0;
98
+ this.lastLines = [];
99
+ this.renderErrors.clear();
92
100
  if (this.sharedMemoryQueue.length > 0 && this.sharedMemoryModule) {
93
101
  for (const handle of this.sharedMemoryQueue) {
94
102
  try {
@@ -172,9 +180,13 @@ export class NesImageRenderer {
172
180
  const layout = computeKittyLayout(tui, widthCells, footerRows, pixelScale);
173
181
  const { availableRows, columns, rows, padLeft } = layout;
174
182
 
175
- this.fillRawBuffer(frameBuffer);
176
- const fd = this.ensureRawFile();
177
- fs.writeSync(fd, this.rawBuffer, 0, this.rawBuffer.length, 0);
183
+ try {
184
+ this.fillRawBuffer(frameBuffer);
185
+ const fd = this.ensureRawFile();
186
+ fs.writeSync(fd, this.rawBuffer, 0, this.rawBuffer.length, 0);
187
+ } catch (error) {
188
+ return this.handleRenderError("kitty-raw", error);
189
+ }
178
190
 
179
191
  let cached = this.cachedRaw;
180
192
  if (!cached || cached.columns !== columns || cached.rows !== rows) {
@@ -220,26 +232,30 @@ export class NesImageRenderer {
220
232
 
221
233
  const hash = hashFrame(frameBuffer, targetWidth, targetHeight);
222
234
  if (!this.cachedImage || this.lastFrameHash !== hash) {
223
- const png = new PNG({ width: targetWidth, height: targetHeight });
224
- for (let y = 0; y < targetHeight; y += 1) {
225
- const srcY = Math.floor((y / targetHeight) * FRAME_HEIGHT);
226
- for (let x = 0; x < targetWidth; x += 1) {
227
- const srcX = Math.floor((x / targetWidth) * FRAME_WIDTH);
228
- const [r, g, b] = readRgb(frameBuffer, srcY * FRAME_WIDTH + srcX);
229
- const idx = (y * targetWidth + x) * 4;
230
- png.data[idx] = r;
231
- png.data[idx + 1] = g;
232
- png.data[idx + 2] = b;
233
- png.data[idx + 3] = 0xff;
235
+ try {
236
+ const png = new PNG({ width: targetWidth, height: targetHeight });
237
+ for (let y = 0; y < targetHeight; y += 1) {
238
+ const srcY = Math.floor((y / targetHeight) * FRAME_HEIGHT);
239
+ for (let x = 0; x < targetWidth; x += 1) {
240
+ const srcX = Math.floor((x / targetWidth) * FRAME_WIDTH);
241
+ const [r, g, b] = readRgb(frameBuffer, srcY * FRAME_WIDTH + srcX);
242
+ const idx = (y * targetWidth + x) * 4;
243
+ png.data[idx] = r;
244
+ png.data[idx + 1] = g;
245
+ png.data[idx + 2] = b;
246
+ png.data[idx + 3] = 0xff;
247
+ }
234
248
  }
249
+ const buffer = PNG.sync.write(png, { deflateLevel: 0, filterType: 0 });
250
+ this.cachedImage = {
251
+ base64: buffer.toString("base64"),
252
+ width: targetWidth,
253
+ height: targetHeight,
254
+ };
255
+ this.lastFrameHash = hash;
256
+ } catch (error) {
257
+ return this.handleRenderError("png", error);
235
258
  }
236
- const buffer = PNG.sync.write(png, { deflateLevel: 0, filterType: 0 });
237
- this.cachedImage = {
238
- base64: buffer.toString("base64"),
239
- width: targetWidth,
240
- height: targetHeight,
241
- };
242
- this.lastFrameHash = hash;
243
259
  }
244
260
 
245
261
  const image = new Image(
@@ -254,6 +270,15 @@ export class NesImageRenderer {
254
270
  return padToHeight ? centerLines(padded, availableRows) : padded;
255
271
  }
256
272
 
273
+ private handleRenderError(kind: string, error: unknown): string[] {
274
+ if (!this.renderErrors.has(kind)) {
275
+ this.renderErrors.add(kind);
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ console.warn(`NES renderer ${kind} failed: ${message}`);
278
+ }
279
+ return this.lastLines.length > 0 ? [...this.lastLines] : [];
280
+ }
281
+
257
282
  private fillRawBuffer(frameBuffer: FrameBuffer): void {
258
283
  this.fillRawBufferTarget(frameBuffer, this.rawBuffer);
259
284
  }
@@ -323,8 +348,10 @@ export class NesImageRenderer {
323
348
  }
324
349
  }
325
350
 
326
- function encodeKittyRawFile(
327
- base64Path: string,
351
+ // Kitty graphics protocol (ESC_G ... ESC\\).
352
+ // t=f (file) / t=s (shared memory), f=24 (RGB), p=1 (no scaling), q=2 (quiet), z=layer.
353
+ function buildKittyParams(
354
+ transport: "f" | "s",
328
355
  options: {
329
356
  widthPx: number;
330
357
  heightPx: number;
@@ -334,13 +361,13 @@ function encodeKittyRawFile(
334
361
  imageId?: number;
335
362
  zIndex?: number;
336
363
  },
337
- ): string {
364
+ ): string[] {
338
365
  const params: string[] = [
339
366
  "a=T",
340
367
  "f=24",
341
- "t=f",
368
+ `t=${transport}`,
342
369
  "p=1",
343
- `q=2`,
370
+ "q=2",
344
371
  `s=${options.widthPx}`,
345
372
  `v=${options.heightPx}`,
346
373
  `S=${options.dataSize}`,
@@ -349,7 +376,22 @@ function encodeKittyRawFile(
349
376
  if (options.rows) params.push(`r=${options.rows}`);
350
377
  if (options.imageId) params.push(`i=${options.imageId}`);
351
378
  if (options.zIndex !== undefined) params.push(`z=${options.zIndex}`);
379
+ return params;
380
+ }
352
381
 
382
+ function encodeKittyRawFile(
383
+ base64Path: string,
384
+ options: {
385
+ widthPx: number;
386
+ heightPx: number;
387
+ dataSize: number;
388
+ columns?: number;
389
+ rows?: number;
390
+ imageId?: number;
391
+ zIndex?: number;
392
+ },
393
+ ): string {
394
+ const params = buildKittyParams("f", options);
353
395
  return `\x1b_G${params.join(",")};${base64Path}\x1b\\`;
354
396
  }
355
397
 
@@ -365,24 +407,11 @@ function encodeKittyRawSharedMemory(
365
407
  zIndex?: number;
366
408
  },
367
409
  ): string {
368
- const params: string[] = [
369
- "a=T",
370
- "f=24",
371
- "t=s",
372
- "p=1",
373
- `q=2`,
374
- `s=${options.widthPx}`,
375
- `v=${options.heightPx}`,
376
- `S=${options.dataSize}`,
377
- ];
378
- if (options.columns) params.push(`c=${options.columns}`);
379
- if (options.rows) params.push(`r=${options.rows}`);
380
- if (options.imageId) params.push(`i=${options.imageId}`);
381
- if (options.zIndex !== undefined) params.push(`z=${options.zIndex}`);
382
-
410
+ const params = buildKittyParams("s", options);
383
411
  return `\x1b_G${params.join(",")};${base64Name}\x1b\\`;
384
412
  }
385
413
 
414
+ // Temp file/SHM selection.
386
415
  function resolveRawDir(): string {
387
416
  const candidates = [process.env.TMPDIR, SHM_DIR, FALLBACK_TMP_DIR, os.tmpdir()].filter(
388
417
  (value): value is string => Boolean(value && value.length > 0),
@@ -401,6 +430,7 @@ function resolveRawDir(): string {
401
430
  return os.tmpdir();
402
431
  }
403
432
 
433
+ // Layout helpers.
404
434
  function getAvailableRows(tui: TUI, footerRows: number): number {
405
435
  return Math.max(1, tui.terminal.rows - footerRows);
406
436
  }
@@ -24,16 +24,3 @@ export async function listRoms(romDir: string): Promise<RomEntry[]> {
24
24
  .sort((a, b) => a.name.localeCompare(b.name));
25
25
  }
26
26
 
27
- export async function resolveRomPath(args: string | undefined, romDir: string): Promise<string | null> {
28
- const trimmed = args?.trim();
29
- if (trimmed && trimmed.length > 0) {
30
- return path.resolve(trimmed);
31
- }
32
-
33
- try {
34
- const roms = await listRoms(romDir);
35
- return roms.length > 0 ? roms[0]?.path ?? null : null;
36
- } catch {
37
- return null;
38
- }
39
- }
@@ -1,10 +1,17 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { getRomDisplayName } from "./roms.js";
4
5
 
6
+ function getSaveId(romPath: string): string {
7
+ const resolved = path.resolve(romPath);
8
+ return createHash("sha1").update(resolved).digest("hex").slice(0, 8);
9
+ }
10
+
5
11
  export function getSavePath(saveDir: string, romPath: string): string {
6
12
  const romName = getRomDisplayName(romPath);
7
- return path.join(saveDir, `${romName}.sav`);
13
+ const hash = getSaveId(romPath);
14
+ return path.join(saveDir, `${romName}-${hash}.sav`);
8
15
  }
9
16
 
10
17
  export async function loadSram(saveDir: string, romPath: string): Promise<Uint8Array | null> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.21",
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."
package/spec.md CHANGED
@@ -60,7 +60,8 @@ pi-nes/
60
60
  - Use `isKeyRelease()` for clean key‑up events.
61
61
 
62
62
  ## Saves
63
- - Store SRAM at `<saveDir>/<rom-name>.sav` (default `/roms/nes/saves`).
63
+ - Store SRAM at `<saveDir>/<rom-name>-<hash>.sav` (default `/roms/nes/saves`).
64
+ - Hash is derived from the full ROM path to avoid collisions; old `<rom-name>.sav` files are ignored.
64
65
  - Load SRAM on ROM start.
65
66
  - Persist on exit and periodically (e.g., every 5–10 seconds).
66
67
 
@@ -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 |