retroemu 0.1.0

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.
Files changed (51) hide show
  1. package/README.md +372 -0
  2. package/bin/cli.js +199 -0
  3. package/cores/atari800_libretro.js +2 -0
  4. package/cores/atari800_libretro.wasm +0 -0
  5. package/cores/beetle_pce_fast_libretro.js +2 -0
  6. package/cores/beetle_pce_fast_libretro.wasm +0 -0
  7. package/cores/fceumm_libretro.js +2 -0
  8. package/cores/fceumm_libretro.wasm +0 -0
  9. package/cores/fmsx_libretro.js +2 -0
  10. package/cores/fmsx_libretro.wasm +0 -0
  11. package/cores/fuse_libretro.js +2 -0
  12. package/cores/fuse_libretro.wasm +0 -0
  13. package/cores/gambatte_libretro.js +2 -0
  14. package/cores/gambatte_libretro.wasm +0 -0
  15. package/cores/gearcoleco_libretro.js +2 -0
  16. package/cores/gearcoleco_libretro.wasm +0 -0
  17. package/cores/genesis_plus_gx_libretro.js +2 -0
  18. package/cores/genesis_plus_gx_libretro.wasm +0 -0
  19. package/cores/handy_libretro.js +2 -0
  20. package/cores/handy_libretro.wasm +0 -0
  21. package/cores/mednafen_ngp_libretro.js +2 -0
  22. package/cores/mednafen_ngp_libretro.wasm +0 -0
  23. package/cores/mednafen_wswan_libretro.js +2 -0
  24. package/cores/mednafen_wswan_libretro.wasm +0 -0
  25. package/cores/mgba_libretro.js +2 -0
  26. package/cores/mgba_libretro.wasm +0 -0
  27. package/cores/pcsx_rearmed_libretro.js +2 -0
  28. package/cores/pcsx_rearmed_libretro.wasm +0 -0
  29. package/cores/prosystem_libretro.js +2 -0
  30. package/cores/prosystem_libretro.wasm +0 -0
  31. package/cores/snes9x2010_libretro.js +2 -0
  32. package/cores/snes9x2010_libretro.wasm +0 -0
  33. package/cores/snes9x_libretro.js +2 -0
  34. package/cores/snes9x_libretro.wasm +0 -0
  35. package/cores/stella2014_libretro.js +2 -0
  36. package/cores/stella2014_libretro.wasm +0 -0
  37. package/cores/vecx_libretro.js +2 -0
  38. package/cores/vecx_libretro.wasm +0 -0
  39. package/index.js +8 -0
  40. package/package.json +52 -0
  41. package/src/audio/AudioBridge.js +61 -0
  42. package/src/constants/libretro.js +97 -0
  43. package/src/core/CoreLoader.js +42 -0
  44. package/src/core/LibretroHost.js +542 -0
  45. package/src/core/RomLoader.js +96 -0
  46. package/src/core/SaveManager.js +115 -0
  47. package/src/core/SystemDetector.js +122 -0
  48. package/src/input/InputManager.js +222 -0
  49. package/src/input/InputMap.js +47 -0
  50. package/src/video/VideoOutput.js +207 -0
  51. package/src/video/videoWorker.js +164 -0
package/README.md ADDED
@@ -0,0 +1,372 @@
1
+ # retroemu
2
+
3
+ Terminal-based retro game emulator. Play classic console games directly in your terminal using libretro WASM cores.
4
+
5
+ - **25+ retro systems** — NES, SNES, Game Boy, Genesis, Atari, and more
6
+ - **Truecolor ANSI rendering** — half-block characters for clean pixel art
7
+ - **2100+ controllers supported** — via SDL2 with automatic mapping
8
+ - **Low-latency audio** — direct SDL2 audio output
9
+ - **Save states & battery saves** — automatic SRAM persistence
10
+
11
+ ```
12
+ retroemu game.nes
13
+ ```
14
+
15
+ ## Supported Systems
16
+
17
+ The emulator auto-detects systems by file extension. All cores are from the [libretro](https://www.libretro.com/) project, compiled to WebAssembly.
18
+
19
+ ### Nintendo
20
+
21
+ | System | ROM Extensions | Core |
22
+ |--------|----------------|------|
23
+ | NES / Famicom | `.nes` `.fds` `.unf` `.unif` | [fceumm](https://github.com/libretro/libretro-fceumm) |
24
+ | Super Nintendo | `.sfc` `.smc` | [snes9x](https://github.com/libretro/snes9x) |
25
+ | Game Boy | `.gb` | [gambatte](https://github.com/libretro/gambatte-libretro) |
26
+ | Game Boy Color | `.gbc` | [gambatte](https://github.com/libretro/gambatte-libretro) |
27
+ | Game Boy Advance | `.gba` | [mgba](https://github.com/libretro/mgba) |
28
+
29
+ ### Sega
30
+
31
+ | System | ROM Extensions | Core |
32
+ |--------|----------------|------|
33
+ | Genesis / Mega Drive | `.md` `.gen` `.smd` `.bin` | [genesis_plus_gx](https://github.com/libretro/Genesis-Plus-GX) |
34
+ | Master System | `.sms` | [genesis_plus_gx](https://github.com/libretro/Genesis-Plus-GX) |
35
+ | Game Gear | `.gg` | [genesis_plus_gx](https://github.com/libretro/Genesis-Plus-GX) |
36
+ | SG-1000 | `.sg` | [genesis_plus_gx](https://github.com/libretro/Genesis-Plus-GX) |
37
+
38
+ ### Atari
39
+
40
+ | System | ROM Extensions | Core |
41
+ |--------|----------------|------|
42
+ | Atari 2600 | `.a26` | [stella2014](https://github.com/libretro/stella2014-libretro) |
43
+ | Atari 5200 | `.a52` | [atari800](https://github.com/libretro/libretro-atari800) |
44
+ | Atari 7800 | `.a78` | [prosystem](https://github.com/libretro/prosystem-libretro) |
45
+ | Atari 800/XL/XE | `.xex` `.atr` `.atx` `.bas` `.car` `.xfd` | [atari800](https://github.com/libretro/libretro-atari800) |
46
+ | Atari Lynx | `.lnx` `.o` | [handy](https://github.com/libretro/libretro-handy) |
47
+
48
+ ### NEC
49
+
50
+ | System | ROM Extensions | Core |
51
+ |--------|----------------|------|
52
+ | TurboGrafx-16 / PC Engine | `.pce` `.cue` `.ccd` `.chd` | [beetle_pce_fast](https://github.com/libretro/beetle-pce-fast-libretro) |
53
+
54
+ ### SNK
55
+
56
+ | System | ROM Extensions | Core |
57
+ |--------|----------------|------|
58
+ | Neo Geo Pocket | `.ngp` | [mednafen_ngp](https://github.com/libretro/beetle-ngp-libretro) |
59
+ | Neo Geo Pocket Color | `.ngc` | [mednafen_ngp](https://github.com/libretro/beetle-ngp-libretro) |
60
+
61
+ ### Bandai
62
+
63
+ | System | ROM Extensions | Core |
64
+ |--------|----------------|------|
65
+ | WonderSwan | `.ws` | [mednafen_wswan](https://github.com/libretro/beetle-wswan-libretro) |
66
+ | WonderSwan Color | `.wsc` | [mednafen_wswan](https://github.com/libretro/beetle-wswan-libretro) |
67
+
68
+ ### Other Consoles
69
+
70
+ | System | ROM Extensions | Core |
71
+ |--------|----------------|------|
72
+ | ColecoVision | `.col` | [gearcoleco](https://github.com/drhelius/Gearcoleco) |
73
+ | Vectrex | `.vec` | [vecx](https://github.com/libretro/libretro-vecx) |
74
+
75
+ ### Home Computers
76
+
77
+ | System | ROM Extensions | Core |
78
+ |--------|----------------|------|
79
+ | ZX Spectrum | `.tzx` `.z80` `.sna` | [fuse](https://github.com/libretro/fuse-libretro) |
80
+ | MSX / MSX2 | `.mx1` `.mx2` `.rom` `.dsk` `.cas` | [fmsx](https://github.com/libretro/fmsx-libretro) |
81
+
82
+ Just run `retroemu <rom-file>` and the correct core loads automatically based on the file extension.
83
+
84
+ **ZIP support:** ROMs can be provided inside `.zip` archives — the emulator will automatically extract and load the first supported ROM file found.
85
+
86
+ ## How It Works
87
+
88
+ The emulator loads libretro cores compiled to WebAssembly via Emscripten. Each frame, the WASM core executes one tick of the emulated CPU, then calls back into JavaScript with:
89
+
90
+ - **Video**: A raw pixel framebuffer (RGB565, XRGB8888, or 0RGB1555) that gets converted to RGBA and rendered to the terminal as truecolor ANSI art via [chafa-wasm](https://github.com/nicholasgasior/chafa-wasm) in a worker thread
91
+ - **Audio**: Interleaved int16 stereo samples sent directly to SDL2 audio device (via [@kmamal/sdl](https://github.com/kmamal/node-sdl))
92
+ - **Input**: Polled from physical gamepads through the W3C Gamepad API (via [gamepad-node](../gamepad-node/)), with keyboard fallback
93
+
94
+ ```
95
+ retroemu <rom>
96
+
97
+ LibretroHost ── loads WASM core, registers callbacks, drives retro_run() at 60fps
98
+
99
+ core._retro_run()
100
+
101
+ ├── input_poll ──► InputManager.poll() ──► navigator.getGamepads()
102
+ ├── input_state ──► InputManager.getState(port, device, index, id)
103
+ ├── [emulate one frame]
104
+ ├── video_refresh ──► VideoOutput ──► worker thread ──► chafa-wasm ──► terminal
105
+ └── audio_batch ──► AudioBridge ──► SDL2 ──► speakers
106
+ ```
107
+
108
+ ## Prerequisites
109
+
110
+ - **Node.js** >= 22.0.0 (for ES modules and worker threads)
111
+ - **Emscripten SDK** (only needed for building cores from source)
112
+ - **A truecolor terminal** (iTerm2, Kitty, Alacritty, Windows Terminal, GNOME Terminal, etc.)
113
+
114
+ ## Installation
115
+
116
+ ```bash
117
+ npm install -g retroemu
118
+ ```
119
+
120
+ ## Building Cores
121
+
122
+ Cores must be compiled from C/C++ source to WASM using Emscripten.
123
+
124
+ Build all cores:
125
+
126
+ ```bash
127
+ npm run build:cores
128
+ ```
129
+
130
+ Build a single core (e.g., NES):
131
+
132
+ ```bash
133
+ bash scripts/cores/fceumm.sh
134
+ ```
135
+
136
+ The build script clones the libretro core repo, compiles it with `emmake`, and links it into a WASM module with the correct exported functions. Output goes to `cores/{name}_libretro.js` + `.wasm`.
137
+
138
+ ### Emscripten Setup
139
+
140
+ If you don't have Emscripten installed:
141
+
142
+ ```bash
143
+ git clone https://github.com/emscripten-core/emsdk.git
144
+ cd emsdk
145
+ ./emsdk install latest
146
+ ./emsdk activate latest
147
+ source ./emsdk_env.sh
148
+ ```
149
+
150
+ ## Usage
151
+
152
+ ```
153
+ retroemu [options] <rom-file>
154
+
155
+ Options:
156
+ --save-dir <dir> Directory for save files (default: <rom-dir>/saves)
157
+ --frame-skip <n> Render every Nth frame to terminal (default: 2)
158
+ --contrast <n> Contrast boost, 1.0=normal, 1.5+=enhanced (default: 1.0)
159
+ --ascii Use detailed Unicode symbols instead of half-blocks
160
+ --no-gamepad Disable gamepad input (keyboard only)
161
+ -h, --help Show help
162
+ ```
163
+
164
+ Examples:
165
+
166
+ ```bash
167
+ retroemu ~/roms/mario.nes
168
+ retroemu ~/roms/zelda.zip # extracts ROM from ZIP automatically
169
+ emu --frame-skip 3 ~/roms/zelda.sfc
170
+ emu --save-dir ~/.emu/saves ~/roms/pokemon.gbc
171
+ emu --contrast 1.5 ~/roms/space_invaders.a26 # boost contrast for dark games
172
+ ```
173
+
174
+ ## Rendering
175
+
176
+ The emulator uses [chafa-wasm](https://github.com/nicholasgasior/chafa-wasm) to convert pixel data to truecolor ANSI sequences.
177
+
178
+ **Default mode (half-blocks):** Uses vertical half-block characters (▀▄) which provide 2 vertical pixels per character cell. This matches the blocky pixel art aesthetic of retro games and renders quickly.
179
+
180
+ **ASCII mode (`--ascii`):** Uses a wider variety of Unicode block and border characters for more detail. May look better for some games but can introduce visual artifacts.
181
+
182
+ **Contrast boost (`--contrast`):** Some games (especially Atari) have low contrast that doesn't translate well to terminal rendering. Use `--contrast 1.5` or higher to boost visibility.
183
+
184
+ ## Controls
185
+
186
+ ### Gamepad
187
+
188
+ Any gamepad recognized by [gamepad-node](../gamepad-node/) works automatically. Buttons are mapped positionally — the south face button is always B, east is A, etc. — regardless of the controller's printed labels.
189
+
190
+ | Gamepad Button | Libretro |
191
+ |---------------|----------|
192
+ | South face (A/Cross) | B |
193
+ | East face (B/Circle) | A |
194
+ | West face (X/Square) | Y |
195
+ | North face (Y/Triangle) | X |
196
+ | L1 / LB | L |
197
+ | R1 / RB | R |
198
+ | L2 / LT | L2 |
199
+ | R2 / RT | R2 |
200
+ | Select / Back | Select |
201
+ | Start / Options | Start |
202
+ | D-Pad | D-Pad |
203
+ | Left Stick | Analog Left |
204
+ | Right Stick | Analog Right |
205
+
206
+ ### Keyboard
207
+
208
+ Keyboard input is available as a fallback for player 1:
209
+
210
+ | Key | Action |
211
+ |-----|--------|
212
+ | Arrow keys | D-Pad |
213
+ | Z | B |
214
+ | X | A |
215
+ | A | Y |
216
+ | S | X |
217
+ | Q | L |
218
+ | W | R |
219
+ | Enter | Start |
220
+ | Shift | Select |
221
+
222
+ ### Hotkeys
223
+
224
+ | Key | Action |
225
+ |-----|--------|
226
+ | F1 | Reset |
227
+ | F5 | Save state (slot 0) |
228
+ | F7 | Load state (slot 0) |
229
+ | ESC | Quit |
230
+ | Ctrl+C | Force quit |
231
+
232
+ ## Save System
233
+
234
+ **SRAM** (battery-backed saves) is automatically saved when you quit and loaded when you start a ROM. Save files are stored as `{rom-name}.srm` in the save directory.
235
+
236
+ **Save states** capture the full emulation state (CPU registers, memory, video state, etc.) and are stored as `{rom-name}.state0`. Use F5 to save and F7 to load.
237
+
238
+ Default save directory: `saves/` next to the ROM file, configurable with `--save-dir`.
239
+
240
+ ## Architecture
241
+
242
+ ```
243
+ retroemu/
244
+ bin/cli.js CLI entry point
245
+ index.js Library exports
246
+ src/
247
+ core/
248
+ LibretroHost.js Main engine: WASM loading, callback registration, frame loop
249
+ CoreLoader.js Dynamic import of Emscripten WASM modules
250
+ SystemDetector.js ROM extension -> system/core mapping
251
+ SaveManager.js SRAM and save state persistence
252
+ video/
253
+ VideoOutput.js Pixel format conversion, aspect ratio, contrast
254
+ videoWorker.js Worker thread for chafa-wasm rendering
255
+ audio/
256
+ AudioBridge.js Direct SDL2 audio output
257
+ input/
258
+ InputManager.js Gamepad polling + keyboard fallback
259
+ InputMap.js W3C <-> libretro button mapping tables
260
+ constants/
261
+ libretro.js Libretro C API constants
262
+ cores/ Pre-built .wasm + .js glue files
263
+ scripts/
264
+ build-core.sh Emscripten build for any libretro core
265
+ build-all-cores.sh Batch build
266
+ cores/*.sh Per-core build configs
267
+ ```
268
+
269
+ ### Key Modules
270
+
271
+ **LibretroHost** (`src/core/LibretroHost.js`) is the central orchestrator. It:
272
+
273
+ 1. Loads a WASM core via `CoreLoader`
274
+ 2. Registers 6 JavaScript callbacks as WASM function pointers using Emscripten's `addFunction()`:
275
+ - `retro_environment` — handles 20+ environment commands from the core (pixel format negotiation, directory queries, variable configuration, capability reporting)
276
+ - `retro_video_refresh` — receives framebuffer data each frame
277
+ - `retro_audio_sample_batch` — receives batched stereo audio samples
278
+ - `retro_audio_sample` — receives individual stereo samples (legacy fallback)
279
+ - `retro_input_poll` — triggers gamepad state refresh
280
+ - `retro_input_state` — returns button/axis state for a given port, device, and button ID
281
+ 3. Allocates the ROM and `retro_game_info` struct in WASM memory
282
+ 4. Reads `retro_system_av_info` to get screen dimensions, FPS, and audio sample rate
283
+ 5. Runs the frame loop at the correct FPS using `setTimeout`/`setImmediate` hybrid timing
284
+
285
+ **VideoOutput** (`src/video/VideoOutput.js`) converts pixel data from the WASM heap into terminal art:
286
+
287
+ - Supports three pixel formats: RGB565, XRGB8888, 0RGB1555
288
+ - Uses pre-computed lookup tables (32-entry and 64-entry `Uint8Array`s) for 16-bit to 8-bit color conversion — no division in the hot loop
289
+ - Default half-block mode (▀▄) doubles vertical resolution and matches pixel art aesthetic
290
+ - Optional detailed mode (`--ascii`) uses block/border Unicode symbols for more variety
291
+ - Contrast boost option for low-contrast games (Atari, etc.)
292
+ - Renders every Nth frame (configurable, default 2) to avoid overwhelming terminal I/O
293
+ - Runs chafa conversion in a worker thread to keep the main loop responsive
294
+
295
+ **AudioBridge** (`src/audio/AudioBridge.js`) sends audio directly to SDL2:
296
+
297
+ - Opens an SDL2 audio device in S16 stereo format (matches libretro exactly)
298
+ - Zero-copy path: passes WASM memory buffer directly to SDL2's queue
299
+ - No sample format conversion needed — libretro and SDL2 both use int16
300
+
301
+ **InputManager** (`src/input/InputManager.js`) multiplexes gamepad and keyboard input:
302
+
303
+ - Calls `navigator.getGamepads()` (provided by gamepad-node) each frame
304
+ - Maps W3C standard button indices to libretro joypad IDs via `InputMap`
305
+ - Supports input bitmasks for modern cores (mGBA, etc.) that query all buttons at once
306
+ - Supports analog sticks (W3C float axes converted to libretro int16 range)
307
+ - Falls back to keyboard for player 1 using stdin raw mode with frame-based key hold timing
308
+
309
+ ### Build System
310
+
311
+ `scripts/build-core.sh` compiles any libretro core to WASM:
312
+
313
+ 1. Clones the core repo (`git clone --depth 1`)
314
+ 2. Builds with `emmake make -f Makefile.libretro platform=emscripten`
315
+ 3. Links the output with `emcc` using flags:
316
+ - `-O3` — full optimization
317
+ - `-s MODULARIZE=1 -s EXPORT_ES6=1` — ES module factory
318
+ - `-s ENVIRONMENT=node` — Node.js target
319
+ - `-s ALLOW_MEMORY_GROWTH=1` — dynamic memory (32MB initial, 256MB max)
320
+ - `-s ALLOW_TABLE_GROWTH=1` — required for `addFunction()` callback registration
321
+ - `-s FILESYSTEM=0` — no Emscripten FS (host handles I/O)
322
+ 4. Exports 23 libretro API functions + Emscripten runtime helpers (`addFunction`, `HEAPU8`, `setValue`, etc.)
323
+
324
+ ## Programmatic API
325
+
326
+ ```javascript
327
+ import { LibretroHost, VideoOutput, AudioBridge, InputManager, SaveManager } from 'retroemu';
328
+
329
+ const video = new VideoOutput();
330
+ await video.init();
331
+
332
+ const audio = new AudioBridge();
333
+ const input = new InputManager();
334
+ const saves = new SaveManager('./saves');
335
+
336
+ const host = new LibretroHost({
337
+ videoOutput: video,
338
+ audioBridge: audio,
339
+ inputManager: input,
340
+ saveManager: saves,
341
+ });
342
+
343
+ await host.loadAndStart('./game.nes');
344
+
345
+ // Later:
346
+ await host.saveState(0);
347
+ await host.loadState(0);
348
+ host.reset();
349
+ await host.shutdown();
350
+ ```
351
+
352
+ ## Dependencies
353
+
354
+ | Package | Purpose |
355
+ |---------|---------|
356
+ | [gamepad-node](../gamepad-node/) | W3C Gamepad API for Node.js via SDL2 — 2100+ controllers with standard mapping |
357
+ | [@kmamal/sdl](https://github.com/kmamal/node-sdl) | Native SDL2 bindings for Node.js — audio output and gamepad input |
358
+ | [chafa-wasm](https://github.com/nicholasgasior/chafa-wasm) | Image-to-ANSI conversion — auto-detects Sixel, Kitty, or Unicode block art |
359
+
360
+ ## Acknowledgments
361
+
362
+ This project is built on top of the amazing work by the [libretro](https://www.libretro.com/) team and the [RetroArch](https://www.retroarch.com/) community. All emulator cores are libretro cores compiled to WebAssembly:
363
+
364
+ - **libretro** provides a standardized API that allows emulator cores to be written once and run on many frontends
365
+ - **RetroArch** is the reference frontend implementation and home to most libretro core development
366
+ - Individual core authors and maintainers who have created and continue to improve these emulators
367
+
368
+ Without the libretro ecosystem and the open-source emulation community, this project would not be possible.
369
+
370
+ ## License
371
+
372
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve, dirname, basename } from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { LibretroHost } from '../src/core/LibretroHost.js';
6
+ import { VideoOutput } from '../src/video/VideoOutput.js';
7
+ import { AudioBridge } from '../src/audio/AudioBridge.js';
8
+ import { InputManager } from '../src/input/InputManager.js';
9
+ import { SaveManager } from '../src/core/SaveManager.js';
10
+ import { detectSystem, getSupportedExtensions } from '../src/core/SystemDetector.js';
11
+ import { loadRom, isZipFile } from '../src/core/RomLoader.js';
12
+
13
+ // Parse arguments
14
+ const args = process.argv.slice(2);
15
+ let romPath = null;
16
+ let saveDir = null;
17
+ let frameSkip = 2;
18
+ let contrast = 1.0;
19
+ let renderMode = 'fast'; // half-blocks by default
20
+ let disableGamepad = false;
21
+ let debugInput = false;
22
+
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === '--save-dir' && args[i + 1]) {
25
+ saveDir = resolve(args[++i]);
26
+ } else if (args[i] === '--frame-skip' && args[i + 1]) {
27
+ frameSkip = parseInt(args[++i], 10);
28
+ } else if (args[i] === '--contrast' && args[i + 1]) {
29
+ contrast = parseFloat(args[++i]);
30
+ } else if (args[i] === '--ascii') {
31
+ renderMode = 'ascii';
32
+ } else if (args[i] === '--braille') {
33
+ renderMode = 'braille';
34
+ } else if (args[i] === '--braille-dither') {
35
+ renderMode = 'braille-dither';
36
+ } else if (args[i] === '--no-gamepad') {
37
+ disableGamepad = true;
38
+ } else if (args[i] === '--debug-input') {
39
+ debugInput = true;
40
+ } else if (args[i] === '--help' || args[i] === '-h') {
41
+ printUsage();
42
+ process.exit(0);
43
+ } else if (!args[i].startsWith('-')) {
44
+ romPath = resolve(args[i]);
45
+ }
46
+ }
47
+
48
+ if (!romPath) {
49
+ printUsage();
50
+ process.exit(1);
51
+ }
52
+
53
+ if (!existsSync(romPath)) {
54
+ console.error(`File not found: ${romPath}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ // Load ROM (handles ZIP extraction if needed)
59
+ let romInfo;
60
+ try {
61
+ romInfo = await loadRom(romPath);
62
+ if (romInfo.zipEntry) {
63
+ console.log(`Extracted: ${romInfo.zipEntry}`);
64
+ }
65
+ } catch (err) {
66
+ console.error(`Error loading ROM: ${err.message}`);
67
+ process.exit(1);
68
+ }
69
+
70
+ // Detect system from the actual ROM file (inside ZIP if applicable)
71
+ const system = detectSystem(romInfo.romPath);
72
+ if (!system) {
73
+ console.error(`Unsupported ROM file extension.`);
74
+ console.error(`Supported: ${getSupportedExtensions().join(', ')}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ // Default save dir is alongside the original file (ZIP or ROM)
79
+ if (!saveDir) {
80
+ saveDir = resolve(dirname(romPath), 'saves');
81
+ }
82
+
83
+ // Initialize subsystems
84
+ const videoOutput = new VideoOutput();
85
+ await videoOutput.init();
86
+ videoOutput.setFrameSkip(frameSkip);
87
+ videoOutput.setContrast(contrast);
88
+ videoOutput.setRenderMode(renderMode);
89
+
90
+ const audioBridge = new AudioBridge();
91
+ const inputManager = new InputManager({ disableGamepad, debugInput });
92
+ const saveManager = new SaveManager(saveDir);
93
+
94
+ const host = new LibretroHost({
95
+ videoOutput,
96
+ audioBridge,
97
+ inputManager,
98
+ saveManager,
99
+ });
100
+
101
+ // Enter alternate screen buffer, hide cursor
102
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
103
+
104
+ // Clean shutdown handler
105
+ let shuttingDown = false;
106
+ async function shutdown() {
107
+ if (shuttingDown) return;
108
+ shuttingDown = true;
109
+
110
+ await host.shutdown();
111
+
112
+ try {
113
+ inputManager.destroy();
114
+ } catch {
115
+ // Ignore SDL controller cleanup errors
116
+ }
117
+
118
+ // Restore terminal
119
+ process.stdout.write('\x1b[?1049l\x1b[?25h');
120
+ process.exit(0);
121
+ }
122
+
123
+ process.on('SIGINT', shutdown);
124
+ process.on('SIGTERM', shutdown);
125
+ process.on('exit', () => {
126
+ // Ensure terminal is restored even on unexpected exit
127
+ process.stdout.write('\x1b[?1049l\x1b[?25h');
128
+ });
129
+
130
+ // Hotkeys via stdin (handled by InputManager, but save/load state needs extra handling)
131
+ if (process.stdin.isTTY) {
132
+ process.stdin.on('data', async (key) => {
133
+ // F5 = save state (ESC [ 1 5 ~)
134
+ if (key === '\x1b[15~') {
135
+ await host.saveState(0);
136
+ }
137
+ // F7 = load state (ESC [ 1 8 ~)
138
+ if (key === '\x1b[18~') {
139
+ await host.loadState(0);
140
+ }
141
+ // F1 = reset (ESC [ 1 1 ~)
142
+ if (key === '\x1b[11~') {
143
+ host.reset();
144
+ }
145
+ // ESC = quit
146
+ if (key === '\x1b' && key.length === 1) {
147
+ await shutdown();
148
+ }
149
+ });
150
+ }
151
+
152
+ // Start retroemulation
153
+ try {
154
+ await host.loadAndStart(romInfo.romPath, { saveDir, romData: romInfo.data });
155
+ } catch (err) {
156
+ process.stdout.write('\x1b[?1049l\x1b[?25h');
157
+ console.error(`Error: ${err.message}`);
158
+ process.exit(1);
159
+ }
160
+
161
+ function printUsage() {
162
+ console.log(`retroemu - Terminal retro game retroemulator`);
163
+ console.log(``);
164
+ console.log(`Usage: retroemu [options] <rom-file>`);
165
+ console.log(``);
166
+ console.log(`Options:`);
167
+ console.log(` --save-dir <dir> Directory for save files (default: <rom-dir>/saves)`);
168
+ console.log(` --frame-skip <n> Render every Nth frame to terminal (default: 2)`);
169
+ console.log(` --contrast <n> Contrast boost, 1.0=normal, 1.5=more contrast (default: 1.0)`);
170
+ console.log(` --ascii Use ASCII characters instead of block characters`);
171
+ console.log(` --braille Use braille characters (black and white)`);
172
+ console.log(` --braille-dither Use braille with Floyd-Steinberg dithering`);
173
+ console.log(` --no-gamepad Disable gamepad input (keyboard only)`);
174
+ console.log(` -h, --help Show this help`);
175
+ console.log(``);
176
+ console.log(`ROM files can be provided directly or inside a .zip archive.`);
177
+ console.log(``);
178
+ console.log(`Supported systems:`);
179
+ console.log(` Nintendo NES (.nes), SNES (.sfc, .smc), GB/GBC (.gb, .gbc), GBA (.gba)`);
180
+ console.log(` Sega Genesis (.md, .gen), Master System (.sms), Game Gear (.gg)`);
181
+ console.log(` Atari 2600 (.a26), 5200 (.a52), 7800 (.a78), 800/XL/XE, Lynx (.lnx)`);
182
+ console.log(` NEC TurboGrafx-16 / PC Engine (.pce)`);
183
+ console.log(` SNK Neo Geo Pocket (.ngp, .ngc)`);
184
+ console.log(` Bandai WonderSwan (.ws, .wsc)`);
185
+ console.log(` Other ColecoVision (.col), Vectrex (.vec)`);
186
+ console.log(` Computers ZX Spectrum (.tzx, .z80), MSX (.mx1, .mx2, .rom)`);
187
+ console.log(` Sony PlayStation (.iso, .pbp, .m3u)`);
188
+ console.log(``);
189
+ console.log(`Controls:`);
190
+ console.log(` Gamepad Automatically detected (2100+ controllers)`);
191
+ console.log(` Keyboard Arrow keys, Z/X (B/A), A/S (Y/X), Enter (Start), Shift (Select)`);
192
+ console.log(``);
193
+ console.log(`Hotkeys:`);
194
+ console.log(` F1 Reset`);
195
+ console.log(` F5 Save state`);
196
+ console.log(` F7 Load state`);
197
+ console.log(` ESC Quit`);
198
+ console.log(` Start+Sel Quit (gamepad, hold 0.5s)`);
199
+ }