retroterm 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 ADDED
@@ -0,0 +1,125 @@
1
+ # retroterm
2
+
3
+ Terminal-based retro game launcher. A blessed TUI frontend for [retroemu](https://github.com/monteslu/retroemu).
4
+
5
+ - **ROM browser** — Scans your ROMs directory and organizes by system
6
+ - **Recent games** — Quick access to recently played games
7
+ - **Keyboard navigation** — vim-style controls
8
+ - **Preferences** — Configurable ROMs and saves directories
9
+
10
+ ```
11
+ retroterm
12
+ ```
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install -g retroterm
18
+ ```
19
+
20
+ This will also install `retroemu` as a dependency.
21
+
22
+ ## Usage
23
+
24
+ Just run `retroterm` to launch the browser:
25
+
26
+ ```bash
27
+ retroterm
28
+ ```
29
+
30
+ On first run, press `S` to configure your ROMs directory.
31
+
32
+ ### Controls
33
+
34
+ | Key | Action |
35
+ |-----|--------|
36
+ | `Enter` | Launch selected game |
37
+ | `j` / `k` or arrows | Navigate list |
38
+ | `A` | Show all ROMs |
39
+ | `R` | Show recent games |
40
+ | `S` | Settings |
41
+ | `F5` | Refresh ROM list |
42
+ | `Q` | Quit |
43
+
44
+ ## Configuration
45
+
46
+ Settings are stored in `~/.config/retroterm/config.json`:
47
+
48
+ ```json
49
+ {
50
+ "romsDir": "/home/user/roms",
51
+ "savesDir": "/home/user/.config/retroterm/saves",
52
+ "recentGames": [],
53
+ "maxRecent": 10
54
+ }
55
+ ```
56
+
57
+ ## Supported Systems
58
+
59
+ retroterm supports all systems that retroemu supports:
60
+
61
+ - **Nintendo** — NES, SNES, Game Boy, Game Boy Color, Game Boy Advance
62
+ - **Sega** — Genesis, Master System, Game Gear, SG-1000
63
+ - **Atari** — 2600, 5200, 7800, 800/XL/XE, Lynx
64
+ - **NEC** — TurboGrafx-16 / PC Engine
65
+ - **SNK** — Neo Geo Pocket, Neo Geo Pocket Color
66
+ - **Bandai** — WonderSwan, WonderSwan Color
67
+ - **Other** — ColecoVision, Vectrex, ZX Spectrum, MSX
68
+
69
+ ## Streaming / Remote Play
70
+
71
+ retroterm can stream games over the network using a custom binary protocol optimized for terminal output.
72
+
73
+ ### Recommended Settings
74
+
75
+ | Terminal Size | Virtual Resolution | Bandwidth (30 FPS) |
76
+ |---------------|-------------------|-------------------|
77
+ | 60 rows | 160×120 | ~600 KB/s (4.8 Mbps) |
78
+ | 120 rows | 320×240 (PS1 native) | ~2.3 MB/s (18 Mbps) |
79
+
80
+ ### Render Modes
81
+
82
+ | Mode | Quality | Bandwidth | Best For |
83
+ |------|---------|-----------|----------|
84
+ | `half-block-256` | Chunky pixels | Lowest | Retro games (default) |
85
+ | `block-256` | Smooth shading | Medium | When detail matters |
86
+ | `ascii-256` | Textured | Medium | Aesthetic preference |
87
+ | `braille` | Dot matrix | Tiny | Monochrome games |
88
+
89
+ ### Custom Binary Protocol
90
+
91
+ Instead of ANSI escape codes (~15 bytes/cell), use raw color indices:
92
+
93
+ ```
94
+ [fg_index][bg_index][fg_index][bg_index]... (2 bytes/cell)
95
+ ```
96
+
97
+ Decode on client:
98
+ ```javascript
99
+ output += `\x1b[38;5;${data[i]};48;5;${data[i+1]}m▀`;
100
+ ```
101
+
102
+ **10x bandwidth reduction** vs standard ANSI output.
103
+
104
+ ### Architecture Options
105
+
106
+ **Option A: WebRTC P2P** (current)
107
+ - Best latency, works peer-to-peer
108
+ - Requires signaling server for NAT traversal
109
+
110
+ **Option B: Simple WebSocket relay**
111
+ - Easier to deploy (single server)
112
+ - Works through all firewalls
113
+ - node-datachannel for optional WebRTC upgrade
114
+
115
+ ## Dependencies
116
+
117
+ | Package | Purpose |
118
+ |---------|---------|
119
+ | [retroemu](https://github.com/monteslu/retroemu) | Terminal emulator engine with libretro WASM cores |
120
+ | [blessed](https://github.com/chjj/blessed) | Terminal UI library |
121
+ | [chafa-wasm](https://github.com/monteslu/chafa-wasm) | Image-to-ANSI conversion (SIMD-optimized fork) |
122
+
123
+ ## License
124
+
125
+ MIT
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/bin/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Launcher } from '../src/Launcher.js';
4
+
5
+ const launcher = new Launcher();
6
+ await launcher.start();
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Launcher } from './src/Launcher.js';
2
+ export { RomScanner } from './src/RomScanner.js';
3
+ export { Preferences } from './src/Preferences.js';
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "retroterm",
3
+ "version": "0.1.0",
4
+ "description": "Terminal-based retro game launcher with ROM browser and box art",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "retroterm": "./bin/cli.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "assets/",
14
+ "index.js"
15
+ ],
16
+ "keywords": [
17
+ "emulator",
18
+ "retro",
19
+ "terminal",
20
+ "launcher",
21
+ "nes",
22
+ "snes",
23
+ "gameboy",
24
+ "genesis",
25
+ "blessed",
26
+ "tui"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/monteslu/retroterm.git"
31
+ },
32
+ "author": "Luis Montes",
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=22.0.0"
36
+ },
37
+ "dependencies": {
38
+ "blessed": "^0.1.81",
39
+ "@monteslu/chafa-wasm": "^0.4.0",
40
+ "gamepad-node": "^1.3.1",
41
+ "hsync": "^0.33.0",
42
+ "retroemu": "^0.1.1",
43
+ "sharp": "^0.34.5",
44
+ "yauzl": "^3.2.0"
45
+ }
46
+ }
@@ -0,0 +1,880 @@
1
+ import blessed from 'blessed';
2
+ import { spawn } from 'child_process';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { installNavigatorShim } from 'gamepad-node';
7
+ import { Preferences } from './Preferences.js';
8
+ import { RomScanner } from './RomScanner.js';
9
+ import initChafa from '@monteslu/chafa-wasm';
10
+ import sharp from 'sharp';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ASSETS_DIR = join(__dirname, '..', 'assets', 'systems');
14
+
15
+ // Chafa constants
16
+ const CHAFA_SYMBOL_TAG_SPACE = 0x1;
17
+ const CHAFA_SYMBOL_TAG_BLOCK = 0x8;
18
+ const CHAFA_CANVAS_MODE_TRUECOLOR = 0;
19
+
20
+ export class Launcher {
21
+ constructor() {
22
+ this.prefs = new Preferences();
23
+ this.screen = null;
24
+ this.romList = null;
25
+ this.statusBar = null;
26
+ this.controllerBox = null;
27
+ this.systemArtBox = null;
28
+ this.chafa = null;
29
+ this.roms = [];
30
+ this.sortedRoms = null;
31
+ this.systems = []; // Available systems
32
+ this.systemRoms = {}; // ROMs grouped by system
33
+ this.currentSystemIndex = 0;
34
+ this._lastSelected = 0;
35
+ this.currentView = 'system'; // 'system' or 'recent'
36
+ this.gamepadManager = null;
37
+ this.gamepadPollInterval = null;
38
+ this._settingsOpen = false;
39
+ }
40
+
41
+ async start() {
42
+ // Initialize chafa for image rendering
43
+ if (!this.chafa) {
44
+ this.chafa = await initChafa();
45
+ }
46
+
47
+ // Initialize gamepad support
48
+ this.gamepadManager = installNavigatorShim();
49
+
50
+ this.screen = blessed.screen({
51
+ smartCSR: true,
52
+ title: 'retroterm',
53
+ fullUnicode: true,
54
+ });
55
+
56
+ this._createUI();
57
+ this._loadRoms();
58
+ this._setupKeys();
59
+ this._startControllerPolling();
60
+
61
+ this.screen.render();
62
+ }
63
+
64
+ _createUI() {
65
+ // System logo box (horizontal banner)
66
+ this.systemArtBox = blessed.box({
67
+ top: 0,
68
+ left: 0,
69
+ width: 40,
70
+ height: 5,
71
+ tags: true,
72
+ });
73
+
74
+ // System info (right of logo)
75
+ this.systemTitle = blessed.box({
76
+ top: 0,
77
+ left: 40,
78
+ width: '100%-40',
79
+ height: 5,
80
+ tags: true,
81
+ padding: { left: 1, top: 1 },
82
+ content: '{bold}{cyan-fg}RETROTERM{/}',
83
+ });
84
+
85
+ // ROM list
86
+ this.romList = blessed.list({
87
+ top: 5,
88
+ left: 0,
89
+ width: '50%',
90
+ bottom: 3,
91
+ border: { type: 'line' },
92
+ style: {
93
+ border: { fg: 'blue' },
94
+ selected: { bg: 'blue', fg: 'white', bold: true },
95
+ item: { fg: 'white' },
96
+ },
97
+ keys: true,
98
+ vi: true,
99
+ mouse: true,
100
+ scrollbar: {
101
+ ch: ' ',
102
+ style: { bg: 'blue' },
103
+ },
104
+ tags: true,
105
+ });
106
+
107
+ // Info panel (for game details and eventually cover art)
108
+ this.infoPanel = blessed.box({
109
+ top: 0,
110
+ right: 0,
111
+ width: '50%',
112
+ bottom: 8,
113
+ border: { type: 'line' },
114
+ style: { border: { fg: 'green' } },
115
+ tags: true,
116
+ padding: { left: 1, right: 1 },
117
+ content: '{gray-fg}Select a game{/}',
118
+ });
119
+
120
+ // Controller status
121
+ this.controllerBox = blessed.box({
122
+ right: 0,
123
+ bottom: 3,
124
+ width: '50%',
125
+ height: 5,
126
+ border: { type: 'line' },
127
+ style: { border: { fg: 'magenta' } },
128
+ tags: true,
129
+ padding: { left: 1, right: 1 },
130
+ content: '{gray-fg}Scanning controllers...{/}',
131
+ });
132
+
133
+ // Status bar
134
+ this.statusBar = blessed.box({
135
+ bottom: 0,
136
+ left: 0,
137
+ width: '100%',
138
+ height: 3,
139
+ border: { type: 'line' },
140
+ style: { border: { fg: 'gray' } },
141
+ tags: true,
142
+ content: ' {bold}A{/} Play {bold}←→{/} System {bold}↑↓{/} Browse {bold}LB/RB{/} Page {bold}X{/} Recent {bold}Y{/} Settings {bold}Q{/} Quit',
143
+ });
144
+
145
+ this.screen.append(this.systemArtBox);
146
+ this.screen.append(this.systemTitle);
147
+ this.screen.append(this.romList);
148
+ this.screen.append(this.infoPanel);
149
+ this.screen.append(this.controllerBox);
150
+ this.screen.append(this.statusBar);
151
+
152
+ this.romList.focus();
153
+
154
+ // Update info panel on selection
155
+ this.romList.on('select item', (item, index) => {
156
+ this._lastSelected = index;
157
+ this._updateInfoPanel(index);
158
+ });
159
+ }
160
+
161
+ async _loadRoms() {
162
+ const romsDir = this.prefs.get('romsDir');
163
+
164
+ if (!existsSync(romsDir)) {
165
+ this.romList.setItems(['{yellow-fg}ROMs directory not found{/}', '', `{gray-fg}${romsDir}{/}`, '', '{white-fg}Press S to configure{/}']);
166
+ this.roms = [];
167
+ this.screen.render();
168
+ return;
169
+ }
170
+
171
+ this.romList.setItems(['{cyan-fg}Scanning ROMs...{/}']);
172
+ this.screen.render();
173
+
174
+ const scanner = new RomScanner(romsDir);
175
+ this.roms = await scanner.scan();
176
+
177
+ if (this.roms.length === 0) {
178
+ this.romList.setItems(['{yellow-fg}No ROMs found{/}', '', `{gray-fg}${romsDir}{/}`, '', '{white-fg}Press S to configure{/}']);
179
+ } else {
180
+ this._groupRomsBySystem();
181
+ this._showCurrentSystem();
182
+ }
183
+
184
+ this.screen.render();
185
+ }
186
+
187
+ _groupRomsBySystem() {
188
+ // Group by system, then alphabetize within each group
189
+ this.systemRoms = {};
190
+ for (const rom of this.roms) {
191
+ if (!this.systemRoms[rom.system]) {
192
+ this.systemRoms[rom.system] = [];
193
+ }
194
+ this.systemRoms[rom.system].push(rom);
195
+ }
196
+
197
+ // Sort systems alphabetically, sort ROMs within each system
198
+ this.systems = Object.keys(this.systemRoms).sort();
199
+ for (const system of this.systems) {
200
+ this.systemRoms[system].sort((a, b) => a.name.localeCompare(b.name));
201
+ }
202
+ }
203
+
204
+ _showCurrentSystem() {
205
+ this.currentView = 'system';
206
+
207
+ if (this.systems.length === 0) return;
208
+
209
+ const system = this.systems[this.currentSystemIndex];
210
+ const roms = this.systemRoms[system];
211
+ this.sortedRoms = roms;
212
+
213
+ const items = roms.map(rom => rom.name);
214
+ this.romList.setItems(items);
215
+ this.romList.select(0);
216
+
217
+ // Update system title
218
+ this.systemTitle.setContent(
219
+ `{bold}{yellow-fg}${system}{/}\n` +
220
+ `{white-fg}${roms.length} games{/} {gray-fg}[${this.currentSystemIndex + 1}/${this.systems.length}]{/}`
221
+ );
222
+
223
+ // Render system art (async, will update when done)
224
+ this._renderSystemArt(system);
225
+
226
+ this._updateInfoPanel(0);
227
+ this.screen.render();
228
+ }
229
+
230
+ async _renderSystemArt(system) {
231
+ const artPath = join(ASSETS_DIR, `${system}.png`);
232
+ if (!existsSync(artPath) || !this.chafa) {
233
+ this.systemArtBox.setContent('');
234
+ return;
235
+ }
236
+
237
+ try {
238
+ // Decode image with sharp
239
+ const { data, info } = await sharp(artPath)
240
+ .raw()
241
+ .ensureAlpha()
242
+ .toBuffer({ resolveWithObject: true });
243
+
244
+ const chafa = this.chafa;
245
+
246
+ // Set up chafa with block characters
247
+ const symbolMap = chafa._chafa_symbol_map_new();
248
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_SPACE | CHAFA_SYMBOL_TAG_BLOCK);
249
+
250
+ const canvasConfig = chafa._chafa_canvas_config_new();
251
+ chafa._chafa_canvas_config_set_geometry(canvasConfig, 36, 6); // 3 rows with half-blocks
252
+ chafa._chafa_canvas_config_set_canvas_mode(canvasConfig, CHAFA_CANVAS_MODE_TRUECOLOR);
253
+ chafa._chafa_canvas_config_set_symbol_map(canvasConfig, symbolMap);
254
+
255
+ const canvas = chafa._chafa_canvas_new(canvasConfig);
256
+
257
+ // Copy RGBA data to heap
258
+ const dataPtr = chafa._malloc(data.length);
259
+ chafa.HEAPU8.set(data, dataPtr);
260
+
261
+ // Set canvas contents
262
+ chafa._chafa_canvas_set_contents_rgba8(canvas, dataPtr, info.width, info.height, info.width * 4);
263
+ chafa._free(dataPtr);
264
+
265
+ // Get ANSI output
266
+ const gsPtr = chafa._chafa_canvas_build_ansi(canvas);
267
+ const strPtr = chafa._g_string_free_and_steal(gsPtr);
268
+ const ansi = chafa.UTF8ToString(strPtr);
269
+ chafa._free(strPtr);
270
+
271
+ // Cleanup
272
+ chafa._chafa_canvas_unref(canvas);
273
+ chafa._chafa_canvas_config_unref(canvasConfig);
274
+ chafa._chafa_symbol_map_unref(symbolMap);
275
+
276
+ // Trim any trailing newlines to prevent clipping
277
+ this.systemArtBox.setContent(ansi.trimEnd());
278
+ this.screen.render();
279
+ } catch (err) {
280
+ this.systemArtBox.setContent('');
281
+ }
282
+ }
283
+
284
+ _showRecentRoms() {
285
+ this.currentView = 'recent';
286
+ const recent = this.prefs.get('recentGames');
287
+ const recentRoms = recent
288
+ .map(path => this.roms.find(r => r.path === path))
289
+ .filter(r => r);
290
+
291
+ this.sortedRoms = recentRoms;
292
+
293
+ if (recentRoms.length === 0) {
294
+ this.romList.setItems(['{gray-fg}No recent games{/}']);
295
+ this.sortedRoms = [];
296
+ } else {
297
+ const items = recentRoms.map(rom => `{cyan-fg}[${rom.system}]{/} ${rom.name}`);
298
+ this.romList.setItems(items);
299
+ }
300
+
301
+ // Update header for recent view
302
+ this.systemTitle.setContent(
303
+ `{bold}{magenta-fg}Recent Games{/}\n` +
304
+ `{white-fg}${recentRoms.length} games{/}`
305
+ );
306
+ this.systemArtBox.setContent('');
307
+
308
+ this.romList.select(0);
309
+ this._updateInfoPanel(0);
310
+ this.screen.render();
311
+ }
312
+
313
+ _updateInfoPanel(index) {
314
+ const rom = this._getSelectedRom(index);
315
+ if (!rom) {
316
+ this.infoPanel.setContent('{gray-fg}Select a game{/}');
317
+ this.screen.render();
318
+ return;
319
+ }
320
+
321
+ const lines = [
322
+ `{bold}{white-fg}${rom.name}{/}`,
323
+ '',
324
+ `{cyan-fg}System:{/} ${rom.system}`,
325
+ `{cyan-fg}File:{/} ${rom.ext}`,
326
+ ];
327
+
328
+ if (rom.zipEntry) {
329
+ lines.push(`{cyan-fg}In ZIP:{/} ${rom.zipEntry}`);
330
+ }
331
+
332
+ lines.push('', `{gray-fg}${rom.path}{/}`);
333
+
334
+ this.infoPanel.setContent(lines.join('\n'));
335
+ this.screen.render();
336
+ }
337
+
338
+ _getSelectedRom(index) {
339
+ return this.sortedRoms ? this.sortedRoms[index] : null;
340
+ }
341
+
342
+ _setupKeys() {
343
+ // Quit
344
+ this.screen.key(['q', 'C-c'], () => {
345
+ process.exit(0);
346
+ });
347
+
348
+ // Launch game
349
+ this.romList.key(['enter'], () => {
350
+ const index = this.romList.selected;
351
+ const rom = this._getSelectedRom(index);
352
+ if (rom) {
353
+ this._launchGame(rom);
354
+ }
355
+ });
356
+
357
+ // View toggles
358
+ this.screen.key(['a'], () => {
359
+ this._showCurrentSystem();
360
+ });
361
+
362
+ // Left/Right arrow keys for system navigation
363
+ this.screen.key(['left'], () => {
364
+ this._navigatePrevSystem();
365
+ });
366
+ this.screen.key(['right'], () => {
367
+ this._navigateNextSystem();
368
+ });
369
+
370
+ this.screen.key(['r'], () => {
371
+ this._showRecentRoms();
372
+ });
373
+
374
+ // Settings
375
+ this.screen.key(['s'], () => {
376
+ this._showSettings();
377
+ });
378
+
379
+ // Refresh
380
+ this.screen.key(['f5'], () => {
381
+ this._loadRoms();
382
+ });
383
+ }
384
+
385
+ _launchGame(rom) {
386
+ this.prefs.addRecentGame(rom.path);
387
+
388
+ // Stop controller polling and clean up
389
+ this._stopControllerPolling();
390
+ if (this.gamepadManager && this.gamepadManager.destroy) {
391
+ try {
392
+ this.gamepadManager.destroy();
393
+ } catch {
394
+ // Ignore cleanup errors
395
+ }
396
+ }
397
+
398
+ // Hide the screen and launch retroemu
399
+ this.screen.destroy();
400
+
401
+ const args = [rom.path];
402
+ const renderMode = this.prefs.get('renderMode');
403
+ if (renderMode === 'ascii') {
404
+ args.push('--ascii');
405
+ } else if (renderMode === 'braille') {
406
+ args.push('--braille');
407
+ } else if (renderMode === 'braille-dither') {
408
+ args.push('--braille-dither');
409
+ }
410
+
411
+ // Convert 1-10 slider to contrast value (1=0.5, 5=1.0, 10=2.0)
412
+ const contrastSlider = this.prefs.get('contrast') || 5;
413
+ const contrastValue = 0.5 + (contrastSlider - 1) * (1.5 / 9);
414
+ if (contrastValue !== 1.0) {
415
+ args.push('--contrast', contrastValue.toFixed(2));
416
+ }
417
+
418
+ const retroemuBin = join(__dirname, '..', 'node_modules', '.bin', 'retroemu');
419
+ const child = spawn(retroemuBin, args, {
420
+ stdio: 'inherit',
421
+ });
422
+
423
+ child.on('close', () => {
424
+ // Give SDL time to clean up before reinitializing
425
+ setTimeout(() => this.start(), 500);
426
+ });
427
+ }
428
+
429
+ _showSettings() {
430
+ this._settingsOpen = true;
431
+ const currentRenderMode = this.prefs.get('renderMode') || 'block';
432
+
433
+ const form = blessed.form({
434
+ parent: this.screen,
435
+ top: 'center',
436
+ left: 'center',
437
+ width: '60%',
438
+ height: 20,
439
+ border: { type: 'line' },
440
+ style: { border: { fg: 'yellow' } },
441
+ keys: true,
442
+ });
443
+
444
+ blessed.text({
445
+ parent: form,
446
+ top: 0,
447
+ left: 2,
448
+ content: '{bold}Settings{/}',
449
+ tags: true,
450
+ });
451
+
452
+ blessed.text({
453
+ parent: form,
454
+ top: 2,
455
+ left: 2,
456
+ content: 'ROMs Directory:',
457
+ });
458
+
459
+ const romsInput = blessed.textbox({
460
+ parent: form,
461
+ top: 3,
462
+ left: 2,
463
+ right: 2,
464
+ height: 3,
465
+ border: { type: 'line' },
466
+ style: {
467
+ border: { fg: 'blue' },
468
+ focus: { border: { fg: 'green' } },
469
+ },
470
+ inputOnFocus: true,
471
+ value: this.prefs.get('romsDir'),
472
+ });
473
+
474
+ blessed.text({
475
+ parent: form,
476
+ top: 7,
477
+ left: 2,
478
+ content: 'Render Mode:',
479
+ });
480
+
481
+ const RENDER_MODES = ['block', 'ascii', 'braille', 'braille-dither'];
482
+ const RENDER_MODE_LABELS = {
483
+ 'block': 'Block characters',
484
+ 'ascii': 'ASCII characters',
485
+ 'braille': 'Braille (B&W)',
486
+ 'braille-dither': 'Braille dithered',
487
+ };
488
+
489
+ const renderModeBox = blessed.box({
490
+ parent: form,
491
+ top: 8,
492
+ left: 2,
493
+ width: 30,
494
+ height: 3,
495
+ border: { type: 'line' },
496
+ style: {
497
+ border: { fg: 'blue' },
498
+ focus: { border: { fg: 'green' } },
499
+ },
500
+ tags: true,
501
+ content: ` ${RENDER_MODE_LABELS[currentRenderMode] || RENDER_MODE_LABELS['block']}`,
502
+ });
503
+
504
+ let selectedRenderMode = currentRenderMode;
505
+
506
+ const toggleRenderMode = () => {
507
+ const idx = RENDER_MODES.indexOf(selectedRenderMode);
508
+ selectedRenderMode = RENDER_MODES[(idx + 1) % RENDER_MODES.length];
509
+ renderModeBox.setContent(` ${RENDER_MODE_LABELS[selectedRenderMode]}`);
510
+ this.screen.render();
511
+ };
512
+
513
+ renderModeBox.on('click', toggleRenderMode);
514
+
515
+ // Contrast slider
516
+ blessed.text({
517
+ parent: form,
518
+ top: 11,
519
+ left: 2,
520
+ content: 'Contrast:',
521
+ });
522
+
523
+ const currentContrast = this.prefs.get('contrast') || 5;
524
+ let selectedContrast = currentContrast;
525
+
526
+ const renderSlider = (value) => {
527
+ const filled = '█'.repeat(value);
528
+ const empty = '░'.repeat(10 - value);
529
+ return ` ${filled}${empty} ${value}`;
530
+ };
531
+
532
+ const contrastBox = blessed.box({
533
+ parent: form,
534
+ top: 12,
535
+ left: 2,
536
+ width: 20,
537
+ height: 3,
538
+ border: { type: 'line' },
539
+ style: {
540
+ border: { fg: 'blue' },
541
+ focus: { border: { fg: 'green' } },
542
+ },
543
+ tags: true,
544
+ content: renderSlider(selectedContrast),
545
+ });
546
+
547
+ const adjustContrast = (delta) => {
548
+ selectedContrast = Math.max(1, Math.min(10, selectedContrast + delta));
549
+ contrastBox.setContent(renderSlider(selectedContrast));
550
+ this.screen.render();
551
+ };
552
+
553
+ blessed.text({
554
+ parent: form,
555
+ top: 15,
556
+ left: 2,
557
+ content: '{white-fg}↑↓{/} field {white-fg}←→{/} adjust {white-fg}X{/} toggle {white-fg}A{/} save {white-fg}B{/} cancel',
558
+ tags: true,
559
+ });
560
+
561
+ const FIELDS = ['roms', 'render', 'contrast'];
562
+ let focusedField = 'roms';
563
+
564
+ const updateFieldStyles = () => {
565
+ romsInput.style.border.fg = focusedField === 'roms' ? 'green' : 'blue';
566
+ renderModeBox.style.border.fg = focusedField === 'render' ? 'green' : 'blue';
567
+ contrastBox.style.border.fg = focusedField === 'contrast' ? 'green' : 'blue';
568
+ if (focusedField === 'roms') romsInput.focus();
569
+ this.screen.render();
570
+ };
571
+
572
+ const focusField = (field) => {
573
+ focusedField = field;
574
+ updateFieldStyles();
575
+ };
576
+
577
+ const focusNextField = () => {
578
+ const idx = FIELDS.indexOf(focusedField);
579
+ focusField(FIELDS[(idx + 1) % FIELDS.length]);
580
+ };
581
+
582
+ const focusPrevField = () => {
583
+ const idx = FIELDS.indexOf(focusedField);
584
+ focusField(FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length]);
585
+ };
586
+
587
+ focusField('roms');
588
+
589
+ const closeForm = () => {
590
+ this._settingsOpen = false;
591
+ this._settingsState = null;
592
+ form.destroy();
593
+ this.romList.focus();
594
+ this.screen.render();
595
+ };
596
+
597
+ const saveAndClose = () => {
598
+ const newPath = romsInput.getValue().trim();
599
+ if (newPath) {
600
+ this.prefs.set('romsDir', newPath);
601
+ }
602
+ this.prefs.set('renderMode', selectedRenderMode);
603
+ this.prefs.set('contrast', selectedContrast);
604
+ this._settingsOpen = false;
605
+ this._settingsState = null;
606
+ form.destroy();
607
+ this._loadRoms();
608
+ this.romList.focus();
609
+ };
610
+
611
+ // Store state for gamepad handling
612
+ this._settingsState = {
613
+ focusNextField,
614
+ focusPrevField,
615
+ toggleRenderMode,
616
+ adjustContrast,
617
+ closeForm,
618
+ saveAndClose,
619
+ getFocusedField: () => focusedField,
620
+ };
621
+
622
+ romsInput.key(['escape'], closeForm);
623
+ romsInput.key(['tab'], focusNextField);
624
+ romsInput.key(['enter'], saveAndClose);
625
+
626
+ form.key(['escape'], closeForm);
627
+ form.key(['tab'], focusNextField);
628
+ form.key(['space'], () => {
629
+ if (focusedField === 'render') toggleRenderMode();
630
+ });
631
+ form.key(['left'], () => {
632
+ if (focusedField === 'contrast') adjustContrast(-1);
633
+ });
634
+ form.key(['right'], () => {
635
+ if (focusedField === 'contrast') adjustContrast(1);
636
+ });
637
+ form.key(['enter'], saveAndClose);
638
+
639
+ this.screen.render();
640
+ }
641
+
642
+ _navigateUp() {
643
+ const index = this.romList.selected - 1;
644
+ if (index >= 0) {
645
+ this.romList.select(index);
646
+ this._updateInfoPanel(index);
647
+ this.screen.render();
648
+ }
649
+ }
650
+
651
+ _navigateDown() {
652
+ const index = this.romList.selected + 1;
653
+ const max = this.sortedRoms ? this.sortedRoms.length : 0;
654
+ if (index < max) {
655
+ this.romList.select(index);
656
+ this._updateInfoPanel(index);
657
+ this.screen.render();
658
+ }
659
+ }
660
+
661
+ _navigatePrevSystem() {
662
+ if (this.currentView !== 'system' || this.systems.length === 0) return;
663
+
664
+ this.currentSystemIndex--;
665
+ if (this.currentSystemIndex < 0) {
666
+ this.currentSystemIndex = this.systems.length - 1;
667
+ }
668
+ this._showCurrentSystem();
669
+ }
670
+
671
+ _navigateNextSystem() {
672
+ if (this.currentView !== 'system' || this.systems.length === 0) return;
673
+
674
+ this.currentSystemIndex++;
675
+ if (this.currentSystemIndex >= this.systems.length) {
676
+ this.currentSystemIndex = 0;
677
+ }
678
+ this._showCurrentSystem();
679
+ }
680
+
681
+ _startControllerPolling() {
682
+ // Track previous button states for edge detection
683
+ this._prevButtons = {};
684
+ // Track held time for repeat
685
+ this._holdTime = {};
686
+ // Wait for all buttons to be released before accepting input (prevents re-launch on resume)
687
+ this._waitingForRelease = true;
688
+
689
+ this.gamepadPollInterval = setInterval(() => {
690
+ const gamepads = navigator.getGamepads().filter(gp => gp !== null);
691
+ this._updateControllerStatus(gamepads);
692
+
693
+ // Check if we're waiting for buttons to be released
694
+ if (this._waitingForRelease && gamepads.length > 0) {
695
+ const gp = gamepads[0];
696
+ const anyPressed = gp.buttons.some(b => b?.pressed);
697
+ if (!anyPressed) {
698
+ this._waitingForRelease = false;
699
+ }
700
+ return; // Skip input handling until released
701
+ }
702
+
703
+ this._handleGamepadInput(gamepads);
704
+ }, 16); // Poll at ~60Hz for responsive input
705
+ }
706
+
707
+ _stopControllerPolling() {
708
+ if (this.gamepadPollInterval) {
709
+ clearInterval(this.gamepadPollInterval);
710
+ this.gamepadPollInterval = null;
711
+ }
712
+ }
713
+
714
+ _updateControllerStatus(gamepads) {
715
+ if (gamepads.length === 0) {
716
+ this.controllerBox.setContent('{gray-fg}No controllers{/}\n{gray-fg}Keyboard: arrows + Enter{/}');
717
+ } else {
718
+ const lines = gamepads.slice(0, 2).map((gp, i) => {
719
+ const name = gp.id.length > 24 ? gp.id.substring(0, 24) + '...' : gp.id;
720
+ return `{green-fg}P${i + 1}:{/} ${name}`;
721
+ });
722
+ if (gamepads.length > 2) {
723
+ lines.push(`{gray-fg}+${gamepads.length - 2} more{/}`);
724
+ }
725
+ this.controllerBox.setContent(lines.join('\n'));
726
+ }
727
+ this.screen.render();
728
+ }
729
+
730
+ _handleGamepadInput(gamepads) {
731
+ if (gamepads.length === 0) return;
732
+
733
+ const now = Date.now();
734
+ const gp = gamepads[0]; // Use first controller for navigation
735
+ const buttons = gp.buttons;
736
+ const prev = this._prevButtons;
737
+ const hold = this._holdTime;
738
+
739
+ // Helper: detect button press (edge detection only)
740
+ const pressed = (idx) => {
741
+ const isPressed = buttons[idx]?.pressed;
742
+ const wasPressed = prev[idx];
743
+ prev[idx] = isPressed;
744
+ return isPressed && !wasPressed;
745
+ };
746
+
747
+ // Handle settings dialog input separately
748
+ if (this._settingsOpen && this._settingsState) {
749
+ const s = this._settingsState;
750
+
751
+ // B button = close/cancel
752
+ if (pressed(1)) {
753
+ s.closeForm();
754
+ return;
755
+ }
756
+
757
+ // A button or Start = save
758
+ if (pressed(0) || pressed(9)) {
759
+ s.saveAndClose();
760
+ return;
761
+ }
762
+
763
+ // D-pad up/down = switch fields
764
+ if (pressed(12)) {
765
+ s.focusPrevField();
766
+ return;
767
+ }
768
+ if (pressed(13)) {
769
+ s.focusNextField();
770
+ return;
771
+ }
772
+
773
+ // D-pad left/right = adjust contrast (when on contrast field)
774
+ if (s.getFocusedField() === 'contrast') {
775
+ if (pressed(14)) {
776
+ s.adjustContrast(-1);
777
+ return;
778
+ }
779
+ if (pressed(15)) {
780
+ s.adjustContrast(1);
781
+ return;
782
+ }
783
+ }
784
+
785
+ // X button = toggle render mode
786
+ if (pressed(2)) {
787
+ s.toggleRenderMode();
788
+ return;
789
+ }
790
+
791
+ return; // Don't process normal navigation while settings open
792
+ }
793
+
794
+ // Helper: detect held with repeat (initial delay 300ms, then repeat every 80ms)
795
+ const heldWithRepeat = (key, isActive) => {
796
+ if (isActive) {
797
+ if (!hold[key]) {
798
+ hold[key] = now;
799
+ return true; // First press
800
+ }
801
+ const elapsed = now - hold[key];
802
+ if (elapsed > 300) {
803
+ // Repeat phase - check if enough time for next repeat
804
+ const repeatElapsed = (elapsed - 300) % 80;
805
+ const prevRepeatElapsed = (elapsed - 300 - 16) % 80; // ~16ms ago
806
+ if (repeatElapsed < prevRepeatElapsed || elapsed - 300 < 16) {
807
+ return true;
808
+ }
809
+ }
810
+ return false;
811
+ } else {
812
+ delete hold[key];
813
+ return false;
814
+ }
815
+ };
816
+
817
+ // D-pad navigation (buttons 12-15) or left stick
818
+ const leftStickY = gp.axes[1] || 0;
819
+ const leftStickX = gp.axes[0] || 0;
820
+
821
+ const upActive = buttons[12]?.pressed || leftStickY < -0.5;
822
+ const downActive = buttons[13]?.pressed || leftStickY > 0.5;
823
+ const leftActive = buttons[14]?.pressed || leftStickX < -0.5;
824
+ const rightActive = buttons[15]?.pressed || leftStickX > 0.5;
825
+
826
+ if (heldWithRepeat('up', upActive)) {
827
+ this._navigateUp();
828
+ }
829
+ if (heldWithRepeat('down', downActive)) {
830
+ this._navigateDown();
831
+ }
832
+ if (heldWithRepeat('left', leftActive)) {
833
+ this._navigatePrevSystem();
834
+ }
835
+ if (heldWithRepeat('right', rightActive)) {
836
+ this._navigateNextSystem();
837
+ }
838
+
839
+ // Shoulder bumpers (buttons 4 and 5) = fast scroll with repeat
840
+ if (heldWithRepeat('lb', buttons[4]?.pressed)) {
841
+ for (let i = 0; i < 10; i++) this._navigateUp();
842
+ }
843
+ if (heldWithRepeat('rb', buttons[5]?.pressed)) {
844
+ for (let i = 0; i < 10; i++) this._navigateDown();
845
+ }
846
+
847
+ // A button (south/button 0) = Enter/Select
848
+ if (pressed(0)) {
849
+ const index = this.romList.selected;
850
+ const rom = this._getSelectedRom(index);
851
+ if (rom) {
852
+ this._launchGame(rom);
853
+ }
854
+ }
855
+
856
+ // B button (east/button 1) = Back to system view
857
+ if (pressed(1)) {
858
+ this._showCurrentSystem();
859
+ }
860
+
861
+ // X button (west/button 2) = Recent
862
+ if (pressed(2)) {
863
+ this._showRecentRoms();
864
+ }
865
+
866
+ // Y button (north/button 3) = Settings
867
+ if (pressed(3)) {
868
+ this._showSettings();
869
+ }
870
+
871
+ // Start button (button 9) = Launch
872
+ if (pressed(9)) {
873
+ const index = this.romList.selected;
874
+ const rom = this._getSelectedRom(index);
875
+ if (rom) {
876
+ this._launchGame(rom);
877
+ }
878
+ }
879
+ }
880
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.config', 'retroterm');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ const DEFAULTS = {
9
+ romsDir: join(homedir(), 'roms'),
10
+ savesDir: join(homedir(), '.config', 'retroterm', 'saves'),
11
+ recentGames: [],
12
+ maxRecent: 10,
13
+ renderMode: 'block', // 'block', 'ascii', 'braille', 'braille-dither'
14
+ contrast: 5, // 1-10, where 5 = 1.0 (normal)
15
+ };
16
+
17
+ export class Preferences {
18
+ constructor() {
19
+ this.config = { ...DEFAULTS };
20
+ this.load();
21
+ }
22
+
23
+ load() {
24
+ try {
25
+ const data = readFileSync(CONFIG_FILE, 'utf-8');
26
+ this.config = { ...DEFAULTS, ...JSON.parse(data) };
27
+ } catch {
28
+ // No config file yet, use defaults
29
+ }
30
+ }
31
+
32
+ save() {
33
+ try {
34
+ mkdirSync(CONFIG_DIR, { recursive: true });
35
+ writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2));
36
+ } catch (err) {
37
+ // Ignore save errors
38
+ }
39
+ }
40
+
41
+ get(key) {
42
+ return this.config[key];
43
+ }
44
+
45
+ set(key, value) {
46
+ this.config[key] = value;
47
+ this.save();
48
+ }
49
+
50
+ addRecentGame(romPath) {
51
+ const recent = this.config.recentGames.filter(p => p !== romPath);
52
+ recent.unshift(romPath);
53
+ this.config.recentGames = recent.slice(0, this.config.maxRecent);
54
+ this.save();
55
+ }
56
+ }
@@ -0,0 +1,149 @@
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { join, extname, basename } from 'path';
3
+ import { open } from 'yauzl';
4
+
5
+ // Supported ROM extensions (from retroemu)
6
+ const ROM_EXTENSIONS = new Set([
7
+ '.nes', '.fds', '.unf', '.unif', // NES
8
+ '.sfc', '.smc', // SNES
9
+ '.gb', '.gbc', '.gba', // Game Boy
10
+ '.md', '.gen', '.smd', '.bin', // Genesis
11
+ '.sms', '.gg', '.sg', // Master System / Game Gear
12
+ '.a26', '.a52', '.a78', // Atari consoles
13
+ '.xex', '.atr', '.atx', '.bas', '.car', '.xfd', // Atari computers
14
+ '.lnx', '.o', // Lynx
15
+ '.pce', '.cue', '.ccd', '.chd', // PC Engine
16
+ '.ngp', '.ngc', // Neo Geo Pocket
17
+ '.ws', '.wsc', // WonderSwan
18
+ '.col', // ColecoVision
19
+ '.vec', // Vectrex
20
+ '.tzx', '.z80', '.sna', // ZX Spectrum
21
+ '.mx1', '.mx2', '.rom', '.dsk', '.cas', // MSX
22
+ '.iso', '.pbp', '.m3u', // PlayStation
23
+ ]);
24
+
25
+ const SYSTEM_NAMES = {
26
+ '.nes': 'NES', '.fds': 'NES', '.unf': 'NES', '.unif': 'NES',
27
+ '.sfc': 'SNES', '.smc': 'SNES',
28
+ '.gb': 'Game Boy', '.gbc': 'Game Boy Color', '.gba': 'Game Boy Advance',
29
+ '.md': 'Genesis', '.gen': 'Genesis', '.smd': 'Genesis', '.bin': 'Genesis',
30
+ '.sms': 'Master System', '.gg': 'Game Gear', '.sg': 'SG-1000',
31
+ '.a26': 'Atari 2600', '.a52': 'Atari 5200', '.a78': 'Atari 7800',
32
+ '.xex': 'Atari 800', '.atr': 'Atari 800', '.atx': 'Atari 800',
33
+ '.bas': 'Atari 800', '.car': 'Atari 800', '.xfd': 'Atari 800',
34
+ '.lnx': 'Lynx', '.o': 'Lynx',
35
+ '.pce': 'PC Engine', '.cue': 'PC Engine', '.ccd': 'PC Engine', '.chd': 'PC Engine',
36
+ '.ngp': 'Neo Geo Pocket', '.ngc': 'Neo Geo Pocket Color',
37
+ '.ws': 'WonderSwan', '.wsc': 'WonderSwan Color',
38
+ '.col': 'ColecoVision',
39
+ '.vec': 'Vectrex',
40
+ '.tzx': 'ZX Spectrum', '.z80': 'ZX Spectrum', '.sna': 'ZX Spectrum',
41
+ '.mx1': 'MSX', '.mx2': 'MSX', '.rom': 'MSX', '.dsk': 'MSX', '.cas': 'MSX',
42
+ '.iso': 'PlayStation', '.pbp': 'PlayStation', '.m3u': 'PlayStation',
43
+ '.zip': 'Archive',
44
+ };
45
+
46
+ export class RomScanner {
47
+ constructor(romsDir) {
48
+ this.romsDir = romsDir;
49
+ }
50
+
51
+ async scan() {
52
+ const roms = [];
53
+ await this._scanDir(this.romsDir, roms);
54
+ return roms.sort((a, b) => a.name.localeCompare(b.name));
55
+ }
56
+
57
+ async _scanDir(dir, roms, depth = 0) {
58
+ if (depth > 3) return; // Don't recurse too deep
59
+
60
+ try {
61
+ const entries = readdirSync(dir);
62
+ for (const entry of entries) {
63
+ if (entry.startsWith('.')) continue;
64
+
65
+ const fullPath = join(dir, entry);
66
+ try {
67
+ const stat = statSync(fullPath);
68
+ if (stat.isDirectory()) {
69
+ await this._scanDir(fullPath, roms, depth + 1);
70
+ } else if (stat.isFile()) {
71
+ const ext = extname(entry).toLowerCase();
72
+ if (ext === '.zip') {
73
+ // Peek inside ZIP to find ROMs
74
+ const zipRoms = await this._scanZip(fullPath);
75
+ roms.push(...zipRoms);
76
+ } else if (ROM_EXTENSIONS.has(ext)) {
77
+ roms.push({
78
+ name: basename(entry, ext),
79
+ path: fullPath,
80
+ ext,
81
+ system: SYSTEM_NAMES[ext] || 'Unknown',
82
+ });
83
+ }
84
+ }
85
+ } catch {
86
+ // Skip inaccessible files
87
+ }
88
+ }
89
+ } catch {
90
+ // Skip inaccessible directories
91
+ }
92
+ }
93
+
94
+ async _scanZip(zipPath) {
95
+ const roms = [];
96
+
97
+ try {
98
+ const zipfile = await new Promise((resolve, reject) => {
99
+ open(zipPath, { lazyEntries: true }, (err, zf) => {
100
+ if (err) reject(err);
101
+ else resolve(zf);
102
+ });
103
+ });
104
+
105
+ const zipName = basename(zipPath, extname(zipPath));
106
+
107
+ await new Promise((resolve) => {
108
+ zipfile.on('entry', (entry) => {
109
+ const ext = extname(entry.fileName).toLowerCase();
110
+ if (ROM_EXTENSIONS.has(ext) && !entry.fileName.startsWith('__MACOSX')) {
111
+ let romName = basename(entry.fileName, ext);
112
+ // If the inner file has a generic/numeric name, use the zip filename instead
113
+ if (/^\d+$/.test(romName) || romName.length < 3) {
114
+ romName = zipName;
115
+ }
116
+ roms.push({
117
+ name: romName,
118
+ path: zipPath,
119
+ zipEntry: entry.fileName,
120
+ ext,
121
+ system: SYSTEM_NAMES[ext] || 'Unknown',
122
+ });
123
+ }
124
+ zipfile.readEntry();
125
+ });
126
+
127
+ zipfile.on('end', resolve);
128
+ zipfile.readEntry();
129
+ });
130
+
131
+ zipfile.close();
132
+ } catch {
133
+ // Skip unreadable ZIPs
134
+ }
135
+
136
+ return roms;
137
+ }
138
+
139
+ getSystemGroups(roms) {
140
+ const groups = {};
141
+ for (const rom of roms) {
142
+ if (!groups[rom.system]) {
143
+ groups[rom.system] = [];
144
+ }
145
+ groups[rom.system].push(rom);
146
+ }
147
+ return groups;
148
+ }
149
+ }