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,1407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetplayServer - RetroArch-compatible netplay server
|
|
3
|
+
*
|
|
4
|
+
* Implements the server (host) side of the netplay protocol:
|
|
5
|
+
* - Accepts TCP connections from clients
|
|
6
|
+
* - Handles handshake (magic, nick, password, info exchange)
|
|
7
|
+
* - Broadcasts input to all connected clients
|
|
8
|
+
* - Sends periodic CRC checks for desync detection
|
|
9
|
+
* - Manages client state and player assignment
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer, type Server, type Socket } from 'net';
|
|
13
|
+
import { EventEmitter } from 'events';
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_PORT,
|
|
17
|
+
MAX_CLIENTS,
|
|
18
|
+
MAX_INPUT_DEVICES,
|
|
19
|
+
NetplayCmd,
|
|
20
|
+
ConnectionState,
|
|
21
|
+
HEX_RADIX,
|
|
22
|
+
HEX_PADDING_WIDTH,
|
|
23
|
+
HEX_PADDING_WIDTH_32,
|
|
24
|
+
MASK_31BIT,
|
|
25
|
+
HEX_PREVIEW_LENGTH,
|
|
26
|
+
SERVER_INPUT_LOG_INTERVAL_FRAMES,
|
|
27
|
+
DESYNC_RECOVERY_COOLDOWN_FRAMES,
|
|
28
|
+
NetplayError,
|
|
29
|
+
isKnownCommand,
|
|
30
|
+
type NetplayServerOptions,
|
|
31
|
+
type ParsedCommand,
|
|
32
|
+
type KnownCommand,
|
|
33
|
+
type NickCommand,
|
|
34
|
+
type PasswordCommand,
|
|
35
|
+
type InfoCommand,
|
|
36
|
+
type InputCommand,
|
|
37
|
+
type CrcCommand,
|
|
38
|
+
type PauseCommand,
|
|
39
|
+
type PlayerChatCommand,
|
|
40
|
+
} from '..';
|
|
41
|
+
import {
|
|
42
|
+
encodeCommand,
|
|
43
|
+
buildNickCommand,
|
|
44
|
+
buildInfoCommand,
|
|
45
|
+
buildSyncCommand,
|
|
46
|
+
buildModeCommand,
|
|
47
|
+
buildInputCommand,
|
|
48
|
+
buildCrcCommand,
|
|
49
|
+
buildPingResponseCommand,
|
|
50
|
+
buildLoadSavestateCommand,
|
|
51
|
+
buildSettingAllowPausingCommand,
|
|
52
|
+
buildSettingInputLatencyFramesCommand,
|
|
53
|
+
buildPauseCommand,
|
|
54
|
+
buildResumeCommand,
|
|
55
|
+
buildPlayerChatCommand,
|
|
56
|
+
parsePlatformMagic,
|
|
57
|
+
} from '../protocol';
|
|
58
|
+
import { NetplayConnection } from '../NetplayConnection';
|
|
59
|
+
import { SyncManager, createSyncManager } from '../SyncManager';
|
|
60
|
+
import { DiscoveryBroadcaster } from '../NetplayDiscovery';
|
|
61
|
+
import { netplayLogger } from '../netplayLogger';
|
|
62
|
+
import { getErrorMessage } from '../../utils/getErrorMessage';
|
|
63
|
+
import {
|
|
64
|
+
notifyNetplayClientConnected,
|
|
65
|
+
notifyNetplayClientDisconnected,
|
|
66
|
+
notifyNetplaySpectatorConnected,
|
|
67
|
+
notifyNetplayConnectionFailed,
|
|
68
|
+
} from '../../frontend/notifications';
|
|
69
|
+
|
|
70
|
+
/** Client session information */
|
|
71
|
+
interface ClientSession {
|
|
72
|
+
/** Unique client ID (0-31) */
|
|
73
|
+
clientId: number;
|
|
74
|
+
|
|
75
|
+
/** Connection wrapper */
|
|
76
|
+
connection: NetplayConnection;
|
|
77
|
+
|
|
78
|
+
/** Client nickname */
|
|
79
|
+
nickname: string;
|
|
80
|
+
|
|
81
|
+
/** Connection state */
|
|
82
|
+
state: ConnectionState;
|
|
83
|
+
|
|
84
|
+
/** Is client playing (vs spectating)? */
|
|
85
|
+
isPlaying: boolean;
|
|
86
|
+
|
|
87
|
+
/** Assigned device/port indices */
|
|
88
|
+
deviceIndices: number[];
|
|
89
|
+
|
|
90
|
+
/** Last frame number received from this client */
|
|
91
|
+
lastInputFrame: number;
|
|
92
|
+
|
|
93
|
+
/** Timestamp of last received data */
|
|
94
|
+
lastActivity: number;
|
|
95
|
+
|
|
96
|
+
/** Timestamp when connection was established */
|
|
97
|
+
connectedAt: number;
|
|
98
|
+
|
|
99
|
+
/** Handshake steps completed */
|
|
100
|
+
handshakeSteps: string[];
|
|
101
|
+
|
|
102
|
+
/** Frame number sent in SYNC command (used to calculate MODE frame) */
|
|
103
|
+
syncFrame: number;
|
|
104
|
+
|
|
105
|
+
/** Client's protocol version (from platform magic in header) */
|
|
106
|
+
protocolVersion: number;
|
|
107
|
+
|
|
108
|
+
/** Whether MODE has been sent to this client (deferred until after LOAD_SAVESTATE) */
|
|
109
|
+
modeSent: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Server events */
|
|
113
|
+
interface ServerEvents {
|
|
114
|
+
'client-connected': (session: ClientSession) => void;
|
|
115
|
+
'client-disconnected': (session: ClientSession, reason: string) => void;
|
|
116
|
+
'client-playing': (session: ClientSession) => void;
|
|
117
|
+
desync: (clientId: number, frameNumber: number) => void;
|
|
118
|
+
error: (error: Error) => void;
|
|
119
|
+
started: () => void;
|
|
120
|
+
stopped: () => void;
|
|
121
|
+
paused: (by: string) => void;
|
|
122
|
+
resumed: () => void;
|
|
123
|
+
chat: (from: string, message: string) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* NetplayServer handles hosting a netplay session.
|
|
128
|
+
*/
|
|
129
|
+
export class NetplayServer extends EventEmitter {
|
|
130
|
+
private server: Server | null = null;
|
|
131
|
+
private readonly clients: Map<number, ClientSession> = new Map();
|
|
132
|
+
private readonly config: Required<NetplayServerOptions>;
|
|
133
|
+
private readonly syncManager: SyncManager;
|
|
134
|
+
private passwordHash: string | null = null;
|
|
135
|
+
private discoveryBroadcaster: DiscoveryBroadcaster | null = null;
|
|
136
|
+
|
|
137
|
+
/** Core/ROM info for handshake */
|
|
138
|
+
private coreInfo = {
|
|
139
|
+
coreName: 'unknown',
|
|
140
|
+
coreVersion: '',
|
|
141
|
+
contentCrc: 0,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Content/game name for discovery */
|
|
145
|
+
private contentName = 'Unknown Game';
|
|
146
|
+
|
|
147
|
+
/** Server player's input for current frame */
|
|
148
|
+
private serverInput: number[] = [];
|
|
149
|
+
|
|
150
|
+
/** Is the server running? */
|
|
151
|
+
private _running = false;
|
|
152
|
+
|
|
153
|
+
/** Flag to send savestate in next postFrame (per RetroArch protocol) */
|
|
154
|
+
private forceSendSavestate = false;
|
|
155
|
+
|
|
156
|
+
/** Frame number when last desync recovery was triggered (for cooldown) */
|
|
157
|
+
private lastRecoveryFrame = -Infinity;
|
|
158
|
+
|
|
159
|
+
/** Current frame number */
|
|
160
|
+
private _currentFrame = 0;
|
|
161
|
+
|
|
162
|
+
/** Is the game paused? */
|
|
163
|
+
private _isPaused = false;
|
|
164
|
+
|
|
165
|
+
/** Who paused the game (nickname) */
|
|
166
|
+
private _pausedBy = '';
|
|
167
|
+
|
|
168
|
+
constructor(options: Partial<NetplayServerOptions> = {}) {
|
|
169
|
+
super();
|
|
170
|
+
|
|
171
|
+
this.config = {
|
|
172
|
+
port: options.port ?? DEFAULT_PORT,
|
|
173
|
+
password: options.password ?? '',
|
|
174
|
+
maxClients: Math.min(options.maxClients ?? MAX_CLIENTS, MAX_CLIENTS),
|
|
175
|
+
inputDelayFrames: options.inputDelayFrames ?? 0,
|
|
176
|
+
requirePassword: options.requirePassword ?? !!options.password,
|
|
177
|
+
nickname: options.nickname ?? 'Server',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Hash password if provided
|
|
181
|
+
if (this.config.password) {
|
|
182
|
+
this.passwordHash = createHash('sha256')
|
|
183
|
+
.update(this.config.password)
|
|
184
|
+
.digest('hex');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create sync manager (server is client 0)
|
|
188
|
+
// Server is authoritative - doesn't stall waiting for client input
|
|
189
|
+
this.syncManager = createSyncManager({
|
|
190
|
+
localClientId: 0,
|
|
191
|
+
inputDelayFrames: this.config.inputDelayFrames,
|
|
192
|
+
isServer: true,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Is the server running? */
|
|
197
|
+
get running(): boolean {
|
|
198
|
+
return this._running;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Get current frame number */
|
|
202
|
+
get currentFrame(): number {
|
|
203
|
+
return this._currentFrame;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Is the game paused? */
|
|
207
|
+
get isPaused(): boolean {
|
|
208
|
+
return this._isPaused;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Who paused the game (nickname, empty if not paused) */
|
|
212
|
+
get pausedBy(): string {
|
|
213
|
+
return this._pausedBy;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Get all connected client sessions */
|
|
217
|
+
get sessions(): ReadonlyMap<number, ClientSession> {
|
|
218
|
+
return this.clients;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Get the sync manager */
|
|
222
|
+
getSyncManager(): SyncManager {
|
|
223
|
+
return this.syncManager;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Get the number of connected clients (including server as player 1) */
|
|
227
|
+
getClientCount(): number {
|
|
228
|
+
// Count server + connected clients who are playing
|
|
229
|
+
let count = 1; // Server is always player 1
|
|
230
|
+
for (const session of this.clients.values()) {
|
|
231
|
+
if (session.isPlaying) {
|
|
232
|
+
count++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return count;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Check if LAN discovery broadcasting is active */
|
|
239
|
+
isDiscoveryActive(): boolean {
|
|
240
|
+
return this.discoveryBroadcaster?.isRunning() ?? false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Set core information for handshake and discovery.
|
|
245
|
+
*/
|
|
246
|
+
setCoreInfo(coreName: string, coreVersion: string, contentCrc: number, contentName?: string): void {
|
|
247
|
+
this.coreInfo = { coreName, coreVersion, contentCrc };
|
|
248
|
+
if (contentName) {
|
|
249
|
+
this.contentName = contentName;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Update discovery broadcaster if already running
|
|
253
|
+
if (this.discoveryBroadcaster) {
|
|
254
|
+
this.discoveryBroadcaster.updateSessionInfo({
|
|
255
|
+
coreName,
|
|
256
|
+
coreVersion,
|
|
257
|
+
contentCrc,
|
|
258
|
+
contentName: contentName ?? this.contentName,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Start the netplay server.
|
|
265
|
+
*/
|
|
266
|
+
async start(): Promise<void> {
|
|
267
|
+
if (this._running) {
|
|
268
|
+
throw new NetplayError('ALREADY_RUNNING');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Start session logging
|
|
272
|
+
netplayLogger.startSession({
|
|
273
|
+
nickname: this.config.nickname,
|
|
274
|
+
mode: 'host',
|
|
275
|
+
port: this.config.port,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
this.server = createServer((socket) => {
|
|
280
|
+
this.handleNewConnection(socket);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
this.server.on('error', (err) => {
|
|
284
|
+
netplayLogger.serverError(`Server error: ${err.message}`);
|
|
285
|
+
this.emit('error', err);
|
|
286
|
+
reject(err);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
this.server.listen(this.config.port, () => {
|
|
290
|
+
this._running = true;
|
|
291
|
+
this.syncManager.initialize(0);
|
|
292
|
+
|
|
293
|
+
netplayLogger.serverStarted(this.config.port, this.config.nickname, this.config.requirePassword);
|
|
294
|
+
|
|
295
|
+
// Start LAN discovery broadcaster
|
|
296
|
+
this.discoveryBroadcaster = new DiscoveryBroadcaster({
|
|
297
|
+
port: this.config.port,
|
|
298
|
+
nickname: this.config.nickname,
|
|
299
|
+
coreName: this.coreInfo.coreName,
|
|
300
|
+
coreVersion: this.coreInfo.coreVersion,
|
|
301
|
+
contentName: this.contentName,
|
|
302
|
+
contentCrc: this.coreInfo.contentCrc,
|
|
303
|
+
hasPassword: this.config.requirePassword,
|
|
304
|
+
hasSpectatePassword: false,
|
|
305
|
+
});
|
|
306
|
+
this.discoveryBroadcaster.start();
|
|
307
|
+
|
|
308
|
+
this.emit('started');
|
|
309
|
+
resolve();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Stop the netplay server.
|
|
316
|
+
*/
|
|
317
|
+
stop(): void {
|
|
318
|
+
if (!this._running) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this._running = false;
|
|
323
|
+
this.lastRecoveryFrame = -Infinity;
|
|
324
|
+
|
|
325
|
+
// Stop LAN discovery broadcaster
|
|
326
|
+
if (this.discoveryBroadcaster) {
|
|
327
|
+
this.discoveryBroadcaster.stop();
|
|
328
|
+
this.discoveryBroadcaster = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Disconnect all clients
|
|
332
|
+
for (const session of this.clients.values()) {
|
|
333
|
+
session.connection.close('server stopped');
|
|
334
|
+
}
|
|
335
|
+
this.clients.clear();
|
|
336
|
+
|
|
337
|
+
// Close server
|
|
338
|
+
if (this.server) {
|
|
339
|
+
this.server.close();
|
|
340
|
+
this.server = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
netplayLogger.serverStopped();
|
|
344
|
+
netplayLogger.endSession('Server stopped');
|
|
345
|
+
this.emit('stopped');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Kick a client.
|
|
350
|
+
*/
|
|
351
|
+
kick(clientId: number, reason = 'Kicked by server'): void {
|
|
352
|
+
const session = this.clients.get(clientId);
|
|
353
|
+
if (!session) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Send disconnect command before closing
|
|
358
|
+
const disconnectCmd = encodeCommand(NetplayCmd.DISCONNECT, Buffer.alloc(0));
|
|
359
|
+
session.connection.send(disconnectCmd);
|
|
360
|
+
|
|
361
|
+
this.disconnectClient(session, reason);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Called before running a frame.
|
|
366
|
+
* Processes incoming input and prepares merged input.
|
|
367
|
+
* shouldCatchUp indicates we're behind and should disable frame limiter.
|
|
368
|
+
*/
|
|
369
|
+
preFrame(localInput: number[]): { input: number[]; shouldStall: boolean; shouldCatchUp: boolean } | null {
|
|
370
|
+
this.serverInput = [...localInput];
|
|
371
|
+
|
|
372
|
+
// Let sync manager prepare the frame
|
|
373
|
+
return this.syncManager.preFrame(localInput);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Called after running a frame.
|
|
378
|
+
* Broadcasts input to all clients.
|
|
379
|
+
*/
|
|
380
|
+
postFrame(serializedState: Buffer): void {
|
|
381
|
+
this._currentFrame++;
|
|
382
|
+
|
|
383
|
+
// Store state in sync manager
|
|
384
|
+
this.syncManager.postFrame(serializedState);
|
|
385
|
+
|
|
386
|
+
// Handle deferred savestate sending (per RetroArch protocol)
|
|
387
|
+
// This must happen BEFORE broadcasting input so clients:
|
|
388
|
+
// 1. Load the savestate (resetting to frame X)
|
|
389
|
+
// 2. Receive INPUT for frame X (so they can advance)
|
|
390
|
+
if (this.forceSendSavestate) {
|
|
391
|
+
this.broadcastSavestate(serializedState);
|
|
392
|
+
this.forceSendSavestate = false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Broadcast server input to all playing clients
|
|
396
|
+
this.broadcastServerInput();
|
|
397
|
+
|
|
398
|
+
// Send CRC check periodically
|
|
399
|
+
if (this.syncManager.shouldSendCrc()) {
|
|
400
|
+
this.broadcastCrc();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check for rollback
|
|
404
|
+
this.syncManager.performRollbackIfNeeded();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Broadcast current savestate to all connected clients.
|
|
409
|
+
* Called when forceSendSavestate flag is set.
|
|
410
|
+
*/
|
|
411
|
+
private broadcastSavestate(state: Buffer): void {
|
|
412
|
+
const frame = this._currentFrame;
|
|
413
|
+
|
|
414
|
+
netplayLogger.debug('SERVER', `Broadcasting savestate to all clients`, {
|
|
415
|
+
frame,
|
|
416
|
+
stateSize: state.length,
|
|
417
|
+
clientCount: this.clients.size,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
for (const session of this.clients.values()) {
|
|
421
|
+
if (session.state === ConnectionState.PLAYING || session.state === ConnectionState.SPECTATING) {
|
|
422
|
+
// Use client's protocol version to determine format
|
|
423
|
+
const loadCmd = buildLoadSavestateCommand(frame, state, session.protocolVersion);
|
|
424
|
+
session.connection.send(loadCmd);
|
|
425
|
+
|
|
426
|
+
netplayLogger.debug('SERVER', `Sent LOAD_SAVESTATE to client ${session.clientId}`, {
|
|
427
|
+
frame,
|
|
428
|
+
stateSize: state.length,
|
|
429
|
+
clientProtocolVersion: session.protocolVersion,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Handle a new TCP connection.
|
|
437
|
+
*/
|
|
438
|
+
private handleNewConnection(socket: Socket): void {
|
|
439
|
+
const remoteAddress = socket.remoteAddress ?? 'unknown';
|
|
440
|
+
const remotePort = socket.remotePort ?? 0;
|
|
441
|
+
|
|
442
|
+
// Find available client ID
|
|
443
|
+
const clientId = this.findAvailableClientId();
|
|
444
|
+
if (clientId < 0) {
|
|
445
|
+
// No slots available
|
|
446
|
+
netplayLogger.warn('SERVER', `Connection rejected from ${remoteAddress}:${remotePort} - no slots available`);
|
|
447
|
+
socket.end();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
netplayLogger.connectionAttempt(clientId, remoteAddress, remotePort);
|
|
452
|
+
|
|
453
|
+
const connection = NetplayConnection.fromSocket(socket, clientId);
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const session: ClientSession = {
|
|
456
|
+
clientId,
|
|
457
|
+
connection,
|
|
458
|
+
nickname: '',
|
|
459
|
+
state: ConnectionState.CONNECTED,
|
|
460
|
+
isPlaying: false,
|
|
461
|
+
deviceIndices: [],
|
|
462
|
+
lastInputFrame: -1,
|
|
463
|
+
lastActivity: now,
|
|
464
|
+
connectedAt: now,
|
|
465
|
+
handshakeSteps: [],
|
|
466
|
+
syncFrame: 0,
|
|
467
|
+
protocolVersion: 0,
|
|
468
|
+
modeSent: false,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
this.clients.set(clientId, session);
|
|
472
|
+
|
|
473
|
+
connection.on('disconnected', (reason: string) => {
|
|
474
|
+
this.disconnectClient(session, reason);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
connection.on('error', (err: Error) => {
|
|
478
|
+
netplayLogger.serverError(`Connection error for client ${clientId}`, { error: err.message });
|
|
479
|
+
this.disconnectClient(session, `Connection error: ${err.message}`);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Perform header exchange asynchronously
|
|
483
|
+
this.performHeaderExchange(session).catch((err: unknown) => {
|
|
484
|
+
const errorMsg = getErrorMessage(err);
|
|
485
|
+
netplayLogger.serverError(`Header exchange failed for client ${clientId}`, { error: errorMsg });
|
|
486
|
+
this.disconnectClient(session, `Header exchange failed: ${errorMsg}`);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Perform the header exchange with a client.
|
|
492
|
+
*
|
|
493
|
+
* Handshake flow per RetroArch docs:
|
|
494
|
+
* 1. Both: Send/receive connection header
|
|
495
|
+
* 2. Both: Send/receive NICK
|
|
496
|
+
* 3. Server: Receive PASSWORD (if required)
|
|
497
|
+
* 4. Server: Send INFO
|
|
498
|
+
* 5. Server: Receive INFO
|
|
499
|
+
* 6. Server: Send SYNC (after client sends PLAY/SPECTATE)
|
|
500
|
+
*/
|
|
501
|
+
private async performHeaderExchange(session: ClientSession): Promise<void> {
|
|
502
|
+
const { connection, clientId } = session;
|
|
503
|
+
|
|
504
|
+
// Step 1a: Send our connection header
|
|
505
|
+
netplayLogger.debug('SERVER', `Sending connection header to client ${clientId}`, {
|
|
506
|
+
nickname: this.config.nickname,
|
|
507
|
+
});
|
|
508
|
+
// Send header with salt=0 if no password required
|
|
509
|
+
const salt = this.config.requirePassword ? this.generateSalt() : 0;
|
|
510
|
+
connection.sendHeader(this.config.nickname, true, salt);
|
|
511
|
+
|
|
512
|
+
// Step 1b: Wait for client's header
|
|
513
|
+
netplayLogger.debug('SERVER', `Waiting for header from client ${clientId}`);
|
|
514
|
+
const clientHeader = await connection.waitForHeader();
|
|
515
|
+
|
|
516
|
+
if (!clientHeader) {
|
|
517
|
+
throw new NetplayError('INVALID_HEADER', 'Invalid or missing client header');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Store nickname from header (may be overwritten by NICK command later)
|
|
521
|
+
session.nickname = clientHeader.nickname || 'unknown';
|
|
522
|
+
session.handshakeSteps.push('header-received');
|
|
523
|
+
|
|
524
|
+
// For CLIENT headers, field4 contains the protocol version
|
|
525
|
+
// (For server headers, field4 would be the salt for password auth)
|
|
526
|
+
const clientProtocolVersion = clientHeader.field4;
|
|
527
|
+
session.protocolVersion = clientProtocolVersion;
|
|
528
|
+
|
|
529
|
+
// Platform magic contains platform info, not protocol version for clients
|
|
530
|
+
const { sizeOfSizeT } = parsePlatformMagic(clientHeader.platformMagic);
|
|
531
|
+
|
|
532
|
+
// Log detailed header parsing for debugging
|
|
533
|
+
const platformMagicHex = clientHeader.platformMagic.toString(HEX_RADIX).padStart(HEX_PADDING_WIDTH_32, '0');
|
|
534
|
+
netplayLogger.debug('SERVER', `Header received from client ${clientId}`, {
|
|
535
|
+
nickname: clientHeader.nickname,
|
|
536
|
+
platformMagicRaw: clientHeader.platformMagic,
|
|
537
|
+
platformMagicHex: `0x${platformMagicHex}`,
|
|
538
|
+
protocolVersion: clientProtocolVersion,
|
|
539
|
+
sizeOfSizeT,
|
|
540
|
+
compression: clientHeader.compression,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Set up command handlers for the handshake phase
|
|
544
|
+
connection.on('rawCommand', (cmd) => {
|
|
545
|
+
netplayLogger.debug('SERVER', `Raw command from client ${clientId}`, {
|
|
546
|
+
cmd: cmd.cmd,
|
|
547
|
+
cmdHex: `0x${cmd.cmd.toString(HEX_RADIX).padStart(HEX_PADDING_WIDTH, '0')}`,
|
|
548
|
+
payloadSize: cmd.payload.length,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
connection.on('command', (cmd: ParsedCommand) => {
|
|
553
|
+
this.handleClientCommand(session, cmd);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Step 2a: Send our NICK (before sending INFO, per protocol spec)
|
|
557
|
+
netplayLogger.debug('SERVER', `Sending NICK to client ${clientId}`, {
|
|
558
|
+
nickname: this.config.nickname,
|
|
559
|
+
});
|
|
560
|
+
connection.send(buildNickCommand(this.config.nickname));
|
|
561
|
+
session.handshakeSteps.push('sent-nick');
|
|
562
|
+
|
|
563
|
+
// INFO is sent after receiving client's NICK (in handleNick)
|
|
564
|
+
// This follows the documented handshake order
|
|
565
|
+
|
|
566
|
+
// Process any commands that arrived with the header
|
|
567
|
+
connection.processBuffer();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Handle a command from a client.
|
|
572
|
+
*/
|
|
573
|
+
private handleClientCommand(session: ClientSession, cmd: ParsedCommand): void {
|
|
574
|
+
session.lastActivity = Date.now();
|
|
575
|
+
|
|
576
|
+
if (!isKnownCommand(cmd)) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
switch (session.state) {
|
|
581
|
+
case ConnectionState.CONNECTED:
|
|
582
|
+
case ConnectionState.HANDSHAKING:
|
|
583
|
+
this.handleHandshakeCommand(session, cmd);
|
|
584
|
+
break;
|
|
585
|
+
case ConnectionState.PLAYING:
|
|
586
|
+
case ConnectionState.SPECTATING:
|
|
587
|
+
this.handlePlayingCommand(session, cmd);
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Handle a handshake command.
|
|
594
|
+
*/
|
|
595
|
+
private handleHandshakeCommand(session: ClientSession, cmd: KnownCommand): void {
|
|
596
|
+
switch (cmd.cmd) {
|
|
597
|
+
case NetplayCmd.NICK:
|
|
598
|
+
this.handleNick(session, cmd);
|
|
599
|
+
break;
|
|
600
|
+
|
|
601
|
+
case NetplayCmd.PASSWORD:
|
|
602
|
+
this.handlePassword(session, cmd);
|
|
603
|
+
break;
|
|
604
|
+
|
|
605
|
+
case NetplayCmd.INFO:
|
|
606
|
+
this.handleInfo(session, cmd);
|
|
607
|
+
break;
|
|
608
|
+
|
|
609
|
+
case NetplayCmd.PLAY:
|
|
610
|
+
this.handlePlayRequest(session);
|
|
611
|
+
break;
|
|
612
|
+
|
|
613
|
+
case NetplayCmd.SPECTATE:
|
|
614
|
+
this.handleSpectateRequest(session);
|
|
615
|
+
break;
|
|
616
|
+
|
|
617
|
+
case NetplayCmd.PING_REQUEST:
|
|
618
|
+
// Respond to ping during handshake too
|
|
619
|
+
session.connection.send(buildPingResponseCommand());
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Handle NICK command.
|
|
626
|
+
* After receiving client NICK, we send INFO (per protocol spec).
|
|
627
|
+
*/
|
|
628
|
+
private handleNick(session: ClientSession, cmd: NickCommand): void {
|
|
629
|
+
session.nickname = cmd.nickname;
|
|
630
|
+
session.state = ConnectionState.HANDSHAKING;
|
|
631
|
+
session.handshakeSteps.push('received-nick');
|
|
632
|
+
|
|
633
|
+
netplayLogger.handshakeStep(session.clientId, 'NICK received', { nickname: cmd.nickname });
|
|
634
|
+
|
|
635
|
+
// Step 4: After receiving NICK, send INFO (per protocol spec)
|
|
636
|
+
// We already sent our NICK in performHeaderExchange, so don't send it again
|
|
637
|
+
session.connection.send(
|
|
638
|
+
buildInfoCommand(this.coreInfo.coreName, this.coreInfo.coreVersion, this.coreInfo.contentCrc)
|
|
639
|
+
);
|
|
640
|
+
session.handshakeSteps.push('sent-info');
|
|
641
|
+
|
|
642
|
+
netplayLogger.debug('SERVER', `Sent INFO to client ${session.clientId}`, {
|
|
643
|
+
coreName: this.coreInfo.coreName,
|
|
644
|
+
contentCrc: this.coreInfo.contentCrc.toString(HEX_RADIX),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Handle PASSWORD command.
|
|
650
|
+
*/
|
|
651
|
+
private handlePassword(session: ClientSession, cmd: PasswordCommand): void {
|
|
652
|
+
session.handshakeSteps.push('PASSWORD');
|
|
653
|
+
|
|
654
|
+
if (!this.config.requirePassword) {
|
|
655
|
+
netplayLogger.handshakeStep(session.clientId, 'PASSWORD received (not required)');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const success = cmd.passwordHash === this.passwordHash;
|
|
660
|
+
netplayLogger.passwordAuth(session.clientId, success);
|
|
661
|
+
|
|
662
|
+
if (!success) {
|
|
663
|
+
// Invalid password - disconnect
|
|
664
|
+
netplayLogger.handshakeFailed(session.clientId, 'Invalid password');
|
|
665
|
+
notifyNetplayConnectionFailed(session.nickname || 'Client', 'Invalid password');
|
|
666
|
+
this.disconnectClient(session, 'Invalid password');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Handle INFO command from client.
|
|
672
|
+
* At this point we've already sent our INFO (after receiving NICK),
|
|
673
|
+
* so we just validate the client's info matches ours.
|
|
674
|
+
*/
|
|
675
|
+
private handleInfo(session: ClientSession, cmd: InfoCommand): void {
|
|
676
|
+
session.handshakeSteps.push('received-info');
|
|
677
|
+
|
|
678
|
+
netplayLogger.handshakeStep(session.clientId, 'INFO received', {
|
|
679
|
+
coreName: cmd.coreName,
|
|
680
|
+
contentCrc: cmd.contentCrc.toString(HEX_RADIX),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Validate core/content match
|
|
684
|
+
if (cmd.coreName !== this.coreInfo.coreName) {
|
|
685
|
+
netplayLogger.mismatch(session.clientId, 'core', this.coreInfo.coreName, cmd.coreName);
|
|
686
|
+
netplayLogger.handshakeFailed(session.clientId, `Core mismatch: ${cmd.coreName} vs ${this.coreInfo.coreName}`);
|
|
687
|
+
notifyNetplayConnectionFailed(session.nickname || 'Client', `Wrong core: ${cmd.coreName}`);
|
|
688
|
+
this.disconnectClient(session, `Core mismatch: ${cmd.coreName} vs ${this.coreInfo.coreName}`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (cmd.contentCrc !== this.coreInfo.contentCrc) {
|
|
693
|
+
netplayLogger.mismatch(session.clientId, 'crc', this.coreInfo.contentCrc, cmd.contentCrc);
|
|
694
|
+
netplayLogger.handshakeFailed(session.clientId, 'Content CRC mismatch');
|
|
695
|
+
notifyNetplayConnectionFailed(session.nickname || 'Client', 'ROM mismatch');
|
|
696
|
+
this.disconnectClient(session, 'Content CRC mismatch');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
netplayLogger.handshakeStep(session.clientId, 'INFO validated - core and CRC match');
|
|
701
|
+
|
|
702
|
+
// Per protocol: Server sends SYNC immediately after INFO exchange
|
|
703
|
+
// Store the frame number used in SYNC so MODE can use frame+1
|
|
704
|
+
session.handshakeSteps.push('info-validated');
|
|
705
|
+
|
|
706
|
+
// Send SYNC with SRAM (not full save state - that's what LOAD_SAVESTATE is for)
|
|
707
|
+
// SYNC synchronizes SRAM and assigns the client number
|
|
708
|
+
this.sendSyncToClient(session);
|
|
709
|
+
session.handshakeSteps.push('sent-sync');
|
|
710
|
+
|
|
711
|
+
netplayLogger.handshakeStep(session.clientId, 'SYNC sent - awaiting PLAY/SPECTATE');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Handle PLAY request.
|
|
716
|
+
*
|
|
717
|
+
* Flow:
|
|
718
|
+
* 1. Send MODE to confirm player assignment
|
|
719
|
+
* 2. Queue savestate to be sent on next frame (proactive, like RetroArch)
|
|
720
|
+
* 3. Also handle REQUEST_SAVESTATE for mid-game resyncs
|
|
721
|
+
*
|
|
722
|
+
* Per RetroArch protocol: the server sends LOAD_SAVESTATE automatically
|
|
723
|
+
* after a client joins, without waiting for REQUEST_SAVESTATE.
|
|
724
|
+
*/
|
|
725
|
+
private handlePlayRequest(session: ClientSession): void {
|
|
726
|
+
session.handshakeSteps.push('PLAY');
|
|
727
|
+
netplayLogger.handshakeStep(session.clientId, 'PLAY request received');
|
|
728
|
+
|
|
729
|
+
// Assign device indices
|
|
730
|
+
const deviceIndex = this.findAvailableDeviceIndex();
|
|
731
|
+
if (deviceIndex < 0) {
|
|
732
|
+
// No device slots available - refuse with MODE showing not playing
|
|
733
|
+
netplayLogger.warn('SERVER', `PLAY request rejected for client ${session.clientId} - no device slots`, {
|
|
734
|
+
nickname: session.nickname,
|
|
735
|
+
});
|
|
736
|
+
notifyNetplayConnectionFailed(session.nickname || 'Client', 'Game is full');
|
|
737
|
+
session.connection.send(
|
|
738
|
+
buildModeCommand(this._currentFrame, true, false, false, session.clientId, 0, [], session.nickname)
|
|
739
|
+
);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
session.deviceIndices = [deviceIndex];
|
|
744
|
+
session.isPlaying = true;
|
|
745
|
+
session.state = ConnectionState.PLAYING;
|
|
746
|
+
|
|
747
|
+
// Register with sync manager
|
|
748
|
+
this.syncManager.addRemoteClient(session.clientId, session.deviceIndices);
|
|
749
|
+
|
|
750
|
+
// Device bitmap: set the bit for the assigned device
|
|
751
|
+
const deviceBitmap = 1 << deviceIndex;
|
|
752
|
+
|
|
753
|
+
// Send MODE to confirm player assignment
|
|
754
|
+
// Client will then send REQUEST_SAVESTATE to get the current state
|
|
755
|
+
const modeFrame = this._currentFrame;
|
|
756
|
+
const modeCmd = buildModeCommand(modeFrame, true, true, false, session.clientId, deviceBitmap, [], session.nickname);
|
|
757
|
+
session.connection.send(modeCmd);
|
|
758
|
+
session.modeSent = true;
|
|
759
|
+
|
|
760
|
+
netplayLogger.debug('SERVER', `Sent MODE to client ${session.clientId}`, {
|
|
761
|
+
modeFrame,
|
|
762
|
+
deviceBitmap,
|
|
763
|
+
clientId: session.clientId,
|
|
764
|
+
cmdHex: modeCmd.toString('hex'),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// Queue savestate to be sent on next frame (proactive, like RetroArch)
|
|
768
|
+
// RetroArch sets force_send_savestate=true in netplay_handshake_ready()
|
|
769
|
+
// and sends LOAD_SAVESTATE automatically without waiting for REQUEST_SAVESTATE
|
|
770
|
+
this.forceSendSavestate = true;
|
|
771
|
+
|
|
772
|
+
netplayLogger.debug('SERVER', `Queued proactive LOAD_SAVESTATE for client ${session.clientId}`, {
|
|
773
|
+
currentFrame: this._currentFrame,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Send a few INPUT commands to start the stream
|
|
777
|
+
// Client will need these once it loads the savestate
|
|
778
|
+
const inputCount = 3;
|
|
779
|
+
for (let i = 0; i < inputCount; i++) {
|
|
780
|
+
const inputFrame = modeFrame + i;
|
|
781
|
+
const inputCmd = buildInputCommand(inputFrame, 0, true, 0, 1);
|
|
782
|
+
session.connection.send(inputCmd);
|
|
783
|
+
netplayLogger.debug('SERVER', `Sent INPUT to client ${session.clientId}`, {
|
|
784
|
+
frame: inputFrame,
|
|
785
|
+
cmdHex: inputCmd.toString('hex'),
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Notify other clients about new player
|
|
790
|
+
this.broadcastModeChange(session);
|
|
791
|
+
|
|
792
|
+
netplayLogger.clientConnected(session.clientId, session.nickname, true, deviceIndex);
|
|
793
|
+
|
|
794
|
+
// Notify user about new player (deviceIndex + 1 because server is player 1)
|
|
795
|
+
notifyNetplayClientConnected(session.nickname, deviceIndex + 1);
|
|
796
|
+
|
|
797
|
+
this.emit('client-connected', session);
|
|
798
|
+
this.emit('client-playing', session);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Handle SPECTATE request.
|
|
803
|
+
*/
|
|
804
|
+
private handleSpectateRequest(session: ClientSession): void {
|
|
805
|
+
session.handshakeSteps.push('SPECTATE');
|
|
806
|
+
netplayLogger.handshakeStep(session.clientId, 'SPECTATE request received');
|
|
807
|
+
|
|
808
|
+
session.isPlaying = false;
|
|
809
|
+
session.state = ConnectionState.SPECTATING;
|
|
810
|
+
|
|
811
|
+
// MODE frame must match SYNC frame (client's server_frame_count)
|
|
812
|
+
const modeFrame = session.syncFrame;
|
|
813
|
+
|
|
814
|
+
// Send MODE to client (YOU flag, no PLAYING flag)
|
|
815
|
+
// Note: SYNC was already sent after INFO exchange
|
|
816
|
+
session.connection.send(
|
|
817
|
+
buildModeCommand(modeFrame, true, false, false, session.clientId, 0, [], session.nickname)
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
// Notify user about new spectator
|
|
821
|
+
notifyNetplaySpectatorConnected(session.nickname);
|
|
822
|
+
|
|
823
|
+
netplayLogger.clientConnected(session.clientId, session.nickname, false, -1);
|
|
824
|
+
|
|
825
|
+
this.emit('client-connected', session);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Send SYNC command to a client.
|
|
830
|
+
* Per protocol, SYNC sends SRAM (battery save), not full save state.
|
|
831
|
+
* Full save states are sent via LOAD_SAVESTATE when needed.
|
|
832
|
+
*/
|
|
833
|
+
private sendSyncToClient(session: ClientSession): void {
|
|
834
|
+
const frame = this._currentFrame;
|
|
835
|
+
const devices = this.buildDeviceArray();
|
|
836
|
+
const deviceClients = this.buildDeviceClientsArray();
|
|
837
|
+
const shareModes = new Array<number>(MAX_INPUT_DEVICES).fill(0);
|
|
838
|
+
|
|
839
|
+
// SYNC sends SRAM (battery-backed save RAM), not full save state
|
|
840
|
+
// For now, send empty SRAM - the emulator can implement getBatteryRam() later
|
|
841
|
+
const sram = Buffer.alloc(0);
|
|
842
|
+
|
|
843
|
+
// Store frame for MODE command (MODE uses syncFrame + 1)
|
|
844
|
+
session.syncFrame = frame;
|
|
845
|
+
|
|
846
|
+
netplayLogger.debug('SERVER', `Sending SYNC to client ${session.clientId}`, {
|
|
847
|
+
frame,
|
|
848
|
+
clientNumber: session.clientId,
|
|
849
|
+
sramSize: sram.length,
|
|
850
|
+
devices: devices.filter(d => d !== 0),
|
|
851
|
+
deviceClients: deviceClients.filter(d => d !== 0),
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const syncCmd = buildSyncCommand(
|
|
855
|
+
frame,
|
|
856
|
+
false, // paused
|
|
857
|
+
session.clientId, // client number being assigned
|
|
858
|
+
devices,
|
|
859
|
+
shareModes,
|
|
860
|
+
deviceClients,
|
|
861
|
+
session.nickname || this.config.nickname,
|
|
862
|
+
sram
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
session.connection.send(syncCmd);
|
|
866
|
+
const syncHex = syncCmd.subarray(0, Math.min(HEX_PREVIEW_LENGTH, syncCmd.length)).toString('hex');
|
|
867
|
+
netplayLogger.debug('SERVER', `SYNC command hex (first ${HEX_PREVIEW_LENGTH} bytes)`, {
|
|
868
|
+
cmdHex: syncHex + (syncCmd.length > HEX_PREVIEW_LENGTH ? '...' : ''),
|
|
869
|
+
totalSize: syncCmd.length,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// Send SETTING commands after SYNC (per reference capture)
|
|
873
|
+
// SETTING_ALLOW_PAUSING: 0 = pausing disabled
|
|
874
|
+
const pauseCmd = buildSettingAllowPausingCommand(false);
|
|
875
|
+
session.connection.send(pauseCmd);
|
|
876
|
+
|
|
877
|
+
// SETTING_INPUT_LATENCY_FRAMES: frames=0, range=0 (no input latency)
|
|
878
|
+
const latencyCmd = buildSettingInputLatencyFramesCommand(0, 0);
|
|
879
|
+
session.connection.send(latencyCmd);
|
|
880
|
+
|
|
881
|
+
netplayLogger.debug('SERVER', `Sent SETTING commands to client ${session.clientId}`, {
|
|
882
|
+
allowPausing: false,
|
|
883
|
+
inputLatencyFrames: 0,
|
|
884
|
+
pauseCmdHex: pauseCmd.toString('hex'),
|
|
885
|
+
latencyCmdHex: latencyCmd.toString('hex'),
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// Send INPUT for the SYNC frame (per reference capture analysis)
|
|
889
|
+
// This INPUT at frame 1512 (SYNC frame) appears BEFORE the client sends PLAY
|
|
890
|
+
const syncInputCmd = buildInputCommand(
|
|
891
|
+
frame, // SYNC frame
|
|
892
|
+
0, // client number (server = 0)
|
|
893
|
+
true, // is server data
|
|
894
|
+
0, // joypad state (no buttons)
|
|
895
|
+
1 // device count
|
|
896
|
+
);
|
|
897
|
+
session.connection.send(syncInputCmd);
|
|
898
|
+
|
|
899
|
+
netplayLogger.debug('SERVER', `Sent INPUT for SYNC frame to client ${session.clientId}`, {
|
|
900
|
+
frame,
|
|
901
|
+
inputCmdHex: syncInputCmd.toString('hex'),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Build controller-client mapping array for SYNC command.
|
|
907
|
+
* Each entry maps a device index to the client bitmap using it.
|
|
908
|
+
*/
|
|
909
|
+
private buildDeviceClientsArray(): number[] {
|
|
910
|
+
const deviceClients: number[] = new Array<number>(MAX_INPUT_DEVICES).fill(0);
|
|
911
|
+
// Server (client 0) uses device 0
|
|
912
|
+
deviceClients[0] = 1 << 0;
|
|
913
|
+
|
|
914
|
+
for (const session of this.clients.values()) {
|
|
915
|
+
if (session.isPlaying) {
|
|
916
|
+
for (const deviceIdx of session.deviceIndices) {
|
|
917
|
+
if (deviceIdx >= 0 && deviceIdx < MAX_INPUT_DEVICES) {
|
|
918
|
+
deviceClients[deviceIdx] |= 1 << session.clientId;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return deviceClients;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Handle a command from a playing client.
|
|
929
|
+
*/
|
|
930
|
+
private handlePlayingCommand(session: ClientSession, cmd: KnownCommand): void {
|
|
931
|
+
netplayLogger.debug('SERVER', `Playing command from client ${session.clientId}`, {
|
|
932
|
+
cmd: cmd.cmd,
|
|
933
|
+
cmdHex: `0x${cmd.cmd.toString(HEX_RADIX).padStart(HEX_PADDING_WIDTH, '0')}`,
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
switch (cmd.cmd) {
|
|
937
|
+
case NetplayCmd.INPUT:
|
|
938
|
+
this.handleClientInput(session, cmd);
|
|
939
|
+
break;
|
|
940
|
+
|
|
941
|
+
case NetplayCmd.NOINPUT:
|
|
942
|
+
// Client indicates no input for a frame - just acknowledge
|
|
943
|
+
netplayLogger.debug('SERVER', `NOINPUT from client ${session.clientId}`);
|
|
944
|
+
break;
|
|
945
|
+
|
|
946
|
+
case NetplayCmd.DISCONNECT:
|
|
947
|
+
this.disconnectClient(session, 'Client disconnected');
|
|
948
|
+
break;
|
|
949
|
+
|
|
950
|
+
case NetplayCmd.CRC:
|
|
951
|
+
this.handleClientCrc(session, cmd);
|
|
952
|
+
break;
|
|
953
|
+
|
|
954
|
+
case NetplayCmd.PING_REQUEST:
|
|
955
|
+
// Respond to ping immediately to keep connection alive
|
|
956
|
+
netplayLogger.debug('SERVER', `PING_REQUEST from client ${session.clientId}, sending response`);
|
|
957
|
+
session.connection.send(buildPingResponseCommand());
|
|
958
|
+
break;
|
|
959
|
+
|
|
960
|
+
case NetplayCmd.PING_RESPONSE:
|
|
961
|
+
// Client responded to our ping - nothing to do
|
|
962
|
+
netplayLogger.debug('SERVER', `PING_RESPONSE from client ${session.clientId}`);
|
|
963
|
+
break;
|
|
964
|
+
|
|
965
|
+
case NetplayCmd.REQUEST_SAVESTATE:
|
|
966
|
+
// Client requests savestate - send immediately to prevent timeout
|
|
967
|
+
// Note: RetroArch defers this to next frame, but clients may timeout
|
|
968
|
+
// if the emulator isn't running frames quickly enough
|
|
969
|
+
this.handleRequestSavestate(session);
|
|
970
|
+
break;
|
|
971
|
+
|
|
972
|
+
case NetplayCmd.NAK:
|
|
973
|
+
// Client rejected something we sent (e.g., LOAD_SAVESTATE failed validation)
|
|
974
|
+
netplayLogger.error('SERVER', `NAK received from client ${session.clientId} - client rejected a command`);
|
|
975
|
+
break;
|
|
976
|
+
|
|
977
|
+
case NetplayCmd.ACK:
|
|
978
|
+
// Client acknowledged a command
|
|
979
|
+
netplayLogger.debug('SERVER', `ACK received from client ${session.clientId}`);
|
|
980
|
+
break;
|
|
981
|
+
|
|
982
|
+
case NetplayCmd.PAUSE:
|
|
983
|
+
this.handlePause(session, cmd);
|
|
984
|
+
break;
|
|
985
|
+
|
|
986
|
+
case NetplayCmd.RESUME:
|
|
987
|
+
this.handleResume(session);
|
|
988
|
+
break;
|
|
989
|
+
|
|
990
|
+
case NetplayCmd.PLAYER_CHAT:
|
|
991
|
+
this.handlePlayerChat(session, cmd);
|
|
992
|
+
break;
|
|
993
|
+
|
|
994
|
+
default:
|
|
995
|
+
netplayLogger.warn('SERVER', `Unhandled playing command from client ${session.clientId}`, {
|
|
996
|
+
cmd: cmd.cmd,
|
|
997
|
+
cmdHex: `0x${cmd.cmd.toString(HEX_RADIX).padStart(HEX_PADDING_WIDTH, '0')}`,
|
|
998
|
+
});
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Handle REQUEST_SAVESTATE from client.
|
|
1005
|
+
*
|
|
1006
|
+
* This is used for mid-game resyncs (e.g., after desync detection).
|
|
1007
|
+
* Initial sync is handled proactively in handlePlayRequest().
|
|
1008
|
+
*
|
|
1009
|
+
* Defer to next frame like RetroArch does - set flag to send in postFrame.
|
|
1010
|
+
*/
|
|
1011
|
+
private handleRequestSavestate(session: ClientSession): void {
|
|
1012
|
+
netplayLogger.debug('SERVER', `REQUEST_SAVESTATE from client ${session.clientId} (resync request)`, {
|
|
1013
|
+
syncFrame: session.syncFrame,
|
|
1014
|
+
currentFrame: this._currentFrame,
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
this.triggerDesyncRecovery(this._currentFrame, 'client-request');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Handle CRC command from client.
|
|
1022
|
+
* Compares the client's CRC against server's CRC for the same frame
|
|
1023
|
+
* and triggers desync recovery if they don't match.
|
|
1024
|
+
*/
|
|
1025
|
+
private handleClientCrc(session: ClientSession, cmd: CrcCommand): void {
|
|
1026
|
+
netplayLogger.debug('SERVER', `CRC from client ${session.clientId}`, {
|
|
1027
|
+
frame: cmd.frameNumber,
|
|
1028
|
+
clientCrc: cmd.crc.toString(HEX_RADIX),
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
const localCrc = this.syncManager.getCrcForFrame(cmd.frameNumber);
|
|
1032
|
+
if (localCrc === null) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (localCrc !== cmd.crc) {
|
|
1037
|
+
netplayLogger.desyncDetected(cmd.frameNumber, localCrc, cmd.crc);
|
|
1038
|
+
this.emit('desync', session.clientId, cmd.frameNumber);
|
|
1039
|
+
this.triggerDesyncRecovery(cmd.frameNumber, 'client-request');
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Trigger desync recovery by sending a fresh savestate to all clients.
|
|
1045
|
+
* Rate-limited by DESYNC_RECOVERY_COOLDOWN_FRAMES to avoid flooding.
|
|
1046
|
+
*/
|
|
1047
|
+
private triggerDesyncRecovery(frameNumber: number, trigger: 'server' | 'client-request'): void {
|
|
1048
|
+
if (this._currentFrame - this.lastRecoveryFrame < DESYNC_RECOVERY_COOLDOWN_FRAMES) {
|
|
1049
|
+
netplayLogger.debug('SERVER', `Desync recovery skipped (cooldown)`, {
|
|
1050
|
+
frame: frameNumber,
|
|
1051
|
+
lastRecovery: this.lastRecoveryFrame,
|
|
1052
|
+
cooldown: DESYNC_RECOVERY_COOLDOWN_FRAMES,
|
|
1053
|
+
});
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
this.forceSendSavestate = true;
|
|
1058
|
+
this.lastRecoveryFrame = this._currentFrame;
|
|
1059
|
+
netplayLogger.desyncRecovery(frameNumber, trigger);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Handle INPUT command from client.
|
|
1064
|
+
*/
|
|
1065
|
+
private handleClientInput(session: ClientSession, cmd: InputCommand): void {
|
|
1066
|
+
if (!session.isPlaying) {
|
|
1067
|
+
netplayLogger.debug('SERVER', `Ignoring INPUT from non-playing client ${session.clientId}`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
session.lastInputFrame = cmd.frameNumber;
|
|
1072
|
+
|
|
1073
|
+
netplayLogger.debug('SERVER', `Processing INPUT from client ${session.clientId}`, {
|
|
1074
|
+
clientFrame: cmd.frameNumber,
|
|
1075
|
+
serverFrame: this._currentFrame,
|
|
1076
|
+
frameDiff: cmd.frameNumber - this._currentFrame,
|
|
1077
|
+
joypad: cmd.joypadState,
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Feed to sync manager
|
|
1081
|
+
const input = [cmd.joypadState, cmd.analogLeft ?? 0, cmd.analogRight ?? 0];
|
|
1082
|
+
this.syncManager.receiveRemoteInput(session.clientId, cmd.frameNumber, input);
|
|
1083
|
+
|
|
1084
|
+
// Relay to other clients
|
|
1085
|
+
this.relayInput(session, cmd);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Handle PAUSE command from client.
|
|
1090
|
+
* Broadcasts pause to all other clients.
|
|
1091
|
+
*/
|
|
1092
|
+
private handlePause(session: ClientSession, cmd: PauseCommand): void {
|
|
1093
|
+
// Use the nickname from the command, or fall back to session nickname
|
|
1094
|
+
const nickname = cmd.nickname || session.nickname;
|
|
1095
|
+
|
|
1096
|
+
netplayLogger.info('SERVER', `Game paused by ${nickname}`);
|
|
1097
|
+
|
|
1098
|
+
this._isPaused = true;
|
|
1099
|
+
this._pausedBy = nickname;
|
|
1100
|
+
|
|
1101
|
+
// Broadcast pause to all other clients
|
|
1102
|
+
const pauseCmd = buildPauseCommand(nickname);
|
|
1103
|
+
for (const [clientId, clientSession] of this.clients) {
|
|
1104
|
+
if (clientId !== session.clientId) {
|
|
1105
|
+
clientSession.connection.send(pauseCmd);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
this.emit('paused', nickname);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Handle RESUME command from client.
|
|
1114
|
+
* Broadcasts resume to all other clients.
|
|
1115
|
+
*/
|
|
1116
|
+
private handleResume(session: ClientSession): void {
|
|
1117
|
+
netplayLogger.info('SERVER', `Game resumed by ${session.nickname}`);
|
|
1118
|
+
|
|
1119
|
+
this._isPaused = false;
|
|
1120
|
+
this._pausedBy = '';
|
|
1121
|
+
|
|
1122
|
+
// Broadcast resume to all other clients
|
|
1123
|
+
const resumeCmd = buildResumeCommand();
|
|
1124
|
+
for (const [clientId, clientSession] of this.clients) {
|
|
1125
|
+
if (clientId !== session.clientId) {
|
|
1126
|
+
clientSession.connection.send(resumeCmd);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
this.emit('resumed');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Handle PLAYER_CHAT command from client.
|
|
1135
|
+
* Broadcasts chat message to all other clients.
|
|
1136
|
+
*/
|
|
1137
|
+
private handlePlayerChat(session: ClientSession, cmd: PlayerChatCommand): void {
|
|
1138
|
+
// Use session nickname if command nickname is empty
|
|
1139
|
+
const nickname = cmd.nickname || session.nickname;
|
|
1140
|
+
netplayLogger.info('SERVER', `Chat from ${nickname}: ${cmd.message}`);
|
|
1141
|
+
|
|
1142
|
+
// Broadcast chat to all other clients
|
|
1143
|
+
const chatCmd = buildPlayerChatCommand(nickname, cmd.message);
|
|
1144
|
+
for (const [clientId, clientSession] of this.clients) {
|
|
1145
|
+
if (clientId !== session.clientId) {
|
|
1146
|
+
clientSession.connection.send(chatCmd);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
this.emit('chat', nickname, cmd.message);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Relay input from one client to all others.
|
|
1155
|
+
*/
|
|
1156
|
+
private relayInput(source: ClientSession, cmd: InputCommand): void {
|
|
1157
|
+
// Build device bitmap from client's assigned device indices
|
|
1158
|
+
const deviceBitmap = source.deviceIndices.reduce((acc, idx) => acc | (1 << idx), 0);
|
|
1159
|
+
|
|
1160
|
+
const inputCmd = buildInputCommand(
|
|
1161
|
+
cmd.frameNumber,
|
|
1162
|
+
source.clientId,
|
|
1163
|
+
false, // Not server data
|
|
1164
|
+
cmd.joypadState,
|
|
1165
|
+
deviceBitmap,
|
|
1166
|
+
cmd.analogLeft,
|
|
1167
|
+
cmd.analogRight
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
let relayCount = 0;
|
|
1171
|
+
for (const [clientId, session] of this.clients) {
|
|
1172
|
+
// Send to both playing clients and spectators so spectators can see player inputs
|
|
1173
|
+
if (clientId !== source.clientId &&
|
|
1174
|
+
(session.state === ConnectionState.PLAYING || session.state === ConnectionState.SPECTATING)) {
|
|
1175
|
+
session.connection.send(inputCmd);
|
|
1176
|
+
relayCount++;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Log relay activity periodically to avoid spam
|
|
1181
|
+
if (cmd.frameNumber % SERVER_INPUT_LOG_INTERVAL_FRAMES === 0) {
|
|
1182
|
+
netplayLogger.debug('SERVER', `Relayed input from client ${source.clientId}`, {
|
|
1183
|
+
frame: cmd.frameNumber,
|
|
1184
|
+
joypadState: cmd.joypadState,
|
|
1185
|
+
recipients: relayCount,
|
|
1186
|
+
hasAnalog: cmd.analogLeft !== undefined || cmd.analogRight !== undefined,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Broadcast server's input to all clients.
|
|
1193
|
+
*/
|
|
1194
|
+
private broadcastServerInput(): void {
|
|
1195
|
+
// Server uses device 0 (bitmap = 1)
|
|
1196
|
+
const inputCmd = buildInputCommand(
|
|
1197
|
+
this._currentFrame,
|
|
1198
|
+
0, // Server is client 0
|
|
1199
|
+
true, // Is server data
|
|
1200
|
+
this.serverInput[0] ?? 0,
|
|
1201
|
+
1 // Device bitmap: server uses device 0
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
let sentCount = 0;
|
|
1205
|
+
for (const session of this.clients.values()) {
|
|
1206
|
+
if (session.state === ConnectionState.PLAYING || session.state === ConnectionState.SPECTATING) {
|
|
1207
|
+
session.connection.send(inputCmd);
|
|
1208
|
+
sentCount++;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Log periodically to avoid spam
|
|
1213
|
+
if (this._currentFrame % SERVER_INPUT_LOG_INTERVAL_FRAMES === 0 || sentCount > 0) {
|
|
1214
|
+
netplayLogger.debug('SERVER', `broadcastServerInput`, {
|
|
1215
|
+
frame: this._currentFrame,
|
|
1216
|
+
clientsCount: this.clients.size,
|
|
1217
|
+
sentCount,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Broadcast CRC check to all clients.
|
|
1224
|
+
*/
|
|
1225
|
+
private broadcastCrc(): void {
|
|
1226
|
+
const crc = this.syncManager.getCurrentCrc();
|
|
1227
|
+
if (crc === null) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const crcCmd = buildCrcCommand(this._currentFrame, crc);
|
|
1232
|
+
|
|
1233
|
+
for (const session of this.clients.values()) {
|
|
1234
|
+
if (session.state === ConnectionState.PLAYING) {
|
|
1235
|
+
session.connection.send(crcCmd);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Broadcast mode change to all clients.
|
|
1242
|
+
*/
|
|
1243
|
+
private broadcastModeChange(changedSession: ClientSession): void {
|
|
1244
|
+
const deviceBitmap = changedSession.deviceIndices.reduce((acc, idx) => acc | (1 << idx), 0);
|
|
1245
|
+
const modeCmd = buildModeCommand(
|
|
1246
|
+
this._currentFrame,
|
|
1247
|
+
false, // Not "you" for other clients
|
|
1248
|
+
changedSession.isPlaying,
|
|
1249
|
+
false,
|
|
1250
|
+
changedSession.clientId,
|
|
1251
|
+
deviceBitmap,
|
|
1252
|
+
[],
|
|
1253
|
+
changedSession.nickname
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
for (const [clientId, session] of this.clients) {
|
|
1257
|
+
if (clientId !== changedSession.clientId) {
|
|
1258
|
+
session.connection.send(modeCmd);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Disconnect a client.
|
|
1265
|
+
*/
|
|
1266
|
+
private disconnectClient(session: ClientSession, reason: string): void {
|
|
1267
|
+
if (!this.clients.has(session.clientId)) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const nickname = session.nickname || 'unknown';
|
|
1272
|
+
const wasConnected = session.state === ConnectionState.PLAYING || session.state === ConnectionState.SPECTATING;
|
|
1273
|
+
const connectedDuration = Date.now() - session.connectedAt;
|
|
1274
|
+
|
|
1275
|
+
// Get human-readable state name
|
|
1276
|
+
const stateNames: Record<ConnectionState, string> = {
|
|
1277
|
+
[ConnectionState.DISCONNECTED]: 'DISCONNECTED',
|
|
1278
|
+
[ConnectionState.CONNECTED]: 'CONNECTED (no handshake started)',
|
|
1279
|
+
[ConnectionState.HANDSHAKING]: 'HANDSHAKING',
|
|
1280
|
+
[ConnectionState.PLAYING]: 'PLAYING',
|
|
1281
|
+
[ConnectionState.SPECTATING]: 'SPECTATING',
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
netplayLogger.clientDisconnected(session.clientId, nickname, reason, {
|
|
1285
|
+
state: stateNames[session.state],
|
|
1286
|
+
handshakeCompleted: wasConnected,
|
|
1287
|
+
commandsReceived: session.handshakeSteps.length > 0 ? session.handshakeSteps : ['none'],
|
|
1288
|
+
connectedDuration,
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
// Notify user about disconnection (only if they completed handshake)
|
|
1292
|
+
if (wasConnected && session.nickname) {
|
|
1293
|
+
notifyNetplayClientDisconnected(session.nickname);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
this.clients.delete(session.clientId);
|
|
1297
|
+
session.connection.close(reason);
|
|
1298
|
+
|
|
1299
|
+
// Remove from sync manager
|
|
1300
|
+
this.syncManager.removeRemoteClient(session.clientId);
|
|
1301
|
+
|
|
1302
|
+
// Notify other clients that this player disconnected (not playing anymore)
|
|
1303
|
+
const modeCmd = buildModeCommand(
|
|
1304
|
+
this._currentFrame,
|
|
1305
|
+
false, // not "you" for other clients
|
|
1306
|
+
false, // not playing (disconnected)
|
|
1307
|
+
false, // not slave
|
|
1308
|
+
session.clientId,
|
|
1309
|
+
0, // no devices
|
|
1310
|
+
[],
|
|
1311
|
+
session.nickname
|
|
1312
|
+
);
|
|
1313
|
+
for (const otherSession of this.clients.values()) {
|
|
1314
|
+
otherSession.connection.send(modeCmd);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
this.emit('client-disconnected', session, reason);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Find an available client ID.
|
|
1322
|
+
*/
|
|
1323
|
+
private findAvailableClientId(): number {
|
|
1324
|
+
// Server is always client 0
|
|
1325
|
+
for (let i = 1; i < this.config.maxClients; i++) {
|
|
1326
|
+
if (!this.clients.has(i)) {
|
|
1327
|
+
return i;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return -1;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Find an available device index.
|
|
1335
|
+
*/
|
|
1336
|
+
private findAvailableDeviceIndex(): number {
|
|
1337
|
+
const usedDevices = new Set<number>([0]); // Server uses device 0
|
|
1338
|
+
|
|
1339
|
+
for (const session of this.clients.values()) {
|
|
1340
|
+
for (const d of session.deviceIndices) {
|
|
1341
|
+
usedDevices.add(d);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
for (let i = 1; i < MAX_CLIENTS; i++) {
|
|
1346
|
+
if (!usedDevices.has(i)) {
|
|
1347
|
+
return i;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return -1;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Build device array for SYNC command.
|
|
1356
|
+
* The devices array tells clients what TYPE of device is at each port (not who owns it).
|
|
1357
|
+
* We pre-configure devices for potential player ports so clients joining later can use them.
|
|
1358
|
+
*/
|
|
1359
|
+
private buildDeviceArray(): number[] {
|
|
1360
|
+
const devices: number[] = new Array<number>(MAX_INPUT_DEVICES).fill(0);
|
|
1361
|
+
|
|
1362
|
+
// Pre-configure devices for potential player ports
|
|
1363
|
+
// Device index 0 = Player 1 (server), Device index 1 = Player 2, etc.
|
|
1364
|
+
// We support up to maxClients + 1 players (server + clients)
|
|
1365
|
+
const maxPlayers = Math.min(this.config.maxClients + 1, MAX_INPUT_DEVICES);
|
|
1366
|
+
for (let i = 0; i < maxPlayers; i++) {
|
|
1367
|
+
devices[i] = 1; // JOYPAD (RETRO_DEVICE_JOYPAD = 1)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return devices;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Generate a random salt for password authentication.
|
|
1375
|
+
*/
|
|
1376
|
+
private generateSalt(): number {
|
|
1377
|
+
// Generate a random non-zero 32-bit value
|
|
1378
|
+
// Use MASK_31BIT * 2 to get close to max uint32 while avoiding overflow
|
|
1379
|
+
const salt = Math.floor(Math.random() * (MASK_31BIT * 2)) + 1;
|
|
1380
|
+
return salt;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Type-safe event emitter methods
|
|
1384
|
+
override on<K extends keyof ServerEvents>(event: K, listener: ServerEvents[K]): this {
|
|
1385
|
+
return super.on(event, listener);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
override off<K extends keyof ServerEvents>(event: K, listener: ServerEvents[K]): this {
|
|
1389
|
+
return super.off(event, listener);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
override emit<K extends keyof ServerEvents>(
|
|
1393
|
+
event: K,
|
|
1394
|
+
...args: Parameters<ServerEvents[K]>
|
|
1395
|
+
): boolean {
|
|
1396
|
+
return super.emit(event, ...args);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Create a new netplay server.
|
|
1402
|
+
*/
|
|
1403
|
+
export const createNetplayServer = (
|
|
1404
|
+
options?: Partial<NetplayServerOptions>
|
|
1405
|
+
): NetplayServer => {
|
|
1406
|
+
return new NetplayServer(options);
|
|
1407
|
+
};
|