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,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetroArch-compatible LAN Discovery
|
|
3
|
+
*
|
|
4
|
+
* Broadcasts UDP packets on the local network so other RetroArch clients
|
|
5
|
+
* can discover hosted netplay sessions via "Scan Local Network".
|
|
6
|
+
*
|
|
7
|
+
* Protocol based on RetroArch's netplay_discovery.c
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSocket, type Socket as DatagramSocket, type RemoteInfo } from 'node:dgram';
|
|
11
|
+
import { networkInterfaces } from 'node:os';
|
|
12
|
+
import { pipe, flatMap, filter, isDefined, map } from 'remeda';
|
|
13
|
+
import { safeClose } from '../../utils/safeClose';
|
|
14
|
+
import { VERSION } from '../../consts';
|
|
15
|
+
import { DEFAULT_PORT, MAX_NICK_LEN, UINT32_SIZE, HEX_RADIX } from '..';
|
|
16
|
+
import { netplayLogger } from '../netplayLogger';
|
|
17
|
+
|
|
18
|
+
export * from './consts';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
DISCOVERY_QUERY_MAGIC,
|
|
22
|
+
DISCOVERY_RESPONSE_MAGIC,
|
|
23
|
+
QUERY_PACKET_SIZE,
|
|
24
|
+
NETPLAY_HOST_STR_LEN,
|
|
25
|
+
NETPLAY_HOST_LONGSTR_LEN,
|
|
26
|
+
BYTE_MASK,
|
|
27
|
+
BROADCAST_INTERVAL_MS,
|
|
28
|
+
PASSWORD_FLAG,
|
|
29
|
+
SPECTATE_PASSWORD_FLAG,
|
|
30
|
+
DISCOVERY_PACKET_SIZE,
|
|
31
|
+
} from './consts';
|
|
32
|
+
|
|
33
|
+
/** Information about a hosted netplay session for discovery */
|
|
34
|
+
export interface DiscoverySessionInfo {
|
|
35
|
+
/** TCP port the server is listening on */
|
|
36
|
+
port: number;
|
|
37
|
+
/** Host's nickname */
|
|
38
|
+
nickname: string;
|
|
39
|
+
/** Core name (e.g., "bsnes") */
|
|
40
|
+
coreName: string;
|
|
41
|
+
/** Core version */
|
|
42
|
+
coreVersion: string;
|
|
43
|
+
/** Content/game name */
|
|
44
|
+
contentName: string;
|
|
45
|
+
/** Content CRC32 */
|
|
46
|
+
contentCrc: number;
|
|
47
|
+
/** Whether password is required */
|
|
48
|
+
hasPassword: boolean;
|
|
49
|
+
/** Whether spectate password is required */
|
|
50
|
+
hasSpectatePassword: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get all broadcast addresses for the local network interfaces.
|
|
55
|
+
*/
|
|
56
|
+
const getBroadcastAddresses = (): string[] => {
|
|
57
|
+
const interfaces = networkInterfaces();
|
|
58
|
+
|
|
59
|
+
const addresses = pipe(
|
|
60
|
+
Object.values(interfaces),
|
|
61
|
+
filter(isDefined),
|
|
62
|
+
flatMap((iface) =>
|
|
63
|
+
pipe(
|
|
64
|
+
iface,
|
|
65
|
+
filter((info) => info.family === 'IPv4' && !info.internal),
|
|
66
|
+
map((info) => {
|
|
67
|
+
const ipParts = info.address.split('.').map(Number);
|
|
68
|
+
const maskParts = info.netmask.split('.').map(Number);
|
|
69
|
+
return ipParts.map((ip, i) => (ip | (~maskParts[i] & BYTE_MASK))).join('.');
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Fallback to generic broadcast if no interfaces found
|
|
76
|
+
return addresses.length > 0 ? addresses : ['255.255.255.255'];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Write a fixed-length string to a buffer (null-padded).
|
|
81
|
+
*/
|
|
82
|
+
const writeFixedString = (buffer: Buffer, str: string, offset: number, length: number): number => {
|
|
83
|
+
const strBuffer = Buffer.from(str.slice(0, length - 1), 'utf8');
|
|
84
|
+
strBuffer.copy(buffer, offset);
|
|
85
|
+
// Null terminate and pad
|
|
86
|
+
buffer.fill(0, offset + strBuffer.length, offset + length);
|
|
87
|
+
return offset + length;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a discovery announcement packet.
|
|
92
|
+
* Matches RetroArch's struct ad_packet format.
|
|
93
|
+
*/
|
|
94
|
+
const createDiscoveryPacket = (info: DiscoverySessionInfo): Buffer => {
|
|
95
|
+
const buffer = Buffer.alloc(DISCOVERY_PACKET_SIZE);
|
|
96
|
+
let offset = 0;
|
|
97
|
+
|
|
98
|
+
// header (magic)
|
|
99
|
+
buffer.writeUInt32BE(DISCOVERY_RESPONSE_MAGIC, offset);
|
|
100
|
+
offset += UINT32_SIZE;
|
|
101
|
+
|
|
102
|
+
// content_crc (int32, network byte order)
|
|
103
|
+
buffer.writeInt32BE(info.contentCrc | 0, offset);
|
|
104
|
+
offset += UINT32_SIZE;
|
|
105
|
+
|
|
106
|
+
// port (int32, network byte order)
|
|
107
|
+
buffer.writeInt32BE(info.port, offset);
|
|
108
|
+
offset += UINT32_SIZE;
|
|
109
|
+
|
|
110
|
+
// has_password (uint32 bitmask: 1=password, 2=spectate_password)
|
|
111
|
+
let hasPasswordFlags = 0;
|
|
112
|
+
if (info.hasPassword) { hasPasswordFlags |= PASSWORD_FLAG; }
|
|
113
|
+
if (info.hasSpectatePassword) { hasPasswordFlags |= SPECTATE_PASSWORD_FLAG; }
|
|
114
|
+
buffer.writeUInt32BE(hasPasswordFlags, offset);
|
|
115
|
+
offset += UINT32_SIZE;
|
|
116
|
+
|
|
117
|
+
// nick[32]
|
|
118
|
+
offset = writeFixedString(buffer, info.nickname, offset, MAX_NICK_LEN);
|
|
119
|
+
|
|
120
|
+
// frontend[32] - platform and architecture (matches RetroArch convention)
|
|
121
|
+
const frontend = `${process.platform} ${process.arch}`;
|
|
122
|
+
offset = writeFixedString(buffer, frontend, offset, NETPLAY_HOST_STR_LEN);
|
|
123
|
+
|
|
124
|
+
// core[32]
|
|
125
|
+
offset = writeFixedString(buffer, info.coreName, offset, NETPLAY_HOST_STR_LEN);
|
|
126
|
+
|
|
127
|
+
// core_version[32]
|
|
128
|
+
offset = writeFixedString(buffer, info.coreVersion, offset, NETPLAY_HOST_STR_LEN);
|
|
129
|
+
|
|
130
|
+
// retroarch_version[32] - we identify as emoemu with our version
|
|
131
|
+
offset = writeFixedString(buffer, `emoemu ${VERSION}`, offset, NETPLAY_HOST_STR_LEN);
|
|
132
|
+
|
|
133
|
+
// content[256]
|
|
134
|
+
offset = writeFixedString(buffer, info.contentName, offset, NETPLAY_HOST_LONGSTR_LEN);
|
|
135
|
+
|
|
136
|
+
// subsystem_name[256]
|
|
137
|
+
writeFixedString(buffer, 'N/A', offset, NETPLAY_HOST_LONGSTR_LEN);
|
|
138
|
+
|
|
139
|
+
return buffer;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* LAN Discovery Broadcaster
|
|
144
|
+
*
|
|
145
|
+
* Handles LAN discovery in two ways:
|
|
146
|
+
* 1. Periodically broadcasts UDP packets to announce the session
|
|
147
|
+
* 2. Listens for RANQ query packets and responds directly to querying clients
|
|
148
|
+
*
|
|
149
|
+
* RetroArch clients can discover sessions either by receiving broadcasts
|
|
150
|
+
* or by sending queries to the discovery port.
|
|
151
|
+
*/
|
|
152
|
+
export class DiscoveryBroadcaster {
|
|
153
|
+
/** Socket for broadcasting announcements */
|
|
154
|
+
private broadcastSocket: DatagramSocket | null = null;
|
|
155
|
+
/** Socket for listening to queries on the discovery port */
|
|
156
|
+
private querySocket: DatagramSocket | null = null;
|
|
157
|
+
private broadcastInterval: ReturnType<typeof setInterval> | null = null;
|
|
158
|
+
private sessionInfo: DiscoverySessionInfo;
|
|
159
|
+
private broadcastAddresses: string[];
|
|
160
|
+
private running = false;
|
|
161
|
+
|
|
162
|
+
constructor(sessionInfo: DiscoverySessionInfo) {
|
|
163
|
+
this.sessionInfo = sessionInfo;
|
|
164
|
+
this.broadcastAddresses = getBroadcastAddresses();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Start broadcasting discovery packets and listening for queries.
|
|
169
|
+
*/
|
|
170
|
+
start(): void {
|
|
171
|
+
if (this.running) { return; }
|
|
172
|
+
|
|
173
|
+
// Create broadcast socket (for sending announcements)
|
|
174
|
+
this.broadcastSocket = createSocket({ type: 'udp4', reuseAddr: true });
|
|
175
|
+
|
|
176
|
+
this.broadcastSocket.on('error', (err) => {
|
|
177
|
+
netplayLogger.discoveryError(`Broadcast socket error: ${err.message}`);
|
|
178
|
+
this.stop();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
this.broadcastSocket.bind(() => {
|
|
182
|
+
if (!this.broadcastSocket) { return; }
|
|
183
|
+
|
|
184
|
+
// Enable broadcast
|
|
185
|
+
this.broadcastSocket.setBroadcast(true);
|
|
186
|
+
|
|
187
|
+
// Start query listener after broadcast socket is ready
|
|
188
|
+
this.startQueryListener();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Start listening for discovery queries on the discovery port.
|
|
194
|
+
*/
|
|
195
|
+
private startQueryListener(): void {
|
|
196
|
+
this.querySocket = createSocket({ type: 'udp4', reuseAddr: true });
|
|
197
|
+
|
|
198
|
+
this.querySocket.on('error', (err) => {
|
|
199
|
+
// Don't fail completely if query socket has issues
|
|
200
|
+
netplayLogger.discoveryError(`Query socket error: ${err.message}`);
|
|
201
|
+
if (this.querySocket) {
|
|
202
|
+
safeClose(this.querySocket);
|
|
203
|
+
this.querySocket = null;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this.querySocket.on('message', (msg, rinfo) => {
|
|
208
|
+
this.handleQuery(msg, rinfo);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Bind to the discovery port to receive queries
|
|
212
|
+
this.querySocket.bind(DEFAULT_PORT, () => {
|
|
213
|
+
this.running = true;
|
|
214
|
+
netplayLogger.discoveryStarted(this.sessionInfo.port, this.broadcastAddresses);
|
|
215
|
+
|
|
216
|
+
// Send initial broadcast
|
|
217
|
+
this.sendBroadcast();
|
|
218
|
+
|
|
219
|
+
// Set up periodic broadcasts
|
|
220
|
+
this.broadcastInterval = setInterval(() => {
|
|
221
|
+
this.sendBroadcast();
|
|
222
|
+
}, BROADCAST_INTERVAL_MS);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle an incoming discovery query.
|
|
228
|
+
*/
|
|
229
|
+
private handleQuery(msg: Buffer, rinfo: RemoteInfo): void {
|
|
230
|
+
// Query should be exactly 4 bytes (the RANQ magic)
|
|
231
|
+
if (msg.length !== QUERY_PACKET_SIZE) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const magic = msg.readUInt32BE(0);
|
|
236
|
+
if (magic !== DISCOVERY_QUERY_MAGIC) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Respond directly to the querying client
|
|
241
|
+
const packet = createDiscoveryPacket(this.sessionInfo);
|
|
242
|
+
|
|
243
|
+
// Debug: log packet details
|
|
244
|
+
const packetMagic = packet.readUInt32BE(0).toString(HEX_RADIX);
|
|
245
|
+
const packetCrc = packet.readInt32BE(UINT32_SIZE);
|
|
246
|
+
const packetPort = packet.readInt32BE(UINT32_SIZE * 2);
|
|
247
|
+
const hasPasswordOffset = 3;
|
|
248
|
+
const packetHasPassword = packet.readUInt32BE(UINT32_SIZE * hasPasswordOffset);
|
|
249
|
+
netplayLogger.debug('DISCOVERY', `Received query from ${rinfo.address}:${rinfo.port}, responding with packet`, {
|
|
250
|
+
packetSize: packet.length,
|
|
251
|
+
magic: packetMagic,
|
|
252
|
+
contentCrc: packetCrc,
|
|
253
|
+
port: packetPort,
|
|
254
|
+
hasPassword: packetHasPassword,
|
|
255
|
+
sessionHasPassword: this.sessionInfo.hasPassword,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (this.broadcastSocket) {
|
|
259
|
+
this.broadcastSocket.send(packet, rinfo.port, rinfo.address, (err) => {
|
|
260
|
+
if (err) {
|
|
261
|
+
netplayLogger.discoveryError(`Failed to respond to query from ${rinfo.address}: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Stop broadcasting and listening.
|
|
269
|
+
*/
|
|
270
|
+
stop(): void {
|
|
271
|
+
const wasRunning = this.running;
|
|
272
|
+
|
|
273
|
+
if (this.broadcastInterval) {
|
|
274
|
+
clearInterval(this.broadcastInterval);
|
|
275
|
+
this.broadcastInterval = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (this.broadcastSocket) {
|
|
279
|
+
safeClose(this.broadcastSocket);
|
|
280
|
+
this.broadcastSocket = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this.querySocket) {
|
|
284
|
+
safeClose(this.querySocket);
|
|
285
|
+
this.querySocket = null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.running = false;
|
|
289
|
+
|
|
290
|
+
if (wasRunning) {
|
|
291
|
+
netplayLogger.discoveryStopped();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Update session info (e.g., when password changes).
|
|
297
|
+
*/
|
|
298
|
+
updateSessionInfo(info: Partial<DiscoverySessionInfo>): void {
|
|
299
|
+
this.sessionInfo = { ...this.sessionInfo, ...info };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Send a broadcast packet to all network interfaces.
|
|
304
|
+
*/
|
|
305
|
+
private sendBroadcast(): void {
|
|
306
|
+
if (!this.broadcastSocket) { return; }
|
|
307
|
+
|
|
308
|
+
const packet = createDiscoveryPacket(this.sessionInfo);
|
|
309
|
+
|
|
310
|
+
for (const address of this.broadcastAddresses) {
|
|
311
|
+
this.broadcastSocket.send(packet, DEFAULT_PORT, address, (err) => {
|
|
312
|
+
if (err) {
|
|
313
|
+
netplayLogger.discoveryError(`Failed to broadcast to ${address}: ${err.message}`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check if the broadcaster is running.
|
|
321
|
+
*/
|
|
322
|
+
isRunning(): boolean {
|
|
323
|
+
return this.running;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* LAN Discovery Listener
|
|
329
|
+
*
|
|
330
|
+
* Listens for discovery broadcasts from other netplay hosts on the LAN.
|
|
331
|
+
* Used for scanning and displaying available sessions.
|
|
332
|
+
*/
|
|
333
|
+
export class DiscoveryListener {
|
|
334
|
+
private socket: DatagramSocket | null = null;
|
|
335
|
+
private running = false;
|
|
336
|
+
private discoveredHosts: Map<string, DiscoverySessionInfo & { address: string; lastSeen: number }> = new Map();
|
|
337
|
+
private broadcastAddresses: string[] = [];
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Start listening for discovery broadcasts.
|
|
341
|
+
*/
|
|
342
|
+
start(onHostFound?: (host: DiscoverySessionInfo & { address: string }) => void): void {
|
|
343
|
+
if (this.running) { return; }
|
|
344
|
+
|
|
345
|
+
// Get broadcast addresses for sending queries
|
|
346
|
+
this.broadcastAddresses = getBroadcastAddresses();
|
|
347
|
+
|
|
348
|
+
this.socket = createSocket({ type: 'udp4', reuseAddr: true });
|
|
349
|
+
|
|
350
|
+
this.socket.on('error', (err) => {
|
|
351
|
+
netplayLogger.discoveryError(`Listener error: ${err.message}`);
|
|
352
|
+
this.stop();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
this.socket.on('message', (msg, rinfo) => {
|
|
356
|
+
const host = this.parseDiscoveryPacket(msg, rinfo.address);
|
|
357
|
+
if (host) {
|
|
358
|
+
const key = `${rinfo.address}:${host.port}`;
|
|
359
|
+
const isNew = !this.discoveredHosts.has(key);
|
|
360
|
+
this.discoveredHosts.set(key, { ...host, address: rinfo.address, lastSeen: Date.now() });
|
|
361
|
+
|
|
362
|
+
if (isNew) {
|
|
363
|
+
netplayLogger.info('DISCOVERY', `Discovered host: ${host.nickname} at ${rinfo.address}:${host.port}`, {
|
|
364
|
+
coreName: host.coreName,
|
|
365
|
+
contentName: host.contentName,
|
|
366
|
+
hasPassword: host.hasPassword,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (onHostFound) {
|
|
371
|
+
onHostFound({ ...host, address: rinfo.address });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
this.socket.bind(DEFAULT_PORT, () => {
|
|
377
|
+
if (!this.socket) { return; }
|
|
378
|
+
// Enable broadcast so we can send queries
|
|
379
|
+
this.socket.setBroadcast(true);
|
|
380
|
+
this.running = true;
|
|
381
|
+
netplayLogger.info('DISCOVERY', 'LAN discovery listener started', { port: DEFAULT_PORT });
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Stop listening.
|
|
387
|
+
*/
|
|
388
|
+
stop(): void {
|
|
389
|
+
if (this.socket) {
|
|
390
|
+
safeClose(this.socket);
|
|
391
|
+
this.socket = null;
|
|
392
|
+
}
|
|
393
|
+
this.running = false;
|
|
394
|
+
this.discoveredHosts.clear();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Send a discovery query to trigger hosts to respond.
|
|
399
|
+
* This broadcasts a RANQ packet to all local network broadcast addresses.
|
|
400
|
+
* Responses come back to our listener socket on DEFAULT_PORT.
|
|
401
|
+
*/
|
|
402
|
+
sendQuery(): void {
|
|
403
|
+
if (!this.running || !this.socket) { return; }
|
|
404
|
+
|
|
405
|
+
// Create RANQ query packet (just the magic number)
|
|
406
|
+
const queryPacket = Buffer.alloc(QUERY_PACKET_SIZE);
|
|
407
|
+
queryPacket.writeUInt32BE(DISCOVERY_QUERY_MAGIC, 0);
|
|
408
|
+
|
|
409
|
+
// Send to all broadcast addresses using the listener socket
|
|
410
|
+
// Hosts will respond directly to our address:DEFAULT_PORT
|
|
411
|
+
for (const address of this.broadcastAddresses) {
|
|
412
|
+
this.socket.send(queryPacket, 0, queryPacket.length, DEFAULT_PORT, address, (err) => {
|
|
413
|
+
if (err) {
|
|
414
|
+
netplayLogger.debug('DISCOVERY', `Query send error to ${address}: ${err.message}`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
netplayLogger.debug('DISCOVERY', 'Sent discovery query', {
|
|
420
|
+
broadcastAddresses: this.broadcastAddresses,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get list of discovered hosts (filtered by recency).
|
|
426
|
+
*/
|
|
427
|
+
getDiscoveredHosts(maxAgeMs: number = 30000): Array<DiscoverySessionInfo & { address: string }> {
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const hosts: Array<DiscoverySessionInfo & { address: string }> = [];
|
|
430
|
+
|
|
431
|
+
for (const [key, host] of this.discoveredHosts) {
|
|
432
|
+
if (now - host.lastSeen <= maxAgeMs) {
|
|
433
|
+
const { lastSeen: _, ...hostInfo } = host;
|
|
434
|
+
hosts.push(hostInfo);
|
|
435
|
+
} else {
|
|
436
|
+
// Remove stale entries
|
|
437
|
+
this.discoveredHosts.delete(key);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return hosts;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Parse a discovery packet from the network.
|
|
446
|
+
* Matches RetroArch's struct ad_packet format.
|
|
447
|
+
*/
|
|
448
|
+
private parseDiscoveryPacket(buffer: Buffer, _address: string): DiscoverySessionInfo | null {
|
|
449
|
+
if (buffer.length < DISCOVERY_PACKET_SIZE) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let offset = 0;
|
|
454
|
+
|
|
455
|
+
// Read fixed-length string helper
|
|
456
|
+
const readFixedString = (length: number): string => {
|
|
457
|
+
const strBuffer = buffer.subarray(offset, offset + length);
|
|
458
|
+
offset += length;
|
|
459
|
+
const nullIndex = strBuffer.indexOf(0);
|
|
460
|
+
return strBuffer.subarray(0, nullIndex === -1 ? length : nullIndex).toString('utf8');
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// header (magic)
|
|
464
|
+
const magic = buffer.readUInt32BE(offset);
|
|
465
|
+
offset += UINT32_SIZE;
|
|
466
|
+
|
|
467
|
+
if (magic !== DISCOVERY_RESPONSE_MAGIC) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// content_crc (int32)
|
|
472
|
+
const contentCrc = buffer.readInt32BE(offset);
|
|
473
|
+
offset += UINT32_SIZE;
|
|
474
|
+
|
|
475
|
+
// port (int32)
|
|
476
|
+
const port = buffer.readInt32BE(offset);
|
|
477
|
+
offset += UINT32_SIZE;
|
|
478
|
+
|
|
479
|
+
// has_password (uint32 bitmask)
|
|
480
|
+
const hasPasswordFlags = buffer.readUInt32BE(offset);
|
|
481
|
+
offset += UINT32_SIZE;
|
|
482
|
+
|
|
483
|
+
const hasPassword = (hasPasswordFlags & PASSWORD_FLAG) !== 0;
|
|
484
|
+
const hasSpectatePassword = (hasPasswordFlags & SPECTATE_PASSWORD_FLAG) !== 0;
|
|
485
|
+
|
|
486
|
+
// nick[32]
|
|
487
|
+
const nickname = readFixedString(MAX_NICK_LEN);
|
|
488
|
+
|
|
489
|
+
// frontend[32] - skip
|
|
490
|
+
readFixedString(NETPLAY_HOST_STR_LEN);
|
|
491
|
+
|
|
492
|
+
// core[32]
|
|
493
|
+
const coreName = readFixedString(NETPLAY_HOST_STR_LEN);
|
|
494
|
+
|
|
495
|
+
// core_version[32]
|
|
496
|
+
const coreVersion = readFixedString(NETPLAY_HOST_STR_LEN);
|
|
497
|
+
|
|
498
|
+
// retroarch_version[32] - skip
|
|
499
|
+
readFixedString(NETPLAY_HOST_STR_LEN);
|
|
500
|
+
|
|
501
|
+
// content[256]
|
|
502
|
+
const contentName = readFixedString(NETPLAY_HOST_LONGSTR_LEN);
|
|
503
|
+
|
|
504
|
+
// subsystem_name[256] - skip
|
|
505
|
+
readFixedString(NETPLAY_HOST_LONGSTR_LEN);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
port,
|
|
509
|
+
nickname,
|
|
510
|
+
coreName,
|
|
511
|
+
coreVersion,
|
|
512
|
+
contentName,
|
|
513
|
+
contentCrc,
|
|
514
|
+
hasPassword,
|
|
515
|
+
hasSpectatePassword,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Check if the listener is running.
|
|
521
|
+
*/
|
|
522
|
+
isRunning(): boolean {
|
|
523
|
+
return this.running;
|
|
524
|
+
}
|
|
525
|
+
}
|