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,984 @@
1
+ /**
2
+ * Sync Manager for Netplay Rollback
3
+ *
4
+ * Coordinates frame synchronization between local and remote players:
5
+ * - Tracks three key frame pointers: self, other, unread
6
+ * - Detects when rollback is needed
7
+ * - Performs state restoration and replay
8
+ * - Handles desync detection via CRC comparison
9
+ */
10
+
11
+ import { EventEmitter } from 'events';
12
+ import { times, find, flatMap, pipe, map, filter, isDefined } from 'remeda';
13
+ import { FrameBuffer } from '../FrameBuffer';
14
+ import { InputBuffer } from '../InputBuffer';
15
+ import {
16
+ TIMING,
17
+ MAX_INPUT_DEVICES,
18
+ DEFAULT_FRAME_BUFFER_SIZE,
19
+ MAX_FRAMES_BEHIND,
20
+ CATCH_UP_THRESHOLD,
21
+ } from '..';
22
+
23
+ /** Number of input values per device */
24
+ const INPUTS_PER_DEVICE = 3;
25
+
26
+ /** Events emitted by sync manager */
27
+ interface SyncManagerEvents {
28
+ /** Rollback is about to start */
29
+ 'rollback-start': (fromFrame: number, toFrame: number) => void;
30
+ /** Rollback completed */
31
+ 'rollback-end': (framesReplayed: number) => void;
32
+ /** Desync detected */
33
+ desync: (frameNumber: number, localCrc: number, remoteCrc: number) => void;
34
+ /** State capture requested */
35
+ 'capture-state': (frameNumber: number) => void;
36
+ /** State restore requested */
37
+ 'restore-state': (frameNumber: number, state: Buffer) => void;
38
+ /** Run frame requested (for replay) */
39
+ 'run-frame': (frameNumber: number, input: number[]) => void;
40
+ }
41
+
42
+ /** Configuration for sync manager */
43
+ export interface SyncManagerConfig {
44
+ /** Frame buffer capacity */
45
+ frameBufferSize?: number;
46
+ /** How often to send CRC checks (frames) */
47
+ crcCheckInterval?: number;
48
+ /** Maximum frames to allow falling behind before stalling */
49
+ maxFramesBehind?: number;
50
+ /** Local client ID */
51
+ localClientId: number;
52
+ /** Input delay frames */
53
+ inputDelayFrames?: number;
54
+ /** Is this the server/host? Servers don't stall waiting for client input */
55
+ isServer?: boolean;
56
+ }
57
+
58
+ /** Desync info for debugging */
59
+ export interface DesyncInfo {
60
+ frameNumber: number;
61
+ localCrc: number;
62
+ remoteCrc: number;
63
+ timestamp: number;
64
+ }
65
+
66
+ /**
67
+ * SyncManager coordinates rollback netplay synchronization.
68
+ *
69
+ * Frame pointers:
70
+ * - self: Current local frame (may be ahead of confirmed state)
71
+ * - other: Last frame where all input is confirmed real
72
+ * - unread: First frame with missing/simulated remote input
73
+ */
74
+ export class SyncManager extends EventEmitter {
75
+ private readonly frameBuffer: FrameBuffer;
76
+ private readonly inputBuffer: InputBuffer;
77
+ private readonly config: Required<SyncManagerConfig>;
78
+
79
+ /** Current local frame number (input read position - frame we're preparing input for) */
80
+ private _selfFrame: number = -1;
81
+
82
+ /** Last completed frame (execution position - frame that has finished running) */
83
+ private _runFrame: number = -1;
84
+
85
+ /** Last fully synchronized frame */
86
+ private _otherFrame: number = -1;
87
+
88
+ /** First frame with incomplete input */
89
+ private _unreadFrame: number | null = null;
90
+
91
+ /** Remote client IDs we're tracking */
92
+ private remoteClients: Set<number> = new Set();
93
+
94
+ /** Map of client ID to their assigned device indices */
95
+ private clientDeviceMap: Map<number, number[]> = new Map();
96
+
97
+ /** Pending CRC checks from remote (frame -> crc) */
98
+ private remoteCrcChecks: Map<number, number> = new Map();
99
+
100
+ /** Recent desync history for debugging */
101
+ private desyncHistory: DesyncInfo[] = [];
102
+
103
+ /** Are we currently in a rollback? */
104
+ private _inRollback: boolean = false;
105
+
106
+ /** Frame number we need to rollback to (-1 if none) */
107
+ private rollbackTarget: number = -1;
108
+
109
+ /** Server-requested stall frames remaining */
110
+ private requestedStallFrames: number = 0;
111
+
112
+ /** Latest frame number received from any remote client (for catch-up detection) */
113
+ private _latestRemoteFrame: number = -1;
114
+
115
+ /** Initial sync frame (frames at or before this don't need remote input) */
116
+ private _initialFrame: number = -1;
117
+
118
+ /**
119
+ * Track the "sync gap end" per client - the frame number of the first INPUT received.
120
+ * Frames from (_initialFrame + 1) to (syncGapEnd - 1) are in the "sync gap" and
121
+ * should be considered as having real (empty) input from this client.
122
+ * This handles the case where INPUT arrives before those frames exist in the buffer.
123
+ */
124
+ private syncGapEnd: Map<number, number> = new Map();
125
+
126
+ /**
127
+ * Per-client read frame tracking (similar to RetroArch's read_frame_count[]).
128
+ * Tracks the next frame we need real input for from each client.
129
+ * This enables O(C) sync pointer updates instead of O(B×C) buffer scans.
130
+ */
131
+ private readFramePerClient: Map<number, number> = new Map();
132
+
133
+ /**
134
+ * Pending remote input for frames that don't exist yet.
135
+ * Map<frameNumber, Map<clientId, { input: number[], isReal: boolean }>>
136
+ * When frames are created, pending input is applied automatically.
137
+ */
138
+ private pendingRemoteInput: Map<number, Map<number, { input: number[]; isReal: boolean }>> = new Map();
139
+
140
+ /**
141
+ * Pre-allocated buffer for merged input (avoids per-frame allocation).
142
+ * Reused each frame - callers must copy if they need to retain the data.
143
+ */
144
+ private readonly mergedInputBuffer: number[];
145
+
146
+ /** Statistics */
147
+ private stats = {
148
+ rollbackCount: 0,
149
+ totalFramesReplayed: 0,
150
+ desyncCount: 0,
151
+ };
152
+
153
+ constructor(config: SyncManagerConfig) {
154
+ super();
155
+
156
+ this.config = {
157
+ frameBufferSize: config.frameBufferSize ?? DEFAULT_FRAME_BUFFER_SIZE,
158
+ crcCheckInterval: config.crcCheckInterval ?? TIMING.CRC_CHECK_INTERVAL_FRAMES,
159
+ maxFramesBehind: config.maxFramesBehind ?? MAX_FRAMES_BEHIND,
160
+ localClientId: config.localClientId,
161
+ inputDelayFrames: config.inputDelayFrames ?? 0,
162
+ isServer: config.isServer ?? false,
163
+ };
164
+
165
+ this.frameBuffer = new FrameBuffer(this.config.frameBufferSize);
166
+ this.inputBuffer = new InputBuffer();
167
+ this.inputBuffer.initialize(this.config.localClientId, this.config.inputDelayFrames);
168
+
169
+ // Pre-allocate merged input buffer (reused each frame)
170
+ this.mergedInputBuffer = new Array<number>(MAX_INPUT_DEVICES * INPUTS_PER_DEVICE).fill(0);
171
+
172
+ // Local client controls device 0 by default
173
+ this.clientDeviceMap.set(this.config.localClientId, [0]);
174
+ }
175
+
176
+ /** Current local frame number */
177
+ get selfFrame(): number {
178
+ return this._selfFrame;
179
+ }
180
+
181
+ /** Last completed frame (execution position) */
182
+ get runFrame(): number {
183
+ return this._runFrame;
184
+ }
185
+
186
+ /** Last fully synchronized frame (all input confirmed) */
187
+ get otherFrame(): number {
188
+ return this._otherFrame;
189
+ }
190
+
191
+ /** First frame with missing remote input */
192
+ get unreadFrame(): number | null {
193
+ return this._unreadFrame;
194
+ }
195
+
196
+ /** Are we currently performing a rollback? */
197
+ get inRollback(): boolean {
198
+ return this._inRollback;
199
+ }
200
+
201
+ /** Input delay in frames */
202
+ get inputDelayFrames(): number {
203
+ return this.config.inputDelayFrames;
204
+ }
205
+
206
+ /** Get rollback statistics */
207
+ get statistics(): Readonly<typeof this.stats> {
208
+ return this.stats;
209
+ }
210
+
211
+ /** Get the frame buffer (for external state access) */
212
+ getFrameBuffer(): FrameBuffer {
213
+ return this.frameBuffer;
214
+ }
215
+
216
+ /** Get the input buffer */
217
+ getInputBuffer(): InputBuffer {
218
+ return this.inputBuffer;
219
+ }
220
+
221
+ /**
222
+ * Request a stall for a specific number of frames.
223
+ * Called when server sends STALL command to throttle a fast client.
224
+ */
225
+ requestStall(frames: number): void {
226
+ // Add to existing stall frames (don't overwrite if already stalling)
227
+ this.requestedStallFrames += frames;
228
+ }
229
+
230
+ /**
231
+ * Initialize sync manager at a specific starting frame.
232
+ * Called when syncing with server or starting fresh.
233
+ */
234
+ initialize(startFrame: number, initialState?: Buffer): void {
235
+ this._selfFrame = startFrame;
236
+ this._runFrame = startFrame; // Both counters start at same frame
237
+ this._otherFrame = startFrame;
238
+ this._unreadFrame = null;
239
+ this.rollbackTarget = -1;
240
+ this._inRollback = false;
241
+ this._initialFrame = startFrame;
242
+ this.requestedStallFrames = 0;
243
+ this.syncGapEnd.clear();
244
+ this.pendingRemoteInput.clear();
245
+ this.readFramePerClient.clear();
246
+
247
+ this.frameBuffer.initializeAt(startFrame);
248
+
249
+ if (initialState) {
250
+ this.frameBuffer.setState(startFrame, initialState);
251
+ }
252
+
253
+ // Mark initial frame as synced for all existing remote clients
254
+ // and initialize per-client read frame tracking
255
+ for (const clientId of this.remoteClients) {
256
+ this.frameBuffer.setRemoteInput(startFrame, clientId, [], true);
257
+ // Next frame we need real input for is startFrame + 1
258
+ this.readFramePerClient.set(clientId, startFrame + 1);
259
+ }
260
+
261
+ this.remoteCrcChecks.clear();
262
+ this.desyncHistory = [];
263
+ }
264
+
265
+ /**
266
+ * Register a remote client for input tracking.
267
+ */
268
+ addRemoteClient(clientId: number, deviceIndices: number[] = []): void {
269
+ this.remoteClients.add(clientId);
270
+ this.inputBuffer.registerClient(clientId, false);
271
+
272
+ // Assign device indices (default to next available)
273
+ if (deviceIndices.length === 0) {
274
+ const usedDevices = new Set(
275
+ flatMap([...this.clientDeviceMap.values()], (devices) => devices)
276
+ );
277
+ const firstUnused = find(times(MAX_INPUT_DEVICES, (i) => i), (i) => !usedDevices.has(i));
278
+ if (firstUnused !== undefined) {
279
+ deviceIndices = [firstUnused];
280
+ }
281
+ }
282
+
283
+ this.clientDeviceMap.set(clientId, deviceIndices);
284
+
285
+ // Mark initial frame as synced for this client (initial state doesn't need input)
286
+ if (this._initialFrame >= 0) {
287
+ this.frameBuffer.setRemoteInput(this._initialFrame, clientId, [], true);
288
+ // Initialize per-client read frame to initial frame + 1 (next frame we need input for)
289
+ this.readFramePerClient.set(clientId, this._initialFrame + 1);
290
+ } else {
291
+ // No initial frame yet, will be set when initialize() is called
292
+ this.readFramePerClient.set(clientId, 0);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Remove a remote client.
298
+ */
299
+ removeRemoteClient(clientId: number): void {
300
+ this.remoteClients.delete(clientId);
301
+ this.inputBuffer.unregisterClient(clientId);
302
+ this.clientDeviceMap.delete(clientId);
303
+ this.readFramePerClient.delete(clientId);
304
+ this.syncGapEnd.delete(clientId);
305
+ }
306
+
307
+ /**
308
+ * Update the local client ID.
309
+ * Called when MODE command assigns our client number.
310
+ * This must be called BEFORE updateLocalDevices to avoid conflicts
311
+ * with remote clients that may share the old ID.
312
+ *
313
+ * @param clientId The client ID assigned by the server
314
+ */
315
+ updateLocalClientId(clientId: number): void {
316
+ const oldId = this.config.localClientId;
317
+
318
+ // Copy device mapping from old ID to new ID
319
+ const oldDevices = this.clientDeviceMap.get(oldId);
320
+ if (oldDevices) {
321
+ // Only delete the old mapping if it's not used by a remote client
322
+ // (e.g., server is client 0, and we were also using 0 temporarily)
323
+ if (!this.remoteClients.has(oldId)) {
324
+ this.clientDeviceMap.delete(oldId);
325
+ }
326
+ this.clientDeviceMap.set(clientId, [...oldDevices]);
327
+ }
328
+
329
+ // Update the config (cast to mutable to update)
330
+ (this.config as { localClientId: number }).localClientId = clientId;
331
+
332
+ // Update input buffer's local client ID
333
+ this.inputBuffer.updateLocalClientId(clientId);
334
+ }
335
+
336
+ /**
337
+ * Update the local client's device mapping.
338
+ * Called when MODE command assigns a device to us.
339
+ *
340
+ * @param deviceBitmap Bitmask of device indices this client controls
341
+ */
342
+ updateLocalDevices(deviceBitmap: number): void {
343
+ // Extract device indices from bitmap
344
+ const devices: number[] = [];
345
+ for (let i = 0; i < MAX_INPUT_DEVICES; i++) {
346
+ if ((deviceBitmap & (1 << i)) !== 0) {
347
+ devices.push(i);
348
+ }
349
+ }
350
+
351
+ // If no devices set in bitmap, default to device 0
352
+ if (devices.length === 0) {
353
+ devices.push(0);
354
+ }
355
+
356
+ this.clientDeviceMap.set(this.config.localClientId, devices);
357
+ }
358
+
359
+ /**
360
+ * Get all remote client IDs.
361
+ */
362
+ getRemoteClientIds(): number[] {
363
+ return Array.from(this.remoteClients);
364
+ }
365
+
366
+ /**
367
+ * Called before running a frame.
368
+ * Sets up input and checks if we need to rollback.
369
+ *
370
+ * Returns the merged input to use for this frame, or null if we should stall.
371
+ * shouldCatchUp indicates the client is behind and should disable frame limiter.
372
+ */
373
+ preFrame(localInput: number[]): { input: number[]; shouldStall: boolean; shouldCatchUp: boolean } | null {
374
+ // Queue local input with delay
375
+ this.inputBuffer.queueLocalInput(this._selfFrame + 1, localInput);
376
+
377
+ // Check for server-requested stall (STALL command)
378
+ if (this.requestedStallFrames > 0) {
379
+ this.requestedStallFrames--;
380
+ return { input: [], shouldStall: true, shouldCatchUp: false };
381
+ }
382
+
383
+ // Check if we're too far ahead of unread (clients only - servers don't stall)
384
+ // Servers are authoritative and continue running regardless of client input
385
+ if (!this.config.isServer && this._unreadFrame !== null) {
386
+ const framesBehind = this._selfFrame + 1 - this._unreadFrame;
387
+ if (framesBehind > this.config.maxFramesBehind) {
388
+ return { input: [], shouldStall: true, shouldCatchUp: false };
389
+ }
390
+ }
391
+
392
+ // Advance to next frame
393
+ this._selfFrame++;
394
+ const frame = this.frameBuffer.advance();
395
+
396
+ // Apply any pending remote input for this frame
397
+ this.applyPendingInput(this._selfFrame);
398
+
399
+ // Get delayed local input for this frame (if available)
400
+ const delayedLocal = this.inputBuffer.getDelayedLocalInput(this._selfFrame);
401
+ if (delayedLocal) {
402
+ frame.localInput = [...delayedLocal];
403
+ }
404
+
405
+ // Build merged input from all clients
406
+ // buildMergedInput returns a reference to internal buffer, so we copy
407
+ const merged = this.buildMergedInput(this._selfFrame);
408
+ const inputCopy = [...merged];
409
+ frame.localInput = inputCopy;
410
+
411
+ // Check if we're behind remote and should catch up (disable frame limiter)
412
+ // This allows smooth fast-forward instead of stuttery pause/resume cycles
413
+ const shouldCatchUp = this._latestRemoteFrame - this._selfFrame > CATCH_UP_THRESHOLD;
414
+
415
+ return { input: inputCopy, shouldStall: false, shouldCatchUp };
416
+ }
417
+
418
+ /**
419
+ * Called after running a frame.
420
+ * Captures state and sends CRC if needed.
421
+ */
422
+ postFrame(serializedState: Buffer): void {
423
+ // Mark this frame as completed (execution position catches up to read position)
424
+ this._runFrame = this._selfFrame;
425
+
426
+ // Store state in frame buffer
427
+ this.frameBuffer.setState(this._selfFrame, serializedState);
428
+
429
+ // Prune old local input from delay queue
430
+ this.inputBuffer.pruneDelayQueue(this._selfFrame - this.config.inputDelayFrames);
431
+
432
+ // Update sync pointers
433
+ this.updateSyncPointers();
434
+
435
+ // Check for CRC verification
436
+ this.checkPendingCrcs();
437
+ }
438
+
439
+ /**
440
+ * Receive remote input from a client.
441
+ * Returns true if this triggered a need for rollback.
442
+ */
443
+ receiveRemoteInput(
444
+ clientId: number,
445
+ frameNumber: number,
446
+ input: number[]
447
+ ): boolean {
448
+ // Record the input
449
+ this.inputBuffer.recordRemoteInput(clientId, frameNumber, input);
450
+
451
+ // Track latest remote frame for catch-up detection
452
+ if (frameNumber > this._latestRemoteFrame) {
453
+ this._latestRemoteFrame = frameNumber;
454
+ }
455
+
456
+ // Track the "sync gap end" when we receive the first INPUT from a client.
457
+ // After LOAD_SAVESTATE, there's typically a 1-3 frame gap where the server
458
+ // doesn't send INPUT. We record where real INPUT starts so we can treat
459
+ // the gap frames as having real (empty) input in isFrameInSyncGap().
460
+ if (!this.syncGapEnd.has(clientId) && this._initialFrame >= 0) {
461
+ this.syncGapEnd.set(clientId, frameNumber);
462
+ // Also try to fill any gap frames that already exist in the buffer
463
+ // For frames that don't exist yet, store as pending
464
+ for (let f = this._initialFrame + 1; f < frameNumber; f++) {
465
+ const stored = this.frameBuffer.setRemoteInput(f, clientId, [], true);
466
+ if (!stored) {
467
+ // Frame doesn't exist yet, store as pending
468
+ this.storePendingInput(f, clientId, [], true);
469
+ }
470
+ }
471
+ // Update per-client read frame to account for sync gap
472
+ // Frames in the gap are considered "read" (real empty input)
473
+ this.readFramePerClient.set(clientId, frameNumber);
474
+ }
475
+
476
+ // Try to store in frame buffer
477
+ // wasNew is true if this is real input replacing simulated input
478
+ const wasNew = this.frameBuffer.setRemoteInput(frameNumber, clientId, input, true);
479
+
480
+ // If frame doesn't exist yet, store as pending for when it's created
481
+ if (!this.frameBuffer.hasFrame(frameNumber)) {
482
+ this.storePendingInput(frameNumber, clientId, input, true);
483
+ }
484
+
485
+ // Update per-client read frame tracking (O(1) operation)
486
+ // This tracks the next frame we need real input for from this client
487
+ this.advanceClientReadFrame(clientId, frameNumber);
488
+
489
+ // Recalculate global sync pointers from per-client tracking (O(C) operation)
490
+ this.updateSyncPointers();
491
+
492
+ if (wasNew && frameNumber <= this._runFrame) {
493
+ // We received real input for a frame we already COMPLETED with simulated input
494
+ // Use _runFrame (not _selfFrame) to avoid triggering rollback for frames still executing
495
+ // This means we need to rollback to replay with correct input
496
+ if (this.rollbackTarget < 0 || frameNumber < this.rollbackTarget) {
497
+ this.rollbackTarget = frameNumber;
498
+ }
499
+ return true;
500
+ }
501
+
502
+ return false;
503
+ }
504
+
505
+ /**
506
+ * Advance frame without input data (for NOINPUT command).
507
+ *
508
+ * Used when the server sends NOINPUT to indicate frame advancement
509
+ * without any input data (e.g., when spectating). This marks the frame
510
+ * as synced with empty input to prevent unnecessary stalling.
511
+ *
512
+ * @param clientId The client ID (typically 0 for server)
513
+ * @param frameNumber The frame that has no input
514
+ */
515
+ advanceFrameWithoutInput(clientId: number, frameNumber: number): void {
516
+ // Track latest remote frame for catch-up detection
517
+ if (frameNumber > this._latestRemoteFrame) {
518
+ this._latestRemoteFrame = frameNumber;
519
+ }
520
+
521
+ // Mark this frame as having real (empty) input
522
+ const emptyInput: number[] = [];
523
+ this.frameBuffer.setRemoteInput(frameNumber, clientId, emptyInput, true);
524
+
525
+ // If frame doesn't exist yet, store as pending
526
+ if (!this.frameBuffer.hasFrame(frameNumber)) {
527
+ this.storePendingInput(frameNumber, clientId, emptyInput, true);
528
+ }
529
+
530
+ // Update per-client read frame tracking
531
+ this.advanceClientReadFrame(clientId, frameNumber);
532
+
533
+ // Recalculate global sync pointers
534
+ this.updateSyncPointers();
535
+ }
536
+
537
+ /**
538
+ * Advance a client's read frame pointer when we receive real input.
539
+ * This enables O(C) sync pointer updates instead of O(B×C) buffer scans.
540
+ *
541
+ * Like RetroArch's read_frame_count[], this tracks the next frame we need
542
+ * real input for from each client.
543
+ */
544
+ private advanceClientReadFrame(clientId: number, frameNumber: number): void {
545
+ const currentReadFrame = this.readFramePerClient.get(clientId) ?? 0;
546
+
547
+ // If this input is for the frame we were waiting for, advance to next
548
+ if (frameNumber === currentReadFrame) {
549
+ // Check if we have real input for subsequent frames too (they may have
550
+ // arrived out of order or been stored as pending)
551
+ let nextFrame = frameNumber + 1;
552
+ const newest = this.frameBuffer.newestFrame;
553
+
554
+ while (nextFrame <= newest) {
555
+ // Check if we have real input for this frame
556
+ const hasRealInput =
557
+ this.frameBuffer.isRemoteInputReal(nextFrame, clientId) ||
558
+ this.isFrameInSyncGap(nextFrame, clientId) ||
559
+ this.hasPendingRealInput(nextFrame, clientId);
560
+
561
+ if (!hasRealInput) {
562
+ break;
563
+ }
564
+ nextFrame++;
565
+ }
566
+
567
+ this.readFramePerClient.set(clientId, nextFrame);
568
+ } else if (frameNumber > currentReadFrame) {
569
+ // Input arrived for a future frame (out of order)
570
+ // Don't advance read pointer yet - we're still waiting for currentReadFrame
571
+ // The input will be applied when we catch up
572
+ }
573
+ // If frameNumber < currentReadFrame, this is old input we already processed
574
+ }
575
+
576
+ /**
577
+ * Check if we have pending real input for a frame from a client.
578
+ */
579
+ private hasPendingRealInput(frameNumber: number, clientId: number): boolean {
580
+ const framePending = this.pendingRemoteInput.get(frameNumber);
581
+ if (!framePending) {
582
+ return false;
583
+ }
584
+ const clientPending = framePending.get(clientId);
585
+ return clientPending?.isReal ?? false;
586
+ }
587
+
588
+ /**
589
+ * Store remote input as pending for a frame that doesn't exist yet.
590
+ */
591
+ private storePendingInput(
592
+ frameNumber: number,
593
+ clientId: number,
594
+ input: number[],
595
+ isReal: boolean
596
+ ): void {
597
+ let framePending = this.pendingRemoteInput.get(frameNumber);
598
+ if (!framePending) {
599
+ framePending = new Map();
600
+ this.pendingRemoteInput.set(frameNumber, framePending);
601
+ }
602
+ framePending.set(clientId, { input: [...input], isReal });
603
+ }
604
+
605
+ /**
606
+ * Apply any pending remote input for a frame that now exists.
607
+ */
608
+ private applyPendingInput(frameNumber: number): void {
609
+ const pending = this.pendingRemoteInput.get(frameNumber);
610
+ if (!pending) {
611
+ return;
612
+ }
613
+
614
+ for (const [clientId, { input, isReal }] of pending) {
615
+ this.frameBuffer.setRemoteInput(frameNumber, clientId, input, isReal);
616
+
617
+ // Advance client read frame if this was real input
618
+ if (isReal) {
619
+ this.advanceClientReadFrame(clientId, frameNumber);
620
+ }
621
+ }
622
+
623
+ // Remove applied pending input
624
+ this.pendingRemoteInput.delete(frameNumber);
625
+
626
+ // Recalculate sync pointers since we just applied input
627
+ this.updateSyncPointers();
628
+ }
629
+
630
+ /**
631
+ * Check if a frame is in the "sync gap" for a client.
632
+ * The sync gap is the range of frames between the initial sync frame and
633
+ * the first INPUT received from a client. These frames should be treated
634
+ * as having real (empty) input from that client.
635
+ */
636
+ isFrameInSyncGap(frameNumber: number, clientId: number): boolean {
637
+ if (this._initialFrame < 0) {
638
+ return false;
639
+ }
640
+ const gapEnd = this.syncGapEnd.get(clientId);
641
+ if (gapEnd === undefined) {
642
+ return false;
643
+ }
644
+ // Frame is in sync gap if it's after initialFrame and before the first INPUT
645
+ return frameNumber > this._initialFrame && frameNumber < gapEnd;
646
+ }
647
+
648
+ /**
649
+ * Receive a CRC check from remote for verification.
650
+ */
651
+ receiveCrcCheck(frameNumber: number, remoteCrc: number): void {
652
+ // Try to verify immediately if we have the frame and it's synced
653
+ const localCrc = this.frameBuffer.getCrc(frameNumber);
654
+ if (localCrc !== null && frameNumber <= this._otherFrame) {
655
+ // Can verify now
656
+ this.verifyCrc(frameNumber, remoteCrc);
657
+ } else {
658
+ // Store for later verification
659
+ this.remoteCrcChecks.set(frameNumber, remoteCrc);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Check if rollback is needed and perform it.
665
+ * Returns true if rollback was performed.
666
+ */
667
+ performRollbackIfNeeded(): boolean {
668
+ if (this.rollbackTarget < 0 || this._inRollback) {
669
+ return false;
670
+ }
671
+
672
+ const targetFrame = this.rollbackTarget;
673
+ this.rollbackTarget = -1;
674
+
675
+ // Find the state to restore (we need the state BEFORE the target frame)
676
+ const restoreFrame = targetFrame - 1;
677
+ const state = this.frameBuffer.getState(restoreFrame);
678
+
679
+ if (!state) {
680
+ // Can't rollback - state not available
681
+ // This is a desync situation
682
+ this.emit('desync', targetFrame, 0, 0);
683
+ return false;
684
+ }
685
+
686
+ this._inRollback = true;
687
+ const startFrame = this._selfFrame;
688
+ this.emit('rollback-start', restoreFrame, startFrame);
689
+
690
+ // Restore state
691
+ this.emit('restore-state', restoreFrame, state);
692
+ this._selfFrame = restoreFrame;
693
+
694
+ // Replay frames from restoreFrame+1 to startFrame
695
+ const framesToReplay = startFrame - restoreFrame;
696
+ for (let f = restoreFrame + 1; f <= startFrame; f++) {
697
+ this._selfFrame = f;
698
+
699
+ // Get merged input for this frame (now with real remote input)
700
+ // buildMergedInput returns a reference to internal buffer, so we copy
701
+ // to frame.localInput (which we need to store) and for the event
702
+ const input = this.buildMergedInput(f);
703
+ const inputCopy = [...input];
704
+
705
+ // Update frame buffer with correct input
706
+ const frame = this.frameBuffer.get(f);
707
+ if (frame) {
708
+ frame.localInput = inputCopy;
709
+ }
710
+
711
+ // Run the frame (emit with copy since internal buffer is reused next iteration)
712
+ this.emit('run-frame', f, inputCopy);
713
+ }
714
+
715
+ this.stats.rollbackCount++;
716
+ this.stats.totalFramesReplayed += framesToReplay;
717
+
718
+ this._inRollback = false;
719
+ this.emit('rollback-end', framesToReplay);
720
+
721
+ return true;
722
+ }
723
+
724
+ /**
725
+ * Get the CRC for the current frame (for sending to remote).
726
+ */
727
+ getCurrentCrc(): number | null {
728
+ return this.frameBuffer.getCrc(this._selfFrame);
729
+ }
730
+
731
+ /**
732
+ * Get the CRC for a specific frame (for verifying against remote CRC).
733
+ */
734
+ getCrcForFrame(frameNumber: number): number | null {
735
+ return this.frameBuffer.getCrc(frameNumber);
736
+ }
737
+
738
+ /**
739
+ * Check if we should send a CRC check this frame.
740
+ */
741
+ shouldSendCrc(): boolean {
742
+ if (this._selfFrame < 0) {
743
+ return false;
744
+ }
745
+ return this._selfFrame % this.config.crcCheckInterval === 0;
746
+ }
747
+
748
+ /**
749
+ * Get input that should be sent to remote for a frame.
750
+ */
751
+ getLocalInputForFrame(frameNumber: number): number[] | null {
752
+ const frame = this.frameBuffer.get(frameNumber);
753
+ return frame?.localInput ?? null;
754
+ }
755
+
756
+ /**
757
+ * Simulate remote input for a client at the current frame.
758
+ * Used when real input hasn't arrived yet.
759
+ */
760
+ simulateRemoteInput(clientId: number): void {
761
+ const { input } = this.inputBuffer.getInputForClient(
762
+ clientId,
763
+ this._selfFrame,
764
+ false
765
+ );
766
+ this.frameBuffer.setRemoteInput(this._selfFrame, clientId, input, false);
767
+ }
768
+
769
+ /**
770
+ * Build merged input from all clients for a frame.
771
+ * Returns a reference to the internal buffer - callers must copy if they need to retain.
772
+ */
773
+ private buildMergedInput(frameNumber: number): number[] {
774
+ // Clear the pre-allocated buffer (faster than creating new array)
775
+ const merged = this.mergedInputBuffer;
776
+ for (let i = 0; i < merged.length; i++) {
777
+ merged[i] = 0;
778
+ }
779
+
780
+ // Add local input
781
+ const localDevices = this.clientDeviceMap.get(this.config.localClientId) ?? [0];
782
+ const delayedLocal = this.inputBuffer.getDelayedLocalInput(frameNumber);
783
+
784
+ for (const deviceIndex of localDevices) {
785
+ if (deviceIndex >= MAX_INPUT_DEVICES) {
786
+ continue;
787
+ }
788
+ const base = deviceIndex * INPUTS_PER_DEVICE;
789
+ if (delayedLocal) {
790
+ merged[base] = delayedLocal[0] ?? 0;
791
+ merged[base + 1] = delayedLocal[1] ?? 0;
792
+ merged[base + 2] = delayedLocal[2] ?? 0;
793
+ }
794
+ }
795
+
796
+ // Add remote input
797
+ for (const clientId of this.remoteClients) {
798
+ const devices = this.clientDeviceMap.get(clientId) ?? [];
799
+ const remoteInput = this.frameBuffer.getRemoteInput(frameNumber, clientId);
800
+ const isReal = this.frameBuffer.isRemoteInputReal(frameNumber, clientId);
801
+
802
+ // If no real input, use prediction
803
+ const input = remoteInput ?? this.inputBuffer.getInputForClient(clientId, frameNumber, isReal).input;
804
+
805
+ for (const deviceIndex of devices) {
806
+ if (deviceIndex >= MAX_INPUT_DEVICES) {
807
+ continue;
808
+ }
809
+ const base = deviceIndex * INPUTS_PER_DEVICE;
810
+ merged[base] = input[0] ?? 0;
811
+ merged[base + 1] = input[1] ?? 0;
812
+ merged[base + 2] = input[2] ?? 0;
813
+ }
814
+ }
815
+
816
+ return merged;
817
+ }
818
+
819
+ /**
820
+ * Update sync pointers based on per-client read frame tracking.
821
+ * This is O(C) where C is the number of clients, not O(B×C) like before.
822
+ *
823
+ * Similar to RetroArch's netplay_update_unread_ptr(), we compute the global
824
+ * unread frame as the minimum of all per-client read frames.
825
+ */
826
+ private updateSyncPointers(): void {
827
+ const remoteIds = this.getRemoteClientIds();
828
+
829
+ if (remoteIds.length === 0 || this._selfFrame < 0) {
830
+ // No remote clients - we're fully synced with ourselves
831
+ this._otherFrame = this._selfFrame;
832
+ this._unreadFrame = null;
833
+ return;
834
+ }
835
+
836
+ // Find minimum read frame across all clients (O(C) operation)
837
+ // This is the first frame where we're missing real input from at least one client
838
+ const readFrames = pipe(
839
+ remoteIds,
840
+ map((id) => this.readFramePerClient.get(id)),
841
+ filter(isDefined),
842
+ );
843
+ const minReadFrame = readFrames.length > 0 ? Math.min(...readFrames) : Number.MAX_SAFE_INTEGER;
844
+
845
+ if (minReadFrame === Number.MAX_SAFE_INTEGER) {
846
+ // No valid read frames - all clients fully synced
847
+ this._otherFrame = this._selfFrame;
848
+ this._unreadFrame = null;
849
+ return;
850
+ }
851
+
852
+ // "other" frame is the last frame where ALL clients have real input
853
+ // This is minReadFrame - 1 (since readFrame is the NEXT frame we need)
854
+ const syncFrame = minReadFrame - 1;
855
+ if (syncFrame >= this._initialFrame) {
856
+ this._otherFrame = syncFrame;
857
+ }
858
+
859
+ // "unread" frame is the first frame with missing input
860
+ // This is minReadFrame (the first frame where at least one client is missing)
861
+ // But only if it's within our buffer range and <= selfFrame
862
+ if (minReadFrame <= this._selfFrame && this.frameBuffer.hasFrame(minReadFrame)) {
863
+ this._unreadFrame = minReadFrame;
864
+ } else if (minReadFrame > this._selfFrame) {
865
+ // All remote input is caught up or ahead - no unread frames
866
+ this._unreadFrame = null;
867
+ } else {
868
+ // minReadFrame is before our buffer - this shouldn't happen normally
869
+ // Fall back to null (no stalling)
870
+ this._unreadFrame = null;
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Check pending CRC verifications.
876
+ */
877
+ private checkPendingCrcs(): void {
878
+ for (const [frameNumber, remoteCrc] of this.remoteCrcChecks) {
879
+ if (frameNumber <= this._otherFrame) {
880
+ this.verifyCrc(frameNumber, remoteCrc);
881
+ this.remoteCrcChecks.delete(frameNumber);
882
+ }
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Verify CRC for a frame.
888
+ */
889
+ private verifyCrc(frameNumber: number, remoteCrc: number): void {
890
+ const localCrc = this.frameBuffer.getCrc(frameNumber);
891
+ if (localCrc === null) {
892
+ return; // Don't have state for this frame yet
893
+ }
894
+
895
+ if (localCrc !== remoteCrc) {
896
+ this.stats.desyncCount++;
897
+ this.desyncHistory.push({
898
+ frameNumber,
899
+ localCrc,
900
+ remoteCrc,
901
+ timestamp: Date.now(),
902
+ });
903
+
904
+ // Keep only last 10 desyncs
905
+ const maxDesyncHistory = 10;
906
+ if (this.desyncHistory.length > maxDesyncHistory) {
907
+ this.desyncHistory.shift();
908
+ }
909
+
910
+ this.emit('desync', frameNumber, localCrc, remoteCrc);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Get recent desync history for debugging.
916
+ */
917
+ getDesyncHistory(): readonly DesyncInfo[] {
918
+ return this.desyncHistory;
919
+ }
920
+
921
+ /**
922
+ * Reset sync manager state.
923
+ */
924
+ reset(): void {
925
+ this._selfFrame = -1;
926
+ this._runFrame = -1;
927
+ this._otherFrame = -1;
928
+ this._unreadFrame = null;
929
+ this.rollbackTarget = -1;
930
+ this._inRollback = false;
931
+ this._initialFrame = -1;
932
+ this.requestedStallFrames = 0;
933
+ this._latestRemoteFrame = -1;
934
+
935
+ this.frameBuffer.clear();
936
+ this.inputBuffer.clear();
937
+ this.inputBuffer.initialize(this.config.localClientId, this.config.inputDelayFrames);
938
+
939
+ this.remoteClients.clear();
940
+ this.clientDeviceMap.clear();
941
+ this.clientDeviceMap.set(this.config.localClientId, [0]);
942
+
943
+ this.remoteCrcChecks.clear();
944
+ this.syncGapEnd.clear();
945
+ this.pendingRemoteInput.clear();
946
+ this.readFramePerClient.clear();
947
+ this.desyncHistory = [];
948
+
949
+ this.stats = {
950
+ rollbackCount: 0,
951
+ totalFramesReplayed: 0,
952
+ desyncCount: 0,
953
+ };
954
+ }
955
+
956
+ // Type-safe event emitter methods
957
+ override on<K extends keyof SyncManagerEvents>(
958
+ event: K,
959
+ listener: SyncManagerEvents[K]
960
+ ): this {
961
+ return super.on(event, listener);
962
+ }
963
+
964
+ override off<K extends keyof SyncManagerEvents>(
965
+ event: K,
966
+ listener: SyncManagerEvents[K]
967
+ ): this {
968
+ return super.off(event, listener);
969
+ }
970
+
971
+ override emit<K extends keyof SyncManagerEvents>(
972
+ event: K,
973
+ ...args: Parameters<SyncManagerEvents[K]>
974
+ ): boolean {
975
+ return super.emit(event, ...args);
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Create a new sync manager.
981
+ */
982
+ export const createSyncManager = (config: SyncManagerConfig): SyncManager => {
983
+ return new SyncManager(config);
984
+ };