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.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- 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
|
+
};
|