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,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetplayClient - RetroArch-compatible netplay client
|
|
3
|
+
*
|
|
4
|
+
* Implements the client side of the netplay protocol:
|
|
5
|
+
* - Connects to a netplay server
|
|
6
|
+
* - Handles handshake (magic, nick, password, info, sync)
|
|
7
|
+
* - Sends local input every frame
|
|
8
|
+
* - Receives and buffers remote input
|
|
9
|
+
* - Integrates with sync manager for rollback
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_PORT,
|
|
15
|
+
NetplayCmd,
|
|
16
|
+
ConnectionState,
|
|
17
|
+
ModeRefusedReason,
|
|
18
|
+
HEX_RADIX,
|
|
19
|
+
DESYNC_RECOVERY_COOLDOWN_FRAMES,
|
|
20
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
21
|
+
NetplayError,
|
|
22
|
+
isKnownCommand,
|
|
23
|
+
type NetplayClientOptions,
|
|
24
|
+
type ParsedCommand,
|
|
25
|
+
type KnownCommand,
|
|
26
|
+
type NickCommand,
|
|
27
|
+
type InfoCommand,
|
|
28
|
+
type SyncCommand,
|
|
29
|
+
type ModeCommand,
|
|
30
|
+
type ModeRefusedCommand,
|
|
31
|
+
type InputCommand,
|
|
32
|
+
type NoInputCommand,
|
|
33
|
+
type CrcCommand,
|
|
34
|
+
type LoadSavestateCommand,
|
|
35
|
+
type PauseCommand,
|
|
36
|
+
type PlayerChatCommand,
|
|
37
|
+
type StallCommand,
|
|
38
|
+
type ResetCommand,
|
|
39
|
+
type SettingCommand,
|
|
40
|
+
type RequestSavestateCommand,
|
|
41
|
+
} from '..';
|
|
42
|
+
import {
|
|
43
|
+
buildNickCommand,
|
|
44
|
+
buildPasswordCommand,
|
|
45
|
+
buildInfoCommand,
|
|
46
|
+
buildInputCommand,
|
|
47
|
+
buildCrcCommand,
|
|
48
|
+
buildPlayCommand,
|
|
49
|
+
buildSpectateCommand,
|
|
50
|
+
buildPingResponseCommand,
|
|
51
|
+
buildAckCommand,
|
|
52
|
+
buildRequestSavestateCommand,
|
|
53
|
+
hashPassword,
|
|
54
|
+
} from '../protocol';
|
|
55
|
+
import { NetplayConnection, createNetplayConnection } from '../NetplayConnection';
|
|
56
|
+
import { SyncManager, createSyncManager } from '../SyncManager';
|
|
57
|
+
import { netplayLogger } from '../netplayLogger';
|
|
58
|
+
import { getErrorMessage } from '../../utils/getErrorMessage';
|
|
59
|
+
|
|
60
|
+
/** Client events */
|
|
61
|
+
interface ClientEvents {
|
|
62
|
+
connected: () => void;
|
|
63
|
+
disconnected: (reason: string) => void;
|
|
64
|
+
synced: (frameNumber: number) => void;
|
|
65
|
+
'mode-changed': (playing: boolean, playerNumber: number) => void;
|
|
66
|
+
'mode-refused': (reason: string) => void;
|
|
67
|
+
'state-load': (frameNumber: number, state: Buffer) => void;
|
|
68
|
+
'savestate-requested': () => void;
|
|
69
|
+
desync: (frameNumber: number, localCrc: number, remoteCrc: number) => void;
|
|
70
|
+
rollback: (frames: number) => void;
|
|
71
|
+
paused: (by: string) => void;
|
|
72
|
+
resumed: () => void;
|
|
73
|
+
chat: (from: string, message: string) => void;
|
|
74
|
+
error: (error: Error) => void;
|
|
75
|
+
reset: (frameNumber: number) => void;
|
|
76
|
+
'setting-changed': (setting: string, value: number) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Server info received during handshake */
|
|
80
|
+
interface ServerInfo {
|
|
81
|
+
coreName: string;
|
|
82
|
+
coreVersion: string;
|
|
83
|
+
contentCrc: number;
|
|
84
|
+
nickname: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* NetplayClient handles connecting to and playing on a netplay server.
|
|
89
|
+
*/
|
|
90
|
+
export class NetplayClient extends EventEmitter {
|
|
91
|
+
private connection: NetplayConnection | null = null;
|
|
92
|
+
private readonly config: Required<NetplayClientOptions>;
|
|
93
|
+
private readonly syncManager: SyncManager;
|
|
94
|
+
|
|
95
|
+
/** Server info from handshake */
|
|
96
|
+
private _serverInfo: ServerInfo | null = null;
|
|
97
|
+
|
|
98
|
+
/** Local core info for validation */
|
|
99
|
+
private coreInfo = {
|
|
100
|
+
coreName: 'unknown',
|
|
101
|
+
coreVersion: '',
|
|
102
|
+
contentCrc: 0,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Client ID assigned by server */
|
|
106
|
+
private _clientId = -1;
|
|
107
|
+
|
|
108
|
+
/** Player number assigned by server (-1 if spectating) */
|
|
109
|
+
private _playerNumber = -1;
|
|
110
|
+
|
|
111
|
+
/** Are we currently playing (vs spectating)? */
|
|
112
|
+
private _isPlaying = false;
|
|
113
|
+
|
|
114
|
+
/** Is the game paused? */
|
|
115
|
+
private _isPaused = false;
|
|
116
|
+
|
|
117
|
+
/** Who paused the game */
|
|
118
|
+
private _pausedBy = '';
|
|
119
|
+
|
|
120
|
+
/** Device bitmap assigned to this client */
|
|
121
|
+
private _deviceBitmap = 0;
|
|
122
|
+
|
|
123
|
+
/** Current frame number */
|
|
124
|
+
private _currentFrame = 0;
|
|
125
|
+
|
|
126
|
+
/** Server's frame count (for spectating, tracks NOINPUT frames) */
|
|
127
|
+
private _serverFrame = 0;
|
|
128
|
+
|
|
129
|
+
/** Local input for current frame */
|
|
130
|
+
private localInput: number[] = [];
|
|
131
|
+
|
|
132
|
+
/** Server setting: is pausing allowed? */
|
|
133
|
+
private _allowPausing = true;
|
|
134
|
+
|
|
135
|
+
/** Server setting: input latency frames */
|
|
136
|
+
private _serverInputLatencyFrames = 0;
|
|
137
|
+
|
|
138
|
+
/** Frame number when last desync recovery was requested (for cooldown) */
|
|
139
|
+
private lastRecoveryRequestFrame = -Infinity;
|
|
140
|
+
|
|
141
|
+
constructor(options: Partial<NetplayClientOptions> = {}) {
|
|
142
|
+
super();
|
|
143
|
+
|
|
144
|
+
this.config = {
|
|
145
|
+
host: options.host ?? 'localhost',
|
|
146
|
+
port: options.port ?? DEFAULT_PORT,
|
|
147
|
+
password: options.password ?? '',
|
|
148
|
+
nickname: options.nickname ?? 'Player',
|
|
149
|
+
inputDelayFrames: options.inputDelayFrames ?? 0,
|
|
150
|
+
spectate: options.spectate ?? false,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Create sync manager (client ID will be assigned by server)
|
|
154
|
+
this.syncManager = createSyncManager({
|
|
155
|
+
localClientId: 0, // Will be updated after server assigns ID
|
|
156
|
+
inputDelayFrames: this.config.inputDelayFrames,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Listen for desync events from sync manager and request recovery from server
|
|
160
|
+
this.syncManager.on('desync', (frameNumber, localCrc, remoteCrc) => {
|
|
161
|
+
this.emit('desync', frameNumber, localCrc, remoteCrc);
|
|
162
|
+
this.requestDesyncRecovery(frameNumber);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Is connected to server? */
|
|
167
|
+
get connected(): boolean {
|
|
168
|
+
return this.connection?.isConnected ?? false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Server info from handshake */
|
|
172
|
+
get serverInfo(): ServerInfo | null {
|
|
173
|
+
return this._serverInfo;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Client ID assigned by server */
|
|
177
|
+
get clientId(): number {
|
|
178
|
+
return this._clientId;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Player number (-1 if spectating) */
|
|
182
|
+
get playerNumber(): number {
|
|
183
|
+
return this._playerNumber;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Are we playing? */
|
|
187
|
+
get isPlaying(): boolean {
|
|
188
|
+
return this._isPlaying;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Is the game paused? */
|
|
192
|
+
get isPaused(): boolean {
|
|
193
|
+
return this._isPaused;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Who paused the game (empty if not paused) */
|
|
197
|
+
get pausedBy(): string {
|
|
198
|
+
return this._pausedBy;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Current frame number */
|
|
202
|
+
get currentFrame(): number {
|
|
203
|
+
return this._currentFrame;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Server's frame count (for spectating) */
|
|
207
|
+
get serverFrame(): number {
|
|
208
|
+
return this._serverFrame;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Is pausing allowed by server? */
|
|
212
|
+
get allowPausing(): boolean {
|
|
213
|
+
return this._allowPausing;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Server's input latency frames setting */
|
|
217
|
+
get serverInputLatencyFrames(): number {
|
|
218
|
+
return this._serverInputLatencyFrames;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Get the sync manager */
|
|
222
|
+
getSyncManager(): SyncManager {
|
|
223
|
+
return this.syncManager;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Set local core information for validation.
|
|
228
|
+
*/
|
|
229
|
+
setCoreInfo(coreName: string, coreVersion: string, contentCrc: number): void {
|
|
230
|
+
this.coreInfo = { coreName, coreVersion, contentCrc };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Connect to a netplay server.
|
|
235
|
+
* Waits for SYNC and MODE commands before returning, ensuring client is ready to run frames.
|
|
236
|
+
*/
|
|
237
|
+
async connect(): Promise<void> {
|
|
238
|
+
if (this.connection) {
|
|
239
|
+
throw new NetplayError('ALREADY_CONNECTED');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Start session logging
|
|
243
|
+
netplayLogger.startSession({
|
|
244
|
+
nickname: this.config.nickname,
|
|
245
|
+
mode: 'client',
|
|
246
|
+
host: this.config.host,
|
|
247
|
+
port: this.config.port,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
netplayLogger.clientConnecting(this.config.host, this.config.port);
|
|
251
|
+
|
|
252
|
+
// Create promise that resolves when we're fully synced (SYNC + MODE received)
|
|
253
|
+
const readyPromise = new Promise<void>((resolve, reject) => {
|
|
254
|
+
// Track which essential commands we've received
|
|
255
|
+
let syncReceived = false;
|
|
256
|
+
let modeReceived = false;
|
|
257
|
+
|
|
258
|
+
const checkReady = (): void => {
|
|
259
|
+
if (syncReceived && modeReceived) {
|
|
260
|
+
netplayLogger.debug('CLIENT', 'Fully synced - ready to run frames');
|
|
261
|
+
this.off('synced', onSynced);
|
|
262
|
+
this.off('mode-changed', onMode);
|
|
263
|
+
this.off('error', onError);
|
|
264
|
+
this.off('disconnected', onDisconnect);
|
|
265
|
+
resolve();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const onSynced = (): void => {
|
|
270
|
+
syncReceived = true;
|
|
271
|
+
checkReady();
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const onMode = (): void => {
|
|
275
|
+
modeReceived = true;
|
|
276
|
+
checkReady();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const onError = (err: Error): void => {
|
|
280
|
+
this.off('synced', onSynced);
|
|
281
|
+
this.off('mode-changed', onMode);
|
|
282
|
+
this.off('error', onError);
|
|
283
|
+
this.off('disconnected', onDisconnect);
|
|
284
|
+
reject(err);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const onDisconnect = (reason: string): void => {
|
|
288
|
+
this.off('synced', onSynced);
|
|
289
|
+
this.off('mode-changed', onMode);
|
|
290
|
+
this.off('error', onError);
|
|
291
|
+
this.off('disconnected', onDisconnect);
|
|
292
|
+
reject(new Error(`Disconnected during handshake: ${reason}`));
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
this.on('synced', onSynced);
|
|
296
|
+
this.on('mode-changed', onMode);
|
|
297
|
+
this.on('error', onError);
|
|
298
|
+
this.on('disconnected', onDisconnect);
|
|
299
|
+
|
|
300
|
+
// Timeout after waiting for handshake
|
|
301
|
+
setTimeout(() => {
|
|
302
|
+
if (!syncReceived || !modeReceived) {
|
|
303
|
+
this.off('synced', onSynced);
|
|
304
|
+
this.off('mode-changed', onMode);
|
|
305
|
+
this.off('error', onError);
|
|
306
|
+
this.off('disconnected', onDisconnect);
|
|
307
|
+
reject(new Error(`Handshake timeout: SYNC=${syncReceived}, MODE=${modeReceived}`));
|
|
308
|
+
}
|
|
309
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
this.connection = await createNetplayConnection(this.config.host, this.config.port);
|
|
314
|
+
|
|
315
|
+
// Set up event handlers
|
|
316
|
+
this.connection.on('command', (cmd: ParsedCommand) => {
|
|
317
|
+
if (isKnownCommand(cmd)) {
|
|
318
|
+
this.handleCommand(cmd);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
this.connection.on('disconnected', (reason: string) => {
|
|
323
|
+
this.handleDisconnect(reason);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this.connection.on('error', (err: Error) => {
|
|
327
|
+
netplayLogger.clientError(`Connection error: ${err.message}`);
|
|
328
|
+
this.emit('error', err);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Per protocol: both sides send header before reading
|
|
332
|
+
// "Note that both the server and the client send the connection header
|
|
333
|
+
// before reading it."
|
|
334
|
+
netplayLogger.debug('CLIENT', 'Sending client header');
|
|
335
|
+
this.connection.sendHeader(this.config.nickname);
|
|
336
|
+
|
|
337
|
+
// Now wait for server's header
|
|
338
|
+
netplayLogger.debug('CLIENT', 'Waiting for server header');
|
|
339
|
+
const serverHeader = await this.connection.waitForHeader();
|
|
340
|
+
if (!serverHeader) {
|
|
341
|
+
netplayLogger.connectionFailed(this.config.host, this.config.port, 'Invalid server header');
|
|
342
|
+
throw new NetplayError('INVALID_HEADER', 'Invalid server header');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
netplayLogger.debug('CLIENT', 'Server header received', {
|
|
346
|
+
serverNickname: serverHeader.nickname,
|
|
347
|
+
platformMagic: serverHeader.platformMagic.toString(HEX_RADIX),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Store server nickname if available
|
|
351
|
+
if (serverHeader.nickname && this._serverInfo) {
|
|
352
|
+
this._serverInfo.nickname = serverHeader.nickname;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Start handshake - send nickname
|
|
356
|
+
this.connection.send(buildNickCommand(this.config.nickname));
|
|
357
|
+
|
|
358
|
+
// Send password if server requires it
|
|
359
|
+
if (this.config.password) {
|
|
360
|
+
netplayLogger.debug('CLIENT', 'Sending password');
|
|
361
|
+
const hash = hashPassword(this.config.password);
|
|
362
|
+
this.connection.send(buildPasswordCommand(hash));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Send our INFO
|
|
366
|
+
netplayLogger.debug('CLIENT', 'Sending INFO', {
|
|
367
|
+
coreName: this.coreInfo.coreName,
|
|
368
|
+
contentCrc: this.coreInfo.contentCrc.toString(HEX_RADIX),
|
|
369
|
+
});
|
|
370
|
+
this.connection.send(
|
|
371
|
+
buildInfoCommand(
|
|
372
|
+
this.coreInfo.coreName,
|
|
373
|
+
this.coreInfo.coreVersion,
|
|
374
|
+
this.coreInfo.contentCrc
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// Request to play or spectate
|
|
379
|
+
if (this.config.spectate) {
|
|
380
|
+
netplayLogger.debug('CLIENT', 'Requesting SPECTATE');
|
|
381
|
+
this.connection.send(buildSpectateCommand());
|
|
382
|
+
} else {
|
|
383
|
+
netplayLogger.debug('CLIENT', 'Requesting PLAY');
|
|
384
|
+
this.connection.send(buildPlayCommand());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Wait for SYNC and MODE before considering connection complete
|
|
388
|
+
await readyPromise;
|
|
389
|
+
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const errorMsg = getErrorMessage(err);
|
|
392
|
+
netplayLogger.connectionFailed(this.config.host, this.config.port, errorMsg);
|
|
393
|
+
this.connection?.close('connection failed');
|
|
394
|
+
this.connection = null;
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Disconnect from server.
|
|
401
|
+
*/
|
|
402
|
+
disconnect(): void {
|
|
403
|
+
if (this.connection) {
|
|
404
|
+
this.connection.close('client disconnect');
|
|
405
|
+
this.connection = null;
|
|
406
|
+
}
|
|
407
|
+
this._serverInfo = null;
|
|
408
|
+
this._clientId = -1;
|
|
409
|
+
this._isPlaying = false;
|
|
410
|
+
this._playerNumber = -1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Called before running a frame.
|
|
415
|
+
* Returns merged input to use, or null if we should stall.
|
|
416
|
+
* shouldCatchUp indicates the client is behind and should disable frame limiter.
|
|
417
|
+
*/
|
|
418
|
+
preFrame(localInput: number[]): { input: number[]; shouldStall: boolean; shouldCatchUp: boolean } | null {
|
|
419
|
+
if (!this.connected || !this._isPlaying || this._isPaused) {
|
|
420
|
+
netplayLogger.debug('CLIENT', 'preFrame returning null', {
|
|
421
|
+
connected: this.connected,
|
|
422
|
+
isPlaying: this._isPlaying,
|
|
423
|
+
isPaused: this._isPaused,
|
|
424
|
+
});
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.localInput = [...localInput];
|
|
429
|
+
|
|
430
|
+
// Let sync manager prepare the frame
|
|
431
|
+
const result = this.syncManager.preFrame(localInput);
|
|
432
|
+
if (result?.shouldStall) {
|
|
433
|
+
netplayLogger.debug('CLIENT', 'preFrame stalling', {
|
|
434
|
+
selfFrame: this.syncManager.selfFrame,
|
|
435
|
+
unreadFrame: this.syncManager.unreadFrame,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Called after running a frame.
|
|
443
|
+
* Sends input to server.
|
|
444
|
+
*/
|
|
445
|
+
postFrame(serializedState: Buffer): void {
|
|
446
|
+
this._currentFrame++;
|
|
447
|
+
netplayLogger.debug('CLIENT', 'postFrame', {
|
|
448
|
+
currentFrame: this._currentFrame,
|
|
449
|
+
stateSize: serializedState.length,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Store state in sync manager
|
|
453
|
+
this.syncManager.postFrame(serializedState);
|
|
454
|
+
|
|
455
|
+
// Send our input to server
|
|
456
|
+
this.sendLocalInput();
|
|
457
|
+
|
|
458
|
+
// Send CRC check periodically
|
|
459
|
+
if (this.syncManager.shouldSendCrc()) {
|
|
460
|
+
this.sendCrc();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Check for rollback
|
|
464
|
+
if (this.syncManager.performRollbackIfNeeded()) {
|
|
465
|
+
const stats = this.syncManager.statistics;
|
|
466
|
+
this.emit('rollback', stats.totalFramesReplayed);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Handle a command from the server.
|
|
472
|
+
*/
|
|
473
|
+
private handleCommand(cmd: KnownCommand): void {
|
|
474
|
+
switch (cmd.cmd) {
|
|
475
|
+
case NetplayCmd.NICK:
|
|
476
|
+
this.handleNick(cmd);
|
|
477
|
+
break;
|
|
478
|
+
|
|
479
|
+
case NetplayCmd.INFO:
|
|
480
|
+
this.handleInfo(cmd);
|
|
481
|
+
break;
|
|
482
|
+
|
|
483
|
+
case NetplayCmd.SYNC:
|
|
484
|
+
this.handleSync(cmd);
|
|
485
|
+
break;
|
|
486
|
+
|
|
487
|
+
case NetplayCmd.MODE:
|
|
488
|
+
this.handleMode(cmd);
|
|
489
|
+
break;
|
|
490
|
+
|
|
491
|
+
case NetplayCmd.INPUT:
|
|
492
|
+
this.handleInput(cmd);
|
|
493
|
+
break;
|
|
494
|
+
|
|
495
|
+
case NetplayCmd.CRC:
|
|
496
|
+
this.handleCrc(cmd);
|
|
497
|
+
break;
|
|
498
|
+
|
|
499
|
+
case NetplayCmd.LOAD_SAVESTATE:
|
|
500
|
+
this.handleLoadSavestate(cmd);
|
|
501
|
+
break;
|
|
502
|
+
|
|
503
|
+
case NetplayCmd.PAUSE:
|
|
504
|
+
this.handlePause(cmd);
|
|
505
|
+
break;
|
|
506
|
+
|
|
507
|
+
case NetplayCmd.RESUME:
|
|
508
|
+
this.handleResume();
|
|
509
|
+
break;
|
|
510
|
+
|
|
511
|
+
case NetplayCmd.PLAYER_CHAT:
|
|
512
|
+
this.handleChat(cmd);
|
|
513
|
+
break;
|
|
514
|
+
|
|
515
|
+
case NetplayCmd.DISCONNECT:
|
|
516
|
+
this.handleDisconnect('Server disconnected');
|
|
517
|
+
break;
|
|
518
|
+
|
|
519
|
+
case NetplayCmd.PING_REQUEST:
|
|
520
|
+
this.handlePingRequest();
|
|
521
|
+
break;
|
|
522
|
+
|
|
523
|
+
case NetplayCmd.STALL:
|
|
524
|
+
this.handleStall(cmd);
|
|
525
|
+
break;
|
|
526
|
+
|
|
527
|
+
case NetplayCmd.RESET:
|
|
528
|
+
this.handleReset(cmd);
|
|
529
|
+
break;
|
|
530
|
+
|
|
531
|
+
case NetplayCmd.MODE_REFUSED:
|
|
532
|
+
this.handleModeRefused(cmd);
|
|
533
|
+
break;
|
|
534
|
+
|
|
535
|
+
case NetplayCmd.SETTING_ALLOW_PAUSING:
|
|
536
|
+
this.handleSettingAllowPausing(cmd);
|
|
537
|
+
break;
|
|
538
|
+
|
|
539
|
+
case NetplayCmd.SETTING_INPUT_LATENCY_FRAMES:
|
|
540
|
+
this.handleSettingInputLatencyFrames(cmd);
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
case NetplayCmd.NOINPUT:
|
|
544
|
+
this.handleNoInput(cmd);
|
|
545
|
+
break;
|
|
546
|
+
|
|
547
|
+
case NetplayCmd.REQUEST_SAVESTATE:
|
|
548
|
+
this.handleRequestSavestate(cmd);
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Handle NICK command (server's nickname).
|
|
555
|
+
*/
|
|
556
|
+
private handleNick(cmd: NickCommand): void {
|
|
557
|
+
// Update server info with nickname
|
|
558
|
+
if (this._serverInfo) {
|
|
559
|
+
this._serverInfo.nickname = cmd.nickname;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Handle INFO command (server's core info).
|
|
565
|
+
*/
|
|
566
|
+
private handleInfo(cmd: InfoCommand): void {
|
|
567
|
+
netplayLogger.debug('CLIENT', 'Received server INFO', {
|
|
568
|
+
coreName: cmd.coreName,
|
|
569
|
+
contentCrc: cmd.contentCrc.toString(HEX_RADIX),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
this._serverInfo = {
|
|
573
|
+
coreName: cmd.coreName,
|
|
574
|
+
coreVersion: cmd.coreVersion,
|
|
575
|
+
contentCrc: cmd.contentCrc,
|
|
576
|
+
nickname: '',
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// Validate compatibility
|
|
580
|
+
if (cmd.coreName !== this.coreInfo.coreName) {
|
|
581
|
+
netplayLogger.clientError(`Core mismatch: server=${cmd.coreName}, local=${this.coreInfo.coreName}`);
|
|
582
|
+
this.disconnect();
|
|
583
|
+
this.emit('error', new Error(`Core mismatch: ${cmd.coreName} vs ${this.coreInfo.coreName}`));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (cmd.contentCrc !== this.coreInfo.contentCrc) {
|
|
588
|
+
netplayLogger.clientError(`CRC mismatch: server=${cmd.contentCrc.toString(HEX_RADIX)}, local=${this.coreInfo.contentCrc.toString(HEX_RADIX)}`);
|
|
589
|
+
this.disconnect();
|
|
590
|
+
this.emit('error', new Error('Content CRC mismatch'));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
netplayLogger.debug('CLIENT', 'Server INFO validated - core and CRC match');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Handle SYNC command (initial state from server).
|
|
598
|
+
*/
|
|
599
|
+
private handleSync(cmd: SyncCommand): void {
|
|
600
|
+
this._currentFrame = cmd.frameNumber;
|
|
601
|
+
this._serverFrame = cmd.frameNumber;
|
|
602
|
+
|
|
603
|
+
netplayLogger.info('CLIENT', 'Received SYNC from server', {
|
|
604
|
+
frameNumber: cmd.frameNumber,
|
|
605
|
+
stateSize: cmd.sram.length,
|
|
606
|
+
paused: cmd.paused,
|
|
607
|
+
clientNumber: cmd.clientNumber,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Initialize sync manager at server's frame
|
|
611
|
+
const initialState = cmd.sram.length > 0 ? cmd.sram : undefined;
|
|
612
|
+
this.syncManager.initialize(cmd.frameNumber, initialState);
|
|
613
|
+
|
|
614
|
+
// Update connection state
|
|
615
|
+
if (this.connection) {
|
|
616
|
+
this.connection.setState(
|
|
617
|
+
this._isPlaying ? ConnectionState.PLAYING : ConnectionState.SPECTATING
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Register server as remote client (server is always client 0)
|
|
622
|
+
this.syncManager.addRemoteClient(0, [0]);
|
|
623
|
+
|
|
624
|
+
netplayLogger.connectedToServer(this.config.host, this.config.port);
|
|
625
|
+
|
|
626
|
+
this.emit('synced', cmd.frameNumber);
|
|
627
|
+
this.emit('connected');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Handle MODE command (player assignment).
|
|
632
|
+
*/
|
|
633
|
+
private handleMode(cmd: ModeCommand): void {
|
|
634
|
+
if (cmd.you) {
|
|
635
|
+
// This is about us
|
|
636
|
+
this._clientId = cmd.clientNumber >= 0 ? cmd.clientNumber : this._clientId;
|
|
637
|
+
this._isPlaying = cmd.playing;
|
|
638
|
+
this._playerNumber = cmd.clientNumber;
|
|
639
|
+
this._deviceBitmap = cmd.deviceBitmap;
|
|
640
|
+
|
|
641
|
+
// Update sync manager's local client ID to our assigned number
|
|
642
|
+
// This must be done BEFORE updateLocalDevices to avoid conflicts with
|
|
643
|
+
// the server (which is registered as client 0)
|
|
644
|
+
if (cmd.clientNumber >= 0) {
|
|
645
|
+
this.syncManager.updateLocalClientId(cmd.clientNumber);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Update sync manager's local device mapping based on assigned deviceBitmap
|
|
649
|
+
// This tells the sync manager which controller slot(s) our input should go to
|
|
650
|
+
this.syncManager.updateLocalDevices(cmd.deviceBitmap);
|
|
651
|
+
|
|
652
|
+
netplayLogger.info('CLIENT', `Mode assigned: ${cmd.playing ? 'PLAYING' : 'SPECTATING'}`, {
|
|
653
|
+
clientNumber: cmd.clientNumber,
|
|
654
|
+
playing: cmd.playing,
|
|
655
|
+
deviceBitmap: cmd.deviceBitmap,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
this.emit('mode-changed', cmd.playing, cmd.clientNumber);
|
|
659
|
+
} else {
|
|
660
|
+
// This is about another player
|
|
661
|
+
if (cmd.playing) {
|
|
662
|
+
netplayLogger.info('CLIENT', `Remote player ${cmd.clientNumber} joined`);
|
|
663
|
+
this.syncManager.addRemoteClient(cmd.clientNumber, [cmd.clientNumber]);
|
|
664
|
+
} else {
|
|
665
|
+
netplayLogger.info('CLIENT', `Remote player ${cmd.clientNumber} left`);
|
|
666
|
+
this.syncManager.removeRemoteClient(cmd.clientNumber);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Handle INPUT command (remote player input).
|
|
673
|
+
*/
|
|
674
|
+
private handleInput(cmd: InputCommand): void {
|
|
675
|
+
const input = [cmd.joypadState, cmd.analogLeft ?? 0, cmd.analogRight ?? 0];
|
|
676
|
+
|
|
677
|
+
// Feed to sync manager
|
|
678
|
+
const needsRollback = this.syncManager.receiveRemoteInput(
|
|
679
|
+
cmd.clientId,
|
|
680
|
+
cmd.frameNumber,
|
|
681
|
+
input
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
if (needsRollback) {
|
|
685
|
+
// Rollback will be performed in next postFrame
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Handle CRC command (desync check from server).
|
|
691
|
+
*/
|
|
692
|
+
private handleCrc(cmd: CrcCommand): void {
|
|
693
|
+
netplayLogger.debug('CLIENT', `CRC check received for frame ${cmd.frameNumber}`, {
|
|
694
|
+
remoteCrc: cmd.crc.toString(HEX_RADIX),
|
|
695
|
+
});
|
|
696
|
+
this.syncManager.receiveCrcCheck(cmd.frameNumber, cmd.crc);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Handle LOAD_SAVESTATE command (resync from server).
|
|
701
|
+
*
|
|
702
|
+
* The LOAD_SAVESTATE frame number indicates the frame the client should start
|
|
703
|
+
* running. The state data represents the game state BEFORE that frame is run
|
|
704
|
+
* (i.e., state at the end of frame N-1).
|
|
705
|
+
*
|
|
706
|
+
* We set _currentFrame to frameNumber - 1 so that when postFrame increments it,
|
|
707
|
+
* the first INPUT we send is for the correct frame (matching the MODE command).
|
|
708
|
+
*/
|
|
709
|
+
private handleLoadSavestate(cmd: LoadSavestateCommand): void {
|
|
710
|
+
netplayLogger.info('CLIENT', 'Received LOAD_SAVESTATE from server', {
|
|
711
|
+
frameNumber: cmd.frameNumber,
|
|
712
|
+
stateSize: cmd.state.length,
|
|
713
|
+
uncompressedSize: cmd.uncompressedSize,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Set _currentFrame to frameNumber - 1 so first INPUT is for frameNumber
|
|
717
|
+
// (postFrame increments before sending)
|
|
718
|
+
this._currentFrame = cmd.frameNumber - 1;
|
|
719
|
+
|
|
720
|
+
// Initialize sync manager at frameNumber - 1 (state is before frame N)
|
|
721
|
+
this.syncManager.initialize(cmd.frameNumber - 1, cmd.state);
|
|
722
|
+
|
|
723
|
+
// Emit event so emulator can load the state into the core
|
|
724
|
+
this.emit('state-load', cmd.frameNumber, cmd.state);
|
|
725
|
+
|
|
726
|
+
// Send ACK to confirm successful savestate load (per RetroArch protocol)
|
|
727
|
+
this.connection?.send(buildAckCommand());
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Handle PAUSE command.
|
|
732
|
+
*/
|
|
733
|
+
private handlePause(cmd: PauseCommand): void {
|
|
734
|
+
this._isPaused = true;
|
|
735
|
+
this._pausedBy = cmd.nickname;
|
|
736
|
+
this.emit('paused', cmd.nickname);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Handle RESUME command.
|
|
741
|
+
*/
|
|
742
|
+
private handleResume(): void {
|
|
743
|
+
this._isPaused = false;
|
|
744
|
+
this._pausedBy = '';
|
|
745
|
+
this.emit('resumed');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Handle PLAYER_CHAT command.
|
|
750
|
+
*/
|
|
751
|
+
private handleChat(cmd: PlayerChatCommand): void {
|
|
752
|
+
this.emit('chat', cmd.nickname, cmd.message);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Handle PING_REQUEST from server.
|
|
757
|
+
* Per RetroArch protocol, we must respond with PING_RESPONSE for latency measurement.
|
|
758
|
+
*/
|
|
759
|
+
private handlePingRequest(): void {
|
|
760
|
+
if (!this.connection) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
netplayLogger.debug('CLIENT', 'Received PING_REQUEST from server, sending PING_RESPONSE');
|
|
764
|
+
this.connection.send(buildPingResponseCommand());
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Handle STALL command from server.
|
|
769
|
+
* Server requests we slow down by a certain number of frames.
|
|
770
|
+
*/
|
|
771
|
+
private handleStall(cmd: StallCommand): void {
|
|
772
|
+
netplayLogger.info('CLIENT', `Server requested stall for ${cmd.frames} frames`);
|
|
773
|
+
this.syncManager.requestStall(cmd.frames);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Handle RESET command from server.
|
|
778
|
+
* Server is requesting a core reset at a specific frame.
|
|
779
|
+
*/
|
|
780
|
+
private handleReset(cmd: ResetCommand): void {
|
|
781
|
+
netplayLogger.info('CLIENT', `Server requested core reset at frame ${cmd.frameNumber}`);
|
|
782
|
+
this._currentFrame = cmd.frameNumber;
|
|
783
|
+
this.emit('reset', cmd.frameNumber);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Handle MODE_REFUSED command from server.
|
|
788
|
+
* Server refused our request to change mode (play/spectate).
|
|
789
|
+
*/
|
|
790
|
+
private handleModeRefused(cmd: ModeRefusedCommand): void {
|
|
791
|
+
const reasonText = this.getModeRefusedReasonText(cmd.reason);
|
|
792
|
+
netplayLogger.info('CLIENT', `Mode change refused: ${reasonText}`);
|
|
793
|
+
this.emit('mode-refused', reasonText);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Convert MODE_REFUSED reason code to human-readable text.
|
|
798
|
+
*/
|
|
799
|
+
private getModeRefusedReasonText(reason: number): string {
|
|
800
|
+
switch (reason) {
|
|
801
|
+
case ModeRefusedReason.NO_SLOTS:
|
|
802
|
+
return 'No slots available';
|
|
803
|
+
case ModeRefusedReason.NOT_ALLOWED:
|
|
804
|
+
return 'Not allowed';
|
|
805
|
+
case ModeRefusedReason.TOO_FAST:
|
|
806
|
+
return 'Too fast (rate limited)';
|
|
807
|
+
default:
|
|
808
|
+
return 'Unknown reason';
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Handle SETTING_ALLOW_PAUSING command from server.
|
|
814
|
+
* Indicates whether pausing is allowed in this session.
|
|
815
|
+
*/
|
|
816
|
+
private handleSettingAllowPausing(cmd: SettingCommand): void {
|
|
817
|
+
this._allowPausing = cmd.value !== 0;
|
|
818
|
+
netplayLogger.info('CLIENT', `Server setting: allow_pausing = ${this._allowPausing}`);
|
|
819
|
+
this.emit('setting-changed', 'allow_pausing', cmd.value);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Handle SETTING_INPUT_LATENCY_FRAMES command from server.
|
|
824
|
+
* Indicates the server's configured input latency frames.
|
|
825
|
+
*/
|
|
826
|
+
private handleSettingInputLatencyFrames(cmd: SettingCommand): void {
|
|
827
|
+
this._serverInputLatencyFrames = cmd.value;
|
|
828
|
+
netplayLogger.info('CLIENT', `Server setting: input_latency_frames = ${cmd.value}`);
|
|
829
|
+
this.emit('setting-changed', 'input_latency_frames', cmd.value);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Handle NOINPUT command from server.
|
|
834
|
+
* Server sends this when spectating to indicate frame advancement without input.
|
|
835
|
+
* Per RetroArch protocol, clients should never send NOINPUT - only servers.
|
|
836
|
+
*/
|
|
837
|
+
private handleNoInput(cmd: NoInputCommand): void {
|
|
838
|
+
// Validate frame sequence - should match expected server frame
|
|
839
|
+
if (cmd.frameNumber < this._serverFrame) {
|
|
840
|
+
// Already processed this frame, ignore
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (cmd.frameNumber !== this._serverFrame) {
|
|
845
|
+
netplayLogger.debug('CLIENT', `NOINPUT frame mismatch: expected ${this._serverFrame}, got ${cmd.frameNumber}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Advance server frame counter
|
|
849
|
+
this._serverFrame = cmd.frameNumber + 1;
|
|
850
|
+
|
|
851
|
+
// Notify sync manager that this frame has no input but should be considered synced
|
|
852
|
+
// This prevents spectators from stalling while waiting for input that won't come
|
|
853
|
+
// Server is always client ID 0
|
|
854
|
+
this.syncManager.advanceFrameWithoutInput(0, cmd.frameNumber);
|
|
855
|
+
|
|
856
|
+
netplayLogger.debug('CLIENT', `Server NOINPUT: advanced to frame ${this._serverFrame}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Handle REQUEST_SAVESTATE command.
|
|
861
|
+
* The peer is requesting we send our current savestate (typically for desync recovery).
|
|
862
|
+
* Emits 'savestate-requested' event so the emulator can respond with LOAD_SAVESTATE.
|
|
863
|
+
*/
|
|
864
|
+
private handleRequestSavestate(_cmd: RequestSavestateCommand): void {
|
|
865
|
+
netplayLogger.info('CLIENT', 'Peer requested savestate');
|
|
866
|
+
this.emit('savestate-requested');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Handle disconnect from server.
|
|
871
|
+
*/
|
|
872
|
+
private handleDisconnect(reason: string): void {
|
|
873
|
+
netplayLogger.disconnectedFromServer(reason);
|
|
874
|
+
netplayLogger.endSession(reason);
|
|
875
|
+
|
|
876
|
+
this.connection = null;
|
|
877
|
+
this._serverInfo = null;
|
|
878
|
+
this._clientId = -1;
|
|
879
|
+
this._isPlaying = false;
|
|
880
|
+
this._playerNumber = -1;
|
|
881
|
+
this.emit('disconnected', reason);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Send local input to server.
|
|
886
|
+
*/
|
|
887
|
+
private sendLocalInput(): void {
|
|
888
|
+
if (!this.connection || !this._isPlaying) {
|
|
889
|
+
netplayLogger.debug('CLIENT', 'sendLocalInput skipped', {
|
|
890
|
+
hasConnection: !!this.connection,
|
|
891
|
+
isPlaying: this._isPlaying,
|
|
892
|
+
});
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const inputCmd = buildInputCommand(
|
|
897
|
+
this._currentFrame,
|
|
898
|
+
this._clientId,
|
|
899
|
+
false, // Not server data
|
|
900
|
+
this.localInput[0] ?? 0
|
|
901
|
+
// Note: deviceBitmap is not included in INPUT wire format per RetroArch protocol
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
netplayLogger.debug('CLIENT', 'sendLocalInput', {
|
|
905
|
+
frame: this._currentFrame,
|
|
906
|
+
clientId: this._clientId,
|
|
907
|
+
input: this.localInput[0] ?? 0,
|
|
908
|
+
deviceBitmap: this._deviceBitmap,
|
|
909
|
+
});
|
|
910
|
+
this.connection.send(inputCmd);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Send CRC check to server.
|
|
915
|
+
*/
|
|
916
|
+
private sendCrc(): void {
|
|
917
|
+
if (!this.connection) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const crc = this.syncManager.getCurrentCrc();
|
|
922
|
+
if (crc === null) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
this.connection.send(buildCrcCommand(this._currentFrame, crc));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Request desync recovery from the server by sending REQUEST_SAVESTATE.
|
|
931
|
+
* Rate-limited by DESYNC_RECOVERY_COOLDOWN_FRAMES to avoid flooding.
|
|
932
|
+
*/
|
|
933
|
+
private requestDesyncRecovery(frameNumber: number): void {
|
|
934
|
+
if (!this.connection) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (this._currentFrame - this.lastRecoveryRequestFrame < DESYNC_RECOVERY_COOLDOWN_FRAMES) {
|
|
939
|
+
netplayLogger.debug('CLIENT', `Desync recovery request skipped (cooldown)`, {
|
|
940
|
+
frame: frameNumber,
|
|
941
|
+
lastRequest: this.lastRecoveryRequestFrame,
|
|
942
|
+
cooldown: DESYNC_RECOVERY_COOLDOWN_FRAMES,
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
this.lastRecoveryRequestFrame = this._currentFrame;
|
|
948
|
+
netplayLogger.desyncRecovery(frameNumber, 'client-request');
|
|
949
|
+
this.connection.send(buildRequestSavestateCommand());
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Type-safe event emitter methods
|
|
953
|
+
override on<K extends keyof ClientEvents>(event: K, listener: ClientEvents[K]): this {
|
|
954
|
+
return super.on(event, listener);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
override off<K extends keyof ClientEvents>(event: K, listener: ClientEvents[K]): this {
|
|
958
|
+
return super.off(event, listener);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
override emit<K extends keyof ClientEvents>(
|
|
962
|
+
event: K,
|
|
963
|
+
...args: Parameters<ClientEvents[K]>
|
|
964
|
+
): boolean {
|
|
965
|
+
return super.emit(event, ...args);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Create a new netplay client.
|
|
971
|
+
*/
|
|
972
|
+
export const createNetplayClient = (
|
|
973
|
+
options?: Partial<NetplayClientOptions>
|
|
974
|
+
): NetplayClient => {
|
|
975
|
+
return new NetplayClient(options);
|
|
976
|
+
};
|