@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
|
@@ -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 "./
|
|
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<
|
|
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.
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
81
|
+
const raw = this.renderKittyRaw(frameBuffer, tui, widthCells, footerRows, pixelScale, padToHeight);
|
|
82
|
+
this.lastLines = [...raw];
|
|
83
|
+
return raw;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
368
|
+
`t=${transport}`,
|
|
342
369
|
"p=1",
|
|
343
|
-
|
|
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
|
|
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
|
}
|
package/extensions/nes/roms.ts
CHANGED
|
@@ -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
|
-
}
|
package/extensions/nes/saves.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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 |
|