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,606 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ NetplayCmd,
4
+ CONNECTION_MAGIC,
5
+ EXTENDED_HEADER_SIZE,
6
+ MAX_NICK_LEN,
7
+ CORE_NAME_LEN,
8
+ MAX_INPUT_DEVICES,
9
+ } from '..';
10
+ import {
11
+ // Protocol functions
12
+ createConnectionHeader,
13
+ validateConnectionHeader,
14
+ parseConnectionHeader,
15
+ hasValidConnectionMagic,
16
+ hashPassword,
17
+ encodeCommand,
18
+ decodeCommand,
19
+ parseCommand,
20
+
21
+ // Command builders
22
+ buildInputCommand,
23
+ buildNoInputCommand,
24
+ buildNickCommand,
25
+ buildPasswordCommand,
26
+ buildInfoCommand,
27
+ buildSyncCommand,
28
+ buildModeCommand,
29
+ buildModeRefusedCommand,
30
+ buildCrcCommand,
31
+ buildLoadSavestateCommand,
32
+ buildPauseCommand,
33
+ buildResumeCommand,
34
+ buildStallCommand,
35
+ buildResetCommand,
36
+ buildPlayerChatCommand,
37
+ buildPingRequestCommand,
38
+ buildPingResponseCommand,
39
+ buildAckCommand,
40
+ buildNakCommand,
41
+ buildDisconnectCommand,
42
+ buildSpectateCommand,
43
+ buildPlayCommand,
44
+ buildRequestSavestateCommand,
45
+
46
+ // Command parsers
47
+ parseInputCommand,
48
+ parseNoInputCommand,
49
+ parseNickCommand,
50
+ parsePasswordCommand,
51
+ parseInfoCommand,
52
+ parseSyncCommand,
53
+ parseModeCommand,
54
+ parseModeRefusedCommand,
55
+ parseCrcCommand,
56
+ parseLoadSavestateCommand,
57
+ parsePauseCommand,
58
+ parseStallCommand,
59
+ parseResetCommand,
60
+ parsePlayerChatCommand,
61
+ } from '.';
62
+
63
+ describe('Netplay Protocol', () => {
64
+ describe('Connection Header', () => {
65
+ it('should create valid 24-byte connection header', () => {
66
+ const header = createConnectionHeader();
67
+ // Extended header is 24 bytes to match RetroArch format
68
+ expect(header.length).toBe(24);
69
+ expect(header.readUInt32BE(0)).toBe(CONNECTION_MAGIC);
70
+ });
71
+
72
+ it('should create header with options (nickname sent via NICK command)', () => {
73
+ const header = createConnectionHeader({ isServer: false });
74
+ // Header is still 24 bytes
75
+ expect(header.length).toBe(24);
76
+ expect(header.readUInt32BE(0)).toBe(CONNECTION_MAGIC);
77
+ });
78
+
79
+ it('should validate correct header magic', () => {
80
+ const header = createConnectionHeader();
81
+ expect(hasValidConnectionMagic(header)).toBe(true);
82
+ // Legacy validation also works
83
+ expect(validateConnectionHeader(header)).toBe(true);
84
+ });
85
+
86
+ it('should parse connection header', () => {
87
+ const header = createConnectionHeader();
88
+ const result = parseConnectionHeader(header);
89
+ // Parses the full 24-byte extended header
90
+ expect(result).not.toBeNull();
91
+ expect(result?.header.magic).toBe(CONNECTION_MAGIC);
92
+ expect(result?.bytesConsumed).toBe(EXTENDED_HEADER_SIZE);
93
+ expect(result?.header.nickname).toBe(''); // No embedded nickname
94
+ });
95
+
96
+ it('should reject invalid header magic', () => {
97
+ const invalid = Buffer.from([0x00, 0x00, 0x00, 0x00]);
98
+ expect(hasValidConnectionMagic(invalid)).toBe(false);
99
+ expect(validateConnectionHeader(invalid)).toBe(false);
100
+ });
101
+
102
+ it('should reject too-short header', () => {
103
+ const short = Buffer.from([0x52, 0x41]);
104
+ expect(hasValidConnectionMagic(short)).toBe(false);
105
+ expect(parseConnectionHeader(short)).toBeNull();
106
+ });
107
+
108
+ it('should return null for incomplete header', () => {
109
+ // Only magic, no other fields
110
+ const partial = Buffer.alloc(4);
111
+ partial.writeUInt32BE(CONNECTION_MAGIC, 0);
112
+ expect(hasValidConnectionMagic(partial)).toBe(true);
113
+ expect(parseConnectionHeader(partial)).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe('Password Hashing', () => {
118
+ it('should hash password with SHA-256', () => {
119
+ const hash = hashPassword('test123');
120
+ expect(hash).toBe('ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae');
121
+ expect(hash.length).toBe(64);
122
+ });
123
+
124
+ it('should produce different hashes for different passwords', () => {
125
+ const hash1 = hashPassword('password1');
126
+ const hash2 = hashPassword('password2');
127
+ expect(hash1).not.toBe(hash2);
128
+ });
129
+ });
130
+
131
+ describe('Command Encoding/Decoding', () => {
132
+ it('should encode command with empty payload', () => {
133
+ const encoded = encodeCommand(NetplayCmd.ACK);
134
+ expect(encoded.length).toBe(8); // 4 bytes cmd + 4 bytes size
135
+ expect(encoded.readUInt32BE(0)).toBe(NetplayCmd.ACK);
136
+ expect(encoded.readUInt32BE(4)).toBe(0);
137
+ });
138
+
139
+ it('should encode command with payload', () => {
140
+ const payload = Buffer.from([1, 2, 3, 4]);
141
+ const encoded = encodeCommand(NetplayCmd.INPUT, payload);
142
+ expect(encoded.length).toBe(12); // 8 header + 4 payload
143
+ expect(encoded.readUInt32BE(0)).toBe(NetplayCmd.INPUT);
144
+ expect(encoded.readUInt32BE(4)).toBe(4);
145
+ expect(encoded.subarray(8)).toEqual(payload);
146
+ });
147
+
148
+ it('should decode complete command', () => {
149
+ const payload = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
150
+ const encoded = encodeCommand(NetplayCmd.CRC, payload);
151
+
152
+ const result = decodeCommand(encoded);
153
+ expect(result).not.toBeNull();
154
+ expect(result!.command.cmd).toBe(NetplayCmd.CRC);
155
+ expect(result!.command.payload).toEqual(payload);
156
+ expect(result!.bytesConsumed).toBe(12);
157
+ });
158
+
159
+ it('should return null for incomplete command', () => {
160
+ const partial = Buffer.from([0x00, 0x00, 0x00, 0x03]); // Only cmd, no size
161
+ expect(decodeCommand(partial)).toBeNull();
162
+ });
163
+
164
+ it('should return null when payload is incomplete', () => {
165
+ const buffer = Buffer.alloc(10);
166
+ buffer.writeUInt32BE(NetplayCmd.INPUT, 0);
167
+ buffer.writeUInt32BE(100, 4); // Claims 100 bytes but only 2 available
168
+ expect(decodeCommand(buffer)).toBeNull();
169
+ });
170
+
171
+ it('should handle multiple commands in buffer', () => {
172
+ const cmd1 = encodeCommand(NetplayCmd.ACK);
173
+ const cmd2 = encodeCommand(NetplayCmd.NAK);
174
+ const combined = Buffer.concat([cmd1, cmd2]);
175
+
176
+ const result1 = decodeCommand(combined);
177
+ expect(result1).not.toBeNull();
178
+ expect(result1!.command.cmd).toBe(NetplayCmd.ACK);
179
+
180
+ const remaining = combined.subarray(result1!.bytesConsumed);
181
+ const result2 = decodeCommand(remaining);
182
+ expect(result2).not.toBeNull();
183
+ expect(result2!.command.cmd).toBe(NetplayCmd.NAK);
184
+ });
185
+ });
186
+
187
+ describe('INPUT Command', () => {
188
+ it('should build INPUT command without analog', () => {
189
+ const encoded = buildInputCommand(1000, 1, false, 0x00ff);
190
+ const result = decodeCommand(encoded)!;
191
+ const parsed = parseInputCommand(result.command.payload);
192
+
193
+ expect(parsed.cmd).toBe(NetplayCmd.INPUT);
194
+ expect(parsed.frameNumber).toBe(1000);
195
+ expect(parsed.clientId).toBe(1);
196
+ expect(parsed.joypadState).toBe(0x00ff);
197
+ expect(parsed.analogLeft).toBeUndefined();
198
+ });
199
+
200
+ it('should build INPUT command with isServer parameter (ignored per RetroArch spec)', () => {
201
+ // Per RetroArch reference (send_input_frame), the isServer flag is NOT sent in the wire format
202
+ // The receiver determines server origin from the connection, not from the INPUT packet
203
+ // The isServer parameter is kept for API compatibility but ignored
204
+ const encoded = buildInputCommand(500, 0, true, 0x1234);
205
+ const result = decodeCommand(encoded)!;
206
+ const parsed = parseInputCommand(result.command.payload);
207
+
208
+ expect(parsed.clientId).toBe(0);
209
+ expect(parsed.joypadState).toBe(0x1234);
210
+ });
211
+
212
+ it('should build INPUT command with deviceBitmap parameter (ignored per RetroArch spec)', () => {
213
+ // Per RetroArch reference (send_input_frame), the device bitmap is NOT sent in the wire format
214
+ // The receiver looks up client_devices[client_num] from SYNC state to know which devices
215
+ // The deviceBitmap parameter is kept for API compatibility but ignored
216
+ const encoded = buildInputCommand(100, 2, false, 0xabcd, 0x6);
217
+ const result = decodeCommand(encoded)!;
218
+ const parsed = parseInputCommand(result.command.payload);
219
+
220
+ expect(parsed.joypadState).toBe(0xabcd);
221
+ expect(parsed.clientId).toBe(2);
222
+ });
223
+
224
+ it('should round-trip through parseCommand', () => {
225
+ const encoded = buildInputCommand(999, 5, true, 0x5555);
226
+ const result = decodeCommand(encoded)!;
227
+ const parsed = parseCommand(result.command);
228
+
229
+ expect(parsed.cmd).toBe(NetplayCmd.INPUT);
230
+ if ('frameNumber' in parsed) {
231
+ expect(parsed.frameNumber).toBe(999);
232
+ }
233
+ if ('clientId' in parsed) {
234
+ expect(parsed.clientId).toBe(5);
235
+ }
236
+ });
237
+ });
238
+
239
+ describe('NOINPUT Command', () => {
240
+ it('should build and parse NOINPUT', () => {
241
+ const encoded = buildNoInputCommand(12345);
242
+ const result = decodeCommand(encoded)!;
243
+ const parsed = parseNoInputCommand(result.command.payload);
244
+
245
+ expect(parsed.cmd).toBe(NetplayCmd.NOINPUT);
246
+ expect(parsed.frameNumber).toBe(12345);
247
+ });
248
+ });
249
+
250
+ describe('NICK Command', () => {
251
+ it('should build and parse NICK', () => {
252
+ const encoded = buildNickCommand('Player1');
253
+ const result = decodeCommand(encoded)!;
254
+ const parsed = parseNickCommand(result.command.payload);
255
+
256
+ expect(parsed.cmd).toBe(NetplayCmd.NICK);
257
+ expect(parsed.nickname).toBe('Player1');
258
+ });
259
+
260
+ it('should truncate long nicknames', () => {
261
+ const longNick = 'A'.repeat(100);
262
+ const encoded = buildNickCommand(longNick);
263
+ const result = decodeCommand(encoded)!;
264
+ const parsed = parseNickCommand(result.command.payload);
265
+
266
+ expect(parsed.nickname.length).toBeLessThanOrEqual(MAX_NICK_LEN - 1);
267
+ });
268
+
269
+ it('should handle empty nickname', () => {
270
+ const encoded = buildNickCommand('');
271
+ const result = decodeCommand(encoded)!;
272
+ const parsed = parseNickCommand(result.command.payload);
273
+
274
+ expect(parsed.nickname).toBe('');
275
+ });
276
+ });
277
+
278
+ describe('PASSWORD Command', () => {
279
+ it('should build and parse PASSWORD', () => {
280
+ const hash = hashPassword('secret');
281
+ const encoded = buildPasswordCommand(hash);
282
+ const result = decodeCommand(encoded)!;
283
+ const parsed = parsePasswordCommand(result.command.payload);
284
+
285
+ expect(parsed.cmd).toBe(NetplayCmd.PASSWORD);
286
+ expect(parsed.passwordHash).toBe(hash);
287
+ });
288
+ });
289
+
290
+ describe('INFO Command', () => {
291
+ it('should build and parse INFO', () => {
292
+ const encoded = buildInfoCommand('bsnes', '115.1', 0xdeadbeef);
293
+ const result = decodeCommand(encoded)!;
294
+ const parsed = parseInfoCommand(result.command.payload);
295
+
296
+ expect(parsed.cmd).toBe(NetplayCmd.INFO);
297
+ expect(parsed.coreName).toBe('bsnes');
298
+ expect(parsed.coreVersion).toBe('115.1');
299
+ expect(parsed.contentCrc).toBe(0xdeadbeef);
300
+ });
301
+
302
+ it('should truncate long core names', () => {
303
+ const longName = 'X'.repeat(100);
304
+ const encoded = buildInfoCommand(longName, '1.0', 0);
305
+ const result = decodeCommand(encoded)!;
306
+ const parsed = parseInfoCommand(result.command.payload);
307
+
308
+ expect(parsed.coreName.length).toBeLessThanOrEqual(CORE_NAME_LEN - 1);
309
+ });
310
+ });
311
+
312
+ describe('SYNC Command', () => {
313
+ it('should build and parse SYNC', () => {
314
+ const devices = [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
315
+ const shareModes = new Array(MAX_INPUT_DEVICES).fill(0);
316
+ const deviceClients = new Array(MAX_INPUT_DEVICES).fill(0);
317
+ const sram = Buffer.from([1, 2, 3, 4, 5]);
318
+ const encoded = buildSyncCommand(1000, false, 2, devices, shareModes, deviceClients, 'Host', sram);
319
+ const result = decodeCommand(encoded)!;
320
+ const parsed = parseSyncCommand(result.command.payload);
321
+
322
+ expect(parsed.cmd).toBe(NetplayCmd.SYNC);
323
+ expect(parsed.frameNumber).toBe(1000);
324
+ expect(parsed.paused).toBe(false);
325
+ expect(parsed.clientNumber).toBe(2);
326
+ expect(parsed.devices.slice(0, 2)).toEqual([1, 1]);
327
+ expect(parsed.clientNick).toBe('Host');
328
+ expect(parsed.sram).toEqual(sram);
329
+ });
330
+
331
+ it('should handle paused state', () => {
332
+ const devices = new Array(MAX_INPUT_DEVICES).fill(0);
333
+ const shareModes = new Array(MAX_INPUT_DEVICES).fill(0);
334
+ const deviceClients = new Array(MAX_INPUT_DEVICES).fill(0);
335
+ const encoded = buildSyncCommand(500, true, 1, devices, shareModes, deviceClients, 'Pauser', Buffer.alloc(0));
336
+ const result = decodeCommand(encoded)!;
337
+ const parsed = parseSyncCommand(result.command.payload);
338
+
339
+ expect(parsed.paused).toBe(true);
340
+ expect(parsed.clientNumber).toBe(1);
341
+ });
342
+ });
343
+
344
+ describe('MODE Command', () => {
345
+ it('should build and parse MODE for playing', () => {
346
+ const shareModes = new Array(MAX_INPUT_DEVICES).fill(0);
347
+ const encoded = buildModeCommand(5000, true, true, false, 1, 0b11, shareModes, 'Player1');
348
+ const result = decodeCommand(encoded)!;
349
+ const parsed = parseModeCommand(result.command.payload);
350
+
351
+ expect(parsed.cmd).toBe(NetplayCmd.MODE);
352
+ expect(parsed.frameNumber).toBe(5000);
353
+ expect(parsed.you).toBe(true);
354
+ expect(parsed.playing).toBe(true);
355
+ expect(parsed.slave).toBe(false);
356
+ expect(parsed.clientNumber).toBe(1);
357
+ expect(parsed.deviceBitmap).toBe(0b11);
358
+ expect(parsed.nick).toBe('Player1');
359
+ });
360
+
361
+ it('should build and parse MODE for spectating', () => {
362
+ const shareModes = new Array(MAX_INPUT_DEVICES).fill(0);
363
+ const encoded = buildModeCommand(100, true, false, false, 0, 0, shareModes, 'Spectator');
364
+ const result = decodeCommand(encoded)!;
365
+ const parsed = parseModeCommand(result.command.payload);
366
+
367
+ expect(parsed.playing).toBe(false);
368
+ });
369
+
370
+ it('should handle slave mode', () => {
371
+ const shareModes = new Array(MAX_INPUT_DEVICES).fill(0);
372
+ const encoded = buildModeCommand(200, false, true, true, 2, 0b01, shareModes, 'Slave');
373
+ const result = decodeCommand(encoded)!;
374
+ const parsed = parseModeCommand(result.command.payload);
375
+
376
+ expect(parsed.slave).toBe(true);
377
+ });
378
+ });
379
+
380
+ describe('MODE_REFUSED Command', () => {
381
+ it('should build and parse MODE_REFUSED', () => {
382
+ const encoded = buildModeRefusedCommand(1); // NO_SLOTS
383
+ const result = decodeCommand(encoded)!;
384
+ const parsed = parseModeRefusedCommand(result.command.payload);
385
+
386
+ expect(parsed.cmd).toBe(NetplayCmd.MODE_REFUSED);
387
+ expect(parsed.reason).toBe(1);
388
+ });
389
+ });
390
+
391
+ describe('CRC Command', () => {
392
+ it('should build and parse CRC', () => {
393
+ const encoded = buildCrcCommand(10000, 0xcafebabe);
394
+ const result = decodeCommand(encoded)!;
395
+ const parsed = parseCrcCommand(result.command.payload);
396
+
397
+ expect(parsed.cmd).toBe(NetplayCmd.CRC);
398
+ expect(parsed.frameNumber).toBe(10000);
399
+ expect(parsed.crc).toBe(0xcafebabe);
400
+ });
401
+ });
402
+
403
+ describe('LOAD_SAVESTATE Command', () => {
404
+ /**
405
+ * Calculate the expected NETPLAY-wrapped size for a raw state.
406
+ * Format: NETPLAY header (8) + MEM block (8 + data + padding to 16) + ACHV block (16) + END block (8)
407
+ */
408
+ const calculateNetplayWrappedSize = (rawSize: number): number => {
409
+ const HEADER_SIZE = 8;
410
+ const BLOCK_HEADER_SIZE = 8;
411
+ const ALIGNMENT = 16;
412
+ const memDataEnd = HEADER_SIZE + BLOCK_HEADER_SIZE + rawSize;
413
+ const memPaddedEnd = Math.ceil(memDataEnd / ALIGNMENT) * ALIGNMENT;
414
+ const achvBlockSize = BLOCK_HEADER_SIZE + 8; // 16
415
+ const endBlockSize = BLOCK_HEADER_SIZE; // 8
416
+ return memPaddedEnd + achvBlockSize + endBlockSize;
417
+ };
418
+
419
+ it('should build LOAD_SAVESTATE with zlib compression and parse back to original state', () => {
420
+ const rawState = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]);
421
+ const encoded = buildLoadSavestateCommand(2000, rawState, 7);
422
+ const result = decodeCommand(encoded)!;
423
+ const parsed = parseLoadSavestateCommand(result.command.payload);
424
+
425
+ expect(parsed.cmd).toBe(NetplayCmd.LOAD_SAVESTATE);
426
+ expect(parsed.frameNumber).toBe(2000);
427
+
428
+ // State should be decompressed and unwrapped back to original
429
+ expect(parsed.state).toEqual(rawState);
430
+ // uncompressed_size should be the NETPLAY-wrapped size (not raw state length)
431
+ expect(parsed.uncompressedSize).toBe(calculateNetplayWrappedSize(rawState.length));
432
+ });
433
+
434
+ it('should compress and decompress state data regardless of protocol version', () => {
435
+ const rawState = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]);
436
+ const encoded = buildLoadSavestateCommand(2000, rawState, 4);
437
+ const result = decodeCommand(encoded)!;
438
+ const parsed = parseLoadSavestateCommand(result.command.payload);
439
+
440
+ expect(parsed.cmd).toBe(NetplayCmd.LOAD_SAVESTATE);
441
+ expect(parsed.frameNumber).toBe(2000);
442
+ // State should be decompressed and unwrapped back to original
443
+ expect(parsed.state).toEqual(rawState);
444
+ // uncompressed_size should be the NETPLAY-wrapped size
445
+ expect(parsed.uncompressedSize).toBe(calculateNetplayWrappedSize(rawState.length));
446
+ });
447
+
448
+ it('should round-trip compressible data correctly', () => {
449
+ // Create a larger, more compressible buffer (repeated data)
450
+ const rawState = Buffer.alloc(1000, 0xaa);
451
+ const encoded = buildLoadSavestateCommand(2000, rawState);
452
+ const result = decodeCommand(encoded)!;
453
+ const parsed = parseLoadSavestateCommand(result.command.payload);
454
+
455
+ // State should be decompressed and unwrapped back to original
456
+ expect(parsed.state).toEqual(rawState);
457
+ expect(parsed.uncompressedSize).toBe(calculateNetplayWrappedSize(rawState.length));
458
+ });
459
+ });
460
+
461
+ describe('PAUSE/RESUME Commands', () => {
462
+ it('should build and parse PAUSE', () => {
463
+ const encoded = buildPauseCommand('Pauser');
464
+ const result = decodeCommand(encoded)!;
465
+ const parsed = parsePauseCommand(result.command.payload);
466
+
467
+ expect(parsed.cmd).toBe(NetplayCmd.PAUSE);
468
+ expect(parsed.nickname).toBe('Pauser');
469
+ });
470
+
471
+ it('should build RESUME', () => {
472
+ const encoded = buildResumeCommand();
473
+ const result = decodeCommand(encoded)!;
474
+ expect(result.command.cmd).toBe(NetplayCmd.RESUME);
475
+ expect(result.command.payload.length).toBe(0);
476
+ });
477
+ });
478
+
479
+ describe('STALL Command', () => {
480
+ it('should build and parse STALL', () => {
481
+ const encoded = buildStallCommand(5);
482
+ const result = decodeCommand(encoded)!;
483
+ const parsed = parseStallCommand(result.command.payload);
484
+
485
+ expect(parsed.cmd).toBe(NetplayCmd.STALL);
486
+ expect(parsed.frames).toBe(5);
487
+ });
488
+ });
489
+
490
+ describe('RESET Command', () => {
491
+ it('should build and parse RESET', () => {
492
+ const encoded = buildResetCommand(7500);
493
+ const result = decodeCommand(encoded)!;
494
+ const parsed = parseResetCommand(result.command.payload);
495
+
496
+ expect(parsed.cmd).toBe(NetplayCmd.RESET);
497
+ expect(parsed.frameNumber).toBe(7500);
498
+ });
499
+ });
500
+
501
+ describe('PLAYER_CHAT Command', () => {
502
+ it('should build and parse PLAYER_CHAT', () => {
503
+ const encoded = buildPlayerChatCommand('Alice', 'Hello, world!');
504
+ const result = decodeCommand(encoded)!;
505
+ const parsed = parsePlayerChatCommand(result.command.payload);
506
+
507
+ expect(parsed.cmd).toBe(NetplayCmd.PLAYER_CHAT);
508
+ expect(parsed.nickname).toBe('Alice');
509
+ expect(parsed.message).toBe('Hello, world!');
510
+ });
511
+
512
+ it('should handle unicode messages', () => {
513
+ const encoded = buildPlayerChatCommand('Bob', 'こんにちは! 🎮');
514
+ const result = decodeCommand(encoded)!;
515
+ const parsed = parsePlayerChatCommand(result.command.payload);
516
+
517
+ expect(parsed.message).toBe('こんにちは! 🎮');
518
+ });
519
+ });
520
+
521
+ describe('PING Commands', () => {
522
+ it('should build and parse PING_REQUEST', () => {
523
+ const encoded = buildPingRequestCommand();
524
+ const result = decodeCommand(encoded)!;
525
+
526
+ expect(result.command.cmd).toBe(NetplayCmd.PING_REQUEST);
527
+ // Per RetroArch spec, PING commands have no payload
528
+ expect(result.command.payload.length).toBe(0);
529
+ });
530
+
531
+ it('should build and parse PING_RESPONSE', () => {
532
+ const encoded = buildPingResponseCommand();
533
+ const result = decodeCommand(encoded)!;
534
+
535
+ expect(result.command.cmd).toBe(NetplayCmd.PING_RESPONSE);
536
+ // Per RetroArch spec, PING commands have no payload
537
+ expect(result.command.payload.length).toBe(0);
538
+ });
539
+ });
540
+
541
+ describe('Simple Commands', () => {
542
+ it('should build ACK', () => {
543
+ const encoded = buildAckCommand();
544
+ const result = decodeCommand(encoded)!;
545
+ expect(result.command.cmd).toBe(NetplayCmd.ACK);
546
+ });
547
+
548
+ it('should build NAK', () => {
549
+ const encoded = buildNakCommand();
550
+ const result = decodeCommand(encoded)!;
551
+ expect(result.command.cmd).toBe(NetplayCmd.NAK);
552
+ });
553
+
554
+ it('should build DISCONNECT', () => {
555
+ const encoded = buildDisconnectCommand();
556
+ const result = decodeCommand(encoded)!;
557
+ expect(result.command.cmd).toBe(NetplayCmd.DISCONNECT);
558
+ });
559
+
560
+ it('should build SPECTATE', () => {
561
+ const encoded = buildSpectateCommand();
562
+ const result = decodeCommand(encoded)!;
563
+ expect(result.command.cmd).toBe(NetplayCmd.SPECTATE);
564
+ });
565
+
566
+ it('should build PLAY', () => {
567
+ const encoded = buildPlayCommand();
568
+ const result = decodeCommand(encoded)!;
569
+ expect(result.command.cmd).toBe(NetplayCmd.PLAY);
570
+ });
571
+
572
+ it('should build REQUEST_SAVESTATE', () => {
573
+ const encoded = buildRequestSavestateCommand();
574
+ const result = decodeCommand(encoded)!;
575
+ expect(result.command.cmd).toBe(NetplayCmd.REQUEST_SAVESTATE);
576
+ });
577
+ });
578
+
579
+ describe('parseCommand dispatcher', () => {
580
+ it('should dispatch to correct parser for INPUT', () => {
581
+ const encoded = buildInputCommand(100, 1, false, 0xff);
582
+ const result = decodeCommand(encoded)!;
583
+ const parsed = parseCommand(result.command);
584
+
585
+ expect(parsed.cmd).toBe(NetplayCmd.INPUT);
586
+ });
587
+
588
+ it('should dispatch to correct parser for INFO', () => {
589
+ const encoded = buildInfoCommand('test', '1.0', 123);
590
+ const result = decodeCommand(encoded)!;
591
+ const parsed = parseCommand(result.command);
592
+
593
+ expect(parsed.cmd).toBe(NetplayCmd.INFO);
594
+ if ('coreName' in parsed) {
595
+ expect(parsed.coreName).toBe('test');
596
+ }
597
+ });
598
+
599
+ it('should return UnknownCommand for unknown command', () => {
600
+ const raw = { cmd: 0xffff as NetplayCmd, payload: Buffer.alloc(0) };
601
+ const parsed = parseCommand(raw);
602
+ // Unknown commands are returned as-is for forward compatibility
603
+ expect(parsed.cmd).toBe(0xffff);
604
+ });
605
+ });
606
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Netplay protocol error types
3
+ */
4
+
5
+ import { createTypedError } from '../../utils/typedError';
6
+
7
+ export type ProtocolErrorCode =
8
+ | 'INVALID_INPUT_PAYLOAD'
9
+ | 'INVALID_NOINPUT_PAYLOAD'
10
+ | 'INVALID_INFO_PAYLOAD'
11
+ | 'INVALID_SYNC_PAYLOAD'
12
+ | 'INVALID_MODE_PAYLOAD'
13
+ | 'INVALID_CRC_PAYLOAD'
14
+ | 'INVALID_SAVESTATE_PAYLOAD'
15
+ | 'DECOMPRESS_FAILED';
16
+
17
+ const { TypedError, isTypedError } = createTypedError<ProtocolErrorCode>('ProtocolError');
18
+ export const ProtocolError = TypedError;
19
+ export type ProtocolError = InstanceType<typeof TypedError>;
20
+ export const isProtocolError = isTypedError;