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,1280 @@
1
+ /**
2
+ * RetroArch Netplay Protocol Encoding/Decoding
3
+ *
4
+ * All multi-byte integers are in network byte order (big-endian).
5
+ * Command format: [cmd: uint32][size: uint32][payload: bytes]
6
+ */
7
+
8
+ import { createHash } from 'crypto';
9
+ import { netplayLogger } from '../netplayLogger';
10
+ import { getErrorMessage } from '../../utils/getErrorMessage';
11
+ import { ProtocolError } from './types';
12
+ import { deflateSync, inflateSync, constants as zlibConstants } from 'zlib';
13
+ import {
14
+ NetplayCmd,
15
+ CONNECTION_MAGIC,
16
+ MAX_NICK_LEN,
17
+ CORE_NAME_LEN,
18
+ CORE_VERSION_LEN,
19
+ MAX_INPUT_DEVICES,
20
+ PASS_HASH_LEN,
21
+ COMMAND_HEADER_SIZE,
22
+ EXTENDED_HEADER_SIZE,
23
+ CONNECTION_MAGIC_SIZE,
24
+ HEADER_PLATFORM_OFFSET,
25
+ HEADER_COMPRESSION_OFFSET,
26
+ HEADER_NICK_SIZE_OFFSET,
27
+ UINT32_SIZE,
28
+ UINT64_SIZE,
29
+ MASK_31BIT,
30
+ MASK_16BIT,
31
+ MASK_8BIT,
32
+ SHIFT_16BIT,
33
+ INPUT_PAYLOAD_MIN_SIZE,
34
+ INPUT_PAYLOAD_WITH_ANALOG_SIZE,
35
+ INPUT_CLIENT_OFFSET,
36
+ INPUT_JOYPAD_OFFSET,
37
+ INPUT_ANALOG_LEFT_OFFSET,
38
+ INPUT_ANALOG_RIGHT_OFFSET,
39
+ NETPLAYSTATE_VERSION,
40
+ NETPLAYSTATE_HEADER_SIZE,
41
+ NETPLAYSTATE_BLOCK_HEADER_SIZE,
42
+ NETPLAYSTATE_ALIGNMENT,
43
+ NETPLAYSTATE_MEM_BLOCK,
44
+ NETPLAYSTATE_CHEEVOS_BLOCK,
45
+ NETPLAYSTATE_END_BLOCK,
46
+ NETPLAYSTATE_MAGIC_LEN,
47
+ NETPLAYSTATE_BLOCK_TYPE_SIZE,
48
+ NETPLAYSTATE_ACHV_DATA_SIZE,
49
+ HEX_RADIX,
50
+ SERVER_DATA_FLAG,
51
+ type RawCommand,
52
+ type ParsedCommand,
53
+ type InputCommand,
54
+ type NoInputCommand,
55
+ type NickCommand,
56
+ type PasswordCommand,
57
+ type InfoCommand,
58
+ type SyncCommand,
59
+ type ModeCommand,
60
+ type ModeRefusedCommand,
61
+ type CrcCommand,
62
+ type LoadSavestateCommand,
63
+ type PauseCommand,
64
+ type ResumeCommand,
65
+ type StallCommand,
66
+ type ResetCommand,
67
+ type PlayerChatCommand,
68
+ type PingRequestCommand,
69
+ type PingResponseCommand,
70
+ type PlayCommand,
71
+ type SettingCommand,
72
+ } from '..';
73
+ export * from './consts';
74
+ export * from './types';
75
+ import {
76
+ PAYLOAD_SIZE_OFFSET,
77
+ NULL_TERMINATOR_SIZE,
78
+ SIZEOF_SIZE_T,
79
+ OUR_PROTOCOL_VERSION,
80
+ COMPRESSION_SUPPORTED,
81
+ CLIENT_PROTOCOL_FIELD,
82
+ HEADER_FIELD5_OFFSET,
83
+ HEADER_FIELD6_OFFSET,
84
+ HEADER_FIELD5_VALUE,
85
+ HEADER_FIELD6_VALUE,
86
+ INPUT_CLIENT_NUM_MASK,
87
+ SHARE_MODES_SIZE,
88
+ NETPLAY_CMD_SYNC_BIT_PAUSED,
89
+ NETPLAY_CMD_MODE_BIT_YOU,
90
+ NETPLAY_CMD_MODE_BIT_PLAYING,
91
+ NETPLAY_CMD_MODE_BIT_SLAVE,
92
+ MODE_FULL_PAYLOAD_SIZE,
93
+ NETPLAY_FORMAT_MIN_VERSION,
94
+ NETPLAY_CMD_PLAY_BIT_SLAVE,
95
+ } from './consts';
96
+
97
+ /**
98
+ * Read a null-terminated string from buffer, up to maxLen bytes.
99
+ */
100
+ const readString = (buffer: Buffer, offset: number, maxLen: number): string => {
101
+ let end = offset;
102
+ const limit = Math.min(offset + maxLen, buffer.length);
103
+ while (end < limit && buffer[end] !== 0) {
104
+ end++;
105
+ }
106
+ return buffer.toString('utf8', offset, end);
107
+ };
108
+
109
+ /**
110
+ * Write a null-terminated string to buffer, padded to exactly len bytes.
111
+ */
112
+ const writeString = (buffer: Buffer, offset: number, str: string, len: number): void => {
113
+ const bytes = Buffer.from(str, 'utf8');
114
+ const copyLen = Math.min(bytes.length, len - NULL_TERMINATOR_SIZE);
115
+ bytes.copy(buffer, offset, 0, copyLen);
116
+ // Ensure null termination and zero padding
117
+ buffer.fill(0, offset + copyLen, offset + len);
118
+ };
119
+
120
+ /**
121
+ * Platform magic encodes: (protocol_version << 16) | sizeof(size_t)
122
+ * This allows version/platform detection.
123
+ */
124
+ const createPlatformMagic = (version: number, sizeOfSizeT: number): number => {
125
+ return (version << SHIFT_16BIT) | sizeOfSizeT;
126
+ };
127
+
128
+ /** Our platform magic */
129
+ const OUR_PLATFORM_MAGIC = createPlatformMagic(OUR_PROTOCOL_VERSION, SIZEOF_SIZE_T);
130
+
131
+ /**
132
+ * Parse platform magic to extract version and sizeof(size_t).
133
+ */
134
+ export const parsePlatformMagic = (magic: number): { version: number; sizeOfSizeT: number } => {
135
+ return {
136
+ version: (magic >> SHIFT_16BIT) & MASK_16BIT,
137
+ sizeOfSizeT: magic & MASK_16BIT,
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Parsed connection header from remote.
143
+ */
144
+ export interface ConnectionHeader {
145
+ magic: number;
146
+ platformMagic: number;
147
+ compression: number;
148
+ /**
149
+ * Field 4 (offset 12) meaning differs by sender:
150
+ * - Server: salt value (0 = no password, non-zero = password required)
151
+ * - Client: highest supported protocol version
152
+ */
153
+ field4: number;
154
+ nickname: string;
155
+ }
156
+
157
+ /** Options for creating a connection header */
158
+ export interface ConnectionHeaderOptions {
159
+ /** Whether this header is from a server (affects field 3 meaning) */
160
+ isServer?: boolean;
161
+ /** Salt value for password authentication (server only, 0 = no password) */
162
+ salt?: number;
163
+ }
164
+
165
+ /**
166
+ * Create the connection header.
167
+ *
168
+ * RetroArch sends a 24-byte header with extra fields after the base 16 bytes.
169
+ * Field 3 (offset 12) has different meanings:
170
+ * - Server: salt (0 = no password, non-zero = password required)
171
+ * - Client: highest supported protocol version (backwards compat hack)
172
+ */
173
+ export const createConnectionHeader = (options: ConnectionHeaderOptions = {}): Buffer => {
174
+ const buffer = Buffer.alloc(EXTENDED_HEADER_SIZE);
175
+ const { isServer = false, salt = 0 } = options;
176
+
177
+ // Write base 16-byte header
178
+ buffer.writeUInt32BE(CONNECTION_MAGIC, 0);
179
+ buffer.writeUInt32BE(OUR_PLATFORM_MAGIC, HEADER_PLATFORM_OFFSET);
180
+ buffer.writeUInt32BE(COMPRESSION_SUPPORTED, HEADER_COMPRESSION_OFFSET);
181
+
182
+ // Field 3: salt (server) or protocol version (client)
183
+ if (isServer) {
184
+ // Server: send salt (0 = no password required)
185
+ buffer.writeUInt32BE(salt >>> 0, HEADER_NICK_SIZE_OFFSET);
186
+ } else {
187
+ // Client: send highest protocol version (for backwards compat)
188
+ buffer.writeUInt32BE(CLIENT_PROTOCOL_FIELD, HEADER_NICK_SIZE_OFFSET);
189
+ }
190
+
191
+ // Write extra fields to match RetroArch format
192
+ // These values are observed from RetroArch 1.22.2 traffic
193
+ buffer.writeUInt32BE(HEADER_FIELD5_VALUE, HEADER_FIELD5_OFFSET);
194
+ buffer.writeUInt32BE(HEADER_FIELD6_VALUE, HEADER_FIELD6_OFFSET);
195
+
196
+ return buffer;
197
+ };
198
+
199
+ /**
200
+ * Check if buffer has valid connection magic (quick check).
201
+ */
202
+ export const hasValidConnectionMagic = (buffer: Buffer): boolean => {
203
+ if (buffer.length < CONNECTION_MAGIC_SIZE) {
204
+ return false;
205
+ }
206
+ return buffer.readUInt32BE(0) === CONNECTION_MAGIC;
207
+ };
208
+
209
+ /**
210
+ * Validate and parse a connection header.
211
+ * RetroArch sends a 24-byte extended header. The fourth field's meaning differs:
212
+ * - Server sends: salt (0 = no password, non-zero = password required)
213
+ * - Client sends: highest supported protocol version
214
+ * Nicknames are exchanged via NICK commands, not embedded in the header.
215
+ *
216
+ * Returns null if buffer doesn't contain a valid header.
217
+ */
218
+ export const parseConnectionHeader = (buffer: Buffer): { header: ConnectionHeader; bytesConsumed: number } | null => {
219
+ // Need at least 24-byte extended header
220
+ if (buffer.length < EXTENDED_HEADER_SIZE) {
221
+ return null;
222
+ }
223
+
224
+ // Validate magic
225
+ const magic = buffer.readUInt32BE(0);
226
+ if (magic !== CONNECTION_MAGIC) {
227
+ return null;
228
+ }
229
+
230
+ // Read header fields
231
+ const platformMagic = buffer.readUInt32BE(HEADER_PLATFORM_OFFSET);
232
+ const compression = buffer.readUInt32BE(HEADER_COMPRESSION_OFFSET);
233
+ const field4 = buffer.readUInt32BE(HEADER_NICK_SIZE_OFFSET);
234
+
235
+ // Consume the full 24-byte extended header
236
+ // Fields 5 and 6 (at offsets 16 and 20) are read but not used
237
+ const bytesConsumed = EXTENDED_HEADER_SIZE;
238
+
239
+ return {
240
+ header: {
241
+ magic,
242
+ platformMagic,
243
+ compression,
244
+ field4, // For clients: protocol version. For servers: salt.
245
+ nickname: '', // Nicknames are exchanged via NICK command
246
+ },
247
+ bytesConsumed,
248
+ };
249
+ };
250
+
251
+ /**
252
+ * Validate a connection header (legacy - checks magic only).
253
+ * @deprecated Use parseConnectionHeader for full validation
254
+ */
255
+ export const validateConnectionHeader = (buffer: Buffer): boolean => {
256
+ return hasValidConnectionMagic(buffer);
257
+ };
258
+
259
+ /**
260
+ * Hash a password using SHA-256 (RetroArch compatible).
261
+ */
262
+ export const hashPassword = (password: string): string => {
263
+ return createHash('sha256').update(password).digest('hex');
264
+ };
265
+
266
+ /**
267
+ * Encode a raw command to wire format.
268
+ */
269
+ export const encodeCommand = (cmd: NetplayCmd, payload: Buffer = Buffer.alloc(0)): Buffer => {
270
+ const buffer = Buffer.alloc(COMMAND_HEADER_SIZE + payload.length);
271
+ buffer.writeUInt32BE(cmd, 0);
272
+ buffer.writeUInt32BE(payload.length, PAYLOAD_SIZE_OFFSET);
273
+ if (payload.length > 0) {
274
+ payload.copy(buffer, COMMAND_HEADER_SIZE);
275
+ }
276
+ return buffer;
277
+ };
278
+
279
+ /**
280
+ * Attempt to decode a command from buffer.
281
+ * Returns null if buffer doesn't contain a complete command.
282
+ * Returns the command and bytes consumed on success.
283
+ */
284
+ export const decodeCommand = (
285
+ buffer: Buffer
286
+ ): { command: RawCommand; bytesConsumed: number } | null => {
287
+ if (buffer.length < COMMAND_HEADER_SIZE) {
288
+ return null;
289
+ }
290
+
291
+ const cmd = buffer.readUInt32BE(0) as NetplayCmd;
292
+ const payloadSize = buffer.readUInt32BE(PAYLOAD_SIZE_OFFSET);
293
+ const totalSize = COMMAND_HEADER_SIZE + payloadSize;
294
+
295
+ if (buffer.length < totalSize) {
296
+ return null;
297
+ }
298
+
299
+ const payload = Buffer.alloc(payloadSize);
300
+ buffer.copy(payload, 0, COMMAND_HEADER_SIZE, totalSize);
301
+
302
+ return {
303
+ command: { cmd, payload },
304
+ bytesConsumed: totalSize,
305
+ };
306
+ };
307
+
308
+ // ============================================================================
309
+ // Command Builders
310
+ // ============================================================================
311
+
312
+ /**
313
+ * Build an INPUT command.
314
+ *
315
+ * Per RetroArch reference (send_input_frame in netplay_frontend.c):
316
+ * The payload format is: frame (4) + client_num (4) + input_data (variable)
317
+ *
318
+ * The client_num field contains the client ID in the lower 16 bits.
319
+ * When isServer is true, the SERVER_DATA_FLAG (bit 31) is set to indicate
320
+ * the input came from the server. Receivers mask with 0xFFFF to extract
321
+ * the client ID.
322
+ *
323
+ * The deviceBitmap parameter is kept for API compatibility but is not used in the wire format.
324
+ */
325
+ export const buildInputCommand = (
326
+ frameNumber: number,
327
+ clientId: number,
328
+ isServer: boolean,
329
+ joypadState: number,
330
+ _deviceBitmap: number = 1, // Unused - kept for API compatibility
331
+ analogLeft?: number,
332
+ analogRight?: number
333
+ ): Buffer => {
334
+ // Per reference: payload is frame (4) + client_num (4) + input data
335
+ // If analog data is present, use larger payload size
336
+ const hasAnalog = analogLeft !== undefined || analogRight !== undefined;
337
+ const payload = Buffer.alloc(hasAnalog ? INPUT_PAYLOAD_WITH_ANALOG_SIZE : INPUT_PAYLOAD_MIN_SIZE);
338
+
339
+ payload.writeUInt32BE(frameNumber, 0);
340
+
341
+ // Set SERVER_DATA_FLAG (bit 31) when this is server data
342
+ // Receivers extract client ID with: client_num & 0xFFFF
343
+ const clientNumWithFlag = isServer ? (clientId | SERVER_DATA_FLAG) >>> 0 : clientId >>> 0;
344
+ payload.writeUInt32BE(clientNumWithFlag, INPUT_CLIENT_OFFSET);
345
+
346
+ // Joypad state
347
+ payload.writeUInt32BE(joypadState >>> 0, INPUT_JOYPAD_OFFSET);
348
+
349
+ // Analog stick data (optional)
350
+ if (hasAnalog) {
351
+ payload.writeUInt32BE((analogLeft ?? 0) >>> 0, INPUT_ANALOG_LEFT_OFFSET);
352
+ payload.writeUInt32BE((analogRight ?? 0) >>> 0, INPUT_ANALOG_RIGHT_OFFSET);
353
+ }
354
+
355
+ return encodeCommand(NetplayCmd.INPUT, payload);
356
+ };
357
+
358
+ /**
359
+ * Build a NOINPUT command.
360
+ */
361
+ export const buildNoInputCommand = (frameNumber: number): Buffer => {
362
+ const payload = Buffer.alloc(UINT32_SIZE);
363
+ payload.writeUInt32BE(frameNumber, 0);
364
+ return encodeCommand(NetplayCmd.NOINPUT, payload);
365
+ };
366
+
367
+ /**
368
+ * Build a NICK command.
369
+ */
370
+ export const buildNickCommand = (nickname: string): Buffer => {
371
+ const payload = Buffer.alloc(MAX_NICK_LEN);
372
+ writeString(payload, 0, nickname, MAX_NICK_LEN);
373
+ return encodeCommand(NetplayCmd.NICK, payload);
374
+ };
375
+
376
+ /**
377
+ * Build a PASSWORD command.
378
+ * Note: Password hash is exactly 64 hex characters, no null termination.
379
+ */
380
+ export const buildPasswordCommand = (passwordHash: string): Buffer => {
381
+ const payload = Buffer.alloc(PASS_HASH_LEN);
382
+ const bytes = Buffer.from(passwordHash, 'utf8');
383
+ bytes.copy(payload, 0, 0, Math.min(bytes.length, PASS_HASH_LEN));
384
+ return encodeCommand(NetplayCmd.PASSWORD, payload);
385
+ };
386
+
387
+ /**
388
+ * Build an INFO command.
389
+ * Payload order per spec: content CRC, core name, core version
390
+ */
391
+ export const buildInfoCommand = (
392
+ coreName: string,
393
+ coreVersion: string,
394
+ contentCrc: number
395
+ ): Buffer => {
396
+ const payload = Buffer.alloc(UINT32_SIZE + CORE_NAME_LEN + CORE_VERSION_LEN);
397
+ payload.writeUInt32BE(contentCrc >>> 0, 0);
398
+ writeString(payload, UINT32_SIZE, coreName, CORE_NAME_LEN);
399
+ writeString(payload, UINT32_SIZE + CORE_NAME_LEN, coreVersion, CORE_VERSION_LEN);
400
+ return encodeCommand(NetplayCmd.INFO, payload);
401
+ };
402
+
403
+ /**
404
+ * Build a SYNC command.
405
+ * Per reference: frame, flags, devices[16], share_modes[16], device_clients[16], nick, sram
406
+ */
407
+ export const buildSyncCommand = (
408
+ frameNumber: number,
409
+ paused: boolean,
410
+ clientNumber: number,
411
+ devices: number[],
412
+ shareModes: number[],
413
+ deviceClients: number[],
414
+ clientNick: string,
415
+ sram: Buffer
416
+ ): Buffer => {
417
+ // Frame (4) + flags (4) + devices (64) + share_modes (16) + device_clients (64) + nick (32)
418
+ const headerSize =
419
+ UINT32_SIZE + UINT32_SIZE +
420
+ MAX_INPUT_DEVICES * UINT32_SIZE +
421
+ SHARE_MODES_SIZE +
422
+ MAX_INPUT_DEVICES * UINT32_SIZE +
423
+ MAX_NICK_LEN;
424
+ const payload = Buffer.alloc(headerSize + sram.length);
425
+
426
+ let offset = 0;
427
+
428
+ // Frame number
429
+ payload.writeUInt32BE(frameNumber, offset);
430
+ offset += UINT32_SIZE;
431
+
432
+ // Flags: bit 31 = paused, bits 0-30 = client number
433
+ const flags = (paused ? NETPLAY_CMD_SYNC_BIT_PAUSED : 0) | (clientNumber & MASK_31BIT);
434
+ payload.writeUInt32BE(flags >>> 0, offset);
435
+ offset += UINT32_SIZE;
436
+
437
+ // Controller devices array (uint32[16])
438
+ for (let i = 0; i < MAX_INPUT_DEVICES; i++) {
439
+ payload.writeUInt32BE((devices[i] ?? 0) >>> 0, offset);
440
+ offset += UINT32_SIZE;
441
+ }
442
+
443
+ // Share modes array (uint8[16])
444
+ for (let i = 0; i < SHARE_MODES_SIZE; i++) {
445
+ payload.writeUInt8(shareModes[i] ?? 0, offset);
446
+ offset++;
447
+ }
448
+
449
+ // Controller-client mapping array (uint32[16])
450
+ for (let i = 0; i < MAX_INPUT_DEVICES; i++) {
451
+ payload.writeUInt32BE((deviceClients[i] ?? 0) >>> 0, offset);
452
+ offset += UINT32_SIZE;
453
+ }
454
+
455
+ // Client nick
456
+ writeString(payload, offset, clientNick, MAX_NICK_LEN);
457
+ offset += MAX_NICK_LEN;
458
+
459
+ // Copy SRAM/state
460
+ sram.copy(payload, offset);
461
+
462
+ return encodeCommand(NetplayCmd.SYNC, payload);
463
+ };
464
+
465
+ /**
466
+ * Build a MODE command.
467
+ * Per reference: frame, flags (you|playing|slave|client_num), device_bitmap, share_modes, nick
468
+ */
469
+ export const buildModeCommand = (
470
+ frameNumber: number,
471
+ you: boolean,
472
+ playing: boolean,
473
+ slave: boolean,
474
+ clientNumber: number,
475
+ deviceBitmap: number = 0,
476
+ shareModes: number[] = [],
477
+ nick: string = ''
478
+ ): Buffer => {
479
+ const payload = Buffer.alloc(MODE_FULL_PAYLOAD_SIZE);
480
+ let offset = 0;
481
+
482
+ // Frame number
483
+ payload.writeUInt32BE(frameNumber, offset);
484
+ offset += UINT32_SIZE;
485
+
486
+ // Flags: bits 31/30/29 = you/playing/slave, bits 0-15 = client number
487
+ let flags = clientNumber & MASK_16BIT;
488
+ if (you) {
489
+ flags |= NETPLAY_CMD_MODE_BIT_YOU;
490
+ }
491
+ if (playing) {
492
+ flags |= NETPLAY_CMD_MODE_BIT_PLAYING;
493
+ }
494
+ if (slave) {
495
+ flags |= NETPLAY_CMD_MODE_BIT_SLAVE;
496
+ }
497
+ payload.writeUInt32BE(flags >>> 0, offset);
498
+ offset += UINT32_SIZE;
499
+
500
+ // Device bitmap
501
+ payload.writeUInt32BE(deviceBitmap >>> 0, offset);
502
+ offset += UINT32_SIZE;
503
+
504
+ // Share modes (uint8[16])
505
+ for (let i = 0; i < SHARE_MODES_SIZE; i++) {
506
+ payload.writeUInt8(shareModes[i] ?? 0, offset);
507
+ offset++;
508
+ }
509
+
510
+ // Nick
511
+ writeString(payload, offset, nick, MAX_NICK_LEN);
512
+
513
+ return encodeCommand(NetplayCmd.MODE, payload);
514
+ };
515
+
516
+ /**
517
+ * Build a MODE_REFUSED command.
518
+ */
519
+ export const buildModeRefusedCommand = (reason: number): Buffer => {
520
+ const payload = Buffer.alloc(UINT32_SIZE);
521
+ payload.writeUInt32BE(reason, 0);
522
+ return encodeCommand(NetplayCmd.MODE_REFUSED, payload);
523
+ };
524
+
525
+ /**
526
+ * Build a CRC command.
527
+ */
528
+ export const buildCrcCommand = (frameNumber: number, crc: number): Buffer => {
529
+ const payload = Buffer.alloc(UINT64_SIZE);
530
+ payload.writeUInt32BE(frameNumber, 0);
531
+ payload.writeUInt32BE(crc >>> 0, UINT32_SIZE);
532
+ return encodeCommand(NetplayCmd.CRC, payload);
533
+ };
534
+
535
+ /**
536
+ * Build a REQUEST_SAVESTATE command.
537
+ */
538
+ export const buildRequestSavestateCommand = (): Buffer => {
539
+ return encodeCommand(NetplayCmd.REQUEST_SAVESTATE);
540
+ };
541
+
542
+ /**
543
+ * Wrap raw core state in NETPLAY format.
544
+ * Format per RetroArch reference capture:
545
+ * - Header: "NETPLAY" (7 bytes) + version (1 byte) = 8 bytes
546
+ * - MEM block: "MEM " (4) + size_LE (4) + data + padding to 16-byte align
547
+ * - ACHV block: "ACHV" (4) + size_LE (4) + zeros (8) = 16 bytes (achievements, optional but sent by RA)
548
+ * - END block: "END " (4) + size_LE 0 (4) = 8 bytes
549
+ *
550
+ * Size fields are LITTLE-ENDIAN. Block data is aligned to 16-byte boundaries.
551
+ */
552
+ const wrapStateInNetplayFormat = (coreState: Buffer): Buffer => {
553
+ // MEM data ends at: header(8) + MEM header(8) + data
554
+ const memDataEnd = NETPLAYSTATE_HEADER_SIZE + NETPLAYSTATE_BLOCK_HEADER_SIZE + coreState.length;
555
+
556
+ // Pad to 16-byte alignment for next block
557
+ const memPaddedEnd = Math.ceil(memDataEnd / NETPLAYSTATE_ALIGNMENT) * NETPLAYSTATE_ALIGNMENT;
558
+ const memPadding = memPaddedEnd - memDataEnd;
559
+
560
+ // ACHV block (achievements): header(8) + data(8) = 16 bytes (matches RetroArch)
561
+ const achvBlockSize = NETPLAYSTATE_BLOCK_HEADER_SIZE + NETPLAYSTATE_ACHV_DATA_SIZE;
562
+
563
+ // END block: header(8) only, size=0
564
+ const endBlockSize = NETPLAYSTATE_BLOCK_HEADER_SIZE;
565
+
566
+ // Total size
567
+ const totalSize = memPaddedEnd + achvBlockSize + endBlockSize;
568
+ const wrapped = Buffer.alloc(totalSize);
569
+
570
+ let offset = 0;
571
+
572
+ // Header: "NETPLAY" + version 1
573
+ wrapped.write('NETPLAY', offset, 'ascii');
574
+ offset += NETPLAYSTATE_MAGIC_LEN;
575
+ wrapped.writeUInt8(NETPLAYSTATE_VERSION, offset);
576
+ offset += 1;
577
+
578
+ // MEM block header - size is LITTLE-ENDIAN, contains actual data size (not padded)
579
+ wrapped.write(NETPLAYSTATE_MEM_BLOCK, offset, 'ascii');
580
+ offset += NETPLAYSTATE_BLOCK_TYPE_SIZE;
581
+ wrapped.writeUInt32LE(coreState.length, offset);
582
+ offset += UINT32_SIZE;
583
+
584
+ // MEM block data
585
+ coreState.copy(wrapped, offset);
586
+ offset += coreState.length;
587
+
588
+ // MEM block padding (zeros, already done by Buffer.alloc)
589
+ offset += memPadding;
590
+
591
+ // ACHV block (achievements) - required by RetroArch, data is 8 zeros
592
+ wrapped.write(NETPLAYSTATE_CHEEVOS_BLOCK, offset, 'ascii');
593
+ offset += NETPLAYSTATE_BLOCK_TYPE_SIZE;
594
+ wrapped.writeUInt32LE(NETPLAYSTATE_ACHV_DATA_SIZE, offset);
595
+ offset += UINT32_SIZE;
596
+ // ACHV data bytes (zeros, already done by Buffer.alloc)
597
+ offset += NETPLAYSTATE_ACHV_DATA_SIZE;
598
+
599
+ // END block
600
+ wrapped.write(NETPLAYSTATE_END_BLOCK, offset, 'ascii');
601
+ offset += NETPLAYSTATE_BLOCK_TYPE_SIZE;
602
+ wrapped.writeUInt32LE(0, offset); // Size = 0
603
+
604
+ return wrapped;
605
+ };
606
+
607
+ /**
608
+ * Unwrap raw core state from NETPLAY format.
609
+ * Returns the raw core state extracted from the MEM block.
610
+ * Returns null if the format is invalid.
611
+ */
612
+ const unwrapNetplayState = (wrapped: Buffer): Buffer | null => {
613
+ // Check minimum size: header (8) + MEM header (8) + at least 1 byte
614
+ const minSize = NETPLAYSTATE_HEADER_SIZE + NETPLAYSTATE_BLOCK_HEADER_SIZE + 1;
615
+ if (wrapped.length < minSize) {
616
+ return null;
617
+ }
618
+
619
+ // Validate "NETPLAY" header
620
+ const header = wrapped.subarray(0, NETPLAYSTATE_MAGIC_LEN).toString('ascii');
621
+ if (header !== 'NETPLAY') {
622
+ return null;
623
+ }
624
+
625
+ // Read version
626
+ const version = wrapped.readUInt8(NETPLAYSTATE_MAGIC_LEN);
627
+ if (version !== NETPLAYSTATE_VERSION) {
628
+ netplayLogger.debug('PROTOCOL', `Unknown NETPLAY state version: ${version}`);
629
+ // Try to parse anyway - format is usually compatible
630
+ }
631
+
632
+ // Look for MEM block
633
+ let offset = NETPLAYSTATE_HEADER_SIZE;
634
+ while (offset + NETPLAYSTATE_BLOCK_HEADER_SIZE <= wrapped.length) {
635
+ const blockType = wrapped.subarray(offset, offset + NETPLAYSTATE_BLOCK_TYPE_SIZE).toString('ascii');
636
+ const blockSize = wrapped.readUInt32LE(offset + NETPLAYSTATE_BLOCK_TYPE_SIZE); // Size is LITTLE-ENDIAN
637
+
638
+ if (blockType === NETPLAYSTATE_MEM_BLOCK) {
639
+ // Found MEM block - extract data
640
+ const dataStart = offset + NETPLAYSTATE_BLOCK_HEADER_SIZE;
641
+ const dataEnd = dataStart + blockSize;
642
+ if (dataEnd <= wrapped.length) {
643
+ return Buffer.from(wrapped.subarray(dataStart, dataEnd));
644
+ }
645
+ return null; // Invalid: data extends past buffer
646
+ }
647
+
648
+ if (blockType === NETPLAYSTATE_END_BLOCK) {
649
+ // Reached END without finding MEM - no state data
650
+ return null;
651
+ }
652
+
653
+ // Move to next block (data + padding to 16-byte alignment)
654
+ const blockEnd = offset + NETPLAYSTATE_BLOCK_HEADER_SIZE + blockSize;
655
+ offset = Math.ceil(blockEnd / NETPLAYSTATE_ALIGNMENT) * NETPLAYSTATE_ALIGNMENT;
656
+ }
657
+
658
+ return null; // No MEM block found
659
+ };
660
+
661
+ /**
662
+ * Build a LOAD_SAVESTATE command.
663
+ *
664
+ * Format per RetroArch netplay protocol:
665
+ * - frame (4 bytes BE) - the frame number for this state
666
+ * - uncompressed_size (4 bytes BE) - size of state when uncompressed (0 if not compressed)
667
+ * - data (variable) - the state data wrapped in NETPLAY format
668
+ *
669
+ * The format of the state data depends on the client's protocol version:
670
+ * - Protocol 7+: NETPLAY wrapper format (bypasses size validation)
671
+ * - Protocol < 7: Raw state data (requires state_size == coremem_size)
672
+ *
673
+ * Per reference (netplay_frontend.c line 7481-7485), NETPLAY format is only
674
+ * sent to protocol 7+ clients. Legacy clients receive raw format.
675
+ */
676
+ export const buildLoadSavestateCommand = (
677
+ frameNumber: number,
678
+ state: Buffer,
679
+ _clientProtocolVersion: number = NETPLAY_FORMAT_MIN_VERSION
680
+ ): Buffer => {
681
+ // Wrap raw core state in NETPLAY format (required for protocol 7+ clients)
682
+ const netplayState = wrapStateInNetplayFormat(state);
683
+
684
+ // Per Wireshark capture of working RetroArch connection:
685
+ // RetroArch compresses the state using zlib and sets uncompressed_size
686
+ // to the actual uncompressed size (NOT zero)
687
+ // Use level 9 (best compression) to match RetroArch's zlib header (78 da)
688
+ const compressedState = deflateSync(netplayState, { level: zlibConstants.Z_BEST_COMPRESSION });
689
+ const uncompressedSize = netplayState.length;
690
+
691
+ // Build payload: frame (4) + uncompressed_size (4) + compressed_state
692
+ const payload = Buffer.alloc(UINT64_SIZE + compressedState.length);
693
+ payload.writeUInt32BE(frameNumber, 0);
694
+ payload.writeUInt32BE(uncompressedSize, UINT32_SIZE);
695
+ compressedState.copy(payload, UINT64_SIZE);
696
+ return encodeCommand(NetplayCmd.LOAD_SAVESTATE, payload);
697
+ };
698
+
699
+ /**
700
+ * Build a PAUSE command.
701
+ */
702
+ export const buildPauseCommand = (nickname: string): Buffer => {
703
+ const payload = Buffer.alloc(MAX_NICK_LEN);
704
+ writeString(payload, 0, nickname, MAX_NICK_LEN);
705
+ return encodeCommand(NetplayCmd.PAUSE, payload);
706
+ };
707
+
708
+ /**
709
+ * Build a RESUME command.
710
+ */
711
+ export const buildResumeCommand = (): Buffer => {
712
+ return encodeCommand(NetplayCmd.RESUME);
713
+ };
714
+
715
+ /**
716
+ * Build a STALL command.
717
+ */
718
+ export const buildStallCommand = (frames: number): Buffer => {
719
+ const payload = Buffer.alloc(UINT32_SIZE);
720
+ payload.writeUInt32BE(frames, 0);
721
+ return encodeCommand(NetplayCmd.STALL, payload);
722
+ };
723
+
724
+ /**
725
+ * Build a RESET command.
726
+ */
727
+ export const buildResetCommand = (frameNumber: number): Buffer => {
728
+ const payload = Buffer.alloc(UINT32_SIZE);
729
+ payload.writeUInt32BE(frameNumber, 0);
730
+ return encodeCommand(NetplayCmd.RESET, payload);
731
+ };
732
+
733
+ /**
734
+ * Build a PLAYER_CHAT command.
735
+ */
736
+ export const buildPlayerChatCommand = (nickname: string, message: string): Buffer => {
737
+ const nickBytes = Buffer.from(nickname, 'utf8').subarray(0, MAX_NICK_LEN - NULL_TERMINATOR_SIZE);
738
+ const msgBytes = Buffer.from(message, 'utf8');
739
+ const payload = Buffer.alloc(MAX_NICK_LEN + msgBytes.length);
740
+ nickBytes.copy(payload, 0);
741
+ payload[nickBytes.length] = 0; // null terminate
742
+ msgBytes.copy(payload, MAX_NICK_LEN);
743
+ return encodeCommand(NetplayCmd.PLAYER_CHAT, payload);
744
+ };
745
+
746
+ /**
747
+ * Build a PING_REQUEST command.
748
+ * Per reference: no payload.
749
+ */
750
+ export const buildPingRequestCommand = (): Buffer => {
751
+ return encodeCommand(NetplayCmd.PING_REQUEST);
752
+ };
753
+
754
+ /**
755
+ * Build a PING_RESPONSE command.
756
+ * Per reference: no payload.
757
+ */
758
+ export const buildPingResponseCommand = (): Buffer => {
759
+ return encodeCommand(NetplayCmd.PING_RESPONSE);
760
+ };
761
+
762
+ /**
763
+ * Build an ACK command.
764
+ */
765
+ export const buildAckCommand = (): Buffer => {
766
+ return encodeCommand(NetplayCmd.ACK);
767
+ };
768
+
769
+ /**
770
+ * Build a NAK command.
771
+ */
772
+ export const buildNakCommand = (): Buffer => {
773
+ return encodeCommand(NetplayCmd.NAK);
774
+ };
775
+
776
+ /**
777
+ * Build a DISCONNECT command.
778
+ */
779
+ export const buildDisconnectCommand = (): Buffer => {
780
+ return encodeCommand(NetplayCmd.DISCONNECT);
781
+ };
782
+
783
+ /**
784
+ * Build a SPECTATE command.
785
+ */
786
+ export const buildSpectateCommand = (): Buffer => {
787
+ return encodeCommand(NetplayCmd.SPECTATE);
788
+ };
789
+
790
+ /**
791
+ * Build a SETTING_ALLOW_PAUSING command.
792
+ * Per reference capture: 4-byte payload with value 0 (pausing disabled).
793
+ */
794
+ export const buildSettingAllowPausingCommand = (allowPausing: boolean = false): Buffer => {
795
+ const payload = Buffer.alloc(UINT32_SIZE);
796
+ payload.writeUInt32BE(allowPausing ? 1 : 0, 0);
797
+ return encodeCommand(NetplayCmd.SETTING_ALLOW_PAUSING, payload);
798
+ };
799
+
800
+ /**
801
+ * Build a SETTING_INPUT_LATENCY_FRAMES command.
802
+ * Per reference capture: 8-byte payload with two uint32 values:
803
+ * - frames (input latency frames)
804
+ * - range (acceptable frame range, usually 0)
805
+ */
806
+ export const buildSettingInputLatencyFramesCommand = (frames: number = 0, range: number = 0): Buffer => {
807
+ const payload = Buffer.alloc(UINT64_SIZE);
808
+ payload.writeUInt32BE(frames, 0);
809
+ payload.writeUInt32BE(range, UINT32_SIZE);
810
+ return encodeCommand(NetplayCmd.SETTING_INPUT_LATENCY_FRAMES, payload);
811
+ };
812
+
813
+ /**
814
+ * Build a PLAY command.
815
+ * Per reference: slave (1 bit), reserved (7 bits), share_mode (8 bits), devices (16 bits)
816
+ */
817
+ export const buildPlayCommand = (
818
+ asSlave: boolean = false,
819
+ shareMode: number = 0,
820
+ requestedDevices: number = 0
821
+ ): Buffer => {
822
+ const payload = Buffer.alloc(UINT32_SIZE);
823
+ let flags = (requestedDevices & MASK_16BIT) | ((shareMode & MASK_8BIT) << SHIFT_16BIT);
824
+ if (asSlave) {
825
+ flags |= NETPLAY_CMD_PLAY_BIT_SLAVE;
826
+ }
827
+ payload.writeUInt32BE(flags >>> 0, 0);
828
+ return encodeCommand(NetplayCmd.PLAY, payload);
829
+ };
830
+
831
+ // ============================================================================
832
+ // Command Parsers
833
+ // ============================================================================
834
+
835
+ /**
836
+ * Parse an INPUT command payload.
837
+ *
838
+ * Per RetroArch reference (netplay_frontend.c), the INPUT payload format is:
839
+ * - frame_num (4 bytes): Frame number for this input
840
+ * - client_num (4 bytes): Plain client number (lower 16 bits used)
841
+ * - input_data (variable): Device input data
842
+ *
843
+ * Note: Device assignment (which devices a client controls) is determined
844
+ * from the SYNC state (client_devices array), not from the INPUT command.
845
+ */
846
+ export const parseInputCommand = (payload: Buffer): InputCommand => {
847
+ if (payload.length < INPUT_PAYLOAD_MIN_SIZE) {
848
+ throw new ProtocolError('INVALID_INPUT_PAYLOAD', `size: ${payload.length}`);
849
+ }
850
+
851
+ const frameNumber = payload.readUInt32BE(0);
852
+ // Extract client number from lower 16 bits (per RetroArch reference: client_num &= 0xFFFF)
853
+ const clientId = payload.readUInt32BE(INPUT_CLIENT_OFFSET) & INPUT_CLIENT_NUM_MASK;
854
+ const joypadState = payload.readUInt32BE(INPUT_JOYPAD_OFFSET);
855
+
856
+ const result: InputCommand = {
857
+ cmd: NetplayCmd.INPUT,
858
+ frameNumber,
859
+ clientId,
860
+ joypadState,
861
+ };
862
+
863
+ if (payload.length >= INPUT_PAYLOAD_WITH_ANALOG_SIZE) {
864
+ result.analogLeft = payload.readUInt32BE(INPUT_ANALOG_LEFT_OFFSET);
865
+ result.analogRight = payload.readUInt32BE(INPUT_ANALOG_RIGHT_OFFSET);
866
+ }
867
+
868
+ return result;
869
+ };
870
+
871
+ /**
872
+ * Parse a NOINPUT command payload.
873
+ */
874
+ export const parseNoInputCommand = (payload: Buffer): NoInputCommand => {
875
+ if (payload.length < UINT32_SIZE) {
876
+ throw new ProtocolError('INVALID_NOINPUT_PAYLOAD', `size: ${payload.length}`);
877
+ }
878
+ return {
879
+ cmd: NetplayCmd.NOINPUT,
880
+ frameNumber: payload.readUInt32BE(0),
881
+ };
882
+ };
883
+
884
+ /**
885
+ * Parse a NICK command payload.
886
+ */
887
+ export const parseNickCommand = (payload: Buffer): NickCommand => {
888
+ return {
889
+ cmd: NetplayCmd.NICK,
890
+ nickname: readString(payload, 0, MAX_NICK_LEN),
891
+ };
892
+ };
893
+
894
+ /**
895
+ * Parse a PASSWORD command payload.
896
+ * Note: Password hash is exactly 64 hex characters, no null termination.
897
+ */
898
+ export const parsePasswordCommand = (payload: Buffer): PasswordCommand => {
899
+ const len = Math.min(payload.length, PASS_HASH_LEN);
900
+ return {
901
+ cmd: NetplayCmd.PASSWORD,
902
+ passwordHash: payload.subarray(0, len).toString('utf8'),
903
+ };
904
+ };
905
+
906
+ /**
907
+ * Parse an INFO command payload.
908
+ * Payload order per spec: content CRC, core name, core version
909
+ */
910
+ export const parseInfoCommand = (payload: Buffer): InfoCommand => {
911
+ if (payload.length < UINT32_SIZE + CORE_NAME_LEN + CORE_VERSION_LEN) {
912
+ throw new ProtocolError('INVALID_INFO_PAYLOAD', `size: ${payload.length}`);
913
+ }
914
+ return {
915
+ cmd: NetplayCmd.INFO,
916
+ contentCrc: payload.readUInt32BE(0),
917
+ coreName: readString(payload, UINT32_SIZE, CORE_NAME_LEN),
918
+ coreVersion: readString(payload, UINT32_SIZE + CORE_NAME_LEN, CORE_VERSION_LEN),
919
+ };
920
+ };
921
+
922
+ /**
923
+ * Parse a SYNC command payload.
924
+ * Per reference: frame, flags, devices[16], share_modes[16], device_clients[16], nick, sram
925
+ */
926
+ export const parseSyncCommand = (payload: Buffer): SyncCommand => {
927
+ const headerSize =
928
+ UINT32_SIZE + UINT32_SIZE +
929
+ MAX_INPUT_DEVICES * UINT32_SIZE +
930
+ SHARE_MODES_SIZE +
931
+ MAX_INPUT_DEVICES * UINT32_SIZE +
932
+ MAX_NICK_LEN;
933
+
934
+ if (payload.length < headerSize) {
935
+ throw new ProtocolError('INVALID_SYNC_PAYLOAD', `size: ${payload.length}`);
936
+ }
937
+
938
+ let offset = 0;
939
+
940
+ // Frame number
941
+ const frameNumber = payload.readUInt32BE(offset);
942
+ offset += UINT32_SIZE;
943
+
944
+ // Flags: bit 31 = paused, bits 0-30 = client number
945
+ const flags = payload.readUInt32BE(offset);
946
+ offset += UINT32_SIZE;
947
+ const paused = (flags & NETPLAY_CMD_SYNC_BIT_PAUSED) !== 0;
948
+ const clientNumber = flags & MASK_31BIT;
949
+
950
+ // Controller devices array (uint32[16])
951
+ const devices: number[] = [];
952
+ for (let i = 0; i < MAX_INPUT_DEVICES; i++) {
953
+ devices.push(payload.readUInt32BE(offset));
954
+ offset += UINT32_SIZE;
955
+ }
956
+
957
+ // Share modes array (uint8[16])
958
+ const shareModes: number[] = [];
959
+ for (let i = 0; i < SHARE_MODES_SIZE; i++) {
960
+ shareModes.push(payload.readUInt8(offset));
961
+ offset++;
962
+ }
963
+
964
+ // Controller-client mapping array (uint32[16])
965
+ const deviceClients: number[] = [];
966
+ for (let i = 0; i < MAX_INPUT_DEVICES; i++) {
967
+ deviceClients.push(payload.readUInt32BE(offset));
968
+ offset += UINT32_SIZE;
969
+ }
970
+
971
+ // Client nick
972
+ const clientNick = readString(payload, offset, MAX_NICK_LEN);
973
+ offset += MAX_NICK_LEN;
974
+
975
+ // SRAM/state
976
+ const sram = payload.subarray(offset);
977
+
978
+ return {
979
+ cmd: NetplayCmd.SYNC,
980
+ frameNumber,
981
+ paused,
982
+ clientNumber,
983
+ devices,
984
+ shareModes,
985
+ deviceClients,
986
+ clientNick,
987
+ sram: Buffer.from(sram),
988
+ };
989
+ };
990
+
991
+ /**
992
+ * Parse a MODE command payload.
993
+ * Per reference: frame, flags (you|playing|slave|client_num), device_bitmap, share_modes, nick
994
+ */
995
+ export const parseModeCommand = (payload: Buffer): ModeCommand => {
996
+ if (payload.length < MODE_FULL_PAYLOAD_SIZE) {
997
+ throw new ProtocolError('INVALID_MODE_PAYLOAD', `size: ${payload.length}`);
998
+ }
999
+
1000
+ let offset = 0;
1001
+
1002
+ // Frame number
1003
+ const frameNumber = payload.readUInt32BE(offset);
1004
+ offset += UINT32_SIZE;
1005
+
1006
+ // Flags: bits 31/30/29 = you/playing/slave, bits 0-15 = client number
1007
+ const flags = payload.readUInt32BE(offset);
1008
+ offset += UINT32_SIZE;
1009
+ const you = (flags & NETPLAY_CMD_MODE_BIT_YOU) !== 0;
1010
+ const playing = (flags & NETPLAY_CMD_MODE_BIT_PLAYING) !== 0;
1011
+ const slave = (flags & NETPLAY_CMD_MODE_BIT_SLAVE) !== 0;
1012
+ const clientNumber = flags & MASK_16BIT;
1013
+
1014
+ // Device bitmap
1015
+ const deviceBitmap = payload.readUInt32BE(offset);
1016
+ offset += UINT32_SIZE;
1017
+
1018
+ // Share modes (uint8[16])
1019
+ const shareModes: number[] = [];
1020
+ for (let i = 0; i < SHARE_MODES_SIZE; i++) {
1021
+ shareModes.push(payload.readUInt8(offset));
1022
+ offset++;
1023
+ }
1024
+
1025
+ // Nick
1026
+ const nick = readString(payload, offset, MAX_NICK_LEN);
1027
+
1028
+ return {
1029
+ cmd: NetplayCmd.MODE,
1030
+ frameNumber,
1031
+ you,
1032
+ playing,
1033
+ slave,
1034
+ clientNumber,
1035
+ deviceBitmap,
1036
+ shareModes,
1037
+ nick,
1038
+ };
1039
+ };
1040
+
1041
+ /**
1042
+ * Parse a MODE_REFUSED command payload.
1043
+ */
1044
+ export const parseModeRefusedCommand = (payload: Buffer): ModeRefusedCommand => {
1045
+ return {
1046
+ cmd: NetplayCmd.MODE_REFUSED,
1047
+ reason: payload.length >= UINT32_SIZE ? payload.readUInt32BE(0) : 0,
1048
+ };
1049
+ };
1050
+
1051
+ /**
1052
+ * Parse a CRC command payload.
1053
+ */
1054
+ export const parseCrcCommand = (payload: Buffer): CrcCommand => {
1055
+ if (payload.length < UINT64_SIZE) {
1056
+ throw new ProtocolError('INVALID_CRC_PAYLOAD', `size: ${payload.length}`);
1057
+ }
1058
+ return {
1059
+ cmd: NetplayCmd.CRC,
1060
+ frameNumber: payload.readUInt32BE(0),
1061
+ crc: payload.readUInt32BE(UINT32_SIZE),
1062
+ };
1063
+ };
1064
+
1065
+ /**
1066
+ * Parse a LOAD_SAVESTATE command payload.
1067
+ * Handles zlib decompression and NETPLAY format unwrapping.
1068
+ */
1069
+ export const parseLoadSavestateCommand = (payload: Buffer): LoadSavestateCommand => {
1070
+ if (payload.length < UINT64_SIZE) {
1071
+ throw new ProtocolError('INVALID_SAVESTATE_PAYLOAD', `size: ${payload.length}`);
1072
+ }
1073
+
1074
+ const frameNumber = payload.readUInt32BE(0);
1075
+ const uncompressedSize = payload.readUInt32BE(UINT32_SIZE);
1076
+ let stateData = payload.subarray(UINT64_SIZE);
1077
+
1078
+ // Decompress if needed (uncompressedSize > 0 indicates compression)
1079
+ if (uncompressedSize > 0) {
1080
+ try {
1081
+ stateData = inflateSync(stateData);
1082
+ netplayLogger.debug('PROTOCOL', 'Decompressed LOAD_SAVESTATE', {
1083
+ compressedSize: payload.length - UINT64_SIZE,
1084
+ decompressedSize: stateData.length,
1085
+ expectedSize: uncompressedSize,
1086
+ });
1087
+ } catch (err) {
1088
+ netplayLogger.error('PROTOCOL', 'Failed to decompress LOAD_SAVESTATE', {
1089
+ error: getErrorMessage(err),
1090
+ });
1091
+ throw new ProtocolError('DECOMPRESS_FAILED', getErrorMessage(err));
1092
+ }
1093
+ }
1094
+
1095
+ // Unwrap NETPLAY format if present (protocol 7+ uses NETPLAY wrapper)
1096
+ const unwrapped = unwrapNetplayState(stateData);
1097
+ if (unwrapped) {
1098
+ netplayLogger.debug('PROTOCOL', 'Unwrapped NETPLAY state format', {
1099
+ wrappedSize: stateData.length,
1100
+ coreStateSize: unwrapped.length,
1101
+ });
1102
+ stateData = unwrapped;
1103
+ } else {
1104
+ // Not in NETPLAY format - use as raw state (legacy protocol)
1105
+ netplayLogger.debug('PROTOCOL', 'Using raw state format (no NETPLAY wrapper)', {
1106
+ stateSize: stateData.length,
1107
+ });
1108
+ }
1109
+
1110
+ return {
1111
+ cmd: NetplayCmd.LOAD_SAVESTATE,
1112
+ frameNumber,
1113
+ uncompressedSize,
1114
+ state: Buffer.from(stateData),
1115
+ };
1116
+ };
1117
+
1118
+ /**
1119
+ * Parse a PAUSE command payload.
1120
+ */
1121
+ export const parsePauseCommand = (payload: Buffer): PauseCommand => {
1122
+ return {
1123
+ cmd: NetplayCmd.PAUSE,
1124
+ nickname: readString(payload, 0, MAX_NICK_LEN),
1125
+ };
1126
+ };
1127
+
1128
+ /**
1129
+ * Parse a RESUME command payload.
1130
+ */
1131
+ export const parseResumeCommand = (): ResumeCommand => {
1132
+ return { cmd: NetplayCmd.RESUME };
1133
+ };
1134
+
1135
+ /**
1136
+ * Parse a STALL command payload.
1137
+ */
1138
+ export const parseStallCommand = (payload: Buffer): StallCommand => {
1139
+ return {
1140
+ cmd: NetplayCmd.STALL,
1141
+ frames: payload.length >= UINT32_SIZE ? payload.readUInt32BE(0) : 0,
1142
+ };
1143
+ };
1144
+
1145
+ /**
1146
+ * Parse a RESET command payload.
1147
+ */
1148
+ export const parseResetCommand = (payload: Buffer): ResetCommand => {
1149
+ return {
1150
+ cmd: NetplayCmd.RESET,
1151
+ frameNumber: payload.length >= UINT32_SIZE ? payload.readUInt32BE(0) : 0,
1152
+ };
1153
+ };
1154
+
1155
+ /**
1156
+ * Parse a PLAYER_CHAT command payload.
1157
+ */
1158
+ export const parsePlayerChatCommand = (payload: Buffer): PlayerChatCommand => {
1159
+ const nickname = readString(payload, 0, MAX_NICK_LEN);
1160
+ const message = payload.subarray(MAX_NICK_LEN).toString('utf8');
1161
+ return {
1162
+ cmd: NetplayCmd.PLAYER_CHAT,
1163
+ nickname,
1164
+ message,
1165
+ };
1166
+ };
1167
+
1168
+ /**
1169
+ * Parse a PING_REQUEST command payload.
1170
+ * Per reference: no payload.
1171
+ */
1172
+ export const parsePingRequestCommand = (): PingRequestCommand => {
1173
+ return { cmd: NetplayCmd.PING_REQUEST };
1174
+ };
1175
+
1176
+ /**
1177
+ * Parse a PING_RESPONSE command payload.
1178
+ * Per reference: no payload.
1179
+ */
1180
+ export const parsePingResponseCommand = (): PingResponseCommand => {
1181
+ return { cmd: NetplayCmd.PING_RESPONSE };
1182
+ };
1183
+
1184
+ /**
1185
+ * Parse a PLAY command payload.
1186
+ * Per reference: slave (1 bit), reserved (7 bits), share_mode (8 bits), devices (16 bits)
1187
+ */
1188
+ export const parsePlayCommand = (payload: Buffer): PlayCommand => {
1189
+ // Payload may be empty (older clients) or 4 bytes
1190
+ if (payload.length === 0) {
1191
+ return {
1192
+ cmd: NetplayCmd.PLAY,
1193
+ asSlave: false,
1194
+ shareMode: 0,
1195
+ requestedDevices: 0,
1196
+ };
1197
+ }
1198
+
1199
+ const flags = payload.readUInt32BE(0);
1200
+ return {
1201
+ cmd: NetplayCmd.PLAY,
1202
+ asSlave: (flags & NETPLAY_CMD_PLAY_BIT_SLAVE) !== 0,
1203
+ shareMode: (flags >>> SHIFT_16BIT) & MASK_8BIT,
1204
+ requestedDevices: flags & MASK_16BIT,
1205
+ };
1206
+ };
1207
+
1208
+ /**
1209
+ * Parse a setting command (SETTING_ALLOW_PAUSING, SETTING_INPUT_LATENCY_FRAMES, etc.)
1210
+ * These are server configuration notifications sent to clients.
1211
+ */
1212
+ const parseSettingCommand = (cmd: NetplayCmd.SETTING_ALLOW_PAUSING | NetplayCmd.SETTING_INPUT_LATENCY_FRAMES, payload: Buffer): SettingCommand => {
1213
+ // Settings typically have a uint32 value
1214
+ const value = payload.length >= UINT32_SIZE ? payload.readUInt32BE(0) : 0;
1215
+ return { cmd, value };
1216
+ };
1217
+
1218
+ /**
1219
+ * Parse a raw command into a typed command object.
1220
+ */
1221
+ export const parseCommand = (raw: RawCommand): ParsedCommand => {
1222
+ switch (raw.cmd) {
1223
+ case NetplayCmd.INPUT:
1224
+ return parseInputCommand(raw.payload);
1225
+ case NetplayCmd.NOINPUT:
1226
+ return parseNoInputCommand(raw.payload);
1227
+ case NetplayCmd.NICK:
1228
+ return parseNickCommand(raw.payload);
1229
+ case NetplayCmd.PASSWORD:
1230
+ return parsePasswordCommand(raw.payload);
1231
+ case NetplayCmd.INFO:
1232
+ return parseInfoCommand(raw.payload);
1233
+ case NetplayCmd.SYNC:
1234
+ return parseSyncCommand(raw.payload);
1235
+ case NetplayCmd.MODE:
1236
+ return parseModeCommand(raw.payload);
1237
+ case NetplayCmd.MODE_REFUSED:
1238
+ return parseModeRefusedCommand(raw.payload);
1239
+ case NetplayCmd.CRC:
1240
+ return parseCrcCommand(raw.payload);
1241
+ case NetplayCmd.LOAD_SAVESTATE:
1242
+ return parseLoadSavestateCommand(raw.payload);
1243
+ case NetplayCmd.PAUSE:
1244
+ return parsePauseCommand(raw.payload);
1245
+ case NetplayCmd.RESUME:
1246
+ return parseResumeCommand();
1247
+ case NetplayCmd.STALL:
1248
+ return parseStallCommand(raw.payload);
1249
+ case NetplayCmd.RESET:
1250
+ return parseResetCommand(raw.payload);
1251
+ case NetplayCmd.PLAYER_CHAT:
1252
+ return parsePlayerChatCommand(raw.payload);
1253
+ case NetplayCmd.PING_REQUEST:
1254
+ return parsePingRequestCommand();
1255
+ case NetplayCmd.PING_RESPONSE:
1256
+ return parsePingResponseCommand();
1257
+ case NetplayCmd.ACK:
1258
+ return { cmd: NetplayCmd.ACK };
1259
+ case NetplayCmd.NAK:
1260
+ return { cmd: NetplayCmd.NAK };
1261
+ case NetplayCmd.DISCONNECT:
1262
+ return { cmd: NetplayCmd.DISCONNECT };
1263
+ case NetplayCmd.REQUEST_SAVESTATE:
1264
+ return { cmd: NetplayCmd.REQUEST_SAVESTATE };
1265
+ case NetplayCmd.SPECTATE:
1266
+ return { cmd: NetplayCmd.SPECTATE };
1267
+ case NetplayCmd.PLAY:
1268
+ return parsePlayCommand(raw.payload);
1269
+ case NetplayCmd.SETTING_ALLOW_PAUSING:
1270
+ return parseSettingCommand(raw.cmd, raw.payload);
1271
+ case NetplayCmd.SETTING_INPUT_LATENCY_FRAMES:
1272
+ return parseSettingCommand(raw.cmd, raw.payload);
1273
+ default:
1274
+ // Log unknown commands but don't throw - allows forward compatibility
1275
+ netplayLogger.debug('PROTOCOL', `Ignoring unknown command: 0x${raw.cmd.toString(HEX_RADIX)}`, {
1276
+ payloadSize: raw.payload.length,
1277
+ });
1278
+ return { cmd: raw.cmd };
1279
+ }
1280
+ };