@tmustier/pi-nes 0.2.3 → 0.2.4

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
@@ -88,7 +88,7 @@ The extension uses the **native Rust core** only (required build step). Battery-
88
88
 
89
89
  ## Rendering
90
90
 
91
- Default renderer is `image`, which uses Kitty's image protocol for high resolution. On Kitty, we **prefer shared-memory transport (`t=s`)** when the native addon is built, falling back to the **file transport (`t=f`)** path if the addon isn’t available; non-Kitty terminals fall back to PNG. Image mode runs **nearly full-screen** (no overlay) because Kitty graphics sequences can't be safely composited inside overlays; it caps to ~90% height and centers vertically to reduce terminal compositor load. Image mode also **throttles rendering to ~30fps** to keep emulation speed stable. Set `renderer: "text"` if you prefer ANSI half-block rendering in an overlay. You can tweak `pixelScale` to 1.5–2.0 for larger images in PNG mode.
91
+ Default renderer is `image`, which uses Kitty's image protocol for high resolution. On Kitty, we **prefer shared-memory transport (`t=s`)** when the native addon is built, falling back to the **file transport (`t=f`)** path if the addon isn’t available; non-Kitty terminals fall back to PNG. Image mode runs **nearly full-screen** (no overlay) because Kitty graphics sequences can't be safely composited inside overlays; it caps to ~90% height and centers vertically + horizontally to reduce terminal compositor load. Image mode also **throttles rendering to ~30fps** to keep emulation speed stable. Set `renderer: "text"` if you prefer ANSI half-block rendering in an overlay. You can tweak `pixelScale` to 1.5–2.0 for larger images in PNG mode.
92
92
 
93
93
  ## Audio
94
94
 
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_INPUT_MAPPING, type InputMapping } from "./input-map.js";
5
+ import { normalizePath } from "./paths.js";
5
6
 
6
7
  export type RendererMode = "image" | "text";
7
8
 
@@ -90,24 +91,6 @@ function normalizePixelScale(raw: unknown): number {
90
91
  return Math.min(4, Math.max(0.5, raw));
91
92
  }
92
93
 
93
- function normalizePath(value: string, fallback: string): string {
94
- const trimmed = value.trim();
95
- if (!trimmed) {
96
- return fallback;
97
- }
98
- return expandHomePath(trimmed);
99
- }
100
-
101
- function expandHomePath(value: string): string {
102
- if (value === "~") {
103
- return os.homedir();
104
- }
105
- if (value.startsWith("~/") || value.startsWith("~\\")) {
106
- return path.join(os.homedir(), value.slice(2));
107
- }
108
- return value;
109
- }
110
-
111
94
  function normalizeKeybindings(raw: unknown): InputMapping {
112
95
  const mapping = cloneMapping(DEFAULT_INPUT_MAPPING);
113
96
  if (!raw || typeof raw !== "object") {
@@ -1,5 +1,4 @@
1
1
  import { promises as fs } from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
4
  import { NesOverlayComponent } from "./nes-component.js";
@@ -13,6 +12,7 @@ import {
13
12
  normalizeConfig,
14
13
  saveConfig,
15
14
  } from "./config.js";
15
+ import { displayPath, resolvePathInput } from "./paths.js";
16
16
  import { NesSession } from "./nes-session.js";
17
17
  import { listRoms } from "./roms.js";
18
18
  import { loadSram } from "./saves.js";
@@ -77,26 +77,6 @@ function parseArgs(args?: string): { debug: boolean; romArg?: string } {
77
77
  return { debug: false, romArg: trimmed };
78
78
  }
79
79
 
80
- function displayPath(value: string): string {
81
- const home = os.homedir();
82
- if (value.startsWith(home)) {
83
- return `~${value.slice(home.length)}`;
84
- }
85
- return value;
86
- }
87
-
88
- function resolvePathInput(input: string, cwd: string): string {
89
- if (input.startsWith("~")) {
90
- const trimmed = input.slice(1);
91
- const suffix = trimmed.startsWith(path.sep) ? trimmed.slice(1) : trimmed;
92
- return path.join(os.homedir(), suffix);
93
- }
94
- if (path.isAbsolute(input)) {
95
- return input;
96
- }
97
- return path.resolve(cwd, input);
98
- }
99
-
100
80
  async function ensureRomDir(pathValue: string, ctx: ExtensionCommandContext): Promise<boolean> {
101
81
  try {
102
82
  const stat = await fs.stat(pathValue);
@@ -11,14 +11,9 @@ const FRAME_WIDTH = 256;
11
11
  const FRAME_HEIGHT = 240;
12
12
 
13
13
  function readRgb(frameBuffer: FrameBuffer, index: number): [number, number, number] {
14
- if (frameBuffer.format === "rgb") {
15
- const data = frameBuffer.data as ReadonlyArray<number>;
16
- const base = index * 3;
17
- return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
18
- }
19
- const data = frameBuffer.data as ReadonlyArray<number>;
20
- const color = data[index] ?? 0;
21
- return [color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff];
14
+ const data = frameBuffer.data;
15
+ const base = index * 3;
16
+ return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
22
17
  }
23
18
 
24
19
  function renderHalfBlock(
@@ -4,11 +4,8 @@ const require = createRequire(import.meta.url);
4
4
 
5
5
  export type NesButton = "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select";
6
6
 
7
- export type FrameBufferFormat = "packed" | "rgb";
8
-
9
7
  export interface FrameBuffer {
10
- format: FrameBufferFormat;
11
- data: ReadonlyArray<number> | Uint8Array;
8
+ data: Uint8Array;
12
9
  }
13
10
 
14
11
  export interface NesCore {
@@ -100,7 +97,7 @@ class NativeNesCore implements NesCore {
100
97
  }
101
98
 
102
99
  getFrameBuffer(): FrameBuffer {
103
- return { format: "rgb", data: this.frameBuffer };
100
+ return { data: this.frameBuffer };
104
101
  }
105
102
 
106
103
  setButton(button: NesButton, pressed: boolean): void {
@@ -0,0 +1,40 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function displayPath(value: string): string {
5
+ const home = os.homedir();
6
+ if (value.startsWith(home)) {
7
+ return `~${value.slice(home.length)}`;
8
+ }
9
+ return value;
10
+ }
11
+
12
+ export function expandHomePath(value: string): string {
13
+ if (value === "~") {
14
+ return os.homedir();
15
+ }
16
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
17
+ return path.join(os.homedir(), value.slice(2));
18
+ }
19
+ return value;
20
+ }
21
+
22
+ export function normalizePath(value: string, fallback: string): string {
23
+ const trimmed = value.trim();
24
+ if (!trimmed) {
25
+ return fallback;
26
+ }
27
+ return expandHomePath(trimmed);
28
+ }
29
+
30
+ export function resolvePathInput(input: string, cwd: string): string {
31
+ const trimmed = input.trim();
32
+ if (!trimmed) {
33
+ return cwd;
34
+ }
35
+ const expanded = expandHomePath(trimmed);
36
+ if (path.isAbsolute(expanded)) {
37
+ return expanded;
38
+ }
39
+ return path.resolve(cwd, expanded);
40
+ }
@@ -131,21 +131,8 @@ export class NesImageRenderer {
131
131
  return null;
132
132
  }
133
133
 
134
- const availableRows = getAvailableRows(tui, footerRows);
135
- const maxRows = getMaxImageRows(tui, footerRows);
136
- const cell = getCellDimensions();
137
- const maxWidthByRows = Math.floor(
138
- (maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
139
- );
140
- const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
141
- const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
142
- const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
143
- const maxScale = Math.min(maxWidthPx / FRAME_WIDTH, maxHeightPx / FRAME_HEIGHT);
144
- const requestedScale = Math.max(0.5, pixelScale) * maxScale;
145
- const scale = Math.min(maxScale, requestedScale);
146
- const columns = Math.max(1, Math.min(maxWidthCells, Math.floor((FRAME_WIDTH * scale) / cell.widthPx)));
147
- const rows = Math.max(1, Math.min(maxRows, Math.floor((FRAME_HEIGHT * scale) / cell.heightPx)));
148
- const padLeft = getHorizontalPadding(widthCells, columns);
134
+ const layout = computeKittyLayout(tui, widthCells, footerRows, pixelScale);
135
+ const { availableRows, columns, rows, padLeft } = layout;
149
136
 
150
137
  this.fillRawBufferTarget(frameBuffer, shared.buffer);
151
138
  const base64Name = Buffer.from(shared.name).toString("base64");
@@ -178,21 +165,8 @@ export class NesImageRenderer {
178
165
  footerRows: number,
179
166
  pixelScale: number,
180
167
  ): string[] {
181
- const availableRows = getAvailableRows(tui, footerRows);
182
- const maxRows = getMaxImageRows(tui, footerRows);
183
- const cell = getCellDimensions();
184
- const maxWidthByRows = Math.floor(
185
- (maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
186
- );
187
- const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
188
- const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
189
- const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
190
- const maxScale = Math.min(maxWidthPx / FRAME_WIDTH, maxHeightPx / FRAME_HEIGHT);
191
- const requestedScale = Math.max(0.5, pixelScale) * maxScale;
192
- const scale = Math.min(maxScale, requestedScale);
193
- const columns = Math.max(1, Math.min(maxWidthCells, Math.floor((FRAME_WIDTH * scale) / cell.widthPx)));
194
- const rows = Math.max(1, Math.min(maxRows, Math.floor((FRAME_HEIGHT * scale) / cell.heightPx)));
195
- const padLeft = getHorizontalPadding(widthCells, columns);
168
+ const layout = computeKittyLayout(tui, widthCells, footerRows, pixelScale);
169
+ const { availableRows, columns, rows, padLeft } = layout;
196
170
 
197
171
  this.fillRawBuffer(frameBuffer);
198
172
  const fd = this.ensureRawFile();
@@ -235,23 +209,8 @@ export class NesImageRenderer {
235
209
  footerRows: number,
236
210
  pixelScale: number,
237
211
  ): string[] {
238
- const availableRows = getAvailableRows(tui, footerRows);
239
- const maxRows = getMaxImageRows(tui, footerRows);
240
- const cell = getCellDimensions();
241
- const maxWidthByRows = Math.floor(
242
- (maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
243
- );
244
- const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
245
- const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
246
- const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
247
- const scale = Math.min(
248
- maxWidthPx / FRAME_WIDTH,
249
- maxHeightPx / FRAME_HEIGHT,
250
- ) * pixelScale;
251
- const targetWidth = Math.max(1, Math.floor(FRAME_WIDTH * scale));
252
- const targetHeight = Math.max(1, Math.floor(FRAME_HEIGHT * scale));
253
- const columns = Math.max(1, Math.min(maxWidthCells, Math.floor(targetWidth / cell.widthPx)));
254
- const padLeft = getHorizontalPadding(widthCells, columns);
212
+ const layout = computePngLayout(tui, widthCells, footerRows, pixelScale);
213
+ const { availableRows, maxWidthCells, padLeft, targetHeight, targetWidth } = layout;
255
214
 
256
215
  const hash = hashFrame(frameBuffer, targetWidth, targetHeight);
257
216
  if (!this.cachedImage || this.lastFrameHash !== hash) {
@@ -293,28 +252,13 @@ export class NesImageRenderer {
293
252
  }
294
253
 
295
254
  private fillRawBufferTarget(frameBuffer: FrameBuffer, target: Uint8Array): void {
296
- if (frameBuffer.format === "rgb") {
297
- const source = frameBuffer.data as ReadonlyArray<number>;
298
- if (source instanceof Uint8Array) {
299
- target.set(source.subarray(0, RAW_FRAME_BYTES));
300
- return;
301
- }
302
- const max = Math.min(source.length, RAW_FRAME_BYTES);
303
- for (let i = 0; i < max; i += 1) {
304
- target[i] = source[i] ?? 0;
305
- }
255
+ const source = frameBuffer.data;
256
+ if (source.length >= RAW_FRAME_BYTES) {
257
+ target.set(source.subarray(0, RAW_FRAME_BYTES));
306
258
  return;
307
259
  }
308
-
309
- let offset = 0;
310
- const source = frameBuffer.data as ReadonlyArray<number>;
311
- for (let i = 0; i < FRAME_WIDTH * FRAME_HEIGHT; i += 1) {
312
- const color = source[i] ?? 0;
313
- target[offset] = color & 0xff;
314
- target[offset + 1] = (color >> 8) & 0xff;
315
- target[offset + 2] = (color >> 16) & 0xff;
316
- offset += 3;
317
- }
260
+ target.set(source);
261
+ target.fill(0, source.length, RAW_FRAME_BYTES);
318
262
  }
319
263
 
320
264
  private getSharedMemoryModule(): KittyShmModule | null {
@@ -459,6 +403,40 @@ function getMaxImageRows(tui: TUI, footerRows: number): number {
459
403
  return Math.max(1, Math.floor(availableRows * IMAGE_HEIGHT_RATIO));
460
404
  }
461
405
 
406
+ function computeLayoutBase(tui: TUI, widthCells: number, footerRows: number) {
407
+ const availableRows = getAvailableRows(tui, footerRows);
408
+ const maxRows = getMaxImageRows(tui, footerRows);
409
+ const cell = getCellDimensions();
410
+ const maxWidthByRows = Math.floor(
411
+ (maxRows * cell.heightPx * FRAME_WIDTH) / (FRAME_HEIGHT * cell.widthPx),
412
+ );
413
+ const maxWidthCells = Math.max(1, Math.min(widthCells, maxWidthByRows));
414
+ const maxWidthPx = Math.max(1, maxWidthCells * cell.widthPx);
415
+ const maxHeightPx = Math.max(1, maxRows * cell.heightPx);
416
+ return { availableRows, maxRows, cell, maxWidthCells, maxWidthPx, maxHeightPx };
417
+ }
418
+
419
+ function computeKittyLayout(tui: TUI, widthCells: number, footerRows: number, pixelScale: number) {
420
+ const base = computeLayoutBase(tui, widthCells, footerRows);
421
+ const maxScale = Math.min(base.maxWidthPx / FRAME_WIDTH, base.maxHeightPx / FRAME_HEIGHT);
422
+ const requestedScale = Math.max(0.5, pixelScale) * maxScale;
423
+ const scale = Math.min(maxScale, requestedScale);
424
+ const columns = Math.max(1, Math.min(base.maxWidthCells, Math.floor((FRAME_WIDTH * scale) / base.cell.widthPx)));
425
+ const rows = Math.max(1, Math.min(base.maxRows, Math.floor((FRAME_HEIGHT * scale) / base.cell.heightPx)));
426
+ const padLeft = getHorizontalPadding(widthCells, columns);
427
+ return { ...base, columns, rows, padLeft };
428
+ }
429
+
430
+ function computePngLayout(tui: TUI, widthCells: number, footerRows: number, pixelScale: number) {
431
+ const base = computeLayoutBase(tui, widthCells, footerRows);
432
+ const scale = Math.min(base.maxWidthPx / FRAME_WIDTH, base.maxHeightPx / FRAME_HEIGHT) * pixelScale;
433
+ const targetWidth = Math.max(1, Math.floor(FRAME_WIDTH * scale));
434
+ const targetHeight = Math.max(1, Math.floor(FRAME_HEIGHT * scale));
435
+ const columns = Math.max(1, Math.min(base.maxWidthCells, Math.floor(targetWidth / base.cell.widthPx)));
436
+ const padLeft = getHorizontalPadding(widthCells, columns);
437
+ return { ...base, targetWidth, targetHeight, padLeft };
438
+ }
439
+
462
440
  function centerLines(lines: string[], totalRows: number): string[] {
463
441
  if (lines.length >= totalRows) {
464
442
  return lines;
@@ -495,37 +473,24 @@ function insertPaddingAfterMoveUp(line: string, padLeft: number): string {
495
473
  }
496
474
 
497
475
  function readRgb(frameBuffer: FrameBuffer, index: number): [number, number, number] {
498
- if (frameBuffer.format === "rgb") {
499
- const data = frameBuffer.data as ReadonlyArray<number>;
500
- const base = index * 3;
501
- return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
502
- }
503
- const data = frameBuffer.data as ReadonlyArray<number>;
504
- const color = data[index] ?? 0;
505
- return [color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff];
506
- }
507
-
508
- function readPacked(frameBuffer: FrameBuffer, index: number): number {
509
- if (frameBuffer.format === "packed") {
510
- const data = frameBuffer.data as ReadonlyArray<number>;
511
- return data[index] ?? 0;
512
- }
513
- const data = frameBuffer.data as ReadonlyArray<number>;
476
+ const data = frameBuffer.data;
514
477
  const base = index * 3;
515
- const r = data[base] ?? 0;
516
- const g = data[base + 1] ?? 0;
517
- const b = data[base + 2] ?? 0;
518
- return r | (g << 8) | (b << 16);
478
+ return [data[base] ?? 0, data[base + 1] ?? 0, data[base + 2] ?? 0];
519
479
  }
520
480
 
521
481
  function hashFrame(frameBuffer: FrameBuffer, width: number, height: number): number {
522
482
  let hash = width ^ (height << 16);
523
483
  const stepX = Math.max(1, Math.floor(FRAME_WIDTH / 64));
524
484
  const stepY = Math.max(1, Math.floor(FRAME_HEIGHT / 64));
485
+ const data = frameBuffer.data;
525
486
  for (let y = 0; y < FRAME_HEIGHT; y += stepY) {
526
487
  const rowOffset = y * FRAME_WIDTH;
527
488
  for (let x = 0; x < FRAME_WIDTH; x += stepX) {
528
- const color = readPacked(frameBuffer, rowOffset + x);
489
+ const base = (rowOffset + x) * 3;
490
+ const r = data[base] ?? 0;
491
+ const g = data[base + 1] ?? 0;
492
+ const b = data[base + 2] ?? 0;
493
+ const color = r | (g << 8) | (b << 16);
529
494
  hash = ((hash << 5) - hash + color) | 0;
530
495
  }
531
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",