@tmustier/pi-nes 0.2.25 → 0.2.26

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
@@ -20,7 +20,7 @@ pi install git:github.com/tmustier/pi-nes
20
20
  /nes ~/roms/smb.nes # Load a specific ROM
21
21
  ```
22
22
 
23
- On first run, you'll be prompted to set your ROM directory and display quality.
23
+ On first run, you'll be prompted to set your ROM directory and display quality. When launching `/nes` without a path, type to filter the ROM list while you navigate.
24
24
 
25
25
  ## Controls
26
26
 
@@ -62,6 +62,7 @@ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup.
62
62
  "saveDir": "/roms/nes/saves",
63
63
  "renderer": "image",
64
64
  "imageQuality": "balanced",
65
+ "videoFilter": "off",
65
66
  "pixelScale": 1.0,
66
67
  "keybindings": {
67
68
  "up": ["up", "w"],
@@ -84,8 +85,11 @@ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup.
84
85
  | `saveDir` | `/roms/nes/saves` | Where to store battery saves (defaults to `<romDir>/saves`) |
85
86
  | `renderer` | `"image"` | `"image"` (Kitty graphics) or `"text"` (ANSI) |
86
87
  | `imageQuality` | `"balanced"` | `"balanced"` (30 fps) or `"high"` (60 fps) |
88
+ | `videoFilter` | `"off"` | `"off"`, `"ntsc-composite"`, `"ntsc-svideo"`, `"ntsc-rgb"` |
87
89
  | `pixelScale` | `1.0` | Display scale (0.5–4.0) |
88
90
 
91
+ `videoFilter` applies a lightweight CRT/NTSC-inspired pass (horizontal bleed + scanlines). It runs in the native core and is optional.
92
+
89
93
  ## Saves
90
94
 
91
95
  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.
@@ -6,6 +6,7 @@ import { normalizePath } from "./paths.js";
6
6
 
7
7
  export type RendererMode = "image" | "text";
8
8
  export type ImageQuality = "balanced" | "high";
9
+ export type VideoFilter = "off" | "ntsc-composite" | "ntsc-svideo" | "ntsc-rgb";
9
10
 
10
11
  export interface NesConfig {
11
12
  romDir: string;
@@ -13,6 +14,7 @@ export interface NesConfig {
13
14
  enableAudio: boolean;
14
15
  renderer: RendererMode;
15
16
  imageQuality: ImageQuality;
17
+ videoFilter: VideoFilter;
16
18
  pixelScale: number;
17
19
  keybindings: InputMapping;
18
20
  }
@@ -29,6 +31,7 @@ export const DEFAULT_CONFIG: NesConfig = {
29
31
  enableAudio: false,
30
32
  renderer: "image",
31
33
  imageQuality: "balanced",
34
+ videoFilter: "off",
32
35
  pixelScale: 1.0,
33
36
  keybindings: cloneMapping(DEFAULT_INPUT_MAPPING),
34
37
  };
@@ -39,6 +42,7 @@ interface RawConfig {
39
42
  enableAudio?: unknown;
40
43
  renderer?: unknown;
41
44
  imageQuality?: unknown;
45
+ videoFilter?: unknown;
42
46
  pixelScale?: unknown;
43
47
  keybindings?: unknown;
44
48
  }
@@ -68,6 +72,7 @@ export function normalizeConfig(raw: unknown): NesConfig {
68
72
  : saveDirFallback;
69
73
  const saveDir = resolveConfigPath(normalizePath(saveDirInput, saveDirFallback));
70
74
  const imageQuality = normalizeImageQuality(parsed.imageQuality);
75
+ const videoFilter = normalizeVideoFilter(parsed.videoFilter);
71
76
  const pixelScale = normalizePixelScale(parsed.pixelScale);
72
77
  return {
73
78
  romDir,
@@ -75,6 +80,7 @@ export function normalizeConfig(raw: unknown): NesConfig {
75
80
  enableAudio: typeof parsed.enableAudio === "boolean" ? parsed.enableAudio : DEFAULT_CONFIG.enableAudio,
76
81
  renderer: parsed.renderer === "text" ? "text" : DEFAULT_CONFIG.renderer,
77
82
  imageQuality,
83
+ videoFilter,
78
84
  pixelScale,
79
85
  keybindings: normalizeKeybindings(parsed.keybindings),
80
86
  };
@@ -136,6 +142,17 @@ function normalizeImageQuality(raw: unknown): ImageQuality {
136
142
  return raw === "high" ? "high" : "balanced";
137
143
  }
138
144
 
145
+ function normalizeVideoFilter(raw: unknown): VideoFilter {
146
+ switch (raw) {
147
+ case "ntsc-composite":
148
+ case "ntsc-svideo":
149
+ case "ntsc-rgb":
150
+ return raw;
151
+ default:
152
+ return "off";
153
+ }
154
+ }
155
+
139
156
  function normalizeKeybindings(raw: unknown): InputMapping {
140
157
  const mapping = cloneMapping(DEFAULT_INPUT_MAPPING);
141
158
  if (!raw || typeof raw !== "object") {
@@ -12,10 +12,12 @@ import {
12
12
  loadConfig,
13
13
  normalizeConfig,
14
14
  saveConfig,
15
+ type VideoFilter,
15
16
  } from "./config.js";
16
17
  import { displayPath, resolvePathInput } from "./paths.js";
17
18
  import { NesSession } from "./nes-session.js";
18
19
  import { listRoms } from "./roms.js";
20
+ import { selectRomWithFilter } from "./rom-selector.js";
19
21
  import { loadSram } from "./saves.js";
20
22
 
21
23
  const IMAGE_RENDER_INTERVAL_BALANCED_MS = 1000 / 30;
@@ -44,17 +46,8 @@ async function selectRom(
44
46
  return null;
45
47
  }
46
48
 
47
- const options = roms.map((rom, index) => `${index + 1}. ${rom.name}`);
48
- const selection = await ctx.ui.select("Select a ROM", options);
49
- if (!selection) {
50
- return null;
51
- }
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;
49
+ const selection = await selectRomWithFilter(ctx, roms);
50
+ return selection;
58
51
  } catch {
59
52
  ctx.ui.notify(`Failed to read ROM directory: ${romDir}. Update ${configPath} to set romDir.`, "error");
60
53
  return null;
@@ -177,6 +170,21 @@ async function configureWithWizard(
177
170
 
178
171
  const isHighQuality = qualityChoice.startsWith("High");
179
172
  const imageQuality = isHighQuality ? "high" : "balanced";
173
+
174
+ const filterOptions: Array<{ label: string; value: VideoFilter }> = [
175
+ { label: "Off (default) — raw RGB", value: "off" },
176
+ { label: "NTSC Composite — strong bleed + scanlines", value: "ntsc-composite" },
177
+ { label: "NTSC S-Video — balanced bleed", value: "ntsc-svideo" },
178
+ { label: "NTSC RGB — subtle blur", value: "ntsc-rgb" },
179
+ ];
180
+ const filterChoice = await ctx.ui.select(
181
+ "Video filter",
182
+ filterOptions.map((option) => option.label),
183
+ );
184
+ if (!filterChoice) {
185
+ return false;
186
+ }
187
+ const videoFilter = filterOptions.find((option) => option.label === filterChoice)?.value ?? "off";
180
188
  const pixelScale = config.pixelScale;
181
189
 
182
190
  const defaultSaveDir = getDefaultSaveDir(config.romDir);
@@ -187,6 +195,7 @@ async function configureWithWizard(
187
195
  romDir,
188
196
  saveDir,
189
197
  imageQuality,
198
+ videoFilter,
190
199
  pixelScale,
191
200
  });
192
201
  await saveConfig(normalized);
@@ -237,7 +246,7 @@ async function createSession(romPath: string, ctx: ExtensionCommandContext, conf
237
246
 
238
247
  let core;
239
248
  try {
240
- core = createNesCore({ enableAudio: config.enableAudio });
249
+ core = createNesCore({ enableAudio: config.enableAudio, videoFilter: config.videoFilter });
241
250
  } catch (error) {
242
251
  const message = error instanceof Error ? error.message : String(error);
243
252
  ctx.ui.notify(`Failed to initialize NES core: ${message}`, "error");
@@ -31,6 +31,7 @@ export class NativeNes {
31
31
  bootup(): void;
32
32
  stepFrame(): void;
33
33
  refreshFramebuffer(): void;
34
+ setVideoFilter(mode: number): void;
34
35
  pressButton(button: number): void;
35
36
  releaseButton(button: number): void;
36
37
  hasBatteryBackedRam(): boolean;
@@ -33,6 +33,7 @@ export declare class NativeNes {
33
33
  bootup(): void
34
34
  stepFrame(): void
35
35
  refreshFramebuffer(): void
36
+ setVideoFilter(mode: number): void
36
37
  pressButton(button: number): void
37
38
  releaseButton(button: number): void
38
39
  hasBatteryBackedRam(): boolean
@@ -7,6 +7,23 @@ 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
+ const FRAME_BYTE_LEN: usize = (SCREEN_WIDTH * SCREEN_HEIGHT * 3) as usize;
11
+
12
+ #[derive(Clone, Copy, PartialEq, Eq)]
13
+ enum VideoFilterMode {
14
+ Off,
15
+ NtscComposite,
16
+ NtscSvideo,
17
+ NtscRgb,
18
+ }
19
+
20
+ struct VideoFilterConfig {
21
+ luma: [f32; 3],
22
+ chroma: [f32; 3],
23
+ scanline_dim: f32,
24
+ chroma_gain: f32,
25
+ }
26
+
10
27
  struct NativeDisplay {
11
28
  pixels: Vec<u8>,
12
29
  }
@@ -14,7 +31,7 @@ struct NativeDisplay {
14
31
  impl NativeDisplay {
15
32
  fn new() -> Self {
16
33
  Self {
17
- pixels: vec![0; (SCREEN_WIDTH * SCREEN_HEIGHT * 3) as usize],
34
+ pixels: vec![0; FRAME_BYTE_LEN],
18
35
  }
19
36
  }
20
37
  }
@@ -77,6 +94,8 @@ pub struct NesDebugState {
77
94
  pub struct NativeNes {
78
95
  nes: Nes,
79
96
  framebuffer: Vec<u8>,
97
+ filter_buffer: Vec<u8>,
98
+ video_filter: VideoFilterMode,
80
99
  }
81
100
 
82
101
  #[napi]
@@ -89,7 +108,9 @@ impl NativeNes {
89
108
  let nes = Nes::new(input, display, audio);
90
109
  Self {
91
110
  nes,
92
- framebuffer: vec![0; (SCREEN_WIDTH * SCREEN_HEIGHT * 3) as usize],
111
+ framebuffer: vec![0; FRAME_BYTE_LEN],
112
+ filter_buffer: vec![0; FRAME_BYTE_LEN],
113
+ video_filter: VideoFilterMode::Off,
93
114
  }
94
115
  }
95
116
 
@@ -112,6 +133,20 @@ impl NativeNes {
112
133
  #[napi]
113
134
  pub fn refresh_framebuffer(&mut self) {
114
135
  self.nes.copy_pixels(&mut self.framebuffer);
136
+ if let Some(config) = video_filter_config(self.video_filter) {
137
+ self.filter_buffer.copy_from_slice(&self.framebuffer);
138
+ apply_video_filter(&self.filter_buffer, &mut self.framebuffer, &config);
139
+ }
140
+ }
141
+
142
+ #[napi]
143
+ pub fn set_video_filter(&mut self, mode: u8) {
144
+ self.video_filter = match mode {
145
+ 1 => VideoFilterMode::NtscComposite,
146
+ 2 => VideoFilterMode::NtscSvideo,
147
+ 3 => VideoFilterMode::NtscRgb,
148
+ _ => VideoFilterMode::Off,
149
+ };
115
150
  }
116
151
 
117
152
  #[napi]
@@ -201,3 +236,81 @@ fn map_button(button: u8) -> Option<Button> {
201
236
  _ => None,
202
237
  }
203
238
  }
239
+
240
+ fn video_filter_config(mode: VideoFilterMode) -> Option<VideoFilterConfig> {
241
+ match mode {
242
+ VideoFilterMode::Off => None,
243
+ VideoFilterMode::NtscComposite => Some(VideoFilterConfig {
244
+ luma: [0.2, 0.6, 0.2],
245
+ chroma: [0.25, 0.5, 0.25],
246
+ scanline_dim: 0.85,
247
+ chroma_gain: 0.9,
248
+ }),
249
+ VideoFilterMode::NtscSvideo => Some(VideoFilterConfig {
250
+ luma: [0.15, 0.7, 0.15],
251
+ chroma: [0.2, 0.6, 0.2],
252
+ scanline_dim: 0.9,
253
+ chroma_gain: 0.95,
254
+ }),
255
+ VideoFilterMode::NtscRgb => Some(VideoFilterConfig {
256
+ luma: [0.1, 0.8, 0.1],
257
+ chroma: [0.1, 0.8, 0.1],
258
+ scanline_dim: 0.95,
259
+ chroma_gain: 1.0,
260
+ }),
261
+ }
262
+ }
263
+
264
+ fn apply_video_filter(source: &[u8], target: &mut [u8], config: &VideoFilterConfig) {
265
+ let width = SCREEN_WIDTH as usize;
266
+ let height = SCREEN_HEIGHT as usize;
267
+ if source.len() < FRAME_BYTE_LEN || target.len() < FRAME_BYTE_LEN {
268
+ return;
269
+ }
270
+ for y in 0..height {
271
+ let scanline = if y % 2 == 0 { 1.0 } else { config.scanline_dim };
272
+ for x in 0..width {
273
+ let left_x = if x == 0 { 0 } else { x - 1 };
274
+ let right_x = if x + 1 >= width { width - 1 } else { x + 1 };
275
+ let center_x = x;
276
+
277
+ let left_idx = (y * width + left_x) * 3;
278
+ let center_idx = (y * width + center_x) * 3;
279
+ let right_idx = (y * width + right_x) * 3;
280
+
281
+ let (y0, i0, q0) = rgb_to_yiq(source[left_idx], source[left_idx + 1], source[left_idx + 2]);
282
+ let (y1, i1, q1) = rgb_to_yiq(source[center_idx], source[center_idx + 1], source[center_idx + 2]);
283
+ let (y2, i2, q2) = rgb_to_yiq(source[right_idx], source[right_idx + 1], source[right_idx + 2]);
284
+
285
+ let luma = config.luma[0] * y0 + config.luma[1] * y1 + config.luma[2] * y2;
286
+ let chroma_i = config.chroma_gain * (config.chroma[0] * i0 + config.chroma[1] * i1 + config.chroma[2] * i2);
287
+ let chroma_q = config.chroma_gain * (config.chroma[0] * q0 + config.chroma[1] * q1 + config.chroma[2] * q2);
288
+
289
+ let (r, g, b) = yiq_to_rgb(luma, chroma_i, chroma_q);
290
+ target[center_idx] = clamp_u8(r * scanline);
291
+ target[center_idx + 1] = clamp_u8(g * scanline);
292
+ target[center_idx + 2] = clamp_u8(b * scanline);
293
+ }
294
+ }
295
+ }
296
+
297
+ fn rgb_to_yiq(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
298
+ let r = r as f32;
299
+ let g = g as f32;
300
+ let b = b as f32;
301
+ let y = 0.299 * r + 0.587 * g + 0.114 * b;
302
+ let i = 0.596 * r - 0.274 * g - 0.322 * b;
303
+ let q = 0.211 * r - 0.523 * g + 0.312 * b;
304
+ (y, i, q)
305
+ }
306
+
307
+ fn yiq_to_rgb(y: f32, i: f32, q: f32) -> (f32, f32, f32) {
308
+ let r = y + 0.956 * i + 0.621 * q;
309
+ let g = y - 0.272 * i - 0.647 * q;
310
+ let b = y - 1.106 * i + 1.703 * q;
311
+ (r, g, b)
312
+ }
313
+
314
+ fn clamp_u8(value: f32) -> u8 {
315
+ value.max(0.0).min(255.0).round() as u8
316
+ }
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  const require = createRequire(import.meta.url);
4
4
 
5
5
  export type NesButton = "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select";
6
+ export type VideoFilterMode = "off" | "ntsc-composite" | "ntsc-svideo" | "ntsc-rgb";
6
7
 
7
8
  export interface FrameBuffer {
8
9
  data: Uint8Array;
@@ -51,6 +52,7 @@ export interface NesCore {
51
52
 
52
53
  export interface CreateNesCoreOptions {
53
54
  enableAudio?: boolean;
55
+ videoFilter?: VideoFilterMode;
54
56
  }
55
57
 
56
58
  interface NativeNesInstance {
@@ -58,6 +60,7 @@ interface NativeNesInstance {
58
60
  bootup(): void;
59
61
  stepFrame(): void;
60
62
  refreshFramebuffer(): void;
63
+ setVideoFilter(mode: number): void;
61
64
  pressButton(button: number): void;
62
65
  releaseButton(button: number): void;
63
66
  hasBatteryBackedRam(): boolean;
@@ -101,13 +104,20 @@ const NATIVE_BUTTON_MAP: Record<NesButton, number> = {
101
104
  right: 7,
102
105
  };
103
106
 
107
+ const NATIVE_VIDEO_FILTER_MAP: Record<VideoFilterMode, number> = {
108
+ off: 0,
109
+ "ntsc-composite": 1,
110
+ "ntsc-svideo": 2,
111
+ "ntsc-rgb": 3,
112
+ };
113
+
104
114
  class NativeNesCore implements NesCore {
105
115
  private readonly nes: NativeNesInstance;
106
116
  private readonly audioWarning: string | null;
107
117
  private readonly frameBuffer: Uint8Array;
108
118
  private hasSram = false;
109
119
 
110
- constructor(enableAudio: boolean) {
120
+ constructor(enableAudio: boolean, videoFilter: VideoFilterMode) {
111
121
  this.audioWarning = enableAudio
112
122
  ? "Audio output is disabled (no safe dependency available)."
113
123
  : null;
@@ -116,6 +126,7 @@ class NativeNesCore implements NesCore {
116
126
  throw new Error("Native NES core addon is not available.");
117
127
  }
118
128
  this.nes = new module.NativeNes();
129
+ this.nes.setVideoFilter(NATIVE_VIDEO_FILTER_MAP[videoFilter]);
119
130
  this.frameBuffer = this.nes.getFramebuffer();
120
131
  }
121
132
 
@@ -186,5 +197,5 @@ class NativeNesCore implements NesCore {
186
197
  }
187
198
 
188
199
  export function createNesCore(options: CreateNesCoreOptions = {}): NesCore {
189
- return new NativeNesCore(options.enableAudio ?? false);
200
+ return new NativeNesCore(options.enableAudio ?? false, options.videoFilter ?? "off");
190
201
  }
@@ -0,0 +1,142 @@
1
+ import type { ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
2
+ import { DynamicBorder, getSelectListTheme } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ Input,
6
+ SelectList,
7
+ Spacer,
8
+ Text,
9
+ TUI,
10
+ type Component,
11
+ type Focusable,
12
+ type SelectItem,
13
+ getEditorKeybindings,
14
+ } from "@mariozechner/pi-tui";
15
+ import type { RomEntry } from "./roms.js";
16
+
17
+ const MAX_VISIBLE_ROMS = 10;
18
+
19
+ class RomSelectorDialog extends Container implements Focusable {
20
+ private readonly tui: TUI;
21
+ private readonly theme: Theme;
22
+ private readonly items: SelectItem[];
23
+ private readonly filterInput: Input;
24
+ private listComponent: Component;
25
+ private selectList: SelectList | null = null;
26
+ private onSelect: (value: string) => void;
27
+ private onCancel: () => void;
28
+ private _focused = false;
29
+
30
+ constructor(
31
+ roms: RomEntry[],
32
+ tui: TUI,
33
+ theme: Theme,
34
+ onSelect: (value: string) => void,
35
+ onCancel: () => void,
36
+ ) {
37
+ super();
38
+ this.tui = tui;
39
+ this.theme = theme;
40
+ this.items = roms.map((rom) => ({ value: rom.path, label: rom.name }));
41
+ this.onSelect = onSelect;
42
+ this.onCancel = onCancel;
43
+ this.filterInput = new Input();
44
+ this.filterInput.setValue("");
45
+ this.listComponent = new Text("", 0, 0);
46
+ this.updateList();
47
+ this.buildLayout();
48
+ }
49
+
50
+ get focused(): boolean {
51
+ return this._focused;
52
+ }
53
+
54
+ set focused(value: boolean) {
55
+ this._focused = value;
56
+ this.filterInput.focused = value;
57
+ }
58
+
59
+ invalidate(): void {
60
+ super.invalidate();
61
+ this.buildLayout();
62
+ }
63
+
64
+ handleInput(data: string): void {
65
+ const kb = getEditorKeybindings();
66
+ if (
67
+ kb.matches(data, "selectUp") ||
68
+ kb.matches(data, "selectDown") ||
69
+ kb.matches(data, "selectConfirm") ||
70
+ kb.matches(data, "selectCancel") ||
71
+ kb.matches(data, "pageUp") ||
72
+ kb.matches(data, "pageDown")
73
+ ) {
74
+ if (this.selectList) {
75
+ this.selectList.handleInput(data);
76
+ } else if (kb.matches(data, "selectCancel")) {
77
+ this.onCancel();
78
+ }
79
+ this.tui.requestRender();
80
+ return;
81
+ }
82
+
83
+ const before = this.filterInput.getValue();
84
+ this.filterInput.handleInput(data);
85
+ const after = this.filterInput.getValue();
86
+ if (after !== before) {
87
+ this.updateList();
88
+ this.buildLayout();
89
+ }
90
+ this.tui.requestRender();
91
+ }
92
+
93
+ private updateList(): void {
94
+ const filter = this.filterInput.getValue().trim().toLowerCase();
95
+ const filteredItems = filter.length
96
+ ? this.items.filter((item) => (item.label ?? item.value).toLowerCase().includes(filter))
97
+ : this.items;
98
+
99
+ if (filteredItems.length === 0) {
100
+ this.selectList = null;
101
+ this.listComponent = new Text(this.theme.fg("warning", " No matching ROMs"), 1, 0);
102
+ return;
103
+ }
104
+
105
+ const list = new SelectList(filteredItems, Math.min(filteredItems.length, MAX_VISIBLE_ROMS), getSelectListTheme());
106
+ list.onSelect = (item) => this.onSelect(item.value);
107
+ list.onCancel = () => this.onCancel();
108
+ this.selectList = list;
109
+ this.listComponent = list;
110
+ }
111
+
112
+ private buildLayout(): void {
113
+ this.clear();
114
+ this.addChild(new DynamicBorder((line) => this.theme.fg("accent", line)));
115
+ this.addChild(new Text(this.theme.fg("accent", this.theme.bold("Select a ROM")), 1, 0));
116
+ this.addChild(new Text(this.theme.fg("dim", "Type to filter · ↑↓ navigate · Enter select · Esc cancel"), 1, 0));
117
+ this.addChild(new Spacer(1));
118
+ this.addChild(new Text(this.theme.fg("muted", "Filter:"), 1, 0));
119
+ this.addChild(this.filterInput);
120
+ this.addChild(new Spacer(1));
121
+ this.addChild(this.listComponent);
122
+ this.addChild(new Spacer(1));
123
+ this.addChild(new DynamicBorder((line) => this.theme.fg("accent", line)));
124
+ }
125
+ }
126
+
127
+ export async function selectRomWithFilter(
128
+ ctx: ExtensionCommandContext,
129
+ roms: RomEntry[],
130
+ ): Promise<string | null> {
131
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
132
+ const dialog = new RomSelectorDialog(
133
+ roms,
134
+ tui,
135
+ theme,
136
+ (value) => done(value),
137
+ () => done(null),
138
+ );
139
+ return dialog;
140
+ });
141
+ return result ?? null;
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
package/spec.md CHANGED
@@ -72,6 +72,7 @@ pi-nes/
72
72
  - `enableAudio`
73
73
  - `renderer` ("image" or "text")
74
74
  - `imageQuality` ("balanced" or "high", controls render fps)
75
+ - `videoFilter` ("off", "ntsc-composite", "ntsc-svideo", "ntsc-rgb")
75
76
  - `pixelScale` (float, e.g. 1.0)
76
77
  - `keybindings` (button-to-keys map, e.g. `{ "a": ["z"] }`)
77
78
 
@@ -91,5 +92,6 @@ Note: audio output is currently disabled; setting `enableAudio` will show a warn
91
92
  - Default ROM dir: `/roms/nes` (configurable).
92
93
  - Default core: `native`.
93
94
  - Default image quality: `balanced` (30 fps).
95
+ - Default video filter: `off`.
94
96
  - Default pixel scale: `1.0`.
95
97
  - Default save dir: `/roms/nes/saves` (configurable).
@@ -9,6 +9,7 @@ describe("config", () => {
9
9
  assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
10
10
  assert.strictEqual(config.renderer, DEFAULT_CONFIG.renderer);
11
11
  assert.strictEqual(config.imageQuality, DEFAULT_CONFIG.imageQuality);
12
+ assert.strictEqual(config.videoFilter, DEFAULT_CONFIG.videoFilter);
12
13
  assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
13
14
  });
14
15
 
@@ -42,6 +43,16 @@ describe("config", () => {
42
43
  assert.strictEqual(config.imageQuality, "balanced");
43
44
  });
44
45
 
46
+ test("accepts valid videoFilter", () => {
47
+ const config = normalizeConfig({ videoFilter: "ntsc-composite" });
48
+ assert.strictEqual(config.videoFilter, "ntsc-composite");
49
+ });
50
+
51
+ test("defaults invalid videoFilter to off", () => {
52
+ const config = normalizeConfig({ videoFilter: "crt" });
53
+ assert.strictEqual(config.videoFilter, "off");
54
+ });
55
+
45
56
  test("clamps pixelScale to valid range", () => {
46
57
  assert.strictEqual(normalizeConfig({ pixelScale: 0.1 }).pixelScale, 0.5);
47
58
  assert.strictEqual(normalizeConfig({ pixelScale: 10 }).pixelScale, 4);