emoemu 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 (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,937 @@
1
+ /**
2
+ * LibretroCore - Wrapper for native libretro cores
3
+ *
4
+ * This class implements the Core interface by wrapping a native libretro
5
+ * core (.dylib/.so/.dll) using FFI. It allows emoemu to run games using
6
+ * existing libretro cores like genesis_plus_gx, mGBA, snes9x, etc.
7
+ */
8
+
9
+ import { readFileSync } from "fs";
10
+ import { extname } from "path";
11
+ import koffi from "koffi";
12
+ import type {
13
+ Core,
14
+ SystemInfo,
15
+ AudioConfig,
16
+ ButtonDefinition,
17
+ CoreMessage,
18
+ CoreMessageCallback,
19
+ } from "../../core/core";
20
+ import { LibretroAPI } from "./api";
21
+ import { EnvironmentHandler } from "./environment";
22
+ import { CallbackManager } from "./callbacks";
23
+ import { convertFramebuffer, detectContentBounds, hasFrameContent, type ContentBounds } from "./pixelFormat";
24
+ import {
25
+ RETRO_DEVICE_ID_JOYPAD,
26
+ RETRO_MEMORY,
27
+ RETRO_DEVICE,
28
+ RETRO_MESSAGE_TYPE,
29
+ RETRO_LOG,
30
+ LibretroError,
31
+ } from "./types";
32
+ import type { MessageSeverity } from "../../core/core";
33
+ import { DEFAULT_SAMPLE_RATE, RGB24_BYTES_PER_PIXEL, ASPECT_RATIO_DECIMALS, FPS_DECIMALS, INT16_MAX_POSITIVE, DEBUG_INITIAL_FRAME_LOG_COUNT, ANALOG_NORMALIZED_THRESHOLD } from "./consts";
34
+ import { logger } from "../../utils/logger";
35
+
36
+ /**
37
+ * Options for creating a LibretroCore instance
38
+ */
39
+ export interface LibretroCoreOptions {
40
+ /** Core options in RetroArch format (e.g., {"mupen64plus-rdp-plugin": "angrylion"}) */
41
+ coreOptions?: Record<string, string>;
42
+ /** System directory path for BIOS files */
43
+ systemDirectory?: string;
44
+ /** Save directory path */
45
+ saveDirectory?: string;
46
+ }
47
+
48
+ /**
49
+ * LibretroCore implements the Core interface for libretro cores
50
+ */
51
+ export class LibretroCore implements Core {
52
+ private api: LibretroAPI;
53
+ private envHandler: EnvironmentHandler;
54
+ private callbacks: CallbackManager;
55
+ private systemInfo: SystemInfo;
56
+ private romData: Buffer | null = null;
57
+ private audioCallback: ((samples: Float32Array) => void) | null = null;
58
+ private gameLoaded = false;
59
+ // Cached pixel format to avoid method call overhead in hot path
60
+ private cachedPixelFormat: number = 0;
61
+ // Detected content bounds for auto-cropping (null = no cropping needed)
62
+ private contentBounds: ContentBounds | null = null;
63
+ // Original display aspect ratio from AV info (used for cropping to preserve intended aspect)
64
+ private originalDisplayAspect: number = 0;
65
+ // Cached RGB24 framebuffer to avoid double conversion during bounds detection
66
+ // The cache is valid only for the current frame (invalidated on next runFrame)
67
+ private cachedRgb24Framebuffer: Uint8Array | null = null;
68
+ private cachedRgb24FrameId: number = 0; // Unique ID to track frame validity
69
+ private currentFrameId: number = 0; // Incremented each runFrame()
70
+ // Reusable buffer for cropping to avoid allocations
71
+ private cropOutputBuffer: Uint8Array | null = null;
72
+ private cropOutputCapacity: number = 0;
73
+
74
+ constructor(corePath: string, options?: LibretroCoreOptions) {
75
+ this.envHandler = new EnvironmentHandler();
76
+
77
+ // Configure directories before core init
78
+ if (options?.systemDirectory) {
79
+ this.envHandler.setSystemDirectory(options.systemDirectory);
80
+ }
81
+ if (options?.saveDirectory) {
82
+ this.envHandler.setSaveDirectory(options.saveDirectory);
83
+ }
84
+
85
+ // Set core options before init (some cores query options during init)
86
+ if (options?.coreOptions) {
87
+ this.envHandler.setCoreOptions(options.coreOptions);
88
+ }
89
+
90
+ this.api = new LibretroAPI(corePath);
91
+ this.callbacks = new CallbackManager(this.envHandler);
92
+
93
+ // Set up callbacks BEFORE retro_init (required by some cores)
94
+ this.callbacks.createCallbacks(this.api);
95
+
96
+ // Initialize the core
97
+ this.api.retro_init();
98
+
99
+ // Build initial system info from core
100
+ this.systemInfo = this.buildSystemInfo();
101
+ }
102
+
103
+ /**
104
+ * Build SystemInfo from the libretro core's system info
105
+ */
106
+ private buildSystemInfo(): SystemInfo {
107
+ const info = this.api.getSystemInfo();
108
+
109
+ // Generate a unique ID from the library name
110
+ // No "libretro-" prefix - core type is identified by path !== "native"
111
+ // Uses underscores to match buildbot naming convention (e.g., mupen64plus_next)
112
+ const id = info.library_name
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, "_")
115
+ .replace(/^_|_$/g, "");
116
+
117
+ // Parse extensions (format: "md|gen|sms|gg")
118
+ const extensions = info.valid_extensions
119
+ .split("|")
120
+ .map((ext) => `.${ext.toLowerCase()}`);
121
+
122
+ return {
123
+ id,
124
+ name: `${info.library_name} ${info.library_version}`,
125
+ coreName: info.library_name,
126
+ coreVersion: info.library_version,
127
+ extensions,
128
+ // Default values - updated after ROM load
129
+ width: 320,
130
+ height: 240,
131
+ fps: 60,
132
+ sampleRate: DEFAULT_SAMPLE_RATE,
133
+ pixelAspectRatio: 1,
134
+ maxPlayers: 2,
135
+ buttons: this.getDefaultButtons(),
136
+ colorSpace: "rgb24", // We convert all formats to RGB24
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Get default button definitions for libretro joypad
142
+ */
143
+ private getDefaultButtons(): ButtonDefinition[] {
144
+ return [
145
+ {
146
+ id: RETRO_DEVICE_ID_JOYPAD.B,
147
+ name: "B",
148
+ defaultKey: "j",
149
+ defaultGamepad: "B",
150
+ },
151
+ {
152
+ id: RETRO_DEVICE_ID_JOYPAD.Y,
153
+ name: "Y",
154
+ defaultKey: "u",
155
+ defaultGamepad: "Y",
156
+ },
157
+ {
158
+ id: RETRO_DEVICE_ID_JOYPAD.SELECT,
159
+ name: "Select",
160
+ defaultKey: " ",
161
+ defaultGamepad: "Back",
162
+ },
163
+ {
164
+ id: RETRO_DEVICE_ID_JOYPAD.START,
165
+ name: "Start",
166
+ defaultKey: "Enter",
167
+ defaultGamepad: "Start",
168
+ },
169
+ {
170
+ id: RETRO_DEVICE_ID_JOYPAD.UP,
171
+ name: "Up",
172
+ defaultKey: "w",
173
+ defaultGamepad: "DPadUp",
174
+ },
175
+ {
176
+ id: RETRO_DEVICE_ID_JOYPAD.DOWN,
177
+ name: "Down",
178
+ defaultKey: "s",
179
+ defaultGamepad: "DPadDown",
180
+ },
181
+ {
182
+ id: RETRO_DEVICE_ID_JOYPAD.LEFT,
183
+ name: "Left",
184
+ defaultKey: "a",
185
+ defaultGamepad: "DPadLeft",
186
+ },
187
+ {
188
+ id: RETRO_DEVICE_ID_JOYPAD.RIGHT,
189
+ name: "Right",
190
+ defaultKey: "d",
191
+ defaultGamepad: "DPadRight",
192
+ },
193
+ {
194
+ id: RETRO_DEVICE_ID_JOYPAD.A,
195
+ name: "A",
196
+ defaultKey: "k",
197
+ defaultGamepad: "A",
198
+ },
199
+ {
200
+ id: RETRO_DEVICE_ID_JOYPAD.X,
201
+ name: "X",
202
+ defaultKey: "i",
203
+ defaultGamepad: "X",
204
+ },
205
+ {
206
+ id: RETRO_DEVICE_ID_JOYPAD.L,
207
+ name: "L",
208
+ defaultKey: "q",
209
+ defaultGamepad: "LB",
210
+ },
211
+ {
212
+ id: RETRO_DEVICE_ID_JOYPAD.R,
213
+ name: "R",
214
+ defaultKey: "e",
215
+ defaultGamepad: "RB",
216
+ },
217
+ {
218
+ id: RETRO_DEVICE_ID_JOYPAD.L2,
219
+ name: "L2",
220
+ defaultKey: "1",
221
+ defaultGamepad: "LT",
222
+ },
223
+ {
224
+ id: RETRO_DEVICE_ID_JOYPAD.R2,
225
+ name: "R2",
226
+ defaultKey: "3",
227
+ defaultGamepad: "RT",
228
+ },
229
+ {
230
+ id: RETRO_DEVICE_ID_JOYPAD.L3,
231
+ name: "L3",
232
+ defaultKey: "z",
233
+ defaultGamepad: "LS",
234
+ },
235
+ {
236
+ id: RETRO_DEVICE_ID_JOYPAD.R3,
237
+ name: "R3",
238
+ defaultKey: "c",
239
+ defaultGamepad: "RS",
240
+ },
241
+ ];
242
+ }
243
+
244
+ //==========================================================================
245
+ // Lifecycle
246
+ //==========================================================================
247
+
248
+ getSystemInfo(): SystemInfo {
249
+ // Start with base system info
250
+ const info = { ...this.systemInfo };
251
+
252
+ // Use actual frame dimensions if we've received frames
253
+ if (this.callbacks.frameWidth > 0 && this.callbacks.frameHeight > 0) {
254
+ info.width = this.callbacks.frameWidth;
255
+ info.height = this.callbacks.frameHeight;
256
+ }
257
+
258
+ // If we detected content bounds (auto-crop), use those dimensions
259
+ // and adjust PAR to preserve the original display aspect ratio from AV info
260
+ if (this.contentBounds) {
261
+ info.width = this.contentBounds.width;
262
+ info.height = this.contentBounds.height;
263
+ // Use the original display aspect ratio (e.g., 4:3 for N64) from AV info
264
+ // newPAR = originalDisplayAspect * croppedHeight / croppedWidth
265
+ if (this.originalDisplayAspect > 0) {
266
+ info.pixelAspectRatio = (this.originalDisplayAspect * info.height) / info.width;
267
+ }
268
+ }
269
+
270
+ // If core reported geometry via SET_GEOMETRY, use that aspect ratio
271
+ // This gives us the actual content dimensions (some cores report cropped content)
272
+ const geometry = this.envHandler.getGeometry();
273
+ if (geometry) {
274
+ info.pixelAspectRatio = geometry.aspectRatio / (info.width / info.height);
275
+ }
276
+
277
+ return info;
278
+ }
279
+
280
+ /**
281
+ * Detect content bounds in the current framebuffer for auto-cropping.
282
+ * Call this after running bootstrap frames to detect blank borders.
283
+ * Bounds can only expand, never shrink - this handles cases where early
284
+ * frames (logos, menus) have smaller content than actual gameplay.
285
+ *
286
+ * @returns object with:
287
+ * - hasContent: true if frame had content (not blank)
288
+ * - boundsChanged: true if bounds expanded and renderer needs update
289
+ */
290
+ detectContentBounds(): { hasContent: boolean; boundsChanged: boolean } {
291
+ const fb = this.callbacks.framebuffer;
292
+ if (!fb || this.callbacks.frameWidth === 0 || this.callbacks.frameHeight === 0) {
293
+ logger.debug(
294
+ `detectContentBounds: no framebuffer (fb=${!!fb}, w=${this.callbacks.frameWidth}, h=${this.callbacks.frameHeight})`,
295
+ 'Core'
296
+ );
297
+ return { hasContent: false, boundsChanged: false };
298
+ }
299
+
300
+ // Quick pre-check: sample native framebuffer to detect blank frames
301
+ // Avoids expensive RGB24 conversion during N64 startup blank frames
302
+ if (this.isFramebufferUniform(fb)) {
303
+ logger.debug('detectContentBounds: native framebuffer is uniform, skipping', 'Core');
304
+ return { hasContent: false, boundsChanged: false };
305
+ }
306
+
307
+ // Convert framebuffer to RGB24 for analysis
308
+ // Cache the result so getFramebuffer() can reuse it (avoids double conversion)
309
+ const rgb24 = convertFramebuffer(
310
+ fb,
311
+ this.callbacks.frameWidth,
312
+ this.callbacks.frameHeight,
313
+ this.callbacks.framePitch,
314
+ this.cachedPixelFormat
315
+ );
316
+ this.cachedRgb24Framebuffer = rgb24;
317
+ this.cachedRgb24FrameId = this.currentFrameId;
318
+
319
+ // Check if frame has actual content (not all black/uniform)
320
+ // N64 games may output many blank frames before video starts
321
+ if (!hasFrameContent(rgb24, this.callbacks.frameWidth, this.callbacks.frameHeight)) {
322
+ logger.debug('detectContentBounds: frame is blank, will retry', 'Core');
323
+ return { hasContent: false, boundsChanged: false };
324
+ }
325
+
326
+ const newBounds = detectContentBounds(rgb24, this.callbacks.frameWidth, this.callbacks.frameHeight);
327
+ if (!newBounds) {
328
+ // Content fills the entire frame - no cropping needed
329
+ if (this.contentBounds) {
330
+ // We had bounds before, now content fills frame - expand to full
331
+ logger.info(
332
+ `Auto-crop expanded to full frame: ${this.contentBounds.width}x${this.contentBounds.height} -> ` +
333
+ `${this.callbacks.frameWidth}x${this.callbacks.frameHeight}`,
334
+ 'Core'
335
+ );
336
+ this.contentBounds = null;
337
+ return { hasContent: true, boundsChanged: true };
338
+ }
339
+ return { hasContent: true, boundsChanged: false };
340
+ }
341
+
342
+ // Expand bounds - only grow, never shrink
343
+ if (!this.contentBounds) {
344
+ // First detection
345
+ this.contentBounds = newBounds;
346
+ logger.info(
347
+ `Auto-crop: ${this.callbacks.frameWidth}x${this.callbacks.frameHeight} -> ` +
348
+ `${newBounds.width}x${newBounds.height} (top=${newBounds.top}, left=${newBounds.left})`,
349
+ 'Core'
350
+ );
351
+ return { hasContent: true, boundsChanged: true };
352
+ }
353
+
354
+ // Expand existing bounds (top/left can decrease, bottom/right can increase)
355
+ const expanded: ContentBounds = {
356
+ top: Math.min(this.contentBounds.top, newBounds.top),
357
+ left: Math.min(this.contentBounds.left, newBounds.left),
358
+ bottom: Math.max(this.contentBounds.bottom, newBounds.bottom),
359
+ right: Math.max(this.contentBounds.right, newBounds.right),
360
+ width: 0, // Calculated below
361
+ height: 0, // Calculated below
362
+ };
363
+ expanded.width = expanded.right - expanded.left + 1;
364
+ expanded.height = expanded.bottom - expanded.top + 1;
365
+
366
+ // Check if bounds actually expanded
367
+ if (expanded.width > this.contentBounds.width || expanded.height > this.contentBounds.height) {
368
+ logger.info(
369
+ `Auto-crop expanded: ${this.contentBounds.width}x${this.contentBounds.height} -> ` +
370
+ `${expanded.width}x${expanded.height}`,
371
+ 'Core'
372
+ );
373
+ this.contentBounds = expanded;
374
+ return { hasContent: true, boundsChanged: true };
375
+ }
376
+
377
+ return { hasContent: true, boundsChanged: false };
378
+ }
379
+
380
+ loadRom(romPath: string): void {
381
+ // Read the ROM file
382
+ try {
383
+ this.romData = readFileSync(romPath);
384
+ } catch (err) {
385
+ throw new LibretroError('ROM_READ_FAILED', romPath);
386
+ }
387
+
388
+ // Clear recent logs to get fresh messages for this load attempt
389
+ this.envHandler.clearRecentLogs();
390
+
391
+ // Load the game into the core
392
+ const success = this.api.loadGame(romPath, this.romData, null);
393
+ if (!success) {
394
+ // Log diagnostic info to help debug ROM rejection
395
+ const coreInfo = this.api.getSystemInfo();
396
+ const romExt = extname(romPath).toLowerCase();
397
+ const romSize = this.romData.length;
398
+ const systemDir = this.envHandler.getSystemDirectory();
399
+
400
+ // Log diagnostic details (the error message itself will be logged when caught)
401
+ logger.error(`Core: ${coreInfo.library_name} ${coreInfo.library_version}`, 'Core');
402
+ logger.error(`ROM extension: ${romExt}`, 'Core');
403
+ logger.error(`Valid extensions: ${coreInfo.valid_extensions || '(none reported)'}`, 'Core');
404
+ logger.error(`ROM size: ${romSize.toLocaleString()} bytes`, 'Core');
405
+ logger.error(`System directory: ${systemDir}`, 'Core');
406
+
407
+ // Include any log messages from the core (already formatted)
408
+ const coreLogs = this.envHandler.getRecentLogsFormatted();
409
+ for (const log of coreLogs) {
410
+ // These are already formatted as [LEVEL] [Core] message, log raw
411
+ console.error(log);
412
+ }
413
+
414
+ throw new LibretroError('ROM_REJECTED', romPath);
415
+ }
416
+
417
+ this.gameLoaded = true;
418
+
419
+ // Update system info with actual AV info from the core
420
+ try {
421
+ const avInfo = this.api.getSystemAVInfo();
422
+ this.systemInfo.width = avInfo.geometry.base_width;
423
+ this.systemInfo.height = avInfo.geometry.base_height;
424
+ this.systemInfo.fps = avInfo.timing.fps;
425
+ this.systemInfo.sampleRate = avInfo.timing.sample_rate || DEFAULT_SAMPLE_RATE;
426
+
427
+ // Store original display aspect ratio for use when cropping
428
+ // This is the intended display aspect (e.g., 4:3 for N64) regardless of actual frame dimensions
429
+ if (avInfo.geometry.aspect_ratio > 0) {
430
+ this.originalDisplayAspect = avInfo.geometry.aspect_ratio;
431
+ } else {
432
+ this.originalDisplayAspect = avInfo.geometry.base_width / avInfo.geometry.base_height;
433
+ }
434
+
435
+ // Calculate pixel aspect ratio if provided
436
+ if (avInfo.geometry.aspect_ratio > 0) {
437
+ // aspect_ratio is display aspect ratio (e.g., 4:3)
438
+ // pixelAspectRatio = display_aspect / pixel_aspect
439
+ // pixel_aspect = width / height
440
+ const pixelAspect =
441
+ avInfo.geometry.base_width / avInfo.geometry.base_height;
442
+ this.systemInfo.pixelAspectRatio =
443
+ avInfo.geometry.aspect_ratio / pixelAspect;
444
+ }
445
+
446
+ // Log core geometry info (RetroArch-style)
447
+ const aspectStr = avInfo.geometry.aspect_ratio > 0
448
+ ? avInfo.geometry.aspect_ratio.toFixed(ASPECT_RATIO_DECIMALS)
449
+ : (avInfo.geometry.base_width / avInfo.geometry.base_height).toFixed(ASPECT_RATIO_DECIMALS);
450
+ logger.info(
451
+ `Geometry: ${avInfo.geometry.base_width}x${avInfo.geometry.base_height}, ` +
452
+ `Aspect: ${aspectStr}, FPS: ${avInfo.timing.fps.toFixed(FPS_DECIMALS)}, ` +
453
+ `Sample rate: ${this.systemInfo.sampleRate.toFixed(FPS_DECIMALS)} Hz`,
454
+ 'Core'
455
+ );
456
+ } catch {
457
+ // Use defaults if AV info fails
458
+ }
459
+
460
+ // Set up controller ports using the best available controller type from the core
461
+ // Device type selection priority:
462
+ // 1. Device subtypes (id >= 256) - core-specific controllers like N64Pad (257)
463
+ // 2. Standard JOYPAD (id=1) - works for most cores (SNES, Genesis, etc.)
464
+ // 3. First available type - fallback
465
+ // This avoids selecting Mouse (2), Keyboard (3), etc. over JOYPAD
466
+ const DEVICE_SUBTYPE_THRESHOLD = 256;
467
+ for (let port = 0; port < 2; port++) {
468
+ const types = this.envHandler.getControllerTypes(port);
469
+ if (types.length === 0) {
470
+ // No controller info from core, use default JOYPAD
471
+ this.api.retro_set_controller_port_device(port, RETRO_DEVICE.JOYPAD);
472
+ logger.debug(`Controller port ${port} set to device ${RETRO_DEVICE.JOYPAD} (JOYPAD)`, 'Core');
473
+ continue;
474
+ }
475
+ // Prefer device subtypes (like N64Pad=257), then JOYPAD, then first available
476
+ const subtypeController = types.find(t => t.id >= DEVICE_SUBTYPE_THRESHOLD);
477
+ const joypadController = types.find(t => t.id === RETRO_DEVICE.JOYPAD);
478
+ const selectedType = subtypeController ?? joypadController ?? types[0];
479
+ this.api.retro_set_controller_port_device(port, selectedType.id);
480
+ logger.debug(`Controller port ${port} set to device ${selectedType.id} (${selectedType.desc})`, 'Core');
481
+ }
482
+
483
+ // Cache pixel format (set by core during init/load via SET_PIXEL_FORMAT)
484
+ this.cachedPixelFormat = this.envHandler.getPixelFormat();
485
+ }
486
+
487
+ reset(): void {
488
+ if (this.gameLoaded) {
489
+ this.api.retro_reset();
490
+ }
491
+ }
492
+
493
+ destroy(): void {
494
+ if (this.gameLoaded) {
495
+ this.api.retro_unload_game();
496
+ this.gameLoaded = false;
497
+ }
498
+ this.api.retro_deinit();
499
+ this.callbacks.destroy();
500
+ this.envHandler.cleanup();
501
+ this.api.destroy();
502
+ this.romData = null;
503
+ }
504
+
505
+ //==========================================================================
506
+ // Emulation
507
+ //==========================================================================
508
+
509
+ // Debug: track frame count
510
+ private runFrameCount = 0;
511
+
512
+ runFrame(): void {
513
+ if (!this.gameLoaded) {return;}
514
+
515
+ // Increment frame ID to invalidate any cached framebuffer from previous frame
516
+ this.currentFrameId++;
517
+
518
+ // Debug: Log initial frames with timing
519
+ this.runFrameCount++;
520
+ const startTime = performance.now();
521
+
522
+ // Run one frame
523
+ this.api.retro_run();
524
+
525
+ if (this.runFrameCount <= DEBUG_INITIAL_FRAME_LOG_COUNT) {
526
+ const elapsed = performance.now() - startTime;
527
+ logger.debug(`runFrame() frame ${this.runFrameCount}: took ${elapsed.toFixed(2)}ms`, 'Core');
528
+ }
529
+
530
+ // Push audio samples if callback is set
531
+ if (this.audioCallback && this.callbacks.hasAudio()) {
532
+ const samples = this.callbacks.drainAudio();
533
+ this.audioCallback(samples);
534
+ }
535
+ }
536
+
537
+ isFrameComplete(): boolean {
538
+ // libretro cores always complete one frame per retro_run()
539
+ return true;
540
+ }
541
+
542
+ //==========================================================================
543
+ // Video Output
544
+ //==========================================================================
545
+
546
+ getFramebuffer(): Uint8Array {
547
+ const fb = this.callbacks.framebuffer;
548
+ if (!fb) {
549
+ // Return empty framebuffer
550
+ const info = this.getSystemInfo();
551
+ return new Uint8Array(info.width * info.height * RGB24_BYTES_PER_PIXEL);
552
+ }
553
+
554
+ // Check if we have a cached RGB24 framebuffer from bounds detection (same frame)
555
+ // This avoids expensive double conversion during periodic bounds checks
556
+ if (this.cachedRgb24Framebuffer && this.cachedRgb24FrameId === this.currentFrameId) {
557
+ if (!this.contentBounds) {
558
+ // No cropping needed - return cached buffer directly
559
+ return this.cachedRgb24Framebuffer;
560
+ }
561
+ // Cropping needed - extract the region from cached buffer
562
+ return this.cropRgb24Framebuffer(
563
+ this.cachedRgb24Framebuffer,
564
+ this.callbacks.frameWidth,
565
+ this.contentBounds
566
+ );
567
+ }
568
+
569
+ // No cache available - convert from native format
570
+ // Apply cropping during conversion (more efficient than converting then cropping)
571
+ const bounds = this.contentBounds ? {
572
+ top: this.contentBounds.top,
573
+ left: this.contentBounds.left,
574
+ width: this.contentBounds.width,
575
+ height: this.contentBounds.height,
576
+ } : undefined;
577
+
578
+ return convertFramebuffer(
579
+ fb,
580
+ this.callbacks.frameWidth,
581
+ this.callbacks.frameHeight,
582
+ this.callbacks.framePitch,
583
+ this.cachedPixelFormat,
584
+ bounds
585
+ );
586
+ }
587
+
588
+ /**
589
+ * Crop an RGB24 framebuffer to the specified bounds.
590
+ * Used to extract content region from cached full-frame buffer.
591
+ * Uses a reusable buffer to avoid per-frame allocations.
592
+ */
593
+ private cropRgb24Framebuffer(
594
+ source: Uint8Array,
595
+ sourceWidth: number,
596
+ bounds: ContentBounds
597
+ ): Uint8Array {
598
+ const { top, left, width, height } = bounds;
599
+ const outputSize = width * height * RGB24_BYTES_PER_PIXEL;
600
+
601
+ // Reuse buffer if possible, otherwise allocate
602
+ if (!this.cropOutputBuffer || this.cropOutputCapacity < outputSize) {
603
+ this.cropOutputCapacity = outputSize;
604
+ this.cropOutputBuffer = new Uint8Array(outputSize);
605
+ }
606
+
607
+ const output = this.cropOutputBuffer;
608
+ for (let y = 0; y < height; y++) {
609
+ const srcRow = (top + y) * sourceWidth + left;
610
+ const srcOffset = srcRow * RGB24_BYTES_PER_PIXEL;
611
+ const dstOffset = y * width * RGB24_BYTES_PER_PIXEL;
612
+ output.set(
613
+ source.subarray(srcOffset, srcOffset + width * RGB24_BYTES_PER_PIXEL),
614
+ dstOffset
615
+ );
616
+ }
617
+
618
+ return output.subarray(0, outputSize);
619
+ }
620
+
621
+ /**
622
+ * Quick check if framebuffer appears uniform (all same color).
623
+ * Samples bytes across the buffer to detect blank frames without
624
+ * expensive RGB24 conversion. Used to skip bounds detection on blank frames.
625
+ */
626
+ private isFramebufferUniform(fb: Uint8Array): boolean {
627
+ // Sample 32 positions across the buffer, comparing 4 bytes at each position
628
+ // (4 bytes covers all pixel formats: XRGB8888=4, RGB565=2, RGB555=2)
629
+ const SAMPLE_COUNT = 32;
630
+ const BYTES_PER_SAMPLE = 4;
631
+ const step = Math.max(1, Math.floor(fb.length / SAMPLE_COUNT));
632
+
633
+ // Compare against first pixel's bytes
634
+ const ref0 = fb[0];
635
+ const ref1 = fb[1];
636
+ const ref2 = fb[2];
637
+ const ref3 = fb[3];
638
+
639
+ for (let i = step; i < fb.length - BYTES_PER_SAMPLE; i += step) {
640
+ // Compare 4 bytes at this position against reference
641
+ const mismatch = fb[i] !== ref0 || fb[i + 1] !== ref1 ||
642
+ fb[i + 2] !== ref2 || fb[i + BYTES_PER_SAMPLE - 1] !== ref3;
643
+ if (mismatch) {
644
+ return false; // Found variation
645
+ }
646
+ }
647
+
648
+ return true; // All samples matched - likely blank frame
649
+ }
650
+
651
+ //==========================================================================
652
+ // Audio Output
653
+ //==========================================================================
654
+
655
+ getAudioConfig(): AudioConfig {
656
+ return {
657
+ sampleRate: this.systemInfo.sampleRate,
658
+ channels: 2, // libretro is always stereo
659
+ };
660
+ }
661
+
662
+ setAudioCallback(callback: ((samples: Float32Array) => void) | null): void {
663
+ this.audioCallback = callback;
664
+ }
665
+
666
+ /**
667
+ * Set audio enable flag
668
+ * Tells the core whether to generate audio samples via GET_AUDIO_VIDEO_ENABLE
669
+ */
670
+ setAudioEnabled(enabled: boolean): void {
671
+ this.envHandler.setAudioEnabled(enabled);
672
+ }
673
+
674
+ /**
675
+ * Set message callback for core notifications (e.g., "State saved", "Disk inserted")
676
+ */
677
+ setMessageCallback(callback: CoreMessageCallback | null): void {
678
+ if (!callback) {
679
+ this.envHandler.setMessageCallback(null);
680
+ return;
681
+ }
682
+
683
+ // Adapter: convert libretro RetroMessageExt to CoreMessage
684
+ this.envHandler.setMessageCallback((retroMsg) => {
685
+ // Map libretro message type to CoreMessage type
686
+ let type: CoreMessage['type'] = 'notification';
687
+ if (retroMsg.type === RETRO_MESSAGE_TYPE.STATUS) {
688
+ type = 'status';
689
+ } else if (retroMsg.type === RETRO_MESSAGE_TYPE.PROGRESS) {
690
+ type = 'progress';
691
+ }
692
+
693
+ // Map libretro severity level to MessageSeverity
694
+ let severity: MessageSeverity = 'info';
695
+ switch (retroMsg.level) {
696
+ case RETRO_LOG.DEBUG: severity = 'debug'; break;
697
+ case RETRO_LOG.WARN: severity = 'warn'; break;
698
+ case RETRO_LOG.ERROR: severity = 'error'; break;
699
+ }
700
+
701
+ const coreMsg: CoreMessage = {
702
+ msg: retroMsg.msg,
703
+ duration: retroMsg.duration,
704
+ priority: retroMsg.priority,
705
+ type,
706
+ progress: retroMsg.progress,
707
+ severity,
708
+ };
709
+
710
+ callback(coreMsg);
711
+ });
712
+ }
713
+
714
+ //==========================================================================
715
+ // Input
716
+ //==========================================================================
717
+
718
+ setButtonState(port: number, button: number, pressed: boolean): void {
719
+ this.callbacks.setButtonState(port, button, pressed);
720
+ }
721
+
722
+ getButtonState(port: number): Map<number, boolean> {
723
+ return this.callbacks.getButtonState(port);
724
+ }
725
+
726
+ /**
727
+ * Set analog stick axis value.
728
+ * @param port - Controller port (0-based)
729
+ * @param index - Analog stick (0=left, 1=right from RETRO_DEVICE_INDEX_ANALOG)
730
+ * @param axis - Axis (0=X, 1=Y from RETRO_DEVICE_ID_ANALOG)
731
+ * @param value - Analog value from -32768 to 32767 (or -1.0 to 1.0 normalized)
732
+ */
733
+ setAnalogState(port: number, index: number, axis: number, value: number): void {
734
+ // If value is in approximate normalized range, convert to int16
735
+ // We use a threshold > 1.0 to handle floating-point precision issues at boundaries
736
+ // (e.g., -32768/32767 = -1.00003 which is slightly outside -1 to 1)
737
+ const int16Value = Math.abs(value) <= ANALOG_NORMALIZED_THRESHOLD ? Math.round(value * INT16_MAX_POSITIVE) : value;
738
+ this.callbacks.setAnalogState(port, index, axis, int16Value);
739
+ }
740
+
741
+ /**
742
+ * Get all analog states for a port.
743
+ * Returns a map of "index.axis" -> value (e.g., "0.0" for left stick X)
744
+ */
745
+ getAnalogStates(port: number): Map<string, number> {
746
+ return this.callbacks.getAnalogStates(port);
747
+ }
748
+
749
+ //==========================================================================
750
+ // State Management
751
+ //==========================================================================
752
+
753
+ /**
754
+ * Get raw binary state data (RetroArch-compatible format).
755
+ */
756
+ getState(): Buffer | null {
757
+ if (!this.gameLoaded) {
758
+ throw new LibretroError('NO_GAME_LOADED');
759
+ }
760
+
761
+ const size = this.api.retro_serialize_size();
762
+ if (size === 0) {
763
+ // Core doesn't support save states
764
+ return null;
765
+ }
766
+
767
+ const buffer = Buffer.alloc(size);
768
+ const success = this.api.retro_serialize(buffer, size);
769
+
770
+ return success ? buffer : null;
771
+ }
772
+
773
+ /**
774
+ * Restore state from raw binary data (RetroArch-compatible format).
775
+ */
776
+ setState(state: Buffer): void {
777
+ if (!this.gameLoaded) {
778
+ throw new LibretroError('NO_GAME_LOADED');
779
+ }
780
+
781
+ const success = this.api.retro_unserialize(state, state.length);
782
+
783
+ if (!success) {
784
+ throw new LibretroError('STATE_LOAD_FAILED');
785
+ }
786
+ }
787
+
788
+ //==========================================================================
789
+ // Battery/SRAM
790
+ //==========================================================================
791
+
792
+ hasBatterySave(): boolean {
793
+ if (!this.gameLoaded) {return false;}
794
+
795
+ // Check standard API first
796
+ const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
797
+ if (size > 0) {return true;}
798
+
799
+ // Fall back to memory map SRAM (for cores like bsnes)
800
+ const memMapSram = this.envHandler.getMemoryMapSram();
801
+ return memMapSram !== null && memMapSram.size > 0;
802
+ }
803
+
804
+ getBatteryRam(): Uint8Array | null {
805
+ if (!this.gameLoaded) {return null;}
806
+
807
+ // Try standard API first
808
+ const stdData = this.api.getMemoryData(RETRO_MEMORY.SAVE_RAM);
809
+ if (stdData) {return stdData;}
810
+
811
+ // Fall back to memory map SRAM (for cores like bsnes)
812
+ const memMapSram = this.envHandler.getMemoryMapSram();
813
+ if (!memMapSram || !memMapSram.ptr) {return null;}
814
+
815
+ // Read from memory map pointer using koffi.view
816
+ const arrayBuffer = koffi.view(memMapSram.ptr, memMapSram.size) as ArrayBuffer;
817
+ const view = new Uint8Array(arrayBuffer);
818
+
819
+ // Copy to a new buffer
820
+ const result = new Uint8Array(memMapSram.size);
821
+ result.set(view);
822
+ return result;
823
+ }
824
+
825
+ setBatteryRam(data: Uint8Array): void {
826
+ if (!this.gameLoaded) {return;}
827
+
828
+ // Try standard API first
829
+ const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
830
+ if (size > 0) {
831
+ this.api.setMemoryData(RETRO_MEMORY.SAVE_RAM, data);
832
+ return;
833
+ }
834
+
835
+ // Fall back to memory map SRAM (for cores like bsnes)
836
+ const memMapSram = this.envHandler.getMemoryMapSram();
837
+ if (!memMapSram || !memMapSram.ptr) {return;}
838
+
839
+ // Write to memory map pointer using koffi.view
840
+ const copySize = Math.min(data.length, memMapSram.size);
841
+ const arrayBuffer = koffi.view(memMapSram.ptr, copySize) as ArrayBuffer;
842
+ const target = new Uint8Array(arrayBuffer);
843
+ target.set(data.subarray(0, copySize));
844
+ }
845
+
846
+ //==========================================================================
847
+ // Core Options
848
+ //==========================================================================
849
+
850
+ /**
851
+ * Set a core option value at runtime.
852
+ * Uses RetroArch-compatible key format (e.g., "mupen64plus-rdp-plugin").
853
+ * Changes take effect on the next frame.
854
+ */
855
+ setCoreOption(key: string, value: string): void {
856
+ this.envHandler.setCoreOption(key, value);
857
+ }
858
+
859
+ /**
860
+ * Set multiple core options at once.
861
+ * @param options Record of key-value pairs in RetroArch format
862
+ */
863
+ setCoreOptions(options: Record<string, string>): void {
864
+ this.envHandler.setCoreOptions(options);
865
+ }
866
+
867
+ /**
868
+ * Get the current value of a core option.
869
+ * Returns the user-configured value, or the default if not set.
870
+ */
871
+ getCoreOption(key: string): string | undefined {
872
+ return this.envHandler.getCoreOption(key);
873
+ }
874
+
875
+ /**
876
+ * Get all configured core options.
877
+ */
878
+ getCoreOptions(): Map<string, string> {
879
+ return this.envHandler.getCoreOptions();
880
+ }
881
+
882
+ /**
883
+ * Get available core option definitions (reported by the core).
884
+ * Each definition includes the key, description, valid values, and default.
885
+ */
886
+ getAvailableCoreOptions(): Array<{
887
+ key: string;
888
+ description: string;
889
+ values: string[];
890
+ defaultValue: string;
891
+ currentValue: string | undefined;
892
+ }> {
893
+ const defs = this.envHandler.getCoreOptionDefs();
894
+ return Array.from(defs.values()).map(def => ({
895
+ key: def.key,
896
+ description: def.description,
897
+ values: def.values,
898
+ defaultValue: def.defaultValue,
899
+ currentValue: this.envHandler.getCoreOption(def.key),
900
+ }));
901
+ }
902
+
903
+ /**
904
+ * Check if a core option exists.
905
+ */
906
+ hasCoreOption(key: string): boolean {
907
+ return this.envHandler.hasCoreOption(key);
908
+ }
909
+
910
+ /**
911
+ * Clear all user-configured core options (revert to defaults).
912
+ */
913
+ clearCoreOptions(): void {
914
+ this.envHandler.clearCoreOptions();
915
+ }
916
+ }
917
+
918
+ export { LibretroAPI } from "./api";
919
+ export { EnvironmentHandler } from "./environment";
920
+ export { CallbackManager } from "./callbacks";
921
+ export * from "./types";
922
+ export * from "./consts";
923
+ export {
924
+ registerLibretroCore,
925
+ unloadLibretroCore,
926
+ isInUserCoresDirectory,
927
+ } from "./loader";
928
+ export {
929
+ loadCoreOptions,
930
+ saveCoreOptions,
931
+ saveCoreSpecificOptions,
932
+ getDefaultCoreOptionsPath,
933
+ getCoreSpecificOptionsPath,
934
+ getGameSpecificOptionsPath,
935
+ getDefaultCoreOptions,
936
+ DEFAULT_CORE_OPTIONS,
937
+ } from "./coreOptions";