@myerscarpenter/quest-dev 1.4.0 → 2.0.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 (151) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.github/workflows/docs.yml +45 -0
  3. package/.github/workflows/publish.yml +11 -1
  4. package/README.md +27 -0
  5. package/build/cast/decoder.d.ts +48 -0
  6. package/build/cast/decoder.d.ts.map +1 -0
  7. package/build/cast/decoder.js +152 -0
  8. package/build/cast/decoder.js.map +1 -0
  9. package/build/cast/session.d.ts +87 -0
  10. package/build/cast/session.d.ts.map +1 -0
  11. package/build/cast/session.js +565 -0
  12. package/build/cast/session.js.map +1 -0
  13. package/build/commands/logcat.d.ts.map +1 -1
  14. package/build/commands/logcat.js +7 -6
  15. package/build/commands/logcat.js.map +1 -1
  16. package/build/commands/open.d.ts.map +1 -1
  17. package/build/commands/open.js +9 -4
  18. package/build/commands/open.js.map +1 -1
  19. package/build/commands/screenshot.d.ts.map +1 -1
  20. package/build/commands/screenshot.js +17 -20
  21. package/build/commands/screenshot.js.map +1 -1
  22. package/build/commands/stay-awake.d.ts +2 -15
  23. package/build/commands/stay-awake.d.ts.map +1 -1
  24. package/build/commands/stay-awake.js +14 -77
  25. package/build/commands/stay-awake.js.map +1 -1
  26. package/build/daemon/cast-manager.d.ts +42 -0
  27. package/build/daemon/cast-manager.d.ts.map +1 -0
  28. package/build/daemon/cast-manager.js +243 -0
  29. package/build/daemon/cast-manager.js.map +1 -0
  30. package/build/daemon/client.d.ts +40 -0
  31. package/build/daemon/client.d.ts.map +1 -0
  32. package/build/daemon/client.js +133 -0
  33. package/build/daemon/client.js.map +1 -0
  34. package/build/daemon/daemon.d.ts +20 -0
  35. package/build/daemon/daemon.d.ts.map +1 -0
  36. package/build/daemon/daemon.js +130 -0
  37. package/build/daemon/daemon.js.map +1 -0
  38. package/build/daemon/deploy.d.ts +44 -0
  39. package/build/daemon/deploy.d.ts.map +1 -0
  40. package/build/daemon/deploy.js +230 -0
  41. package/build/daemon/deploy.js.map +1 -0
  42. package/build/daemon/logcat-manager.d.ts +39 -0
  43. package/build/daemon/logcat-manager.d.ts.map +1 -0
  44. package/build/daemon/logcat-manager.js +194 -0
  45. package/build/daemon/logcat-manager.js.map +1 -0
  46. package/build/daemon/server.d.ts +19 -0
  47. package/build/daemon/server.d.ts.map +1 -0
  48. package/build/daemon/server.js +482 -0
  49. package/build/daemon/server.js.map +1 -0
  50. package/build/daemon/stay-awake-manager.d.ts +22 -0
  51. package/build/daemon/stay-awake-manager.d.ts.map +1 -0
  52. package/build/daemon/stay-awake-manager.js +74 -0
  53. package/build/daemon/stay-awake-manager.js.map +1 -0
  54. package/build/index.js +285 -45
  55. package/build/index.js.map +1 -1
  56. package/build/public/dashboard.js +749 -0
  57. package/build/public/index.html +12 -0
  58. package/build/public/style.css +106 -0
  59. package/build/utils/adb.d.ts +12 -0
  60. package/build/utils/adb.d.ts.map +1 -1
  61. package/build/utils/adb.js +116 -51
  62. package/build/utils/adb.js.map +1 -1
  63. package/build/utils/casting-apk.d.ts +40 -0
  64. package/build/utils/casting-apk.d.ts.map +1 -0
  65. package/build/utils/casting-apk.js +252 -0
  66. package/build/utils/casting-apk.js.map +1 -0
  67. package/build/utils/config.d.ts +5 -3
  68. package/build/utils/config.d.ts.map +1 -1
  69. package/build/utils/config.js +18 -38
  70. package/build/utils/config.js.map +1 -1
  71. package/build/utils/exec.d.ts +5 -0
  72. package/build/utils/exec.d.ts.map +1 -1
  73. package/build/utils/exec.js +17 -0
  74. package/build/utils/exec.js.map +1 -1
  75. package/build/utils/filename.d.ts +7 -1
  76. package/build/utils/filename.d.ts.map +1 -1
  77. package/build/utils/filename.js +17 -2
  78. package/build/utils/filename.js.map +1 -1
  79. package/build/utils/filename.test.js +33 -1
  80. package/build/utils/filename.test.js.map +1 -1
  81. package/build/utils/jpeg-comment.d.ts +14 -0
  82. package/build/utils/jpeg-comment.d.ts.map +1 -0
  83. package/build/utils/jpeg-comment.js +28 -0
  84. package/build/utils/jpeg-comment.js.map +1 -0
  85. package/build/utils/test-properties.d.ts +34 -0
  86. package/build/utils/test-properties.d.ts.map +1 -0
  87. package/build/utils/test-properties.js +73 -0
  88. package/build/utils/test-properties.js.map +1 -0
  89. package/build/utils/verbose.d.ts +3 -0
  90. package/build/utils/verbose.d.ts.map +1 -0
  91. package/build/utils/verbose.js +13 -0
  92. package/build/utils/verbose.js.map +1 -0
  93. package/package.json +11 -5
  94. package/packages/cast2-protocol/README.md +86 -0
  95. package/packages/cast2-protocol/docs/_config.yml +4 -0
  96. package/packages/cast2-protocol/docs/feature-flags.md +102 -0
  97. package/packages/cast2-protocol/docs/index.md +24 -0
  98. package/packages/cast2-protocol/docs/open-investigations.md +149 -0
  99. package/packages/cast2-protocol/docs/protocol.md +602 -0
  100. package/packages/cast2-protocol/package.json +46 -0
  101. package/packages/cast2-protocol/src/constants.ts +65 -0
  102. package/packages/cast2-protocol/src/index.ts +7 -0
  103. package/packages/cast2-protocol/src/mgik.ts +69 -0
  104. package/packages/cast2-protocol/src/mud.ts +294 -0
  105. package/packages/cast2-protocol/src/pose.ts +99 -0
  106. package/packages/cast2-protocol/src/resolutions.ts +34 -0
  107. package/packages/cast2-protocol/src/types.ts +64 -0
  108. package/packages/cast2-protocol/src/xrsp.ts +73 -0
  109. package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
  110. package/packages/cast2-protocol/tests/mud.test.ts +295 -0
  111. package/packages/cast2-protocol/tests/pose.test.ts +173 -0
  112. package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
  113. package/packages/cast2-protocol/tsconfig.json +20 -0
  114. package/pnpm-workspace.yaml +2 -0
  115. package/src/cast/decoder.ts +178 -0
  116. package/src/cast/session.ts +708 -0
  117. package/src/commands/logcat.ts +6 -5
  118. package/src/commands/open.ts +10 -3
  119. package/src/commands/screenshot.ts +19 -13
  120. package/src/commands/stay-awake.ts +22 -91
  121. package/src/daemon/adbkit-apkreader.d.ts +14 -0
  122. package/src/daemon/cast-manager.ts +282 -0
  123. package/src/daemon/client.ts +166 -0
  124. package/src/daemon/daemon.ts +169 -0
  125. package/src/daemon/deploy.ts +307 -0
  126. package/src/daemon/logcat-manager.ts +229 -0
  127. package/src/daemon/server.ts +595 -0
  128. package/src/daemon/stay-awake-manager.ts +83 -0
  129. package/src/index.ts +340 -56
  130. package/src/public/dashboard.js +288 -0
  131. package/src/public/index.html +12 -0
  132. package/src/public/style.css +106 -0
  133. package/src/utils/adb.ts +129 -42
  134. package/src/utils/casting-apk.ts +276 -0
  135. package/src/utils/config.ts +18 -36
  136. package/src/utils/exec.ts +20 -0
  137. package/src/utils/filename.test.ts +41 -1
  138. package/src/utils/filename.ts +18 -2
  139. package/src/utils/jpeg-comment.ts +30 -0
  140. package/src/utils/test-properties.ts +94 -0
  141. package/src/utils/verbose.ts +14 -0
  142. package/tests/cast/auto-layer.test.ts +87 -0
  143. package/tests/cast/decoder.test.ts +82 -0
  144. package/tests/cast/session-restart.test.ts +107 -0
  145. package/tests/config.test.ts +17 -22
  146. package/tests/daemon/api-status.test.ts +82 -0
  147. package/tests/daemon/cast-manager.test.ts +69 -0
  148. package/tests/daemon/mjpeg-stream.test.ts +144 -0
  149. package/tests/daemon/pose-endpoint.test.ts +63 -0
  150. package/tests/daemon/start-guard.test.ts +77 -0
  151. package/vitest.config.ts +10 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * CastSession: manages TCP connections to Quest, protocol handshake,
3
+ * H.264 video decoding, pose control, and stay-awake lifecycle.
4
+ */
5
+
6
+ import { createServer, type Server, type Socket } from "node:net";
7
+ import { EventEmitter } from "node:events";
8
+ import { randomUUID } from "node:crypto";
9
+ import { FrameDecoder } from "./decoder.js";
10
+ import {
11
+ type LayerInfo,
12
+ type PoseOffset,
13
+ type PoseDelta,
14
+ type PoseState,
15
+ type XrspHeader,
16
+ createPoseState,
17
+ eulerToQuat,
18
+ updatePose,
19
+ setPoseOffset,
20
+ parseXrspHeader,
21
+ xrspPayloadSize,
22
+ packXrsp,
23
+ XRSP_HEADER_SIZE,
24
+ ACK_MARKER,
25
+ CONFIG_MARKER,
26
+ VIDEO_META_MARKER,
27
+ MGIK_MAGIC,
28
+ CAST_PORT,
29
+ QUEST_CAST_PORT,
30
+ CMD_SHORT_ACK_65,
31
+ CMD_SHORT_ACK_CD,
32
+ CMD_SHORT_ACK_12D,
33
+ KEEPALIVE_ACK_INCREMENT,
34
+ LAYER_PANEL_APP,
35
+ packMgikSub,
36
+ detectSubMagic,
37
+ isMgikMagic,
38
+ buildInit,
39
+ buildConfig,
40
+ buildKeepalive,
41
+ buildShortAck,
42
+ buildPose,
43
+ buildDisplayConfig,
44
+ buildDisconnect,
45
+ buildSetProperty,
46
+ buildVirtualMouse,
47
+ buildInputForwardingState,
48
+ buildStartInputForwarding,
49
+ buildActivateLayer,
50
+ buildMud,
51
+ parseLayerConfiguration,
52
+ RESOLUTIONS,
53
+ DEFAULT_RESOLUTION,
54
+ } from "@myerscarpenter/cast2-protocol";
55
+ import { execCommand } from "../utils/exec.js";
56
+ import { verbose } from "../utils/verbose.js";
57
+
58
+ export interface CastSessionOptions {
59
+ listenPort?: number;
60
+ width?: number;
61
+ height?: number;
62
+ }
63
+
64
+ export class CastSession extends EventEmitter {
65
+ // Configuration
66
+ private _listenPort: number;
67
+
68
+ // Connection state
69
+ private _connected = false;
70
+ private _running = false;
71
+ private server: Server | null = null;
72
+ private controlSocket: Socket | null = null;
73
+ private videoSocket: Socket | null = null;
74
+ private controlSeq = 0;
75
+
76
+ // Protocol state
77
+ private subMagic: number | null = null;
78
+ private controlMsgSeq = 0;
79
+ private sessionUuid: string;
80
+ private sessionTimestamp: string;
81
+ private questMsgSeq = 0;
82
+
83
+ // Video state
84
+ private decoder: FrameDecoder;
85
+ private currentSpsPps: Buffer | null = null;
86
+ private currentIdr: Buffer | null = null;
87
+ private _frameCount = 0;
88
+ private _byteCount = 0;
89
+ private startTime = 0;
90
+
91
+ // Display
92
+ private _width: number;
93
+ private _height: number;
94
+
95
+ // Pose
96
+ private _pose: PoseState;
97
+ private _poseLoopTimer: ReturnType<typeof setInterval> | null = null;
98
+ private _poseLoopActive = false;
99
+
100
+ // Eye mode
101
+ private _eye = 1; // EYE_LEFT default
102
+
103
+ // Input forwarding
104
+ private inputForwardingStarted = false;
105
+ private _layerId = 0;
106
+ private _layerAutoSelected = false;
107
+ private _layers = new Map<number, LayerInfo>();
108
+
109
+ constructor(options: CastSessionOptions = {}) {
110
+ super();
111
+ this._listenPort = options.listenPort ?? CAST_PORT;
112
+ const def = RESOLUTIONS[DEFAULT_RESOLUTION];
113
+ this._width = options.width ?? def.width;
114
+ this._height = options.height ?? def.height;
115
+ this.sessionUuid = randomUUID();
116
+ this.sessionTimestamp = String(Date.now());
117
+ this._pose = createPoseState();
118
+ this.decoder = new FrameDecoder();
119
+
120
+ this.decoder.on("frame", () => {
121
+ this.emit("frame");
122
+ });
123
+ }
124
+
125
+ // --- Public getters ---
126
+
127
+ get listenPort(): number { return this._listenPort; }
128
+ get connected(): boolean { return this._connected; }
129
+ get running(): boolean { return this._running; }
130
+ get frameCount(): number { return this._frameCount; }
131
+ get byteCount(): number { return this._byteCount; }
132
+ get width(): number { return this._width; }
133
+ get height(): number { return this._height; }
134
+ get pose(): PoseState { return this._pose; }
135
+ get layers(): Map<number, LayerInfo> { return this._layers; }
136
+ get layerId(): number { return this._layerId; }
137
+
138
+ get poseLoopActive(): boolean { return this._poseLoopActive; }
139
+ get eye(): number { return this._eye; }
140
+ get elapsedSeconds(): number {
141
+ return this.startTime > 0 ? Math.round((Date.now() - this.startTime) / 100) / 10 : 0;
142
+ }
143
+
144
+ get fps(): number {
145
+ const elapsed = this.startTime > 0 ? (Date.now() - this.startTime) / 1000 : 0;
146
+ return elapsed > 0 ? Math.round((this._frameCount / elapsed) * 10) / 10 : 0;
147
+ }
148
+
149
+ // --- Lifecycle ---
150
+
151
+ /** Bind the TCP server, trying successive ports on EADDRINUSE. */
152
+ async bind(): Promise<void> {
153
+ FrameDecoder.checkFfmpeg();
154
+
155
+ const maxAttempts = 10;
156
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
157
+ try {
158
+ await this.tryBind(this._listenPort);
159
+ verbose(`TCP server listening on port ${this._listenPort}`);
160
+ return;
161
+ } catch (err: unknown) {
162
+ if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
163
+ verbose(`Port ${this._listenPort} in use, trying ${this._listenPort + 1}`);
164
+ this._listenPort++;
165
+ } else {
166
+ throw err;
167
+ }
168
+ }
169
+ }
170
+ throw new Error(`No available port found (tried ${maxAttempts} ports)`);
171
+ }
172
+
173
+ private tryBind(port: number): Promise<void> {
174
+ return new Promise((resolve, reject) => {
175
+ this.server = createServer();
176
+ this.server.listen(port, "0.0.0.0", () => resolve());
177
+ this.server.on("error", reject);
178
+ });
179
+ }
180
+
181
+ async start(questIp?: string): Promise<void> {
182
+ this._running = true;
183
+ this.startTime = Date.now();
184
+
185
+ await this.waitForConnections(questIp);
186
+ }
187
+
188
+ async stop(): Promise<void> {
189
+ this._running = false;
190
+
191
+ if (this._connected && this.subMagic) {
192
+ try {
193
+ this.sendXrsp(buildDisconnect(this.subMagic, this.nextSeq()));
194
+ } catch {
195
+ // Best effort
196
+ }
197
+ }
198
+
199
+ this.stopPoseLoop();
200
+ this.decoder.stop();
201
+ this.controlSocket?.destroy();
202
+ this.videoSocket?.destroy();
203
+ this.server?.close();
204
+ this._connected = false;
205
+ this.emit("disconnected");
206
+ }
207
+
208
+ async restart(): Promise<void> {
209
+ verbose("Restarting cast session...");
210
+ await this.stop();
211
+
212
+ // Reset protocol state
213
+ this.sessionUuid = randomUUID();
214
+ this.sessionTimestamp = String(Date.now());
215
+ this.subMagic = null;
216
+ this.controlMsgSeq = 0;
217
+ this.controlSeq = 0;
218
+ this._frameCount = 0;
219
+ this._byteCount = 0;
220
+ this.currentSpsPps = null;
221
+ this.currentIdr = null;
222
+ this._pose = createPoseState();
223
+ this.inputForwardingStarted = false;
224
+ this._layerAutoSelected = false;
225
+ this._layers.clear();
226
+
227
+ await new Promise((r) => setTimeout(r, 1000));
228
+
229
+ // Re-bind the TCP server (stop() closed it)
230
+ if (this.server) {
231
+ await new Promise<void>((resolve, reject) => {
232
+ this.server!.listen(this._listenPort, "0.0.0.0", () => resolve());
233
+ this.server!.once("error", reject);
234
+ });
235
+ verbose(`TCP server re-listening on port ${this._listenPort}`);
236
+ } else {
237
+ await this.bind();
238
+ }
239
+
240
+ await this.start();
241
+ }
242
+
243
+ // --- ADB Setup ---
244
+
245
+ async adbSetup(questIp: string): Promise<void> {
246
+ verbose("Setting up ADB for Quest at", questIp);
247
+ const device = `${questIp}:5555`;
248
+
249
+ await execCommand("adb", ["connect", device]);
250
+ await execCommand("adb", [
251
+ "-s", device, "shell",
252
+ "setprop debug.oculus.command_line_media_capture true",
253
+ ]);
254
+ // Set the port the Quest casting service will connect to
255
+ await execCommand("adb", [
256
+ "-s", device, "shell",
257
+ `setprop debug.oculus.magic.port ${QUEST_CAST_PORT}`,
258
+ ]);
259
+ // Set up reverse mappings for both ports (matching MQDH)
260
+ verbose("Setting up ADB reverse port forward...");
261
+ for (const port of [CAST_PORT, QUEST_CAST_PORT]) {
262
+ try {
263
+ await execCommand("adb", ["-s", device, "reverse", "--remove", `tcp:${port}`]);
264
+ } catch {
265
+ // No existing mapping — that's fine
266
+ }
267
+ await execCommand("adb", [
268
+ "-s", device, "reverse",
269
+ `tcp:${port}`, `tcp:${this.listenPort}`,
270
+ ]);
271
+ }
272
+ }
273
+
274
+ async startCastService(questIp: string): Promise<void> {
275
+ const device = `${questIp}:5555`;
276
+ verbose("Starting cast service on Quest...");
277
+ await execCommand("adb", [
278
+ "-s", device, "shell",
279
+ "am start-foreground-service -n com.oculus.magicislandcastingservice/.CastingService",
280
+ ]);
281
+ await new Promise((r) => setTimeout(r, 1000));
282
+ await execCommand("adb", [
283
+ "-s", device, "shell",
284
+ "am broadcast -a com.oculus.magicislandcastingservice.CONNECT",
285
+ ]);
286
+ }
287
+
288
+ // --- TCP ---
289
+
290
+ private waitForConnections(questIp?: string): Promise<void> {
291
+ return new Promise((resolve, reject) => {
292
+ if (!this.server) {
293
+ reject(new Error("TCP server not bound — call bind() first"));
294
+ return;
295
+ }
296
+
297
+ const connections: Socket[] = [];
298
+ let resolved = false;
299
+
300
+ this.server.on("connection", (socket: Socket) => {
301
+ const idx = connections.length + 1;
302
+ verbose(`Connection #${idx} from ${socket.remoteAddress}`);
303
+ connections.push(socket);
304
+
305
+ if (connections.length === 2) {
306
+ this.controlSocket = connections[0];
307
+ this.videoSocket = connections[1];
308
+ this._connected = true;
309
+ verbose("Both connections established");
310
+ this.emit("connected");
311
+ this.handleVideo();
312
+ if (!resolved) {
313
+ resolved = true;
314
+ resolve();
315
+ }
316
+ }
317
+ });
318
+
319
+ // Trigger the cast service now that we're listening
320
+ if (questIp) {
321
+ this.startCastService(questIp).catch((err) => {
322
+ if (!resolved) {
323
+ resolved = true;
324
+ reject(err);
325
+ }
326
+ });
327
+ }
328
+
329
+ // Timeout after 15 seconds
330
+ const timeout = setTimeout(() => {
331
+ if (!resolved) {
332
+ resolved = true;
333
+ if (connections.length < 2) {
334
+ verbose(`Timed out waiting for Quest connections (got ${connections.length})`);
335
+ this.server?.close();
336
+ reject(new Error(`Timed out waiting for Quest connections (got ${connections.length}/2)`));
337
+ }
338
+ }
339
+ }, 15000);
340
+
341
+ this.server.on("error", (err) => {
342
+ clearTimeout(timeout);
343
+ if (!resolved) {
344
+ resolved = true;
345
+ reject(err);
346
+ }
347
+ });
348
+
349
+ // Clear timeout once we get both connections
350
+ this.once("connected", () => clearTimeout(timeout));
351
+ });
352
+ }
353
+
354
+ // --- XRSP I/O ---
355
+
356
+ private sendXrsp(payload: Buffer, flags?: number): void {
357
+ if (!this.controlSocket || this.controlSocket.destroyed) return;
358
+ const pkt = packXrsp(this.controlSeq, payload, flags);
359
+ this.controlSeq++;
360
+ this.controlSocket.write(pkt);
361
+ }
362
+
363
+ private nextSeq(): number {
364
+ return this.controlMsgSeq++;
365
+ }
366
+
367
+ // --- Video handling (main protocol loop) ---
368
+
369
+ private handleVideo(): void {
370
+ if (!this.videoSocket) return;
371
+
372
+ let buffer = Buffer.alloc(0);
373
+ let handshakeStage = 0;
374
+ let ackVal = 0;
375
+
376
+ this.videoSocket.on("data", (chunk: Buffer) => {
377
+ buffer = Buffer.concat([buffer, chunk]);
378
+
379
+ while (buffer.length >= XRSP_HEADER_SIZE) {
380
+ const header = parseXrspHeader(buffer);
381
+ const payloadSize = xrspPayloadSize(header);
382
+ const totalSize = XRSP_HEADER_SIZE + payloadSize;
383
+
384
+ if (buffer.length < totalSize) break; // Need more data
385
+
386
+ const payload = buffer.subarray(XRSP_HEADER_SIZE, totalSize);
387
+ buffer = buffer.subarray(totalSize);
388
+
389
+ this.processVideoPayload(payload, header, handshakeStage, ackVal, (newStage, newAck) => {
390
+ handshakeStage = newStage;
391
+ ackVal = newAck;
392
+ });
393
+ }
394
+ });
395
+
396
+ this.videoSocket.on("end", () => {
397
+ verbose("Video connection EOF");
398
+ this._running = false;
399
+ this.emit("disconnected");
400
+ });
401
+
402
+ this.videoSocket.on("error", (err) => {
403
+ verbose("Video connection error:", err.message);
404
+ this._running = false;
405
+ this.emit("error", err);
406
+ });
407
+ }
408
+
409
+ private processVideoPayload(
410
+ payload: Buffer,
411
+ _header: XrspHeader,
412
+ handshakeStage: number,
413
+ ackVal: number,
414
+ setState: (stage: number, ack: number) => void,
415
+ ): void {
416
+ if (payload.length < 4) return;
417
+
418
+ const first4 = payload.readUInt32BE(0);
419
+
420
+ // Skip MGIK magic header
421
+ if (isMgikMagic(payload)) return;
422
+
423
+ // Learn sub-magic from first Quest message
424
+ if (this.subMagic === null && payload.length === 24) {
425
+ const detected = detectSubMagic(payload);
426
+ if (detected !== null) {
427
+ this.subMagic = detected;
428
+ this.questMsgSeq = payload.readUInt32BE(4);
429
+ verbose(`Learned sub-magic: 0x${this.subMagic.toString(16).padStart(8, "0")}`);
430
+ return;
431
+ }
432
+ }
433
+
434
+ // Sub-magic echo (update quest msg seq)
435
+ if (this.subMagic && first4 === this.subMagic) {
436
+ this.questMsgSeq = payload.readUInt32BE(4);
437
+ return;
438
+ }
439
+
440
+ // Transport string
441
+ if (first4 === 0x00000001 && payload.length >= 12) {
442
+ const strLen = payload.readUInt32BE(8);
443
+ if (12 + strLen <= payload.length && strLen > 4) {
444
+ const candidate = payload.subarray(12, 12 + strLen);
445
+ if (candidate.every((b) => b >= 32 && b < 127)) {
446
+ verbose("Quest transport:", candidate.toString("ascii"));
447
+ if (this.subMagic && handshakeStage === 0) {
448
+ this.sendXrsp(buildInit(this.subMagic, this.nextSeq()));
449
+ setState(1, ackVal);
450
+ }
451
+ return;
452
+ }
453
+ }
454
+
455
+ // H.264 NAL unit
456
+ const nalType = payload[4] & 0x1f;
457
+ if (nalType === 7 || nalType === 5 || nalType === 1 || nalType === 6 || nalType === 8 ||
458
+ (nalType > 0 && payload.length > 50)) {
459
+ if (nalType === 7) {
460
+ this.currentSpsPps = Buffer.from(payload);
461
+ verbose(`SPS+PPS (${payload.length} bytes)`);
462
+ if (!this.decoder.isRunning) {
463
+ this.decoder.start();
464
+ }
465
+ } else if (nalType === 5) {
466
+ this.currentIdr = Buffer.from(payload);
467
+ verbose(`IDR keyframe (${payload.length} bytes)`);
468
+ }
469
+ this._frameCount++;
470
+ this._byteCount += payload.length;
471
+ this.decoder.feed(payload);
472
+ }
473
+ return;
474
+ }
475
+
476
+ // Quest ACK
477
+ if (first4 === ACK_MARKER && payload.length === 8) {
478
+ const val = payload.readUInt32BE(4);
479
+ verbose(`Quest ACK: ${val} (stage=${handshakeStage})`);
480
+
481
+ if (this.subMagic) {
482
+ if (handshakeStage === 1) {
483
+ this.sendXrsp(
484
+ buildConfig(this.subMagic, this.nextSeq(),
485
+ this._width, this._height,
486
+ this.sessionUuid, this.sessionTimestamp),
487
+ );
488
+ setState(2, ackVal);
489
+ } else if (handshakeStage === 3) {
490
+ this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_65));
491
+ ackVal = 0x195;
492
+ this.sendXrsp(
493
+ buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
494
+ this.sessionUuid, this.sessionTimestamp),
495
+ );
496
+ setState(4, ackVal);
497
+ } else if (handshakeStage === 4) {
498
+ ackVal = 0x25d;
499
+ this.sendXrsp(
500
+ buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
501
+ this.sessionUuid, this.sessionTimestamp),
502
+ );
503
+ setState(5, ackVal);
504
+ } else if (handshakeStage >= 5) {
505
+ ackVal += KEEPALIVE_ACK_INCREMENT;
506
+ this.sendXrsp(
507
+ buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
508
+ this.sessionUuid, this.sessionTimestamp),
509
+ );
510
+ setState(handshakeStage, ackVal);
511
+ }
512
+ }
513
+ return;
514
+ }
515
+
516
+ // LayerConfiguration (type 300 = 0x12C)
517
+ if (first4 === CONFIG_MARKER) {
518
+ const layer = parseLayerConfiguration(payload);
519
+ if (layer) {
520
+ verbose(`LayerConfig: id=${layer.id} ${layer.width}x${layer.height} type=${layer.typeName} app=${layer.app}`);
521
+ this._layers.set(layer.id, layer);
522
+ this._width = layer.width;
523
+ this._height = layer.height;
524
+ if (layer.type === LAYER_PANEL_APP && !this._layerAutoSelected) {
525
+ this._layerId = layer.id;
526
+ this._layerAutoSelected = true;
527
+ verbose(`Auto-selected PANEL_APP layer ${layer.id} for input`);
528
+ }
529
+ this.emit("layer", layer);
530
+ } else if (payload.length >= 16) {
531
+ this._width = payload.readUInt32BE(8);
532
+ this._height = payload.readUInt32BE(12);
533
+ verbose(`Quest config: ${this._width}x${this._height} (short payload)`);
534
+ }
535
+
536
+ if (this.subMagic && handshakeStage === 2) {
537
+ ackVal = KEEPALIVE_ACK_INCREMENT; // 0xC8
538
+ this.sendXrsp(
539
+ buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
540
+ this.sessionUuid, this.sessionTimestamp),
541
+ );
542
+ for (let i = 0; i < 3; i++) {
543
+ this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_CD));
544
+ }
545
+ this.sendXrsp(buildShortAck(this.subMagic, this.nextSeq(), CMD_SHORT_ACK_12D));
546
+ setState(3, ackVal);
547
+ }
548
+ return;
549
+ }
550
+
551
+ // VideoMeta
552
+ if (first4 === VIDEO_META_MARKER && payload.length === 16) {
553
+ if (this._frameCount > 0 && this._frameCount % 6 === 0 && this.subMagic) {
554
+ ackVal += KEEPALIVE_ACK_INCREMENT;
555
+ this.sendXrsp(
556
+ buildKeepalive(this.subMagic, this.questMsgSeq, ackVal,
557
+ this.sessionUuid, this.sessionTimestamp),
558
+ );
559
+ setState(handshakeStage, ackVal);
560
+ }
561
+ }
562
+ }
563
+
564
+ // --- Public control methods ---
565
+
566
+ getScreenshot(): Buffer | null {
567
+ return this.decoder.getFrame();
568
+ }
569
+
570
+ sendPose(pose: PoseState): void {
571
+ this._pose = pose;
572
+ if (this._connected && this.subMagic) {
573
+ this.sendXrsp(buildPose(this.subMagic, this.nextSeq(), this._pose));
574
+ }
575
+ // Auto-start periodic pose loop on first pose send so the Quest
576
+ // accepts our camera override (requires continuous ~27 Hz updates).
577
+ if (!this._poseLoopActive) {
578
+ this.startPoseLoop();
579
+ }
580
+ }
581
+
582
+ applyPoseDelta(delta: PoseDelta): void {
583
+ this._pose = updatePose(this._pose, delta);
584
+ this.sendPose(this._pose);
585
+ }
586
+
587
+ setPoseOffset(abs: PoseOffset): void {
588
+ this._pose = setPoseOffset(this._pose, abs);
589
+ this.sendPose(this._pose);
590
+ }
591
+
592
+ /** Start periodic pose refresh at ~27 Hz (matching MQDH cadence). */
593
+ startPoseLoop(): void {
594
+ if (this._poseLoopActive) return;
595
+ this._poseLoopActive = true;
596
+ verbose("Pose loop started (~27 Hz)");
597
+ this._poseLoopTimer = setInterval(() => {
598
+ if (this._connected && this.subMagic) {
599
+ this.sendXrsp(buildPose(this.subMagic, this.nextSeq(), this._pose));
600
+ }
601
+ }, 37);
602
+ this.emit("pose-loop", true);
603
+ }
604
+
605
+ /** Stop periodic pose refresh. */
606
+ stopPoseLoop(): void {
607
+ if (!this._poseLoopActive) return;
608
+ this._poseLoopActive = false;
609
+ if (this._poseLoopTimer) {
610
+ clearInterval(this._poseLoopTimer);
611
+ this._poseLoopTimer = null;
612
+ }
613
+ verbose("Pose loop stopped");
614
+ this.emit("pose-loop", false);
615
+ }
616
+
617
+ sendDisplayConfig(width: number, height: number, eye?: number): void {
618
+ if (this._connected && this.subMagic) {
619
+ // Reset first, then config (matching Python behavior)
620
+ this.sendXrsp(buildInit(this.subMagic, this.nextSeq()));
621
+ this.sendXrsp(buildDisplayConfig(this.subMagic, this.nextSeq(), width, height, eye));
622
+ this._width = width;
623
+ this._height = height;
624
+ if (eye !== undefined) this._eye = eye;
625
+ }
626
+ }
627
+
628
+ async sendClick(x = 0.5, y = 0.5, layerId?: number, holdMs = 50): Promise<void> {
629
+ if (!this._connected || !this.subMagic) return;
630
+
631
+ // Auto-select layer
632
+ const lid = layerId ?? this.autoSelectLayer();
633
+
634
+ // MOVE events to position virtual cursor
635
+ for (let i = 0; i < 3; i++) {
636
+ this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 1, x, y));
637
+ await new Promise((r) => setTimeout(r, 16));
638
+ }
639
+
640
+ // DOWN
641
+ this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 3, x, y));
642
+ await new Promise((r) => setTimeout(r, holdMs));
643
+
644
+ // UP
645
+ this.sendXrsp(buildVirtualMouse(this.subMagic, this.nextSeq(), lid, 4, x, y));
646
+ }
647
+
648
+ ensureInputForwarding(): void {
649
+ if (this.inputForwardingStarted) return;
650
+ if (!this._connected || !this.subMagic) return;
651
+
652
+ this.sendXrsp(buildSetProperty(this.subMagic, this.nextSeq(),
653
+ "input_forwarding_gaze_click", "true"));
654
+ this.sendXrsp(buildInputForwardingState(this.subMagic, this.nextSeq(), 1));
655
+ this.sendXrsp(buildStartInputForwarding(this.subMagic, this.nextSeq()));
656
+ this.sendXrsp(buildActivateLayer(this.subMagic, this.nextSeq(), this._layerId));
657
+ this.inputForwardingStarted = true;
658
+ verbose("Input forwarding enabled (CAMERA mode, gaze_click=true)");
659
+ }
660
+
661
+ sendInputForwardingState(state: number): void {
662
+ if (this._connected && this.subMagic) {
663
+ this.sendXrsp(buildInputForwardingState(this.subMagic, this.nextSeq(), state));
664
+ if (state === 0) {
665
+ this.inputForwardingStarted = false;
666
+ }
667
+ }
668
+ }
669
+
670
+ resetView(): void {
671
+ this.stopPoseLoop();
672
+ this.sendInputForwardingState(0);
673
+ this._pose = createPoseState();
674
+ }
675
+
676
+ sendSetProperty(name: string, value: string): void {
677
+ if (this._connected && this.subMagic) {
678
+ this.sendXrsp(buildSetProperty(this.subMagic, this.nextSeq(), name, value));
679
+ }
680
+ }
681
+
682
+ sendMud(typeId: number, payload?: Buffer): void {
683
+ if (this._connected && this.subMagic) {
684
+ this.sendXrsp(buildMud(this.subMagic, this.nextSeq(), typeId, payload));
685
+ }
686
+ }
687
+
688
+ sendRotation(pitch = 0, yaw = 0, forward = 0, strafe = 0): void {
689
+ this.applyPoseDelta({ dYaw: yaw, dPitch: pitch, dForward: forward, dStrafe: strafe });
690
+ }
691
+
692
+ // --- Helpers ---
693
+
694
+ private autoSelectLayer(): number {
695
+ let bestId = this._layerId;
696
+ let bestArea = 0;
697
+ for (const [id, info] of this._layers) {
698
+ if (info.type === 0 || info.type === 3) { // PANEL_APP or VOLUMETRIC_WINDOW
699
+ const area = info.width * info.height;
700
+ if (area > bestArea) {
701
+ bestId = id;
702
+ bestArea = area;
703
+ }
704
+ }
705
+ }
706
+ return bestId;
707
+ }
708
+ }