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,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TCP Connection Wrapper for Netplay
|
|
3
|
+
*
|
|
4
|
+
* Handles buffered reads, command parsing, and connection state management.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Socket, createConnection } from 'net';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import {
|
|
10
|
+
ConnectionState,
|
|
11
|
+
DEFAULT_PORT,
|
|
12
|
+
TCP_KEEPALIVE_MS,
|
|
13
|
+
CONNECTION_HEADER_SIZE,
|
|
14
|
+
CONNECTION_MAGIC_SIZE,
|
|
15
|
+
CONNECTION_MAGIC,
|
|
16
|
+
TIMEOUT_CLEANUP_DELAY_MS,
|
|
17
|
+
HEX_PREVIEW_LENGTH,
|
|
18
|
+
HEX_RADIX,
|
|
19
|
+
HEX_PADDING_WIDTH_32,
|
|
20
|
+
RECEIVE_BUFFER_INITIAL_SIZE,
|
|
21
|
+
RECEIVE_BUFFER_GROWTH_FACTOR,
|
|
22
|
+
NetplayError,
|
|
23
|
+
type RawCommand,
|
|
24
|
+
type ParsedCommand,
|
|
25
|
+
type ClientInfo,
|
|
26
|
+
} from '..';
|
|
27
|
+
import { netplayLogger } from '../netplayLogger';
|
|
28
|
+
import {
|
|
29
|
+
decodeCommand,
|
|
30
|
+
parseCommand,
|
|
31
|
+
createConnectionHeader,
|
|
32
|
+
parseConnectionHeader,
|
|
33
|
+
type ConnectionHeader,
|
|
34
|
+
} from '../protocol';
|
|
35
|
+
|
|
36
|
+
/** Connection event types */
|
|
37
|
+
interface ConnectionEvents {
|
|
38
|
+
command: (command: ParsedCommand) => void;
|
|
39
|
+
rawCommand: (command: RawCommand) => void;
|
|
40
|
+
connected: () => void;
|
|
41
|
+
disconnected: (reason: string) => void;
|
|
42
|
+
error: (error: Error) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* NetplayConnection wraps a TCP socket with buffered command parsing.
|
|
47
|
+
*/
|
|
48
|
+
export class NetplayConnection extends EventEmitter {
|
|
49
|
+
private socket: Socket | null = null;
|
|
50
|
+
/** Pre-allocated receive buffer (grows as needed, avoids per-packet allocation) */
|
|
51
|
+
private receiveBuffer: Buffer = Buffer.alloc(RECEIVE_BUFFER_INITIAL_SIZE);
|
|
52
|
+
/** Number of valid bytes currently in receiveBuffer */
|
|
53
|
+
private receiveBufferLength: number = 0;
|
|
54
|
+
private _state: ConnectionState = ConnectionState.DISCONNECTED;
|
|
55
|
+
private _id: number = 0;
|
|
56
|
+
private _nickname: string = '';
|
|
57
|
+
private _playerNumber: number = -1;
|
|
58
|
+
private _spectating: boolean = false;
|
|
59
|
+
private _latency: number = 0;
|
|
60
|
+
private _lastReceivedFrame: number = 0;
|
|
61
|
+
private _devices: number[] = [];
|
|
62
|
+
private _address: string = '';
|
|
63
|
+
private _port: number = 0;
|
|
64
|
+
private _draining: boolean = false;
|
|
65
|
+
|
|
66
|
+
/** Unique client ID */
|
|
67
|
+
get id(): number {
|
|
68
|
+
return this._id;
|
|
69
|
+
}
|
|
70
|
+
set id(value: number) {
|
|
71
|
+
this._id = value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Client nickname */
|
|
75
|
+
get nickname(): string {
|
|
76
|
+
return this._nickname;
|
|
77
|
+
}
|
|
78
|
+
set nickname(value: string) {
|
|
79
|
+
this._nickname = value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Player number (-1 if spectating) */
|
|
83
|
+
get playerNumber(): number {
|
|
84
|
+
return this._playerNumber;
|
|
85
|
+
}
|
|
86
|
+
set playerNumber(value: number) {
|
|
87
|
+
this._playerNumber = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Is this connection spectating? */
|
|
91
|
+
get spectating(): boolean {
|
|
92
|
+
return this._spectating;
|
|
93
|
+
}
|
|
94
|
+
set spectating(value: boolean) {
|
|
95
|
+
this._spectating = value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Estimated latency in ms */
|
|
99
|
+
get latency(): number {
|
|
100
|
+
return this._latency;
|
|
101
|
+
}
|
|
102
|
+
set latency(value: number) {
|
|
103
|
+
this._latency = value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Last frame received from this connection */
|
|
107
|
+
get lastReceivedFrame(): number {
|
|
108
|
+
return this._lastReceivedFrame;
|
|
109
|
+
}
|
|
110
|
+
set lastReceivedFrame(value: number) {
|
|
111
|
+
this._lastReceivedFrame = value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Input devices */
|
|
115
|
+
get devices(): number[] {
|
|
116
|
+
return this._devices;
|
|
117
|
+
}
|
|
118
|
+
set devices(value: number[]) {
|
|
119
|
+
this._devices = value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Remote address */
|
|
123
|
+
get address(): string {
|
|
124
|
+
return this._address;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Remote port */
|
|
128
|
+
get port(): number {
|
|
129
|
+
return this._port;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Current connection state */
|
|
133
|
+
get state(): ConnectionState {
|
|
134
|
+
return this._state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Is the connection currently open? */
|
|
138
|
+
get isConnected(): boolean {
|
|
139
|
+
return (
|
|
140
|
+
this._state !== ConnectionState.DISCONNECTED && this.socket !== null && !this.socket.destroyed
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a connection from an existing socket (server accepting a client).
|
|
146
|
+
*/
|
|
147
|
+
static fromSocket(socket: Socket, clientId: number): NetplayConnection {
|
|
148
|
+
const conn = new NetplayConnection();
|
|
149
|
+
conn.socket = socket;
|
|
150
|
+
conn._id = clientId;
|
|
151
|
+
conn._state = ConnectionState.CONNECTED;
|
|
152
|
+
|
|
153
|
+
const addr = socket.remoteAddress ?? 'unknown';
|
|
154
|
+
const port = socket.remotePort ?? 0;
|
|
155
|
+
conn._address = addr;
|
|
156
|
+
conn._port = port;
|
|
157
|
+
|
|
158
|
+
conn.setupSocketHandlers();
|
|
159
|
+
return conn;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Connect to a remote server.
|
|
164
|
+
*/
|
|
165
|
+
async connect(host: string, port: number = DEFAULT_PORT): Promise<void> {
|
|
166
|
+
if (this.socket) {
|
|
167
|
+
throw new NetplayError('ALREADY_CONNECTED');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
this._address = host;
|
|
172
|
+
this._port = port;
|
|
173
|
+
|
|
174
|
+
this.socket = createConnection({ host, port }, () => {
|
|
175
|
+
this._state = ConnectionState.CONNECTED;
|
|
176
|
+
this.setupSocketHandlers();
|
|
177
|
+
this.emit('connected');
|
|
178
|
+
resolve();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
this.socket.once('error', (err) => {
|
|
182
|
+
this._state = ConnectionState.DISCONNECTED;
|
|
183
|
+
reject(err);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Set socket options
|
|
187
|
+
this.socket.setNoDelay(true);
|
|
188
|
+
this.socket.setKeepAlive(true, TCP_KEEPALIVE_MS);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Close the connection.
|
|
194
|
+
*/
|
|
195
|
+
close(reason: string = 'closed'): void {
|
|
196
|
+
// Log any pending data in receive buffer before closing
|
|
197
|
+
const bufferPreviewLength = HEX_PREVIEW_LENGTH * 2; // 64 bytes
|
|
198
|
+
if (this.receiveBufferLength > 0) {
|
|
199
|
+
const bufferPreview = this.receiveBuffer.subarray(0, Math.min(bufferPreviewLength, this.receiveBufferLength)).toString('hex');
|
|
200
|
+
netplayLogger.debug('CONNECTION', `Connection closing with ${this.receiveBufferLength} bytes in buffer`, {
|
|
201
|
+
reason,
|
|
202
|
+
bufferedBytes: this.receiveBufferLength,
|
|
203
|
+
bufferHex: bufferPreview + (this.receiveBufferLength > bufferPreviewLength ? '...' : ''),
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
netplayLogger.debug('CONNECTION', `Connection closing`, { reason, bufferedBytes: 0 });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (this.socket) {
|
|
210
|
+
this.socket.destroy();
|
|
211
|
+
this.socket = null;
|
|
212
|
+
}
|
|
213
|
+
if (this._state !== ConnectionState.DISCONNECTED) {
|
|
214
|
+
this._state = ConnectionState.DISCONNECTED;
|
|
215
|
+
this.emit('disconnected', reason);
|
|
216
|
+
}
|
|
217
|
+
// Reset buffer length instead of reallocating (keeps the buffer for potential reuse)
|
|
218
|
+
this.receiveBufferLength = 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Is the socket buffer full and draining? */
|
|
222
|
+
get draining(): boolean {
|
|
223
|
+
return this._draining;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send raw data over the connection.
|
|
228
|
+
* Returns false if socket is unavailable or buffer is full.
|
|
229
|
+
*/
|
|
230
|
+
send(data: Buffer): boolean {
|
|
231
|
+
if (!this.socket || this.socket.destroyed) {
|
|
232
|
+
netplayLogger.debug('CONNECTION', `Send failed - socket not available`, {
|
|
233
|
+
socketExists: !!this.socket,
|
|
234
|
+
socketDestroyed: this.socket?.destroyed ?? 'N/A',
|
|
235
|
+
dataSize: data.length,
|
|
236
|
+
});
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// If already draining, skip non-critical data to avoid flooding
|
|
241
|
+
if (this._draining) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const success = this.socket.write(data);
|
|
246
|
+
if (!success) {
|
|
247
|
+
this._draining = true;
|
|
248
|
+
netplayLogger.debug('CONNECTION', `Socket buffer full, entering drain mode`, {
|
|
249
|
+
dataSize: data.length,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return success;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Send the connection header.
|
|
257
|
+
* @param nickname The nickname (for logging only, not sent in header)
|
|
258
|
+
* @param isServer Whether this is a server sending to a client
|
|
259
|
+
* @param salt Password salt (server only, 0 = no password required)
|
|
260
|
+
*/
|
|
261
|
+
sendHeader(nickname: string = 'emoemu', isServer: boolean = false, salt: number = 0): boolean {
|
|
262
|
+
const header = createConnectionHeader({ isServer, salt });
|
|
263
|
+
netplayLogger.debug('CONNECTION', `Sending connection header to ${this._address}`, {
|
|
264
|
+
headerHex: header.toString('hex'),
|
|
265
|
+
headerSize: header.length,
|
|
266
|
+
nickname,
|
|
267
|
+
});
|
|
268
|
+
return this.send(header);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Wait for and parse the connection header from remote.
|
|
273
|
+
* Returns the parsed header on success, or null on failure.
|
|
274
|
+
*/
|
|
275
|
+
async waitForHeader(timeoutMs: number = 5000): Promise<ConnectionHeader | null> {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
let resolved = false;
|
|
278
|
+
|
|
279
|
+
const timeout = setTimeout(() => {
|
|
280
|
+
if (!resolved) {
|
|
281
|
+
resolved = true;
|
|
282
|
+
netplayLogger.debug('CONNECTION', `Header timeout waiting for ${this._address}`, {
|
|
283
|
+
bufferSize: this.receiveBufferLength,
|
|
284
|
+
});
|
|
285
|
+
resolve(null);
|
|
286
|
+
}
|
|
287
|
+
}, timeoutMs);
|
|
288
|
+
|
|
289
|
+
const checkHeader = (): void => {
|
|
290
|
+
if (resolved) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Need at least the minimum header size to start checking
|
|
295
|
+
if (this.receiveBufferLength < CONNECTION_HEADER_SIZE) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Try to parse the full header (includes variable nickname)
|
|
300
|
+
// Create a view of only the valid data for parsing
|
|
301
|
+
const validData = this.receiveBuffer.subarray(0, this.receiveBufferLength);
|
|
302
|
+
const result = parseConnectionHeader(validData);
|
|
303
|
+
if (result) {
|
|
304
|
+
resolved = true;
|
|
305
|
+
clearTimeout(timeout);
|
|
306
|
+
// Shift consumed bytes out of buffer
|
|
307
|
+
this.consumeBuffer(result.bytesConsumed);
|
|
308
|
+
this._state = ConnectionState.HANDSHAKING;
|
|
309
|
+
|
|
310
|
+
netplayLogger.debug('CONNECTION', `Received valid header from ${this._address}`, {
|
|
311
|
+
nickname: result.header.nickname,
|
|
312
|
+
platformMagic: result.header.platformMagic.toString(HEX_RADIX),
|
|
313
|
+
compression: result.header.compression,
|
|
314
|
+
bytesConsumed: result.bytesConsumed,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
resolve(result.header);
|
|
318
|
+
} else if (this.receiveBufferLength >= CONNECTION_MAGIC_SIZE) {
|
|
319
|
+
// Check if magic is wrong (invalid connection)
|
|
320
|
+
const magic = this.receiveBuffer.readUInt32BE(0);
|
|
321
|
+
if (magic !== CONNECTION_MAGIC) {
|
|
322
|
+
resolved = true;
|
|
323
|
+
clearTimeout(timeout);
|
|
324
|
+
netplayLogger.debug('CONNECTION', `Invalid magic from ${this._address}`, {
|
|
325
|
+
expectedMagic: CONNECTION_MAGIC.toString(HEX_RADIX),
|
|
326
|
+
receivedMagic: magic.toString(HEX_RADIX).padStart(HEX_PADDING_WIDTH_32, '0'),
|
|
327
|
+
});
|
|
328
|
+
resolve(null);
|
|
329
|
+
}
|
|
330
|
+
// Otherwise, magic is valid but we don't have enough data yet - wait for more
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Check if we already have the header
|
|
335
|
+
checkHeader();
|
|
336
|
+
|
|
337
|
+
// Listen for more data
|
|
338
|
+
const onData = (): void => {
|
|
339
|
+
checkHeader();
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
this.socket?.on('data', onData);
|
|
343
|
+
|
|
344
|
+
// Cleanup after resolution
|
|
345
|
+
setTimeout(() => {
|
|
346
|
+
this.socket?.off('data', onData);
|
|
347
|
+
}, timeoutMs + TIMEOUT_CLEANUP_DELAY_MS);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Update connection state.
|
|
353
|
+
*/
|
|
354
|
+
setState(state: ConnectionState): void {
|
|
355
|
+
this._state = state;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get client info snapshot.
|
|
360
|
+
*/
|
|
361
|
+
getClientInfo(): ClientInfo {
|
|
362
|
+
return {
|
|
363
|
+
id: this._id,
|
|
364
|
+
nickname: this._nickname,
|
|
365
|
+
address: this._address,
|
|
366
|
+
port: this._port,
|
|
367
|
+
state: this._state,
|
|
368
|
+
playerNumber: this._playerNumber,
|
|
369
|
+
spectating: this._spectating,
|
|
370
|
+
latency: this._latency,
|
|
371
|
+
lastReceivedFrame: this._lastReceivedFrame,
|
|
372
|
+
devices: [...this._devices],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Process any complete commands in the receive buffer.
|
|
378
|
+
*/
|
|
379
|
+
processBuffer(): void {
|
|
380
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
381
|
+
while (true) {
|
|
382
|
+
// Create a view of only the valid data for parsing
|
|
383
|
+
const validData = this.receiveBuffer.subarray(0, this.receiveBufferLength);
|
|
384
|
+
const result = decodeCommand(validData);
|
|
385
|
+
if (!result) {
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const { command, bytesConsumed } = result;
|
|
390
|
+
// Shift consumed bytes out of buffer
|
|
391
|
+
this.consumeBuffer(bytesConsumed);
|
|
392
|
+
|
|
393
|
+
// Emit raw command
|
|
394
|
+
this.emit('rawCommand', command);
|
|
395
|
+
|
|
396
|
+
// Parse and emit typed command
|
|
397
|
+
try {
|
|
398
|
+
const parsed = parseCommand(command);
|
|
399
|
+
this.emit('command', parsed);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Append data to the receive buffer, growing if necessary.
|
|
408
|
+
* This avoids Buffer.concat allocation on every packet.
|
|
409
|
+
*/
|
|
410
|
+
private appendToBuffer(data: Buffer): void {
|
|
411
|
+
const requiredSize = this.receiveBufferLength + data.length;
|
|
412
|
+
|
|
413
|
+
// Grow buffer if needed
|
|
414
|
+
if (requiredSize > this.receiveBuffer.length) {
|
|
415
|
+
let newSize = this.receiveBuffer.length;
|
|
416
|
+
while (newSize < requiredSize) {
|
|
417
|
+
newSize *= RECEIVE_BUFFER_GROWTH_FACTOR;
|
|
418
|
+
}
|
|
419
|
+
const newBuffer = Buffer.alloc(newSize);
|
|
420
|
+
// Copy existing valid data
|
|
421
|
+
this.receiveBuffer.copy(newBuffer, 0, 0, this.receiveBufferLength);
|
|
422
|
+
this.receiveBuffer = newBuffer;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Append new data
|
|
426
|
+
data.copy(this.receiveBuffer, this.receiveBufferLength);
|
|
427
|
+
this.receiveBufferLength += data.length;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Remove consumed bytes from the front of the receive buffer.
|
|
432
|
+
* Uses in-place copy to avoid allocation.
|
|
433
|
+
*/
|
|
434
|
+
private consumeBuffer(bytesConsumed: number): void {
|
|
435
|
+
if (bytesConsumed >= this.receiveBufferLength) {
|
|
436
|
+
// All data consumed, just reset length
|
|
437
|
+
this.receiveBufferLength = 0;
|
|
438
|
+
} else {
|
|
439
|
+
// Shift remaining data to front
|
|
440
|
+
this.receiveBuffer.copy(
|
|
441
|
+
this.receiveBuffer,
|
|
442
|
+
0,
|
|
443
|
+
bytesConsumed,
|
|
444
|
+
this.receiveBufferLength
|
|
445
|
+
);
|
|
446
|
+
this.receiveBufferLength -= bytesConsumed;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Set up socket event handlers.
|
|
452
|
+
*/
|
|
453
|
+
private setupSocketHandlers(): void {
|
|
454
|
+
if (!this.socket) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.socket.on('data', (data: Buffer) => {
|
|
459
|
+
// Log raw data received for debugging
|
|
460
|
+
const hexPreview = data.subarray(0, Math.min(HEX_PREVIEW_LENGTH, data.length)).toString('hex');
|
|
461
|
+
netplayLogger.debug('CONNECTION', `Received ${data.length} bytes from ${this._address}`, {
|
|
462
|
+
bytesReceived: data.length,
|
|
463
|
+
hexPreview: hexPreview + (data.length > HEX_PREVIEW_LENGTH ? '...' : ''),
|
|
464
|
+
bufferSizeBefore: this.receiveBufferLength,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Append to receive buffer (grows as needed, avoids per-packet allocation)
|
|
468
|
+
this.appendToBuffer(data);
|
|
469
|
+
// Process any complete commands
|
|
470
|
+
this.processBuffer();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
this.socket.on('close', () => {
|
|
474
|
+
this.close('connection closed');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
this.socket.on('error', (err) => {
|
|
478
|
+
// Capture additional error details for debugging
|
|
479
|
+
const errWithCode = err as NodeJS.ErrnoException;
|
|
480
|
+
const errDetails = {
|
|
481
|
+
message: err.message,
|
|
482
|
+
code: errWithCode.code,
|
|
483
|
+
syscall: errWithCode.syscall,
|
|
484
|
+
errno: errWithCode.errno,
|
|
485
|
+
};
|
|
486
|
+
netplayLogger.debug('CONNECTION', `Socket error from ${this._address}`, errDetails);
|
|
487
|
+
this.emit('error', err);
|
|
488
|
+
this.close(`error: ${err.message}`);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
this.socket.on('timeout', () => {
|
|
492
|
+
this.close('timeout');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
this.socket.on('drain', () => {
|
|
496
|
+
if (this._draining) {
|
|
497
|
+
this._draining = false;
|
|
498
|
+
netplayLogger.debug('CONNECTION', `Socket drained, resuming sends to ${this._address}`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Type-safe event emitter methods
|
|
504
|
+
override on<K extends keyof ConnectionEvents>(
|
|
505
|
+
event: K,
|
|
506
|
+
listener: ConnectionEvents[K]
|
|
507
|
+
): this {
|
|
508
|
+
return super.on(event, listener);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
override off<K extends keyof ConnectionEvents>(
|
|
512
|
+
event: K,
|
|
513
|
+
listener: ConnectionEvents[K]
|
|
514
|
+
): this {
|
|
515
|
+
return super.off(event, listener);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
override emit<K extends keyof ConnectionEvents>(
|
|
519
|
+
event: K,
|
|
520
|
+
...args: Parameters<ConnectionEvents[K]>
|
|
521
|
+
): boolean {
|
|
522
|
+
return super.emit(event, ...args);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Create a connection and connect to a server.
|
|
528
|
+
*/
|
|
529
|
+
export const createNetplayConnection = async (
|
|
530
|
+
host: string,
|
|
531
|
+
port: number = DEFAULT_PORT
|
|
532
|
+
): Promise<NetplayConnection> => {
|
|
533
|
+
const conn = new NetplayConnection();
|
|
534
|
+
await conn.connect(host, port);
|
|
535
|
+
return conn;
|
|
536
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MAX_NICK_LEN, TIMING, UINT32_SIZE } from '..';
|
|
2
|
+
|
|
3
|
+
/** Discovery query magic: "RANQ" (RetroArch Netplay Query) */
|
|
4
|
+
export const DISCOVERY_QUERY_MAGIC = 0x52414e51;
|
|
5
|
+
|
|
6
|
+
/** Discovery response magic: "RANS" (RetroArch Netplay Server) */
|
|
7
|
+
export const DISCOVERY_RESPONSE_MAGIC = 0x52414e53;
|
|
8
|
+
|
|
9
|
+
/** Size of a query packet (just the magic header) */
|
|
10
|
+
export const QUERY_PACKET_SIZE = 4;
|
|
11
|
+
|
|
12
|
+
/** String length constants from RetroArch */
|
|
13
|
+
export const NETPLAY_HOST_STR_LEN = 32;
|
|
14
|
+
export const NETPLAY_HOST_LONGSTR_LEN = 256;
|
|
15
|
+
|
|
16
|
+
/** Microseconds to milliseconds conversion */
|
|
17
|
+
export const MS_PER_USEC = 1000;
|
|
18
|
+
|
|
19
|
+
/** Byte mask for broadcast address calculation */
|
|
20
|
+
export const BYTE_MASK = 255;
|
|
21
|
+
|
|
22
|
+
/** Broadcast interval in milliseconds (5 seconds) */
|
|
23
|
+
export const BROADCAST_INTERVAL_MS = TIMING.ANNOUNCE_AFTER_USEC / MS_PER_USEC;
|
|
24
|
+
|
|
25
|
+
/** Password bitmask values */
|
|
26
|
+
export const PASSWORD_FLAG = 1;
|
|
27
|
+
export const SPECTATE_PASSWORD_FLAG = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discovery packet structure field count (header, content_crc, port, has_password)
|
|
31
|
+
*/
|
|
32
|
+
export const DISCOVERY_HEADER_FIELDS = 4;
|
|
33
|
+
|
|
34
|
+
export const DISCOVERY_PACKET_SIZE = (UINT32_SIZE * DISCOVERY_HEADER_FIELDS) +
|
|
35
|
+
MAX_NICK_LEN + // nick[32]
|
|
36
|
+
NETPLAY_HOST_STR_LEN + // frontend[32]
|
|
37
|
+
NETPLAY_HOST_STR_LEN + // core[32]
|
|
38
|
+
NETPLAY_HOST_STR_LEN + // core_version[32]
|
|
39
|
+
NETPLAY_HOST_STR_LEN + // retroarch_version[32]
|
|
40
|
+
NETPLAY_HOST_LONGSTR_LEN + // content[256]
|
|
41
|
+
NETPLAY_HOST_LONGSTR_LEN; // subsystem_name[256]
|