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
@@ -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
+ }