@tmustier/pi-nes 0.2.35 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -20,11 +20,12 @@ pi-nes/
20
20
  ├── config.ts # User config (~/.pi/nes/config.json)
21
21
  ├── paths.ts # Path resolution utilities
22
22
  ├── roms.ts # ROM directory listing
23
+ ├── rom-selector.ts # Filterable ROM picker UI
23
24
  ├── saves.ts # SRAM persistence
24
25
  └── native/
25
26
  ├── nes-core/ # Rust NES emulator addon (required)
26
- │ ├── Cargo.toml # Dependencies: vendored nes_rust, napi
27
- │ ├── vendor/nes_rust/ # Patched nes_rust crate (SRAM helpers)
27
+ │ ├── Cargo.toml # Dependencies: vendored nes_rust, napi, optional cpal
28
+ │ ├── vendor/nes_rust/ # Patched nes_rust crate (SRAM helpers + mapper fixes)
28
29
  │ ├── src/lib.rs # Exposes NativeNes class via napi-rs
29
30
  │ └── index.node # Compiled binary
30
31
  └── kitty-shm/ # Rust shared memory addon (optional)
@@ -34,7 +35,7 @@ pi-nes/
34
35
 
35
36
  ### Native Core
36
37
 
37
- The emulator uses the [`nes_rust`](https://crates.io/crates/nes_rust) crate (vendored + patched in `native/nes-core/vendor/nes_rust` for SRAM helpers) with [napi-rs](https://napi.rs) bindings.
38
+ The emulator uses the [`nes_rust`](https://crates.io/crates/nes_rust) crate (vendored + patched in `native/nes-core/vendor/nes_rust`) with [napi-rs](https://napi.rs) bindings. Optional audio uses a Rust-only backend (`cpal`) when built with `audio-cpal`.
38
39
 
39
40
  ### Vendored `nes_rust` workflow
40
41
 
@@ -46,12 +47,16 @@ The emulator uses the [`nes_rust`](https://crates.io/crates/nes_rust) crate (ven
46
47
  **API exposed to JavaScript:**
47
48
  - `new NativeNes()` - Create emulator instance
48
49
  - `setRom(Uint8Array)` - Load ROM data
49
- - `bootup()` / `reset()` - Start/restart emulation
50
+ - `bootup()` - Start emulation
50
51
  - `stepFrame()` - Advance one frame (~60fps)
52
+ - `refreshFramebuffer()` - Copy framebuffer from core
51
53
  - `pressButton(n)` / `releaseButton(n)` - Controller input (0=select, 1=start, 2=A, 3=B, 4-7=dpad)
54
+ - `setVideoFilter(mode)` - 0=off, 1=ntsc-composite, 2=ntsc-svideo, 3=ntsc-rgb
55
+ - `setAudioEnabled(bool)` - Returns false if audio backend unavailable
52
56
  - `hasBatteryBackedRam()` - Whether the ROM supports battery SRAM
53
57
  - `getSram()` / `setSram(Uint8Array)` - Read/write SRAM
54
58
  - `isSramDirty()` / `markSramSaved()` - Dirty tracking for SRAM persistence
59
+ - `getDebugState()` - CPU/mapper debug info
55
60
  - `getFramebuffer()` - Returns RGB pixel data (256×240×3 bytes, zero-copy via external buffer)
56
61
 
57
62
  ### Rendering Pipeline
@@ -67,7 +72,7 @@ NES Core → RGB framebuffer (256×240×3) → Renderer → Terminal
67
72
 
68
73
  - Image mode (`renderer: "image"`) runs at ~30fps to keep emulation stable
69
74
  - Text mode (`renderer: "text"`) runs at ~60fps in an overlay
70
- - Image mode uses near-fullscreen (90% height) because Kitty graphics can't composite in overlays
75
+ - Image mode uses a windowed overlay (90% width/height) to avoid Kitty full-screen artifacts
71
76
 
72
77
  ### Session Lifecycle
73
78
 
@@ -86,8 +91,11 @@ Requires Rust toolchain (cargo + rustc).
86
91
  cd extensions/nes/native/nes-core
87
92
  npm install && npm run build
88
93
 
94
+ # NES core with audio (optional)
95
+ npm run build:audio
96
+
89
97
  # Kitty shared memory (optional, faster rendering)
90
- cd extensions/nes/native/kitty-shm
98
+ cd ../kitty-shm
91
99
  npm install && npm run build
92
100
  ```
93
101
 
@@ -95,13 +103,13 @@ The addons compile to `index.node`. The JS wrapper (`index.js`) tries to load it
95
103
 
96
104
  ## Known Limitations
97
105
 
98
- - **No audio** — `enableAudio` config exists but no audio backend is implemented
99
- - **No save states** — Only battery-backed SRAM saves are persisted
100
- - **SRAM for native core** — Tracked in issue #3
106
+ - **Audio is opt-in** — Requires a native core built with `audio-cpal` and `enableAudio: true`.
107
+ - **Overlay flicker** — Kitty overlay can show a 1-line flicker at overlay boundaries (see issue #9).
108
+ - **No save states** — Only battery-backed SRAM saves are persisted.
101
109
 
102
110
  ## Release and Publishing
103
111
 
104
- ## Version Bumps (Git + npm)
112
+ ### Version Bumps (Git + npm)
105
113
 
106
114
  Preferred flow (creates a git tag):
107
115
 
@@ -127,7 +135,7 @@ git push
127
135
  git push --tags
128
136
  ```
129
137
 
130
- ## npm Publish
138
+ ### npm Publish
131
139
 
132
140
  ```bash
133
141
  npm login
@@ -140,7 +148,7 @@ If you need to validate the tarball first:
140
148
  npm pack
141
149
  ```
142
150
 
143
- ## GitHub Release Notes
151
+ ### GitHub Release Notes
144
152
 
145
153
  ```bash
146
154
  gh release create vX.Y.Z --title "vX.Y.Z" --notes "<release notes>"
package/README.md CHANGED
@@ -51,13 +51,13 @@ On first run, you'll be prompted to set your ROM directory and display quality.
51
51
  |---------|-------------|
52
52
  | `/nes` | Pick a ROM or reattach to running session |
53
53
  | `/nes <path>` | Load a specific ROM file |
54
- | `/nes config` | Configure ROM directory and quality |
55
- | `/nes-config` | Toggle audio + access advanced config options |
54
+ | `/nes config` | Quick setup (ROM directory + audio) |
55
+ | `/nes-config` | Toggle audio, quality, and display style + advanced options |
56
56
  | `/nes debug` | Show FPS and memory stats |
57
57
 
58
58
  ## Configuration
59
59
 
60
- Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup, or `/nes-config` to toggle audio inline and access advanced options.
60
+ Config is stored at `~/.pi/nes/config.json`. Use `/nes config` for quick setup (ROM dir + audio), or `/nes-config` to toggle audio/quality/style inline and access advanced options.
61
61
 
62
62
  ```json
63
63
  {
@@ -14,6 +14,7 @@ import {
14
14
  loadConfig,
15
15
  normalizeConfig,
16
16
  saveConfig,
17
+ type ImageQuality,
17
18
  type NesConfig,
18
19
  type VideoFilter,
19
20
  } from "./config.js";
@@ -27,6 +28,17 @@ const IMAGE_RENDER_INTERVAL_BALANCED_MS = 1000 / 30;
27
28
  const IMAGE_RENDER_INTERVAL_HIGH_MS = 1000 / 60;
28
29
  const TEXT_RENDER_INTERVAL_MS = 1000 / 60;
29
30
 
31
+ const AUDIO_OPTIONS = ["off (default)", "on"] as const;
32
+ const QUALITY_OPTIONS: Array<{ label: string; value: ImageQuality }> = [
33
+ { label: "Balanced (default) — 30 fps", value: "balanced" },
34
+ { label: "High — 60 fps", value: "high" },
35
+ ];
36
+ const DISPLAY_FILTER_OPTIONS: Array<{ label: string; value: VideoFilter }> = [
37
+ { label: "CRT Classic (default) — authentic scanlines + color bleed", value: "ntsc-composite" },
38
+ { label: "CRT Soft — subtle retro look", value: "ntsc-rgb" },
39
+ { label: "Sharp — pixel-perfect, no filtering", value: "off" },
40
+ ];
41
+
30
42
  let activeSession: NesSession | null = null;
31
43
 
32
44
  // ROM selection helpers.
@@ -163,31 +175,13 @@ async function configureWithWizard(
163
175
  }
164
176
  }
165
177
 
166
- const qualityChoice = await ctx.ui.select("Quality", [
167
- "Balanced (recommended) — 30 fps",
168
- "High — 60 fps",
169
- ]);
170
- if (!qualityChoice) {
171
- return false;
172
- }
173
-
174
- const isHighQuality = qualityChoice.startsWith("High");
175
- const imageQuality = isHighQuality ? "high" : "balanced";
176
-
177
- const filterOptions: Array<{ label: string; value: VideoFilter }> = [
178
- { label: "CRT Classic (default) — authentic scanlines + color bleed", value: "ntsc-composite" },
179
- { label: "CRT Soft — subtle retro look", value: "ntsc-rgb" },
180
- { label: "Sharp — pixel-perfect, no filtering", value: "off" },
181
- ];
182
- const filterChoice = await ctx.ui.select(
183
- "Display style",
184
- filterOptions.map((option) => option.label),
185
- );
186
- if (!filterChoice) {
178
+ const audioChoice = await ctx.ui.select("Audio", ["Off (default)", "On"]);
179
+ if (!audioChoice) {
187
180
  return false;
188
181
  }
189
- const videoFilter =
190
- filterOptions.find((option) => option.label === filterChoice)?.value ?? DEFAULT_CONFIG.videoFilter;
182
+ const enableAudio = audioChoice === "On";
183
+ const imageQuality = config.imageQuality;
184
+ const videoFilter = config.videoFilter;
191
185
  const pixelScale = config.pixelScale;
192
186
 
193
187
  const defaultSaveDir = getDefaultSaveDir(config.romDir);
@@ -197,35 +191,62 @@ async function configureWithWizard(
197
191
  ...config,
198
192
  romDir,
199
193
  saveDir,
194
+ enableAudio,
200
195
  imageQuality,
201
196
  videoFilter,
202
197
  pixelScale,
203
198
  });
204
199
  await saveConfig(normalized);
205
200
  ctx.ui.notify(`Saved config to ${getConfigPath()}`, "info");
201
+ await ctx.ui.select(
202
+ "Toggle audio, quality, and more in /nes-config",
203
+ ["Run /nes to load your games"],
204
+ );
206
205
  return true;
207
206
  }
208
207
 
209
208
  type ConfigMenuResult = "close" | "more";
210
209
 
210
+ type ConfigUpdate = Partial<NesConfig>;
211
+
211
212
  class NesConfigMenu extends Container {
212
213
  private readonly settingsList: SettingsList;
213
214
 
214
215
  constructor(
215
216
  config: NesConfig,
216
217
  theme: Theme,
217
- onAudioChange: (enabled: boolean) => void,
218
+ onUpdate: (update: ConfigUpdate) => void,
218
219
  onDone: (result: ConfigMenuResult) => void,
219
220
  ) {
220
221
  super();
221
222
  const audioValue = config.enableAudio ? "on" : "off (default)";
223
+ const qualityValue =
224
+ QUALITY_OPTIONS.find((option) => option.value === config.imageQuality)?.label
225
+ ?? QUALITY_OPTIONS[0].label;
226
+ const displayValue =
227
+ DISPLAY_FILTER_OPTIONS.find((option) => option.value === config.videoFilter)?.label
228
+ ?? DISPLAY_FILTER_OPTIONS[0].label;
222
229
  const items: SettingItem[] = [
223
230
  {
224
231
  id: "audio",
225
232
  label: "Audio",
226
233
  description: "Enable audio output (requires native core built with audio-cpal)",
227
234
  currentValue: audioValue,
228
- values: ["off (default)", "on"],
235
+ values: [...AUDIO_OPTIONS],
236
+ },
237
+ {
238
+ id: "quality",
239
+ label: "Quality",
240
+ description: "Target frame rate for image rendering",
241
+ currentValue: qualityValue,
242
+ values: QUALITY_OPTIONS.map((option) => option.label),
243
+ },
244
+ {
245
+ id: "display",
246
+ label: "Display style",
247
+ description: "CRT filter preset (applied in the native core)",
248
+ currentValue: displayValue,
249
+ values: DISPLAY_FILTER_OPTIONS.map((option) => option.label),
229
250
  },
230
251
  {
231
252
  id: "more",
@@ -238,11 +259,21 @@ class NesConfigMenu extends Container {
238
259
 
239
260
  this.settingsList = new SettingsList(
240
261
  items,
241
- 6,
262
+ 8,
242
263
  getSettingsListTheme(),
243
264
  (id, value) => {
244
265
  if (id === "audio") {
245
- onAudioChange(value === "on");
266
+ onUpdate({ enableAudio: value === "on" });
267
+ return;
268
+ }
269
+ if (id === "quality") {
270
+ const option = QUALITY_OPTIONS.find((entry) => entry.label === value);
271
+ onUpdate({ imageQuality: option?.value ?? config.imageQuality });
272
+ return;
273
+ }
274
+ if (id === "display") {
275
+ const option = DISPLAY_FILTER_OPTIONS.find((entry) => entry.label === value);
276
+ onUpdate({ videoFilter: option?.value ?? config.videoFilter });
246
277
  return;
247
278
  }
248
279
  if (id === "more") {
@@ -271,20 +302,14 @@ async function editConfig(ctx: ExtensionCommandContext): Promise<void> {
271
302
  return;
272
303
  }
273
304
  const config = await loadConfig();
305
+ const updateConfig = async (update: ConfigUpdate) => {
306
+ const normalized = normalizeConfig({ ...config, ...update });
307
+ await saveConfig(normalized);
308
+ Object.assign(config, normalized);
309
+ ctx.ui.notify(`Saved config to ${getConfigPath()}`, "info");
310
+ };
274
311
  const action = await ctx.ui.custom<ConfigMenuResult>((_tui, theme, _keybindings, done) =>
275
- new NesConfigMenu(
276
- config,
277
- theme,
278
- (enabled) => {
279
- void (async () => {
280
- const normalized = normalizeConfig({ ...config, enableAudio: enabled });
281
- await saveConfig(normalized);
282
- config.enableAudio = normalized.enableAudio;
283
- ctx.ui.notify(`Saved config to ${getConfigPath()}`, "info");
284
- })();
285
- },
286
- done,
287
- ),
312
+ new NesConfigMenu(config, theme, (update) => void updateConfig(update), done),
288
313
  );
289
314
  if (action !== "more") {
290
315
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,6 +33,7 @@
33
33
  "pi": {
34
34
  "extensions": [
35
35
  "./extensions/nes"
36
- ]
36
+ ],
37
+ "image": "https://raw.githubusercontent.com/tmustier/pi-nes/main/assets/demo.gif"
37
38
  }
38
39
  }