Technical guidance for AI agents working on this codebase.
pi-nes is a NES emulator extension for pi. It uses a Rust-based emulator core compiled as a Node.js native addon.
pi-nes/
├── package.json # Pi package manifest (pi.extensions points to ./extensions/nes)
└── extensions/nes/
├── index.ts # Extension entry - registers /nes and /nes-config commands
├── nes-core.ts # TypeScript wrapper for native addon
├── nes-session.ts # Game session management (frame timing, SRAM saves)
├── nes-component.ts # TUI overlay component (input handling, render loop)
├── renderer.ts # Frame rendering (Kitty image protocol, PNG, ANSI)
├── config.ts # User config (~/.pi/nes/config.json)
├── paths.ts # Path resolution utilities
├── roms.ts # ROM directory listing
├── rom-selector.ts # Filterable ROM picker UI
├── saves.ts # SRAM persistence
└── native/
├── nes-core/ # Rust NES emulator addon (required)
│ ├── Cargo.toml # Dependencies: vendored nes_rust, napi, optional cpal
│ ├── vendor/nes_rust/ # Patched nes_rust crate (SRAM helpers + mapper fixes)
│ ├── src/lib.rs # Exposes NativeNes class via napi-rs
│ └── index.node # Compiled binary
└── kitty-shm/ # Rust shared memory addon (optional)
├── src/lib.rs # POSIX shm_open/mmap for zero-copy frames
└── index.node # Compiled binary
The emulator uses the nes_rust crate (vendored + patched in native/nes-core/vendor/nes_rust) with napi-rs bindings. Optional audio uses a Rust-only backend (cpal) when built with audio-cpal.
- Source of truth is the fork (intended):
https://github.com/tmustier/nes-rust. - Make changes in the fork first, then re-vendor via
scripts/update-vendor-nes-rust.sh. - Update
extensions/nes/native/nes-core/vendor/nes_rust/VENDOR.mdwith the fork commit + date + patch summary. - Keep TODO inventory in
extensions/nes/native/nes-core/vendor/nes_rust/TODO_INVENTORY.md.
API exposed to JavaScript:
new NativeNes()- Create emulator instancesetRom(Uint8Array)- Load ROM databootup()- Start emulationstepFrame()- Advance one frame (~60fps)refreshFramebuffer()- Copy framebuffer from corepressButton(n)/releaseButton(n)- Controller input (0=select, 1=start, 2=A, 3=B, 4-7=dpad)setVideoFilter(mode)- 0=off, 1=ntsc-composite, 2=ntsc-svideo, 3=ntsc-rgbsetAudioEnabled(bool)- Returns false if audio backend unavailablehasBatteryBackedRam()- Whether the ROM supports battery SRAMgetSram()/setSram(Uint8Array)- Read/write SRAMisSramDirty()/markSramSaved()- Dirty tracking for SRAM persistencegetDebugState()- CPU/mapper debug infogetFramebuffer()- Returns RGB pixel data (256×240×3 bytes, zero-copy via external buffer)
NES Core → RGB framebuffer (256×240×3) → Renderer → Terminal
│
├─ Kitty shared memory (t=s) — fastest, requires kitty-shm addon
├─ Kitty file transport (t=f) — writes to /dev/shm or temp file
├─ Kitty PNG — base64-encoded PNG fallback
└─ ANSI half-blocks — ▀▄ characters, works everywhere
- Image mode (
renderer: "image") runs at ~30fps to keep emulation stable - Text mode (
renderer: "text") runs at ~60fps in an overlay - Image mode uses a windowed overlay (90% width/height) to avoid Kitty full-screen artifacts
/nescreates aNesSessionthat owns the core and runs the frame loopNesOverlayComponentattaches to display frames and handle inputCtrl+Qdetaches the component but keeps the session running in background/nesreattaches to existing session;/nes <path>starts a new oneQorsession_shutdownevent stops the session and disposes the core
Requires Rust toolchain (cargo + rustc).
# NES core (required)
cd extensions/nes/native/nes-core
npm install && npm run build
# NES core with audio (optional)
npm run build:audio
# Kitty shared memory (optional, faster rendering)
cd ../kitty-shm
npm install && npm run buildThe addons compile to index.node. The JS wrapper (index.js) tries to load it and exports isAvailable: boolean.
- Audio is opt-in — Requires a native core built with
audio-cpalandenableAudio: true. - Overlay flicker — Kitty overlay can show a 1-line flicker at overlay boundaries (see issue #9).
- No save states — Only battery-backed SRAM saves are persisted.
Preferred flow (creates a git tag):
# Ensure clean working tree
npm version patch # or minor/major
git push --follow-tagsIf you need more control over tagging:
# Manually edit package.json version
# Then:
git add package.json
git commit -m "chore(release): vX.Y.Z"
git tag vX.Y.Z
git push
git push --tagsnpm login
npm publish --access publicIf you need to validate the tarball first:
npm packgh release create vX.Y.Z --title "vX.Y.Z" --notes "<release notes>"