emoemu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,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
+ }