emoemu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,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
+ };