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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { RETRO_MEMORY_SAVE_RAM } from '../constants/libretro.js';
|
|
4
|
+
|
|
5
|
+
export class SaveManager {
|
|
6
|
+
constructor(saveDir) {
|
|
7
|
+
this.saveDir = saveDir;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async saveSRAM(core, romPath, silent = false) {
|
|
11
|
+
const dataPtr = core._retro_get_memory_data(RETRO_MEMORY_SAVE_RAM);
|
|
12
|
+
const size = core._retro_get_memory_size(RETRO_MEMORY_SAVE_RAM);
|
|
13
|
+
if (!dataPtr || !size) {
|
|
14
|
+
if (!silent) process.stderr.write(`SRAM save skipped: ptr=${dataPtr}, size=${size}\n`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Debug: write to file since terminal alternate buffer hides output
|
|
19
|
+
const debugLog = (msg) => fs.appendFile('/tmp/sram-debug.log', msg + '\n').catch(() => {});
|
|
20
|
+
if (!silent) {
|
|
21
|
+
await debugLog(`SRAM debug at save time: ptr=${dataPtr}, size=${size}`);
|
|
22
|
+
// Check entire buffer for any non-zero data
|
|
23
|
+
let nonZeroRanges = [];
|
|
24
|
+
let inNonZero = false;
|
|
25
|
+
let rangeStart = 0;
|
|
26
|
+
for (let i = 0; i < size; i++) {
|
|
27
|
+
const val = core.HEAPU8[dataPtr + i];
|
|
28
|
+
if (val !== 0 && !inNonZero) {
|
|
29
|
+
inNonZero = true;
|
|
30
|
+
rangeStart = i;
|
|
31
|
+
} else if (val === 0 && inNonZero) {
|
|
32
|
+
inNonZero = false;
|
|
33
|
+
nonZeroRanges.push(`0x${rangeStart.toString(16)}-0x${(i-1).toString(16)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (inNonZero) nonZeroRanges.push(`0x${rangeStart.toString(16)}-0x${(size-1).toString(16)}`);
|
|
37
|
+
await debugLog(`Non-zero ranges in SRAM: ${nonZeroRanges.length ? nonZeroRanges.join(', ') : 'NONE - all zeros'}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Copy the data (not just a view) since WASM memory may change during async write
|
|
41
|
+
const data = Buffer.from(core.HEAPU8.slice(dataPtr, dataPtr + size));
|
|
42
|
+
const savePath = this._sramPath(romPath);
|
|
43
|
+
await fs.mkdir(path.dirname(savePath), { recursive: true });
|
|
44
|
+
await fs.writeFile(savePath, data);
|
|
45
|
+
|
|
46
|
+
// Debug: show first 32 bytes of saved data
|
|
47
|
+
const preview = Array.from(data.slice(0, 32)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
|
48
|
+
if (!silent) await debugLog(`SRAM saved: ${savePath} (${size} bytes)\nSaved data: ${preview}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async loadSRAM(core, romPath) {
|
|
52
|
+
const savePath = this._sramPath(romPath);
|
|
53
|
+
const dataPtr = core._retro_get_memory_data(RETRO_MEMORY_SAVE_RAM);
|
|
54
|
+
const size = core._retro_get_memory_size(RETRO_MEMORY_SAVE_RAM);
|
|
55
|
+
|
|
56
|
+
if (!dataPtr || !size) return;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const data = await fs.readFile(savePath);
|
|
60
|
+
if (data.length === size) {
|
|
61
|
+
core.HEAPU8.set(data, dataPtr);
|
|
62
|
+
process.stderr.write(`SRAM loaded: ${savePath} (${size} bytes)\n`);
|
|
63
|
+
} else {
|
|
64
|
+
process.stderr.write(`SRAM size mismatch: file=${data.length}, expected=${size}\n`);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// No save file - core already initialized SRAM during retro_load_game()
|
|
68
|
+
process.stderr.write(`SRAM: no existing save, using core defaults\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async saveState(core, romPath, slot = 0) {
|
|
73
|
+
const size = core._retro_serialize_size();
|
|
74
|
+
if (!size) return;
|
|
75
|
+
|
|
76
|
+
const ptr = core._malloc(size);
|
|
77
|
+
try {
|
|
78
|
+
const ok = core._retro_serialize(ptr, size);
|
|
79
|
+
if (ok) {
|
|
80
|
+
const data = Buffer.from(core.HEAPU8.buffer, ptr, size);
|
|
81
|
+
const statePath = this._statePath(romPath, slot);
|
|
82
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
83
|
+
await fs.writeFile(statePath, data);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
core._free(ptr);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async loadState(core, romPath, slot = 0) {
|
|
91
|
+
const statePath = this._statePath(romPath, slot);
|
|
92
|
+
try {
|
|
93
|
+
const data = await fs.readFile(statePath);
|
|
94
|
+
const ptr = core._malloc(data.length);
|
|
95
|
+
try {
|
|
96
|
+
core.HEAPU8.set(data, ptr);
|
|
97
|
+
core._retro_unserialize(ptr, data.length);
|
|
98
|
+
} finally {
|
|
99
|
+
core._free(ptr);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// No state file exists
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_sramPath(romPath) {
|
|
107
|
+
const name = path.basename(romPath, path.extname(romPath));
|
|
108
|
+
return path.join(this.saveDir, `${name}.srm`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_statePath(romPath, slot) {
|
|
112
|
+
const name = path.basename(romPath, path.extname(romPath));
|
|
113
|
+
return path.join(this.saveDir, `${name}.state${slot}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
const EXTENSION_MAP = {
|
|
4
|
+
// NES
|
|
5
|
+
'.nes': { system: 'nes', core: 'fceumm' },
|
|
6
|
+
'.fds': { system: 'nes', core: 'fceumm' },
|
|
7
|
+
'.unf': { system: 'nes', core: 'fceumm' },
|
|
8
|
+
'.unif': { system: 'nes', core: 'fceumm' },
|
|
9
|
+
// SNES
|
|
10
|
+
'.sfc': { system: 'snes', core: 'snes9x' },
|
|
11
|
+
'.smc': { system: 'snes', core: 'snes9x' },
|
|
12
|
+
// GBA
|
|
13
|
+
'.gba': { system: 'gba', core: 'mgba' },
|
|
14
|
+
// Game Boy
|
|
15
|
+
'.gb': { system: 'gb', core: 'gambatte' },
|
|
16
|
+
// Game Boy Color
|
|
17
|
+
'.gbc': { system: 'gbc', core: 'gambatte' },
|
|
18
|
+
// Genesis / Mega Drive
|
|
19
|
+
'.md': { system: 'genesis', core: 'genesis_plus_gx' },
|
|
20
|
+
'.gen': { system: 'genesis', core: 'genesis_plus_gx' },
|
|
21
|
+
'.smd': { system: 'genesis', core: 'genesis_plus_gx' },
|
|
22
|
+
'.bin': { system: 'genesis', core: 'genesis_plus_gx' },
|
|
23
|
+
// Sega Master System
|
|
24
|
+
'.sms': { system: 'sms', core: 'genesis_plus_gx' },
|
|
25
|
+
// Sega Game Gear
|
|
26
|
+
'.gg': { system: 'gg', core: 'genesis_plus_gx' },
|
|
27
|
+
// Sega SG-1000
|
|
28
|
+
'.sg': { system: 'sg1000', core: 'genesis_plus_gx' },
|
|
29
|
+
// Atari 2600
|
|
30
|
+
'.a26': { system: 'atari2600', core: 'stella2014' },
|
|
31
|
+
// Atari 5200
|
|
32
|
+
'.a52': { system: 'atari5200', core: 'atari800' },
|
|
33
|
+
// Atari 8-bit computers (400/800/XL/XE)
|
|
34
|
+
'.xex': { system: 'atari800', core: 'atari800' },
|
|
35
|
+
'.atr': { system: 'atari800', core: 'atari800' },
|
|
36
|
+
'.atx': { system: 'atari800', core: 'atari800' },
|
|
37
|
+
'.bas': { system: 'atari800', core: 'atari800' },
|
|
38
|
+
'.car': { system: 'atari800', core: 'atari800' },
|
|
39
|
+
'.xfd': { system: 'atari800', core: 'atari800' },
|
|
40
|
+
// Atari 7800
|
|
41
|
+
'.a78': { system: 'atari7800', core: 'prosystem' },
|
|
42
|
+
// Atari Lynx
|
|
43
|
+
'.lnx': { system: 'lynx', core: 'handy' },
|
|
44
|
+
'.o': { system: 'lynx', core: 'handy' },
|
|
45
|
+
// TurboGrafx-16 / PC Engine
|
|
46
|
+
'.pce': { system: 'pce', core: 'beetle_pce_fast' },
|
|
47
|
+
'.cue': { system: 'pce', core: 'beetle_pce_fast' },
|
|
48
|
+
'.ccd': { system: 'pce', core: 'beetle_pce_fast' },
|
|
49
|
+
'.chd': { system: 'pce', core: 'beetle_pce_fast' },
|
|
50
|
+
// Neo Geo Pocket / Color
|
|
51
|
+
'.ngp': { system: 'ngp', core: 'mednafen_ngp' },
|
|
52
|
+
'.ngc': { system: 'ngpc', core: 'mednafen_ngp' },
|
|
53
|
+
// WonderSwan / Color
|
|
54
|
+
'.ws': { system: 'wswan', core: 'mednafen_wswan' },
|
|
55
|
+
'.wsc': { system: 'wswanc', core: 'mednafen_wswan' },
|
|
56
|
+
// ColecoVision
|
|
57
|
+
'.col': { system: 'coleco', core: 'gearcoleco' },
|
|
58
|
+
// Vectrex
|
|
59
|
+
'.vec': { system: 'vectrex', core: 'vecx' },
|
|
60
|
+
// ZX Spectrum
|
|
61
|
+
'.tzx': { system: 'spectrum', core: 'fuse' },
|
|
62
|
+
'.z80': { system: 'spectrum', core: 'fuse' },
|
|
63
|
+
'.sna': { system: 'spectrum', core: 'fuse' },
|
|
64
|
+
// MSX / MSX2
|
|
65
|
+
'.mx1': { system: 'msx', core: 'fmsx' },
|
|
66
|
+
'.mx2': { system: 'msx', core: 'fmsx' },
|
|
67
|
+
'.rom': { system: 'msx', core: 'fmsx' },
|
|
68
|
+
'.dsk': { system: 'msx', core: 'fmsx' },
|
|
69
|
+
'.cas': { system: 'msx', core: 'fmsx' },
|
|
70
|
+
// PlayStation 1
|
|
71
|
+
'.iso': { system: 'psx', core: 'pcsx_rearmed' },
|
|
72
|
+
'.pbp': { system: 'psx', core: 'pcsx_rearmed' },
|
|
73
|
+
'.m3u': { system: 'psx', core: 'pcsx_rearmed' },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const SYSTEM_NAMES = {
|
|
77
|
+
nes: 'Nintendo Entertainment System',
|
|
78
|
+
snes: 'Super Nintendo',
|
|
79
|
+
gba: 'Game Boy Advance',
|
|
80
|
+
gb: 'Game Boy',
|
|
81
|
+
gbc: 'Game Boy Color',
|
|
82
|
+
genesis: 'Sega Genesis / Mega Drive',
|
|
83
|
+
sms: 'Sega Master System',
|
|
84
|
+
gg: 'Sega Game Gear',
|
|
85
|
+
sg1000: 'Sega SG-1000',
|
|
86
|
+
atari2600: 'Atari 2600',
|
|
87
|
+
atari5200: 'Atari 5200',
|
|
88
|
+
atari7800: 'Atari 7800',
|
|
89
|
+
atari800: 'Atari 800/XL/XE',
|
|
90
|
+
lynx: 'Atari Lynx',
|
|
91
|
+
pce: 'TurboGrafx-16 / PC Engine',
|
|
92
|
+
ngp: 'Neo Geo Pocket',
|
|
93
|
+
ngpc: 'Neo Geo Pocket Color',
|
|
94
|
+
wswan: 'WonderSwan',
|
|
95
|
+
wswanc: 'WonderSwan Color',
|
|
96
|
+
coleco: 'ColecoVision',
|
|
97
|
+
vectrex: 'Vectrex',
|
|
98
|
+
spectrum: 'ZX Spectrum',
|
|
99
|
+
msx: 'MSX / MSX2',
|
|
100
|
+
psx: 'PlayStation',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function detectSystem(romPath) {
|
|
104
|
+
const ext = path.extname(romPath).toLowerCase();
|
|
105
|
+
const entry = EXTENSION_MAP[ext];
|
|
106
|
+
if (!entry) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
...entry,
|
|
111
|
+
systemName: SYSTEM_NAMES[entry.system],
|
|
112
|
+
extension: ext,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getSupportedExtensions() {
|
|
117
|
+
return Object.keys(EXTENSION_MAP);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getSystemName(systemId) {
|
|
121
|
+
return SYSTEM_NAMES[systemId] || systemId;
|
|
122
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { installNavigatorShim } from 'gamepad-node';
|
|
2
|
+
import { appendFileSync } from 'fs';
|
|
3
|
+
import { LIBRETRO_TO_W3C, axisToLibretro } from './InputMap.js';
|
|
4
|
+
import {
|
|
5
|
+
RETRO_DEVICE_JOYPAD,
|
|
6
|
+
RETRO_DEVICE_ANALOG,
|
|
7
|
+
RETRO_DEVICE_INDEX_ANALOG_LEFT,
|
|
8
|
+
RETRO_DEVICE_INDEX_ANALOG_RIGHT,
|
|
9
|
+
RETRO_DEVICE_ID_ANALOG_X,
|
|
10
|
+
RETRO_DEVICE_ID_ANALOG_Y,
|
|
11
|
+
JOYPAD_MASK,
|
|
12
|
+
} from '../constants/libretro.js';
|
|
13
|
+
|
|
14
|
+
export class InputManager {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.disableGamepad = options.disableGamepad || false;
|
|
17
|
+
this.debugInput = options.debugInput || false;
|
|
18
|
+
this.manager = this.disableGamepad ? null : installNavigatorShim();
|
|
19
|
+
this.currentGamepads = [];
|
|
20
|
+
this._debugLoggedButtons = new Set(); // Avoid spam
|
|
21
|
+
this._exitComboHeld = 0; // Frames Start+Select held together
|
|
22
|
+
|
|
23
|
+
// Keyboard state for players without controllers
|
|
24
|
+
// Maps button id -> frame number when last pressed
|
|
25
|
+
this._keyLastPressed = new Map();
|
|
26
|
+
this._currentFrame = 0;
|
|
27
|
+
this._keyHoldFrames = 15; // Hold key for 15 frames (~250ms at 60fps)
|
|
28
|
+
this._setupKeyboard();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
poll() {
|
|
32
|
+
this._currentFrame++;
|
|
33
|
+
if (!this.disableGamepad) {
|
|
34
|
+
const gamepads = navigator.getGamepads().filter((gp) => gp !== null);
|
|
35
|
+
|
|
36
|
+
// Log gamepad info once
|
|
37
|
+
if (this.debugInput && gamepads.length > 0 && !this._loggedGamepadInfo) {
|
|
38
|
+
this._loggedGamepadInfo = true;
|
|
39
|
+
const gp = gamepads[0];
|
|
40
|
+
appendFileSync('/tmp/emu-input.log', `\n=== Gamepad: ${gp.id} ===\n`);
|
|
41
|
+
appendFileSync('/tmp/emu-input.log', `Buttons: ${gp.buttons.length}, Axes: ${gp.axes.length}\n`);
|
|
42
|
+
gp.buttons.forEach((btn, i) => {
|
|
43
|
+
if (btn.pressed || btn.value > 0.1) {
|
|
44
|
+
appendFileSync('/tmp/emu-input.log', ` btn[${i}] pressed=${btn.pressed} value=${btn.value}\n`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
gp.axes.forEach((val, i) => {
|
|
48
|
+
if (Math.abs(val) > 0.1) {
|
|
49
|
+
appendFileSync('/tmp/emu-input.log', ` axis[${i}] = ${val}\n`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.currentGamepads = gamepads;
|
|
55
|
+
|
|
56
|
+
// Check for Start+Select exit combo (buttons 8 and 9)
|
|
57
|
+
if (gamepads.length > 0) {
|
|
58
|
+
const gp = gamepads[0];
|
|
59
|
+
const startPressed = gp.buttons[9]?.pressed;
|
|
60
|
+
const selectPressed = gp.buttons[8]?.pressed;
|
|
61
|
+
if (startPressed && selectPressed) {
|
|
62
|
+
this._exitComboHeld++;
|
|
63
|
+
// Exit after ~0.5 seconds (30 frames at 60fps)
|
|
64
|
+
if (this._exitComboHeld >= 30) {
|
|
65
|
+
process.emit('SIGINT');
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
this._exitComboHeld = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getState(port, device, index, id) {
|
|
75
|
+
// Try gamepad first, fall back to keyboard for port 0
|
|
76
|
+
const gamepad = this.currentGamepads[port];
|
|
77
|
+
|
|
78
|
+
if (device === RETRO_DEVICE_JOYPAD) {
|
|
79
|
+
// Handle bitmask query (all buttons at once)
|
|
80
|
+
if (id === JOYPAD_MASK) {
|
|
81
|
+
let mask = 0;
|
|
82
|
+
for (let btnId = 0; btnId < 16; btnId++) {
|
|
83
|
+
if (this._getButtonState(gamepad, port, btnId)) {
|
|
84
|
+
mask |= (1 << btnId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return mask;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (id < 0 || id >= 16) return 0;
|
|
91
|
+
return this._getButtonState(gamepad, port, id) ? 1 : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (device === RETRO_DEVICE_ANALOG && gamepad) {
|
|
95
|
+
// Analog stick input
|
|
96
|
+
// index: LEFT=0, RIGHT=1
|
|
97
|
+
// id: X=0, Y=1
|
|
98
|
+
let axisIndex;
|
|
99
|
+
if (index === RETRO_DEVICE_INDEX_ANALOG_LEFT) {
|
|
100
|
+
axisIndex = id === RETRO_DEVICE_ID_ANALOG_X ? 0 : 1;
|
|
101
|
+
} else if (index === RETRO_DEVICE_INDEX_ANALOG_RIGHT) {
|
|
102
|
+
axisIndex = id === RETRO_DEVICE_ID_ANALOG_X ? 2 : 3;
|
|
103
|
+
} else {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (axisIndex < gamepad.axes.length) {
|
|
108
|
+
return axisToLibretro(gamepad.axes[axisIndex]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_getButtonState(gamepad, port, id) {
|
|
116
|
+
// Gamepad input
|
|
117
|
+
if (gamepad) {
|
|
118
|
+
const w3cIndex = LIBRETRO_TO_W3C[id];
|
|
119
|
+
if (w3cIndex >= 0 && w3cIndex < gamepad.buttons.length) {
|
|
120
|
+
const btn = gamepad.buttons[w3cIndex];
|
|
121
|
+
if (btn?.pressed) {
|
|
122
|
+
if (this.debugInput && !this._debugLoggedButtons.has(id)) {
|
|
123
|
+
appendFileSync('/tmp/emu-input.log', `Button: libretro=${id} w3c=${w3cIndex} value=${btn.value}\n`);
|
|
124
|
+
this._debugLoggedButtons.add(id);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
} else {
|
|
128
|
+
this._debugLoggedButtons.delete(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Keyboard fallback for port 0
|
|
134
|
+
if (port === 0) {
|
|
135
|
+
const lastPressed = this._keyLastPressed.get(id);
|
|
136
|
+
if (lastPressed !== undefined && (this._currentFrame - lastPressed) < this._keyHoldFrames) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_setupKeyboard() {
|
|
145
|
+
// Default keyboard mapping for port 0 (arrow keys + Z/X/A/S + Enter/Shift)
|
|
146
|
+
// Maps keyboard key names to libretro joypad IDs
|
|
147
|
+
const keyMap = {
|
|
148
|
+
up: 4, // JOYPAD_UP
|
|
149
|
+
down: 5, // JOYPAD_DOWN
|
|
150
|
+
left: 6, // JOYPAD_LEFT
|
|
151
|
+
right: 7, // JOYPAD_RIGHT
|
|
152
|
+
z: 0, // JOYPAD_B (action button)
|
|
153
|
+
x: 8, // JOYPAD_A
|
|
154
|
+
a: 1, // JOYPAD_Y
|
|
155
|
+
s: 9, // JOYPAD_X
|
|
156
|
+
return: 3, // JOYPAD_START
|
|
157
|
+
shift: 2, // JOYPAD_SELECT
|
|
158
|
+
q: 10, // JOYPAD_L
|
|
159
|
+
w: 11, // JOYPAD_R
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (process.stdin.isTTY) {
|
|
163
|
+
process.stdin.setRawMode(true);
|
|
164
|
+
process.stdin.resume();
|
|
165
|
+
process.stdin.setEncoding('utf8');
|
|
166
|
+
|
|
167
|
+
process.stdin.on('data', (key) => {
|
|
168
|
+
// Ctrl+C to exit
|
|
169
|
+
if (key === '\u0003') {
|
|
170
|
+
process.emit('SIGINT');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Handle arrow keys (escape sequences)
|
|
175
|
+
if (key === '\u001b[A') {
|
|
176
|
+
this._pressKey('up');
|
|
177
|
+
} else if (key === '\u001b[B') {
|
|
178
|
+
this._pressKey('down');
|
|
179
|
+
} else if (key === '\u001b[C') {
|
|
180
|
+
this._pressKey('right');
|
|
181
|
+
} else if (key === '\u001b[D') {
|
|
182
|
+
this._pressKey('left');
|
|
183
|
+
} else if (key === '\r' || key === '\n') {
|
|
184
|
+
this._pressKey('return');
|
|
185
|
+
} else {
|
|
186
|
+
const lower = key.toLowerCase();
|
|
187
|
+
if (keyMap[lower] !== undefined) {
|
|
188
|
+
this._pressKey(lower);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Store the key map for lookups
|
|
194
|
+
this._keyMap = keyMap;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_pressKey(keyName) {
|
|
199
|
+
const id = this._keyMap[keyName];
|
|
200
|
+
if (id === undefined) return;
|
|
201
|
+
|
|
202
|
+
// Record the frame when this key was pressed
|
|
203
|
+
// Key will be considered "held" for _keyHoldFrames frames
|
|
204
|
+
this._keyLastPressed.set(id, this._currentFrame);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
destroy() {
|
|
208
|
+
// Clean up gamepad manager
|
|
209
|
+
if (this.manager && this.manager.destroy) {
|
|
210
|
+
try {
|
|
211
|
+
this.manager.destroy();
|
|
212
|
+
} catch {
|
|
213
|
+
// Ignore cleanup errors
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (process.stdin.isTTY) {
|
|
218
|
+
process.stdin.setRawMode(false);
|
|
219
|
+
process.stdin.pause();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as LR from '../constants/libretro.js';
|
|
2
|
+
|
|
3
|
+
// W3C Standard Gamepad button index → libretro RETRO_DEVICE_ID_JOYPAD_*
|
|
4
|
+
//
|
|
5
|
+
// W3C standard mapping:
|
|
6
|
+
// 0=South(A), 1=East(B), 2=West(X), 3=North(Y),
|
|
7
|
+
// 4=L1, 5=R1, 6=L2, 7=R2,
|
|
8
|
+
// 8=Select, 9=Start, 10=L3, 11=R3,
|
|
9
|
+
// 12=DPad Up, 13=DPad Down, 14=DPad Left, 15=DPad Right, 16=Guide
|
|
10
|
+
//
|
|
11
|
+
// gamepad-node uses positional mapping: South=A, East=B, West=X, North=Y
|
|
12
|
+
// libretro SNES layout: B=South, A=East, Y=West, X=North
|
|
13
|
+
|
|
14
|
+
export const W3C_TO_LIBRETRO = new Int8Array(17);
|
|
15
|
+
W3C_TO_LIBRETRO[0] = LR.JOYPAD_B; // W3C South → libretro B (south)
|
|
16
|
+
W3C_TO_LIBRETRO[1] = LR.JOYPAD_A; // W3C East → libretro A (east)
|
|
17
|
+
W3C_TO_LIBRETRO[2] = LR.JOYPAD_Y; // W3C West → libretro Y (west)
|
|
18
|
+
W3C_TO_LIBRETRO[3] = LR.JOYPAD_X; // W3C North → libretro X (north)
|
|
19
|
+
W3C_TO_LIBRETRO[4] = LR.JOYPAD_L; // W3C L1 → libretro L
|
|
20
|
+
W3C_TO_LIBRETRO[5] = LR.JOYPAD_R; // W3C R1 → libretro R
|
|
21
|
+
W3C_TO_LIBRETRO[6] = LR.JOYPAD_L2; // W3C L2 → libretro L2
|
|
22
|
+
W3C_TO_LIBRETRO[7] = LR.JOYPAD_R2; // W3C R2 → libretro R2
|
|
23
|
+
W3C_TO_LIBRETRO[8] = LR.JOYPAD_SELECT; // W3C Select → libretro Select
|
|
24
|
+
W3C_TO_LIBRETRO[9] = LR.JOYPAD_START; // W3C Start → libretro Start
|
|
25
|
+
W3C_TO_LIBRETRO[10] = LR.JOYPAD_L3; // W3C L3 → libretro L3
|
|
26
|
+
W3C_TO_LIBRETRO[11] = LR.JOYPAD_R3; // W3C R3 → libretro R3
|
|
27
|
+
W3C_TO_LIBRETRO[12] = LR.JOYPAD_UP; // W3C DUp → libretro Up
|
|
28
|
+
W3C_TO_LIBRETRO[13] = LR.JOYPAD_DOWN; // W3C DDown → libretro Down
|
|
29
|
+
W3C_TO_LIBRETRO[14] = LR.JOYPAD_LEFT; // W3C DLeft → libretro Left
|
|
30
|
+
W3C_TO_LIBRETRO[15] = LR.JOYPAD_RIGHT; // W3C DRight → libretro Right
|
|
31
|
+
W3C_TO_LIBRETRO[16] = -1; // W3C Guide → unmapped
|
|
32
|
+
|
|
33
|
+
// Reverse map: libretro joypad ID → W3C button index
|
|
34
|
+
export const LIBRETRO_TO_W3C = new Int8Array(16);
|
|
35
|
+
for (let w3c = 0; w3c < 16; w3c++) {
|
|
36
|
+
const lr = W3C_TO_LIBRETRO[w3c];
|
|
37
|
+
if (lr >= 0 && lr < 16) {
|
|
38
|
+
LIBRETRO_TO_W3C[lr] = w3c;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// W3C analog axes: 0=leftX, 1=leftY, 2=rightX, 3=rightY
|
|
43
|
+
// Libretro analog: index=LEFT/RIGHT, id=X/Y
|
|
44
|
+
// Convert W3C axis float (-1..1) to libretro int16 (-32768..32767)
|
|
45
|
+
export function axisToLibretro(value) {
|
|
46
|
+
return Math.round(Math.max(-1, Math.min(1, value)) * 32767);
|
|
47
|
+
}
|