emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,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
|
+
};
|