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.
- package/README.md +372 -0
- package/bin/cli.js +199 -0
- package/cores/atari800_libretro.js +2 -0
- package/cores/atari800_libretro.wasm +0 -0
- package/cores/beetle_pce_fast_libretro.js +2 -0
- package/cores/beetle_pce_fast_libretro.wasm +0 -0
- package/cores/fceumm_libretro.js +2 -0
- package/cores/fceumm_libretro.wasm +0 -0
- package/cores/fmsx_libretro.js +2 -0
- package/cores/fmsx_libretro.wasm +0 -0
- package/cores/fuse_libretro.js +2 -0
- package/cores/fuse_libretro.wasm +0 -0
- package/cores/gambatte_libretro.js +2 -0
- package/cores/gambatte_libretro.wasm +0 -0
- package/cores/gearcoleco_libretro.js +2 -0
- package/cores/gearcoleco_libretro.wasm +0 -0
- package/cores/genesis_plus_gx_libretro.js +2 -0
- package/cores/genesis_plus_gx_libretro.wasm +0 -0
- package/cores/handy_libretro.js +2 -0
- package/cores/handy_libretro.wasm +0 -0
- package/cores/mednafen_ngp_libretro.js +2 -0
- package/cores/mednafen_ngp_libretro.wasm +0 -0
- package/cores/mednafen_wswan_libretro.js +2 -0
- package/cores/mednafen_wswan_libretro.wasm +0 -0
- package/cores/mgba_libretro.js +2 -0
- package/cores/mgba_libretro.wasm +0 -0
- package/cores/pcsx_rearmed_libretro.js +2 -0
- package/cores/pcsx_rearmed_libretro.wasm +0 -0
- package/cores/prosystem_libretro.js +2 -0
- package/cores/prosystem_libretro.wasm +0 -0
- package/cores/snes9x2010_libretro.js +2 -0
- package/cores/snes9x2010_libretro.wasm +0 -0
- package/cores/snes9x_libretro.js +2 -0
- package/cores/snes9x_libretro.wasm +0 -0
- package/cores/stella2014_libretro.js +2 -0
- package/cores/stella2014_libretro.wasm +0 -0
- package/cores/vecx_libretro.js +2 -0
- package/cores/vecx_libretro.wasm +0 -0
- package/index.js +8 -0
- package/package.json +52 -0
- package/src/audio/AudioBridge.js +61 -0
- package/src/constants/libretro.js +97 -0
- package/src/core/CoreLoader.js +42 -0
- package/src/core/LibretroHost.js +542 -0
- package/src/core/RomLoader.js +96 -0
- package/src/core/SaveManager.js +115 -0
- package/src/core/SystemDetector.js +122 -0
- package/src/input/InputManager.js +222 -0
- package/src/input/InputMap.js +47 -0
- package/src/video/VideoOutput.js +207 -0
- 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
|
+
}
|