@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 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).
@@ -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 romDir =
59
+ const romDirInput =
53
60
  typeof parsed.romDir === "string" && parsed.romDir.length > 0
54
- ? normalizePath(parsed.romDir, DEFAULT_CONFIG.romDir)
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 saveDir =
65
+ const saveDirInput =
58
66
  typeof parsed.saveDir === "string" && parsed.saveDir.length > 0
59
- ? normalizePath(parsed.saveDir, saveDirFallback)
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
- config = normalizeConfig(JSON.parse(raw));
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
  }
@@ -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 selection = await ctx.ui.select(
47
- "Select a ROM",
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 match = roms.find((rom) => rom.name === selection);
54
- return match?.path ?? null;
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
- async function validateRomDir(pathValue: string, ctx: ExtensionCommandContext): Promise<boolean> {
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 (must exist)",
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 valid = await validateRomDir(romDir, ctx);
168
- if (!valid) {
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 ~/Projects/pi-nes/extensions/nes/native/nes-core && npm install && npm run build",
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;
@@ -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 "./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.20",
3
+ "version": "0.2.22",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
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