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,148 @@
1
+ /**
2
+ * Type guards for validating partial libretro structs decoded from FFI
3
+ */
4
+
5
+ import { isString, isNumber, isBoolean, isPlainObject } from 'remeda';
6
+
7
+ /**
8
+ * Type guard that validates a value is a plain object with string keys.
9
+ * Unlike isPlainObject, this narrows to Record<string, unknown> which
10
+ * allows safe property access with string keys.
11
+ */
12
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
13
+ if (!isPlainObject(value)) {
14
+ return false;
15
+ }
16
+ // Verify all keys are strings (not symbols)
17
+ return Object.keys(value).every((key) => typeof key === 'string');
18
+ };
19
+
20
+ /**
21
+ * Partial RetroGameGeometry as decoded from koffi.
22
+ * All properties are optional since FFI decoding may fail.
23
+ */
24
+ export interface PartialRetroGameGeometry {
25
+ base_width?: number;
26
+ base_height?: number;
27
+ max_width?: number;
28
+ max_height?: number;
29
+ aspect_ratio?: number;
30
+ }
31
+
32
+ /**
33
+ * Type guard for partial RetroGameGeometry from FFI decoding.
34
+ */
35
+ export const isPartialRetroGameGeometry = (value: unknown): value is PartialRetroGameGeometry => {
36
+ if (!isRecord(value)) {
37
+ return false;
38
+ }
39
+
40
+ if ('base_width' in value && !isNumber(value.base_width)) {
41
+ return false;
42
+ }
43
+ if ('base_height' in value && !isNumber(value.base_height)) {
44
+ return false;
45
+ }
46
+ if ('max_width' in value && !isNumber(value.max_width)) {
47
+ return false;
48
+ }
49
+ if ('max_height' in value && !isNumber(value.max_height)) {
50
+ return false;
51
+ }
52
+ if ('aspect_ratio' in value && !isNumber(value.aspect_ratio)) {
53
+ return false;
54
+ }
55
+
56
+ return true;
57
+ };
58
+
59
+ /**
60
+ * Partial RetroSystemTiming as decoded from koffi.
61
+ */
62
+ export interface PartialRetroSystemTiming {
63
+ fps?: number;
64
+ sample_rate?: number;
65
+ }
66
+
67
+ /**
68
+ * Type guard for partial RetroSystemTiming from FFI decoding.
69
+ */
70
+ export const isPartialRetroSystemTiming = (value: unknown): value is PartialRetroSystemTiming => {
71
+ if (!isRecord(value)) {
72
+ return false;
73
+ }
74
+
75
+ if ('fps' in value && !isNumber(value.fps)) {
76
+ return false;
77
+ }
78
+ if ('sample_rate' in value && !isNumber(value.sample_rate)) {
79
+ return false;
80
+ }
81
+
82
+ return true;
83
+ };
84
+
85
+ /**
86
+ * Partial RetroSystemAVInfo as decoded from koffi.
87
+ * Contains nested geometry and timing objects.
88
+ */
89
+ export interface PartialRetroSystemAVInfo {
90
+ geometry?: PartialRetroGameGeometry;
91
+ timing?: PartialRetroSystemTiming;
92
+ }
93
+
94
+ /**
95
+ * Type guard for partial RetroSystemAVInfo from FFI decoding.
96
+ */
97
+ export const isPartialRetroSystemAVInfo = (value: unknown): value is PartialRetroSystemAVInfo => {
98
+ if (!isRecord(value)) {
99
+ return false;
100
+ }
101
+
102
+ if ('geometry' in value && !isPartialRetroGameGeometry(value.geometry)) {
103
+ return false;
104
+ }
105
+ if ('timing' in value && !isPartialRetroSystemTiming(value.timing)) {
106
+ return false;
107
+ }
108
+
109
+ return true;
110
+ };
111
+
112
+ /**
113
+ * Partial RetroSystemInfo as decoded from koffi.
114
+ */
115
+ export interface PartialRetroSystemInfo {
116
+ library_name?: string;
117
+ library_version?: string;
118
+ valid_extensions?: string;
119
+ need_fullpath?: boolean;
120
+ block_extract?: boolean;
121
+ }
122
+
123
+ /**
124
+ * Type guard for partial RetroSystemInfo from FFI decoding.
125
+ */
126
+ export const isPartialRetroSystemInfo = (value: unknown): value is PartialRetroSystemInfo => {
127
+ if (!isRecord(value)) {
128
+ return false;
129
+ }
130
+
131
+ if ('library_name' in value && !isString(value.library_name)) {
132
+ return false;
133
+ }
134
+ if ('library_version' in value && !isString(value.library_version)) {
135
+ return false;
136
+ }
137
+ if ('valid_extensions' in value && !isString(value.valid_extensions)) {
138
+ return false;
139
+ }
140
+ if ('need_fullpath' in value && !isBoolean(value.need_fullpath)) {
141
+ return false;
142
+ }
143
+ if ('block_extract' in value && !isBoolean(value.block_extract)) {
144
+ return false;
145
+ }
146
+
147
+ return true;
148
+ };
@@ -0,0 +1,41 @@
1
+ // =============================================================================
2
+ // Audio Constants
3
+ // =============================================================================
4
+
5
+ /** Initial audio buffer capacity in samples (stereo frames * 2 channels) */
6
+ export const INITIAL_AUDIO_BUFFER_SIZE = 4096;
7
+
8
+ /** Maximum value for signed 16-bit audio samples */
9
+ export const INT16_MAX = 32768;
10
+
11
+ /** Audio buffer growth factor (1.5x is more memory-efficient than 2x) */
12
+ export const AUDIO_BUFFER_GROWTH_FACTOR = 1.5;
13
+
14
+ // =============================================================================
15
+ // Input Constants
16
+ // =============================================================================
17
+
18
+ /** Libretro RETRO_DEVICE_ID_JOYPAD_MASK value for bitmask input */
19
+ export const JOYPAD_BITMASK_ID = 256;
20
+
21
+ /** Minimum value for signed 16-bit (used for analog clamping) */
22
+ export const INT16_MIN = -32768;
23
+
24
+ /** Maximum positive value for signed 16-bit (used for analog normalization) */
25
+ export const INT16_MAX_POSITIVE = 32767;
26
+
27
+ // =============================================================================
28
+ // Debug Logging Constants
29
+ // =============================================================================
30
+
31
+ /** Number of initial video callbacks to log with detailed info */
32
+ export const DEBUG_VIDEO_CALLBACK_COUNT = 10;
33
+
34
+ /** Number of initial frames to always log video frame info */
35
+ export const DEBUG_INITIAL_FRAMES_TO_LOG = 3;
36
+
37
+ /** Frames between periodic video frame log messages (every N frames) */
38
+ export const DEBUG_VIDEO_FRAME_LOG_INTERVAL = 60;
39
+
40
+ /** Minimum analog value change threshold to trigger debug logging */
41
+ export const DEBUG_ANALOG_CHANGE_THRESHOLD = 1000;
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Callback manager for libretro cores
3
+ * Handles video, audio, and input callbacks from native code
4
+ */
5
+
6
+ import { clamp } from 'remeda';
7
+ import koffi from "koffi";
8
+ import type { LibretroAPI, KoffiCallback } from "../api";
9
+ import {
10
+ retro_environment_t,
11
+ retro_video_refresh_t,
12
+ retro_audio_sample_t,
13
+ retro_audio_sample_batch_t,
14
+ retro_input_poll_t,
15
+ retro_input_state_t,
16
+ } from "../api";
17
+ import type { EnvironmentHandler } from "../environment";
18
+ import { RETRO_DEVICE, RETRO_DEVICE_INDEX_ANALOG, RETRO_DEVICE_ID_ANALOG, FRAMEBUFFER_HEADROOM } from "..";
19
+ import { logger } from "@/utils/logger";
20
+
21
+ // Callback-specific constants
22
+ import {
23
+ INITIAL_AUDIO_BUFFER_SIZE,
24
+ JOYPAD_BITMASK_ID,
25
+ INT16_MAX,
26
+ INT16_MIN,
27
+ INT16_MAX_POSITIVE,
28
+ AUDIO_BUFFER_GROWTH_FACTOR,
29
+ DEBUG_VIDEO_CALLBACK_COUNT,
30
+ DEBUG_INITIAL_FRAMES_TO_LOG,
31
+ DEBUG_VIDEO_FRAME_LOG_INTERVAL,
32
+ DEBUG_ANALOG_CHANGE_THRESHOLD,
33
+ } from "./consts";
34
+
35
+ export * from './consts';
36
+
37
+ /**
38
+ * CallbackManager handles all callbacks between the libretro core and the frontend
39
+ */
40
+ export class CallbackManager {
41
+ // Registered koffi callbacks - must keep references to prevent GC
42
+ private environmentCallback: KoffiCallback | null = null;
43
+ private videoCallback: KoffiCallback | null = null;
44
+ private audioSampleCallback: KoffiCallback | null = null;
45
+ private audioBatchCallback: KoffiCallback | null = null;
46
+ private inputPollCallback: KoffiCallback | null = null;
47
+ private inputStateCallback: KoffiCallback | null = null;
48
+
49
+ // Frame data
50
+ framebuffer: Uint8Array | null = null;
51
+ frameWidth = 0;
52
+ frameHeight = 0;
53
+ framePitch = 0;
54
+ private framebufferCapacity = 0;
55
+
56
+ // Audio buffer (grows as needed)
57
+ private audioBufferCapacity = INITIAL_AUDIO_BUFFER_SIZE;
58
+ audioBuffer: Int16Array = new Int16Array(this.audioBufferCapacity);
59
+ audioSamples = 0;
60
+
61
+ // Reusable Float32Array for drainAudio() output to avoid per-frame allocations
62
+ private audioOutputBuffer: Float32Array | null = null;
63
+ private audioOutputCapacity = 0;
64
+
65
+ // Input state per port - use arrays for O(1) access
66
+ // buttonState[port][buttonId] = pressed (boolean), sparse arrays allow undefined
67
+ private buttonState: Array<Array<boolean | undefined> | undefined> = [];
68
+ // Cached bitmask per port - updated on setButtonState() for O(1) bitmask queries
69
+ private buttonBitmask: number[] = [];
70
+ // Analog state per port - analogState[port][index][axis] = value (-32768 to 32767)
71
+ // index: 0=left stick, 1=right stick; axis: 0=X, 1=Y
72
+ private analogState: Array<Array<Array<number> | undefined> | undefined> = [];
73
+
74
+ constructor(private envHandler: EnvironmentHandler) {}
75
+
76
+ /**
77
+ * Create and register all callbacks with the libretro API
78
+ */
79
+ createCallbacks(api: LibretroAPI): void {
80
+ // Environment callback - MUST be set before retro_init() for some cores
81
+ this.environmentCallback = koffi.register(
82
+ (cmd: number, data: Buffer | null): boolean => {
83
+ return this.envHandler.handle(cmd, data);
84
+ },
85
+ koffi.pointer(retro_environment_t)
86
+ );
87
+ api.retro_set_environment(this.environmentCallback);
88
+
89
+ // Video refresh callback
90
+ this.videoCallback = koffi.register(
91
+ (
92
+ data: Buffer | null,
93
+ width: number,
94
+ height: number,
95
+ pitch: number
96
+ ): void => {
97
+ // Debug: Log callback invocation (first N calls with detailed info)
98
+ if (this.debugFrameCount < DEBUG_VIDEO_CALLBACK_COUNT) {
99
+ const dataInfo = data ? `present (type=${typeof data}, length=${data.length || 'N/A'})` : 'null';
100
+ logger.debug(`Video callback #${this.debugFrameCount + 1}: data=${dataInfo}, ${width}x${height}, pitch=${pitch}`, 'Video');
101
+ }
102
+ this.handleVideoRefresh(data, width, height, pitch);
103
+ },
104
+ koffi.pointer(retro_video_refresh_t)
105
+ );
106
+ api.retro_set_video_refresh(this.videoCallback);
107
+ logger.debug('Video callback registered', 'Video');
108
+
109
+ // Audio sample callback (single sample, stereo)
110
+ this.audioSampleCallback = koffi.register(
111
+ (left: number, right: number): void => {
112
+ this.handleAudioSample(left, right);
113
+ },
114
+ koffi.pointer(retro_audio_sample_t)
115
+ );
116
+ api.retro_set_audio_sample(this.audioSampleCallback);
117
+
118
+ // Audio sample batch callback (multiple frames at once)
119
+ this.audioBatchCallback = koffi.register(
120
+ (data: Buffer | null, frames: number): number => {
121
+ return this.handleAudioBatch(data, frames);
122
+ },
123
+ koffi.pointer(retro_audio_sample_batch_t)
124
+ );
125
+ api.retro_set_audio_sample_batch(this.audioBatchCallback);
126
+
127
+ // Input poll callback
128
+ this.inputPollCallback = koffi.register((): void => {
129
+ // No-op - we update input state externally
130
+ }, koffi.pointer(retro_input_poll_t));
131
+ api.retro_set_input_poll(this.inputPollCallback);
132
+
133
+ // Input state callback
134
+ this.inputStateCallback = koffi.register(
135
+ (port: number, device: number, index: number, id: number): number => {
136
+ return this.handleInputState(port, device, index, id);
137
+ },
138
+ koffi.pointer(retro_input_state_t)
139
+ );
140
+ api.retro_set_input_state(this.inputStateCallback);
141
+ }
142
+
143
+ // Debug: track frame count for video callback diagnostics
144
+ private debugFrameCount = 0;
145
+
146
+ /**
147
+ * Handle video refresh callback from core
148
+ */
149
+ private handleVideoRefresh(
150
+
151
+ data: any,
152
+ width: number,
153
+ height: number,
154
+ pitch: number
155
+ ): void {
156
+ // data can be null for duplicate frames when GET_CAN_DUPE is true
157
+ if (!data) {return;}
158
+
159
+ this.frameWidth = width;
160
+ this.frameHeight = height;
161
+ this.framePitch = pitch;
162
+
163
+ // Calculate required buffer size (pitch * height)
164
+ const requiredSize = pitch * height;
165
+
166
+ // Allocate or grow framebuffer if needed
167
+ if (!this.framebuffer || this.framebufferCapacity < requiredSize) {
168
+ // Allocate with some headroom
169
+ this.framebufferCapacity = requiredSize + FRAMEBUFFER_HEADROOM;
170
+ this.framebuffer = new Uint8Array(this.framebufferCapacity);
171
+ }
172
+
173
+ // Use koffi.view() to get direct access to the framebuffer memory without copying
174
+ // This creates an ArrayBuffer view into the native memory
175
+ const arrayBuffer = koffi.view(data, requiredSize);
176
+ const srcData = new Uint8Array(arrayBuffer);
177
+
178
+ // Copy to our internal buffer (we must copy because the source memory
179
+ // is only valid during this callback)
180
+ this.framebuffer.set(srcData);
181
+
182
+ // Debug: log frame info periodically (first few frames and then at regular intervals)
183
+ this.debugFrameCount++;
184
+ if (this.debugFrameCount <= DEBUG_INITIAL_FRAMES_TO_LOG || this.debugFrameCount % DEBUG_VIDEO_FRAME_LOG_INTERVAL === 0) {
185
+ const pixelFormat = this.envHandler.getPixelFormat();
186
+ logger.debug(`Video frame ${this.debugFrameCount}: ${width}x${height}, pitch=${pitch}, format=${pixelFormat}`, 'Video');
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handle single audio sample callback (less common, used by some cores)
192
+ */
193
+ private handleAudioSample(left: number, right: number): void {
194
+ this.ensureAudioCapacity(2);
195
+ this.audioBuffer[this.audioSamples++] = left;
196
+ this.audioBuffer[this.audioSamples++] = right;
197
+ }
198
+
199
+ /**
200
+ * Handle audio batch callback (most common, more efficient)
201
+ */
202
+
203
+ private handleAudioBatch(data: any, frames: number): number {
204
+ if (!data || frames === 0) {return frames;}
205
+
206
+ // Each frame is 2 samples (left, right) as int16 (2 bytes each)
207
+ const samples = frames * 2;
208
+ const byteSize = samples * 2; // 2 bytes per int16 sample
209
+ this.ensureAudioCapacity(samples);
210
+
211
+ // Use koffi.view() to get direct access to the audio memory
212
+ const arrayBuffer = koffi.view(data, byteSize);
213
+ const srcData = new Int16Array(arrayBuffer);
214
+
215
+ // Copy to our internal buffer
216
+ for (let i = 0; i < samples; i++) {
217
+ this.audioBuffer[this.audioSamples++] = srcData[i];
218
+ }
219
+
220
+ return frames;
221
+ }
222
+
223
+ /**
224
+ * Ensure audio buffer has enough capacity.
225
+ * Uses 1.5x growth factor for more memory-efficient expansion.
226
+ */
227
+ private ensureAudioCapacity(additionalSamples: number): void {
228
+ const required = this.audioSamples + additionalSamples;
229
+ if (required > this.audioBufferCapacity) {
230
+ // Grow by 1.5x until sufficient (more memory-efficient than 2x)
231
+ while (this.audioBufferCapacity < required) {
232
+ this.audioBufferCapacity = Math.ceil(this.audioBufferCapacity * AUDIO_BUFFER_GROWTH_FACTOR);
233
+ }
234
+ const newBuffer = new Int16Array(this.audioBufferCapacity);
235
+ newBuffer.set(this.audioBuffer.subarray(0, this.audioSamples));
236
+ this.audioBuffer = newBuffer;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Handle input state query from core
242
+ * Supports both joypad (digital buttons) and analog stick queries.
243
+ * Uses cached bitmask for O(1) bitmask queries and array lookup for individual buttons.
244
+ */
245
+ private handleInputState(
246
+ port: number,
247
+ device: number,
248
+ index: number,
249
+ id: number
250
+ ): number {
251
+ // Handle analog stick queries
252
+ if (device === RETRO_DEVICE.ANALOG) {
253
+ return this.getAnalogState(port, index, id);
254
+ }
255
+
256
+ // Handle joypad (digital button) queries
257
+ if (device !== RETRO_DEVICE.JOYPAD) {return 0;}
258
+
259
+ // id=JOYPAD_BITMASK_ID (256) is RETRO_DEVICE_ID_JOYPAD_MASK - return cached bitmask
260
+ if (id === JOYPAD_BITMASK_ID) {
261
+ return this.buttonBitmask[port] ?? 0;
262
+ }
263
+
264
+ // Individual button query - O(1) array lookup
265
+ const portState = this.buttonState[port];
266
+ if (!portState) {return 0;}
267
+ return portState[id] ? 1 : 0;
268
+ }
269
+
270
+ // Track which analog indices have been queried
271
+ private loggedAnalogIndices = new Set<number>();
272
+
273
+ /**
274
+ * Get analog axis value for a port/stick/axis
275
+ * @param port - Controller port (0-based)
276
+ * @param index - Analog stick (0=left, 1=right, 2=analog buttons)
277
+ * @param axis - Axis (0=X, 1=Y)
278
+ * @returns Analog value from -32768 to 32767
279
+ */
280
+ // Track last returned values to avoid spamming logs
281
+ private lastLoggedAnalogValue: Map<string, number> = new Map();
282
+
283
+ private getAnalogState(port: number, index: number, axis: number): number {
284
+ const portState = this.analogState[port];
285
+ if (!portState) {return 0;}
286
+ const stickState = portState[index];
287
+ if (!stickState) {return 0;}
288
+ const value = stickState[axis] ?? 0;
289
+
290
+ // Debug: Log first time core queries each analog index
291
+ if (!this.loggedAnalogIndices.has(index)) {
292
+ logger.debug(`Core queries analog index=${index} (LEFT=0, RIGHT=1)`, 'Input');
293
+ this.loggedAnalogIndices.add(index);
294
+ }
295
+
296
+ // Debug: Log significant return values (changed by more than threshold from last logged)
297
+ const key = `${port}.${index}.${axis}`;
298
+ const lastLogged = this.lastLoggedAnalogValue.get(key) ?? 0;
299
+ if (Math.abs(value - lastLogged) > DEBUG_ANALOG_CHANGE_THRESHOLD) {
300
+ logger.debug(`getAnalogState RETURN: port=${port} index=${index} axis=${axis} → ${value}`, 'Input');
301
+ this.lastLoggedAnalogValue.set(key, value);
302
+ }
303
+
304
+ return value;
305
+ }
306
+
307
+ /**
308
+ * Set button state for input handling.
309
+ * Updates both the button array and cached bitmask for O(1) queries.
310
+ */
311
+ setButtonState(port: number, button: number, pressed: boolean): void {
312
+ // Initialize port state array if needed
313
+ if (!this.buttonState[port]) {
314
+ this.buttonState[port] = [];
315
+ this.buttonBitmask[port] = 0;
316
+ }
317
+
318
+ // Update button state
319
+ this.buttonState[port][button] = pressed;
320
+
321
+ // Update cached bitmask
322
+ if (pressed) {
323
+ this.buttonBitmask[port] |= (1 << button);
324
+ } else {
325
+ this.buttonBitmask[port] &= ~(1 << button);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Get all button states for a port.
331
+ * Converts internal array to Map for API compatibility.
332
+ */
333
+ getButtonState(port: number): Map<number, boolean> {
334
+ const portState = this.buttonState[port];
335
+ if (!portState) {return new Map<number, boolean>();}
336
+
337
+ const result = new Map<number, boolean>();
338
+ for (let i = 0; i < portState.length; i++) {
339
+ const pressed = portState[i];
340
+ if (pressed !== undefined) {
341
+ result.set(i, pressed);
342
+ }
343
+ }
344
+ return result;
345
+ }
346
+
347
+ /**
348
+ * Set analog axis state for a port/stick/axis.
349
+ * @param port - Controller port (0-based)
350
+ * @param index - Analog stick (0=left, 1=right from RETRO_DEVICE_INDEX_ANALOG)
351
+ * @param axis - Axis (0=X, 1=Y from RETRO_DEVICE_ID_ANALOG)
352
+ * @param value - Analog value from -32768 to 32767
353
+ */
354
+ setAnalogState(port: number, index: number, axis: number, value: number): void {
355
+ // Clamp value to valid range
356
+ const clampedValue = clamp(Math.round(value), { min: INT16_MIN, max: INT16_MAX_POSITIVE });
357
+
358
+ // Debug: Log significant analog values being stored
359
+ if (Math.abs(clampedValue) > DEBUG_ANALOG_CHANGE_THRESHOLD) {
360
+ logger.debug(`setAnalogState: port=${port} index=${index} axis=${axis} value=${clampedValue}`, 'Input');
361
+ }
362
+
363
+ // Initialize port state if needed
364
+ if (!this.analogState[port]) {
365
+ this.analogState[port] = [];
366
+ }
367
+ // Initialize stick state if needed
368
+ if (!this.analogState[port]![index]) {
369
+ this.analogState[port]![index] = [0, 0];
370
+ }
371
+ // Set axis value
372
+ this.analogState[port]![index]![axis] = clampedValue;
373
+ }
374
+
375
+ /**
376
+ * Get all analog states for a port.
377
+ * Returns a map of "index.axis" -> value
378
+ */
379
+ getAnalogStates(port: number): Map<string, number> {
380
+ const result = new Map<string, number>();
381
+ const portState = this.analogState[port];
382
+ if (!portState) {return result;}
383
+
384
+ // Iterate over both analog sticks (left=0, right=1)
385
+ const analogIndices = [RETRO_DEVICE_INDEX_ANALOG.LEFT, RETRO_DEVICE_INDEX_ANALOG.RIGHT];
386
+ const axisIds = [RETRO_DEVICE_ID_ANALOG.X, RETRO_DEVICE_ID_ANALOG.Y];
387
+
388
+ for (const index of analogIndices) {
389
+ const stickState = portState[index];
390
+ if (!stickState) {continue;}
391
+ for (const axis of axisIds) {
392
+ const value = stickState[axis];
393
+ // Only include non-zero values to reduce map size
394
+ if (value !== 0) {
395
+ result.set(`${index}.${axis}`, value);
396
+ }
397
+ }
398
+ }
399
+ return result;
400
+ }
401
+
402
+ /**
403
+ * Drain the audio buffer and return samples as Float32Array
404
+ * Converts from Int16 [-32768, 32767] to Float32 [-1.0, 1.0]
405
+ * Reuses internal buffer to avoid per-frame allocations.
406
+ */
407
+ drainAudio(): Float32Array {
408
+ const count = this.audioSamples;
409
+
410
+ // Grow output buffer if needed
411
+ if (!this.audioOutputBuffer || this.audioOutputCapacity < count) {
412
+ this.audioOutputCapacity = Math.max(count, this.audioBufferCapacity);
413
+ this.audioOutputBuffer = new Float32Array(this.audioOutputCapacity);
414
+ }
415
+
416
+ const output = this.audioOutputBuffer;
417
+ const input = this.audioBuffer;
418
+
419
+ for (let i = 0; i < count; i++) {
420
+ output[i] = input[i] / INT16_MAX;
421
+ }
422
+
423
+ this.audioSamples = 0;
424
+ return output.subarray(0, count);
425
+ }
426
+
427
+ /**
428
+ * Check if there are audio samples available
429
+ */
430
+ hasAudio(): boolean {
431
+ return this.audioSamples > 0;
432
+ }
433
+
434
+ /**
435
+ * Clean up callbacks
436
+ */
437
+ destroy(): void {
438
+ // Note: koffi registered callbacks are cleaned up when the library is unloaded
439
+ // We just need to clear our references
440
+ this.environmentCallback = null;
441
+ this.videoCallback = null;
442
+ this.audioSampleCallback = null;
443
+ this.audioBatchCallback = null;
444
+ this.inputPollCallback = null;
445
+ this.inputStateCallback = null;
446
+ this.framebuffer = null;
447
+ this.audioBuffer = new Int16Array(0);
448
+ this.audioOutputBuffer = null;
449
+ this.audioOutputCapacity = 0;
450
+ this.audioSamples = 0;
451
+ this.buttonState = [];
452
+ this.buttonBitmask = [];
453
+ this.analogState = [];
454
+ this.lastLoggedAnalogValue.clear();
455
+ }
456
+ }
@@ -0,0 +1,45 @@
1
+ // =============================================================================
2
+ // Audio Constants
3
+ // =============================================================================
4
+
5
+ /** Default audio sample rate in Hz (standard for libretro) */
6
+ export const DEFAULT_SAMPLE_RATE = 44100;
7
+
8
+ // =============================================================================
9
+ // Input Constants
10
+ // =============================================================================
11
+
12
+ /** Maximum positive value for signed 16-bit analog values */
13
+ export const INT16_MAX_POSITIVE = 32767;
14
+
15
+ // =============================================================================
16
+ // Video/Buffer Constants
17
+ // =============================================================================
18
+
19
+ /** Extra headroom bytes added when allocating framebuffer */
20
+ export const FRAMEBUFFER_HEADROOM = 1024;
21
+
22
+ /** Number of bytes per RGB24 pixel */
23
+ export const RGB24_BYTES_PER_PIXEL = 3;
24
+
25
+ // =============================================================================
26
+ // Number Formatting Constants
27
+ // =============================================================================
28
+
29
+ export { HEX_RADIX } from '../../utils';
30
+
31
+ /** Decimal places for aspect ratio formatting */
32
+ export const ASPECT_RATIO_DECIMALS = 3;
33
+
34
+ /** Decimal places for FPS/sample rate formatting */
35
+ export const FPS_DECIMALS = 2;
36
+
37
+ // =============================================================================
38
+ // Debug Constants
39
+ // =============================================================================
40
+
41
+ /** Number of initial frames to log timing for */
42
+ export const DEBUG_INITIAL_FRAME_LOG_COUNT = 5;
43
+
44
+ /** Maximum normalized analog value threshold (for int16 conversion detection) */
45
+ export const ANALOG_NORMALIZED_THRESHOLD = 1.5;