@tmustier/pi-nes 0.2.20 → 0.2.22
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/README.md +6 -0
- package/extensions/nes/config.ts +19 -5
- package/extensions/nes/index.ts +18 -24
- 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/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 +1 -1
- package/spec.md +2 -1
package/README.md
CHANGED
|
@@ -86,6 +86,12 @@ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup.
|
|
|
86
86
|
| `imageQuality` | `"balanced"` | `"balanced"` (30 fps) or `"high"` (60 fps) |
|
|
87
87
|
| `pixelScale` | `1.0` | Display scale (0.5–4.0) |
|
|
88
88
|
|
|
89
|
+
## Saves
|
|
90
|
+
|
|
91
|
+
Battery-backed SRAM is saved to `<saveDir>/<rom-name>-<hash>.sav` where the hash is derived from the full ROM path to avoid collisions. Old `<rom-name>.sav` files are ignored.
|
|
92
|
+
|
|
93
|
+
Saves are flushed on quit and periodically during play.
|
|
94
|
+
|
|
89
95
|
## Terminal Support
|
|
90
96
|
|
|
91
97
|
**Best experience:** a Kitty-protocol terminal like Ghostty, Kitty, or WezTerm (image protocol + key-up events).
|
package/extensions/nes/config.ts
CHANGED
|
@@ -47,17 +47,26 @@ export function getConfigPath(): string {
|
|
|
47
47
|
return path.join(os.homedir(), ".pi", "nes", "config.json");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function resolveConfigPath(value: string): string {
|
|
51
|
+
if (path.isAbsolute(value)) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return path.resolve(path.dirname(getConfigPath()), value);
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export function normalizeConfig(raw: unknown): NesConfig {
|
|
51
58
|
const parsed = typeof raw === "object" && raw !== null ? (raw as RawConfig) : {};
|
|
52
|
-
const
|
|
59
|
+
const romDirInput =
|
|
53
60
|
typeof parsed.romDir === "string" && parsed.romDir.length > 0
|
|
54
|
-
?
|
|
61
|
+
? parsed.romDir
|
|
55
62
|
: DEFAULT_CONFIG.romDir;
|
|
63
|
+
const romDir = resolveConfigPath(normalizePath(romDirInput, DEFAULT_CONFIG.romDir));
|
|
56
64
|
const saveDirFallback = getDefaultSaveDir(romDir);
|
|
57
|
-
const
|
|
65
|
+
const saveDirInput =
|
|
58
66
|
typeof parsed.saveDir === "string" && parsed.saveDir.length > 0
|
|
59
|
-
?
|
|
67
|
+
? parsed.saveDir
|
|
60
68
|
: saveDirFallback;
|
|
69
|
+
const saveDir = resolveConfigPath(normalizePath(saveDirInput, saveDirFallback));
|
|
61
70
|
const imageQuality = normalizeImageQuality(parsed.imageQuality);
|
|
62
71
|
const pixelScale = normalizePixelScale(parsed.pixelScale);
|
|
63
72
|
return {
|
|
@@ -80,7 +89,12 @@ export async function loadConfig(): Promise<NesConfig> {
|
|
|
80
89
|
let config: NesConfig;
|
|
81
90
|
try {
|
|
82
91
|
const raw = await fs.readFile(configPath, "utf8");
|
|
83
|
-
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
const normalized = normalizeConfig(parsed);
|
|
94
|
+
if (raw.trim() !== formatConfig(normalized)) {
|
|
95
|
+
await saveConfig(normalized);
|
|
96
|
+
}
|
|
97
|
+
config = normalized;
|
|
84
98
|
} catch {
|
|
85
99
|
config = DEFAULT_CONFIG;
|
|
86
100
|
}
|
package/extensions/nes/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ const TEXT_RENDER_INTERVAL_MS = 1000 / 60;
|
|
|
24
24
|
|
|
25
25
|
let activeSession: NesSession | null = null;
|
|
26
26
|
|
|
27
|
+
// ROM selection helpers.
|
|
27
28
|
async function selectRom(
|
|
28
29
|
args: string | undefined,
|
|
29
30
|
romDir: string,
|
|
@@ -43,21 +44,24 @@ async function selectRom(
|
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
roms.map((rom) => rom.name),
|
|
49
|
-
);
|
|
47
|
+
const options = roms.map((rom, index) => `${index + 1}. ${rom.name}`);
|
|
48
|
+
const selection = await ctx.ui.select("Select a ROM", options);
|
|
50
49
|
if (!selection) {
|
|
51
50
|
return null;
|
|
52
51
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
52
|
+
const index = options.indexOf(selection);
|
|
53
|
+
if (index < 0) {
|
|
54
|
+
ctx.ui.notify("ROM selection failed. Please try again.", "error");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return roms[index]?.path ?? null;
|
|
55
58
|
} catch {
|
|
56
59
|
ctx.ui.notify(`Failed to read ROM directory: ${romDir}. Update ${configPath} to set romDir.`, "error");
|
|
57
60
|
return null;
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// Command argument parsing.
|
|
61
65
|
function parseArgs(args?: string): { debug: boolean; romArg?: string } {
|
|
62
66
|
if (!args) {
|
|
63
67
|
return { debug: false, romArg: undefined };
|
|
@@ -79,6 +83,7 @@ function parseArgs(args?: string): { debug: boolean; romArg?: string } {
|
|
|
79
83
|
return { debug: false, romArg: trimmed };
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
// ROM directory validation/creation.
|
|
82
87
|
async function ensureRomDir(pathValue: string, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
83
88
|
try {
|
|
84
89
|
const stat = await fs.stat(pathValue);
|
|
@@ -99,20 +104,7 @@ async function ensureRomDir(pathValue: string, ctx: ExtensionCommandContext): Pr
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
const stat = await fs.stat(pathValue);
|
|
105
|
-
if (!stat.isDirectory()) {
|
|
106
|
-
ctx.ui.notify(`ROM directory is not a folder: ${pathValue}`, "error");
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
return true;
|
|
110
|
-
} catch {
|
|
111
|
-
ctx.ui.notify(`ROM directory not found: ${pathValue}`, "error");
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
107
|
+
// Config UI.
|
|
116
108
|
async function editConfigJson(
|
|
117
109
|
ctx: ExtensionCommandContext,
|
|
118
110
|
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
@@ -145,7 +137,7 @@ async function configureWithWizard(
|
|
|
145
137
|
const romDirDefaultLabel = config.romDir === DEFAULT_CONFIG.romDir ? "Use default" : "Use current";
|
|
146
138
|
const romDirOptions = [
|
|
147
139
|
`${romDirDefaultLabel} (${romDirDisplay}) — creates if missing`,
|
|
148
|
-
"Enter a custom path (
|
|
140
|
+
"Enter a custom path (creates if missing)",
|
|
149
141
|
];
|
|
150
142
|
const romDirChoice = await ctx.ui.select("ROM directory", romDirOptions);
|
|
151
143
|
if (!romDirChoice) {
|
|
@@ -164,8 +156,8 @@ async function configureWithWizard(
|
|
|
164
156
|
return false;
|
|
165
157
|
}
|
|
166
158
|
romDir = resolvePathInput(trimmedRomDir, ctx.cwd);
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
159
|
+
const ensured = await ensureRomDir(romDir, ctx);
|
|
160
|
+
if (!ensured) {
|
|
169
161
|
return false;
|
|
170
162
|
}
|
|
171
163
|
} else {
|
|
@@ -233,6 +225,7 @@ async function editConfig(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
233
225
|
ctx.ui.notify(`Saved config to ${getConfigPath()}`, "info");
|
|
234
226
|
}
|
|
235
227
|
|
|
228
|
+
// Session lifecycle.
|
|
236
229
|
async function createSession(romPath: string, ctx: ExtensionCommandContext, config: Awaited<ReturnType<typeof loadConfig>>): Promise<NesSession | null> {
|
|
237
230
|
let romData: Uint8Array;
|
|
238
231
|
try {
|
|
@@ -249,7 +242,7 @@ async function createSession(romPath: string, ctx: ExtensionCommandContext, conf
|
|
|
249
242
|
const message = error instanceof Error ? error.message : String(error);
|
|
250
243
|
ctx.ui.notify(`Failed to initialize NES core: ${message}`, "error");
|
|
251
244
|
ctx.ui.notify(
|
|
252
|
-
"Build native core: cd
|
|
245
|
+
"Build native core: cd extensions/nes/native/nes-core && npm install && npm run build",
|
|
253
246
|
"warning",
|
|
254
247
|
);
|
|
255
248
|
return null;
|
|
@@ -335,6 +328,7 @@ async function attachSession(
|
|
|
335
328
|
return shouldStop;
|
|
336
329
|
}
|
|
337
330
|
|
|
331
|
+
// Command registration.
|
|
338
332
|
export default function (pi: ExtensionAPI) {
|
|
339
333
|
pi.on("session_shutdown", async () => {
|
|
340
334
|
if (activeSession) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export function nativeVersion(): string;
|
|
2
|
-
|
|
3
1
|
export interface CpuDebugState {
|
|
4
2
|
pc: number;
|
|
5
3
|
a: number;
|
|
@@ -33,7 +31,6 @@ export class NativeNes {
|
|
|
33
31
|
bootup(): void;
|
|
34
32
|
stepFrame(): void;
|
|
35
33
|
refreshFramebuffer(): void;
|
|
36
|
-
reset(): void;
|
|
37
34
|
pressButton(button: number): void;
|
|
38
35
|
releaseButton(button: number): void;
|
|
39
36
|
hasBatteryBackedRam(): boolean;
|
|
Binary file
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
/* auto-generated by NAPI-RS */
|
|
5
5
|
|
|
6
|
-
export declare function nativeVersion(): string
|
|
7
6
|
export interface CpuDebugState {
|
|
8
7
|
pc: number
|
|
9
8
|
a: number
|
|
@@ -34,7 +33,6 @@ export declare class NativeNes {
|
|
|
34
33
|
bootup(): void
|
|
35
34
|
stepFrame(): void
|
|
36
35
|
refreshFramebuffer(): void
|
|
37
|
-
reset(): void
|
|
38
36
|
pressButton(button: number): void
|
|
39
37
|
releaseButton(button: number): void
|
|
40
38
|
hasBatteryBackedRam(): boolean
|
|
@@ -7,11 +7,6 @@ use nes_rust::display::{Display, SCREEN_HEIGHT, SCREEN_WIDTH};
|
|
|
7
7
|
use nes_rust::rom::Rom;
|
|
8
8
|
use nes_rust::Nes;
|
|
9
9
|
|
|
10
|
-
#[napi]
|
|
11
|
-
pub fn native_version() -> String {
|
|
12
|
-
env!("CARGO_PKG_VERSION").to_string()
|
|
13
|
-
}
|
|
14
|
-
|
|
15
10
|
struct NativeDisplay {
|
|
16
11
|
pixels: Vec<u8>,
|
|
17
12
|
}
|
|
@@ -119,11 +114,6 @@ impl NativeNes {
|
|
|
119
114
|
self.nes.copy_pixels(&mut self.framebuffer);
|
|
120
115
|
}
|
|
121
116
|
|
|
122
|
-
#[napi]
|
|
123
|
-
pub fn reset(&mut self) {
|
|
124
|
-
self.nes.reset();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
117
|
#[napi]
|
|
128
118
|
pub fn press_button(&mut self, button: u8) {
|
|
129
119
|
if let Some(mapped) = map_button(button) {
|
|
@@ -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
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
|
|