@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,65 @@
1
+ /** XRSP / MGIK / MUD protocol constants for Cast 2.0. */
2
+
3
+ // XRSP header
4
+ export const XRSP_FLAGS_STANDARD = 0x10;
5
+ export const XRSP_FLAGS_ALIGNMENT_PAD = 0x18;
6
+ export const XRSP_TOPIC_CAST = 2;
7
+ export const XRSP_HEADER_SIZE = 8;
8
+
9
+ // Markers
10
+ export const VIDEO_META_MARKER = 0x64;
11
+ export const ACK_MARKER = 0x03;
12
+ export const CONFIG_MARKER = 0x12c; // LayerConfiguration (300)
13
+ export const MGIK_MAGIC = 0x4d47494b; // "MGIK" as big-endian u32
14
+
15
+ // MGIK sub-header tail signature: (0, 1, 2)
16
+ export const MGIK_TAIL_SIGNATURE = [0, 1, 2] as const;
17
+ export const MGIK_SUB_HEADER_SIZE = 24;
18
+
19
+ // MUD command IDs
20
+ export const CMD_KEEPALIVE = 0x04;
21
+ export const CMD_CONFIG = 0x07;
22
+ export const CMD_DISCONNECT = 0x08;
23
+ export const CMD_DISPLAY_CONFIG = 0x09;
24
+ export const CMD_SET_PROPERTY = 10; // 0x0a
25
+ export const CMD_SHORT_ACK_65 = 0x65;
26
+ export const CMD_VIDEO_META = 0x64;
27
+ export const CMD_POSE = 0xce;
28
+ export const CMD_SHORT_ACK_CD = 0xcd;
29
+ export const CMD_VIRTUAL_MOUSE = 200; // 0xc8
30
+ export const CMD_INPUT_FORWARDING_STATE = 205; // 0xcd
31
+ export const CMD_START_INPUT_FORWARDING = 207; // 0xcf
32
+ export const CMD_INIT = 0x258;
33
+ export const CMD_LAYER_CONFIGURATION = 0x12c; // 300
34
+ export const CMD_SHORT_ACK_12D = 0x12d;
35
+ export const CMD_ACTIVATE_LAYER = 301; // 0x12d
36
+
37
+ // VirtualMouse actions
38
+ export const MOUSE_MOVE = 1;
39
+ export const MOUSE_DOWN = 3;
40
+ export const MOUSE_UP = 4;
41
+
42
+ // Input forwarding states
43
+ export const INPUT_STATE_NORMAL = 0;
44
+ export const INPUT_STATE_CAMERA = 1;
45
+
46
+ // Layer types
47
+ export const LAYER_PANEL_APP = 0;
48
+ export const LAYER_EYE_BUFFER = 1;
49
+ export const LAYER_AETHER_APP = 2;
50
+ export const LAYER_VOLUMETRIC_WINDOW = 3;
51
+
52
+ export const LAYER_TYPE_NAMES: Record<number, string> = {
53
+ [LAYER_PANEL_APP]: "PANEL_APP",
54
+ [LAYER_EYE_BUFFER]: "EYE_BUFFER",
55
+ [LAYER_AETHER_APP]: "AETHER_APP",
56
+ [LAYER_VOLUMETRIC_WINDOW]: "VOLUMETRIC_WINDOW",
57
+ };
58
+
59
+ // Cast TCP ports — MQDH uses 4446 for debug.oculus.magic.port, maps both 4445 and 4446
60
+ export const CAST_PORT = 4445;
61
+ export const QUEST_CAST_PORT = 4446;
62
+
63
+ // Keepalive ack value progression
64
+ export const KEEPALIVE_INITIAL_ACK = 0xc8;
65
+ export const KEEPALIVE_ACK_INCREMENT = 0xc8;
@@ -0,0 +1,7 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./xrsp.js";
4
+ export * from "./mgik.js";
5
+ export * from "./mud.js";
6
+ export * from "./pose.js";
7
+ export * from "./resolutions.js";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * MGIK sub-header packing and parsing.
3
+ *
4
+ * MGIK sub-header is 24 bytes, big-endian:
5
+ * sub_magic(4 BE) + msg_seq(4 BE) + ack_seq(4 BE) + 0(4 BE) + 1(4 BE) + 2(4 BE)
6
+ *
7
+ * The sub_magic is learned from the Quest's first message.
8
+ * The tail (0, 1, 2) is a fixed signature.
9
+ */
10
+
11
+ import { MGIK_MAGIC, MGIK_SUB_HEADER_SIZE } from "./constants.js";
12
+ import type { MgikSubHeader } from "./types.js";
13
+
14
+ /**
15
+ * Pack a MGIK sub-header (24 bytes, big-endian).
16
+ * ack_seq defaults to msg_seq (matching the Python implementation).
17
+ */
18
+ export function packMgikSub(subMagic: number, msgSeq: number): Buffer {
19
+ const buf = Buffer.alloc(MGIK_SUB_HEADER_SIZE);
20
+ buf.writeUInt32BE(subMagic, 0);
21
+ buf.writeUInt32BE(msgSeq, 4);
22
+ buf.writeUInt32BE(msgSeq, 8); // ack_seq = msg_seq
23
+ buf.writeUInt32BE(0, 12);
24
+ buf.writeUInt32BE(1, 16);
25
+ buf.writeUInt32BE(2, 20);
26
+ return buf;
27
+ }
28
+
29
+ /**
30
+ * Parse a MGIK sub-header from a buffer.
31
+ * Returns null if the buffer is too small.
32
+ */
33
+ export function parseMgikSub(data: Buffer): MgikSubHeader | null {
34
+ if (data.length < MGIK_SUB_HEADER_SIZE) {
35
+ return null;
36
+ }
37
+ return {
38
+ subMagic: data.readUInt32BE(0),
39
+ msgSeq: data.readUInt32BE(4),
40
+ ackSeq: data.readUInt32BE(8),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Detect the sub_magic from a Quest initial message.
46
+ *
47
+ * The Quest sends a 24-byte payload where the last 12 bytes are (0, 1, 2).
48
+ * The first 4 bytes are the sub_magic value.
49
+ */
50
+ export function detectSubMagic(payload: Buffer): number | null {
51
+ if (payload.length !== MGIK_SUB_HEADER_SIZE) {
52
+ return null;
53
+ }
54
+ const tail0 = payload.readUInt32BE(12);
55
+ const tail1 = payload.readUInt32BE(16);
56
+ const tail2 = payload.readUInt32BE(20);
57
+ if (tail0 === 0 && tail1 === 1 && tail2 === 2) {
58
+ return payload.readUInt32BE(0);
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Check if the first 4 bytes of payload match "MGIK" magic.
65
+ */
66
+ export function isMgikMagic(payload: Buffer): boolean {
67
+ if (payload.length < 4) return false;
68
+ return payload.readUInt32BE(0) === MGIK_MAGIC;
69
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * MUD (Messages Under Delivery) message builders and parsers.
3
+ *
4
+ * All MUD payloads are big-endian and prefixed with a MGIK sub-header.
5
+ * Each builder returns a complete XRSP payload: sub_header + command_id + body.
6
+ */
7
+
8
+ import {
9
+ CMD_ACTIVATE_LAYER,
10
+ CMD_CONFIG,
11
+ CMD_DISCONNECT,
12
+ CMD_DISPLAY_CONFIG,
13
+ CMD_INIT,
14
+ CMD_INPUT_FORWARDING_STATE,
15
+ CMD_KEEPALIVE,
16
+ CMD_POSE,
17
+ CMD_SET_PROPERTY,
18
+ CMD_START_INPUT_FORWARDING,
19
+ CMD_VIRTUAL_MOUSE,
20
+ LAYER_TYPE_NAMES,
21
+ } from "./constants.js";
22
+ import { packMgikSub } from "./mgik.js";
23
+ import type { LayerInfo, PoseState } from "./types.js";
24
+
25
+ /**
26
+ * Pack a MUD-style length-prefixed string with 4-byte alignment padding.
27
+ */
28
+ export function packMudString(s: string): Buffer {
29
+ const raw = Buffer.from(s, "utf-8");
30
+ const padLen = (4 - (raw.length % 4)) % 4;
31
+ const buf = Buffer.alloc(4 + raw.length + padLen);
32
+ buf.writeUInt32BE(raw.length, 0);
33
+ raw.copy(buf, 4);
34
+ return buf;
35
+ }
36
+
37
+ /**
38
+ * Parse a MUD-style length-prefixed string from a buffer at an offset.
39
+ * Returns the parsed string and the next offset (after alignment padding).
40
+ */
41
+ export function parseMudString(
42
+ buf: Buffer,
43
+ offset: number,
44
+ ): { value: string; nextOffset: number } {
45
+ if (offset + 4 > buf.length) {
46
+ return { value: "", nextOffset: buf.length };
47
+ }
48
+ const slen = buf.readUInt32BE(offset);
49
+ offset += 4;
50
+ if (offset + slen > buf.length) {
51
+ return { value: "", nextOffset: buf.length };
52
+ }
53
+ const value = buf.subarray(offset, offset + slen).toString("utf-8");
54
+ offset += slen;
55
+ offset += (4 - (slen % 4)) % 4; // alignment padding
56
+ return { value, nextOffset: offset };
57
+ }
58
+
59
+ /** Build Init command (0x258) payload. */
60
+ export function buildInit(subMagic: number, seq: number): Buffer {
61
+ const sub = packMgikSub(subMagic, seq);
62
+ const body = Buffer.alloc(4 + 16); // cmd + 16 zero bytes
63
+ body.writeUInt32BE(CMD_INIT, 0);
64
+ return Buffer.concat([sub, body]);
65
+ }
66
+
67
+ /** Build Config command (0x07) payload. */
68
+ export function buildConfig(
69
+ subMagic: number,
70
+ seq: number,
71
+ width: number,
72
+ height: number,
73
+ uuid: string,
74
+ timestamp: string,
75
+ ): Buffer {
76
+ const sub = packMgikSub(subMagic, seq);
77
+ // cmd(4) + width(4) + height(4) + 0(4) + 1(4) + 0(4) + 0(4) = 28 bytes
78
+ const body = Buffer.alloc(28);
79
+ body.writeUInt32BE(CMD_CONFIG, 0);
80
+ body.writeUInt32BE(width, 4);
81
+ body.writeUInt32BE(height, 8);
82
+ body.writeUInt32BE(0, 12);
83
+ body.writeUInt32BE(1, 16);
84
+ body.writeUInt32BE(0, 20);
85
+ body.writeUInt32BE(0, 24);
86
+ const uuidStr = packMudString(uuid);
87
+ const tsStr = packMudString(timestamp);
88
+ return Buffer.concat([sub, body, uuidStr, tsStr]);
89
+ }
90
+
91
+ /** Build Keepalive command (0x04) payload. */
92
+ export function buildKeepalive(
93
+ subMagic: number,
94
+ msgSeq: number,
95
+ ackVal: number,
96
+ uuid: string,
97
+ timestamp: string,
98
+ ): Buffer {
99
+ const sub = packMgikSub(subMagic, msgSeq);
100
+ const body = Buffer.alloc(8);
101
+ body.writeUInt32BE(CMD_KEEPALIVE, 0);
102
+ body.writeUInt32BE(ackVal, 4);
103
+ const uuidStr = packMudString(uuid);
104
+ const tsStr = packMudString(timestamp);
105
+ return Buffer.concat([sub, body, uuidStr, tsStr]);
106
+ }
107
+
108
+ /** Build short ack payload (sub_header + cmd + 0). */
109
+ export function buildShortAck(
110
+ subMagic: number,
111
+ msgSeq: number,
112
+ cmd: number,
113
+ ): Buffer {
114
+ const sub = packMgikSub(subMagic, msgSeq);
115
+ const body = Buffer.alloc(8);
116
+ body.writeUInt32BE(cmd, 0);
117
+ body.writeUInt32BE(0, 4);
118
+ return Buffer.concat([sub, body]);
119
+ }
120
+
121
+ /** Build Pose command (0xCE) payload. */
122
+ export function buildPose(subMagic: number, seq: number, pose: PoseState): Buffer {
123
+ const sub = packMgikSub(subMagic, seq);
124
+ // cmd(4) + 8 floats (32 bytes) = 36 bytes
125
+ const body = Buffer.alloc(36);
126
+ body.writeUInt32BE(CMD_POSE, 0);
127
+ // Wire order: type(0), posX, posY, posZ, qw, qx, qy, qz
128
+ body.writeFloatBE(0.0, 4);
129
+ body.writeFloatBE(pose.x, 8);
130
+ body.writeFloatBE(pose.y, 12);
131
+ body.writeFloatBE(pose.z, 16);
132
+ body.writeFloatBE(pose.qw, 20);
133
+ body.writeFloatBE(pose.qx, 24);
134
+ body.writeFloatBE(pose.qy, 28);
135
+ body.writeFloatBE(pose.qz, 32);
136
+ return Buffer.concat([sub, body]);
137
+ }
138
+
139
+ /** Eye selector values for DisplayConfig (cmd 0x09). */
140
+ export const EYE_RIGHT = 0;
141
+ export const EYE_LEFT = 1;
142
+ export const EYE_STEREO = 2;
143
+
144
+ /** Build DisplayConfig / RES_CHANGE command (0x09) payload. */
145
+ export function buildDisplayConfig(
146
+ subMagic: number,
147
+ seq: number,
148
+ width: number,
149
+ height: number,
150
+ eye: number = EYE_LEFT,
151
+ ): Buffer {
152
+ const sub = packMgikSub(subMagic, seq);
153
+ // Wire: u32(cmd) + u32(width) + u32(height) + u32(0) + u32(eye) + u32(0) + u32(0)
154
+ const body = Buffer.alloc(4 * 7);
155
+ body.writeUInt32BE(CMD_DISPLAY_CONFIG, 0);
156
+ body.writeUInt32BE(width, 4);
157
+ body.writeUInt32BE(height, 8);
158
+ body.writeUInt32BE(eye, 16);
159
+ return Buffer.concat([sub, body]);
160
+ }
161
+
162
+ /** Build Disconnect command (0x08) payload. */
163
+ export function buildDisconnect(subMagic: number, seq: number): Buffer {
164
+ const sub = packMgikSub(subMagic, seq);
165
+ const body = Buffer.alloc(4);
166
+ body.writeUInt32BE(CMD_DISCONNECT, 0);
167
+ return Buffer.concat([sub, body]);
168
+ }
169
+
170
+ /** Build SetProperty MUD type 10 payload. */
171
+ export function buildSetProperty(
172
+ subMagic: number,
173
+ seq: number,
174
+ name: string,
175
+ value: string,
176
+ ): Buffer {
177
+ const sub = packMgikSub(subMagic, seq);
178
+ const cmd = Buffer.alloc(4);
179
+ cmd.writeUInt32BE(CMD_SET_PROPERTY, 0);
180
+ return Buffer.concat([sub, cmd, packMudString(name), packMudString(value)]);
181
+ }
182
+
183
+ /** Build VirtualMouse MUD type 200 payload. */
184
+ export function buildVirtualMouse(
185
+ subMagic: number,
186
+ seq: number,
187
+ layerId: number,
188
+ action: number,
189
+ x: number,
190
+ y: number,
191
+ ): Buffer {
192
+ const sub = packMgikSub(subMagic, seq);
193
+ const body = Buffer.alloc(4 + 4 + 4 + 4 + 4); // cmd + layerId + action + x(f32) + y(f32)
194
+ body.writeUInt32BE(CMD_VIRTUAL_MOUSE, 0);
195
+ body.writeUInt32BE(layerId, 4);
196
+ body.writeUInt32BE(action, 8);
197
+ body.writeFloatBE(x, 12);
198
+ body.writeFloatBE(y, 16);
199
+ return Buffer.concat([sub, body]);
200
+ }
201
+
202
+ /** Build InputForwardingStateChange MUD type 205 payload. */
203
+ export function buildInputForwardingState(
204
+ subMagic: number,
205
+ seq: number,
206
+ state: number,
207
+ ): Buffer {
208
+ const sub = packMgikSub(subMagic, seq);
209
+ const body = Buffer.alloc(8);
210
+ body.writeUInt32BE(CMD_INPUT_FORWARDING_STATE, 0);
211
+ body.writeUInt32BE(state, 4);
212
+ return Buffer.concat([sub, body]);
213
+ }
214
+
215
+ /** Build StartInputForwarding MUD type 207 payload. */
216
+ export function buildStartInputForwarding(
217
+ subMagic: number,
218
+ seq: number,
219
+ ): Buffer {
220
+ const sub = packMgikSub(subMagic, seq);
221
+ const cmd = Buffer.alloc(4);
222
+ cmd.writeUInt32BE(CMD_START_INPUT_FORWARDING, 0);
223
+ return Buffer.concat([sub, cmd]);
224
+ }
225
+
226
+ /** Build ActivateLayer MUD type 301 payload. */
227
+ export function buildActivateLayer(
228
+ subMagic: number,
229
+ seq: number,
230
+ layerId: number,
231
+ ): Buffer {
232
+ const sub = packMgikSub(subMagic, seq);
233
+ const body = Buffer.alloc(8);
234
+ body.writeUInt32BE(CMD_ACTIVATE_LAYER, 0);
235
+ body.writeUInt32BE(layerId, 4);
236
+ return Buffer.concat([sub, body]);
237
+ }
238
+
239
+ /** Build arbitrary MUD message: sub_header + cmd(4) + payload. */
240
+ export function buildMud(
241
+ subMagic: number,
242
+ seq: number,
243
+ typeId: number,
244
+ payload: Buffer = Buffer.alloc(0),
245
+ ): Buffer {
246
+ const sub = packMgikSub(subMagic, seq);
247
+ const cmd = Buffer.alloc(4);
248
+ cmd.writeUInt32BE(typeId, 0);
249
+ return Buffer.concat([sub, cmd, payload]);
250
+ }
251
+
252
+ /**
253
+ * Parse a LayerConfiguration (type 0x12C / 300) payload.
254
+ * Returns null if the payload is too short.
255
+ */
256
+ export function parseLayerConfiguration(payload: Buffer): LayerInfo | null {
257
+ // Minimum: cmd(4) + id(4) + w(4) + h(4) + posX(4) + posY(4) + depth(4) + type(4) = 32
258
+ if (payload.length < 32) {
259
+ return null;
260
+ }
261
+
262
+ const id = payload.readUInt32BE(4);
263
+ const width = payload.readUInt32BE(8);
264
+ const height = payload.readUInt32BE(12);
265
+ const posX = payload.readFloatBE(16);
266
+ const posY = payload.readFloatBE(20);
267
+ const depth = payload.readFloatBE(24);
268
+ const type = payload.readUInt32BE(28);
269
+ const typeName = LAYER_TYPE_NAMES[type] ?? `UNKNOWN(${type})`;
270
+
271
+ // Parse 4 strings: appName, layerName, deviceSerial, packageName
272
+ const strings: string[] = [];
273
+ let offset = 32;
274
+ for (let i = 0; i < 4; i++) {
275
+ const result = parseMudString(payload, offset);
276
+ strings.push(result.value);
277
+ offset = result.nextOffset;
278
+ }
279
+
280
+ return {
281
+ id,
282
+ width,
283
+ height,
284
+ posX,
285
+ posY,
286
+ depth,
287
+ type,
288
+ typeName,
289
+ app: strings[0] ?? "",
290
+ layer: strings[1] ?? "",
291
+ serial: strings[2] ?? "",
292
+ pkg: strings[3] ?? "",
293
+ };
294
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Pose state management: euler-to-quaternion conversion, incremental updates,
3
+ * and screen-to-yaw/pitch mapping.
4
+ *
5
+ * OpenXR coordinate convention: +X=right, +Y=up, -Z=forward.
6
+ *
7
+ * All pose coordinates are offsets from the headset's position at the time
8
+ * casting started — they are NOT absolute world-space coordinates. The origin
9
+ * (0, 0, 0) corresponds to the headset's initial position; moving the pose
10
+ * shifts the virtual camera relative to that starting point.
11
+ */
12
+
13
+ import type { PoseOffset, PoseDelta, PoseState } from "./types.js";
14
+
15
+ /** Create a default pose state (identity orientation, zero offset from headset). */
16
+ export function createPoseState(): PoseState {
17
+ return {
18
+ x: 0, y: 0, z: 0,
19
+ qw: 1, qx: 0, qy: 0, qz: 0,
20
+ yaw: 0, pitch: 0,
21
+ };
22
+ }
23
+
24
+ /** Convert yaw/pitch (radians) to quaternion (w, x, y, z). */
25
+ export function eulerToQuat(yaw: number, pitch: number): {
26
+ qw: number; qx: number; qy: number; qz: number;
27
+ } {
28
+ const cy = Math.cos(yaw * 0.5);
29
+ const sy = Math.sin(yaw * 0.5);
30
+ const cp = Math.cos(pitch * 0.5);
31
+ const sp = Math.sin(pitch * 0.5);
32
+ return {
33
+ qw: cy * cp,
34
+ qx: cy * sp,
35
+ qy: sy * cp,
36
+ qz: -sy * sp,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Apply incremental movement to a pose state.
42
+ * Returns a new PoseState (does not mutate the input).
43
+ */
44
+ export function updatePose(state: PoseState, delta: PoseDelta): PoseState {
45
+ const yaw = state.yaw + (delta.dYaw ?? 0);
46
+ const pitch = Math.max(
47
+ -Math.PI / 2,
48
+ Math.min(Math.PI / 2, state.pitch + (delta.dPitch ?? 0)),
49
+ );
50
+
51
+ const dForward = delta.dForward ?? 0;
52
+ const dStrafe = delta.dStrafe ?? 0;
53
+ const dUp = delta.dUp ?? 0;
54
+
55
+ // Move in the direction we're facing (yaw only, not pitch).
56
+ // OpenXR: +X=right, +Y=up, -Z=forward. Yaw rotates around Y.
57
+ const cy = Math.cos(yaw);
58
+ const sy = Math.sin(yaw);
59
+ const x = state.x - dForward * sy + dStrafe * cy;
60
+ const y = state.y + dUp;
61
+ const z = state.z - dForward * cy - dStrafe * sy;
62
+
63
+ const q = eulerToQuat(yaw, pitch);
64
+ return { x, y, z, ...q, yaw, pitch };
65
+ }
66
+
67
+ /**
68
+ * Set pose offset directly. Only provided fields are updated.
69
+ * Returns a new PoseState.
70
+ */
71
+ export function setPoseOffset(state: PoseState, abs: PoseOffset): PoseState {
72
+ const x = abs.x ?? state.x;
73
+ const y = abs.y ?? state.y;
74
+ const z = abs.z ?? state.z;
75
+ const yaw = abs.yaw ?? state.yaw;
76
+ const pitch = abs.pitch ?? state.pitch;
77
+ const q = eulerToQuat(yaw, pitch);
78
+ return { x, y, z, ...q, yaw, pitch };
79
+ }
80
+
81
+ /**
82
+ * Convert normalized screen coordinates (0-1) to yaw/pitch angles.
83
+ * Screen center (0.5, 0.5) maps to the current camera orientation.
84
+ */
85
+ export function screenToYawPitch(
86
+ x: number,
87
+ y: number,
88
+ currentYaw: number,
89
+ currentPitch: number,
90
+ width: number,
91
+ height: number,
92
+ ): { yaw: number; pitch: number } {
93
+ const hFov = Math.PI / 2; // 90 degrees
94
+ const vFov = hFov * (height / width);
95
+ return {
96
+ yaw: currentYaw - (x - 0.5) * hFov,
97
+ pitch: currentPitch - (y - 0.5) * vFov,
98
+ };
99
+ }
@@ -0,0 +1,34 @@
1
+ export interface Resolution {
2
+ width: number;
3
+ height: number;
4
+ label: string;
5
+ }
6
+
7
+ export const RESOLUTIONS: Record<string, Resolution> = {
8
+ "720p": { width: 1280, height: 720, label: "720p" },
9
+ "1080p": { width: 1920, height: 1080, label: "1080p" },
10
+ "native": { width: 2064, height: 1162, label: "native" },
11
+ };
12
+
13
+ export const DEFAULT_RESOLUTION = "720p";
14
+
15
+ export function resolveResolution(
16
+ resolution?: string,
17
+ width?: number,
18
+ height?: number,
19
+ ): { width: number; height: number } {
20
+ if (resolution) {
21
+ const preset = RESOLUTIONS[resolution];
22
+ if (!preset) {
23
+ throw new Error(
24
+ `Unknown resolution "${resolution}". Valid: ${Object.keys(RESOLUTIONS).join(", ")}`,
25
+ );
26
+ }
27
+ return { width: preset.width, height: preset.height };
28
+ }
29
+ if (width != null && height != null) {
30
+ return { width, height };
31
+ }
32
+ const def = RESOLUTIONS[DEFAULT_RESOLUTION];
33
+ return { width: def.width, height: def.height };
34
+ }
@@ -0,0 +1,64 @@
1
+ /** Type definitions for Cast 2.0 protocol structures. */
2
+
3
+ export interface XrspHeader {
4
+ flags: number;
5
+ topic: number;
6
+ wordCount: number;
7
+ seq: number;
8
+ padding: number;
9
+ }
10
+
11
+ export interface MgikSubHeader {
12
+ subMagic: number;
13
+ msgSeq: number;
14
+ ackSeq: number;
15
+ }
16
+
17
+ export interface LayerInfo {
18
+ id: number;
19
+ width: number;
20
+ height: number;
21
+ posX: number;
22
+ posY: number;
23
+ depth: number;
24
+ type: number;
25
+ typeName: string;
26
+ app: string;
27
+ layer: string;
28
+ serial: string;
29
+ pkg: string;
30
+ }
31
+
32
+ /**
33
+ * Camera pose as an offset from the headset's position when casting started.
34
+ * Coordinates are relative to the headset, not absolute world space.
35
+ */
36
+ export interface PoseState {
37
+ x: number;
38
+ y: number;
39
+ z: number;
40
+ qw: number;
41
+ qx: number;
42
+ qy: number;
43
+ qz: number;
44
+ yaw: number;
45
+ pitch: number;
46
+ }
47
+
48
+ /** Incremental movement relative to the current camera offset. */
49
+ export interface PoseDelta {
50
+ dForward?: number;
51
+ dStrafe?: number;
52
+ dUp?: number;
53
+ dYaw?: number;
54
+ dPitch?: number;
55
+ }
56
+
57
+ /** Set camera offset directly (relative to headset, not world space). */
58
+ export interface PoseOffset {
59
+ x?: number;
60
+ y?: number;
61
+ z?: number;
62
+ yaw?: number;
63
+ pitch?: number;
64
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * XRSP frame packing and parsing.
3
+ *
4
+ * XRSP header is 8 bytes, little-endian:
5
+ * flags(1) + topic(1) + word_count(2 LE) + seq(2 LE) + pad(2 LE)
6
+ *
7
+ * Payload is padded to 4-byte alignment.
8
+ * Total packet size = word_count × 4 bytes (includes the 8-byte header as 2 words).
9
+ * Payload size = (word_count - 1) × 4 bytes... wait, looking at Python:
10
+ * word_count = len(padded_payload) / 4 + 1
11
+ * So header takes 2 words (8 bytes) but word_count only accounts for 1 word of header?
12
+ * Actually the pack_xrsp packs the 8-byte header separately, then appends padded payload.
13
+ * word_count = padded_payload_len/4 + 1, so payload_size = (word_count - 1) * 4.
14
+ * The header itself is NOT counted in word_count properly — it's prepended separately.
15
+ */
16
+
17
+ import {
18
+ XRSP_FLAGS_STANDARD,
19
+ XRSP_HEADER_SIZE,
20
+ XRSP_TOPIC_CAST,
21
+ } from "./constants.js";
22
+ import type { XrspHeader } from "./types.js";
23
+
24
+ /**
25
+ * Pack an XRSP frame: 8-byte LE header + padded payload.
26
+ */
27
+ export function packXrsp(
28
+ seq: number,
29
+ payload: Buffer,
30
+ flags: number = XRSP_FLAGS_STANDARD,
31
+ topic: number = XRSP_TOPIC_CAST,
32
+ ): Buffer {
33
+ const padLen = (4 - (payload.length % 4)) % 4;
34
+ const paddedLen = payload.length + padLen;
35
+ const wordCount = paddedLen / 4 + 1;
36
+
37
+ const header = Buffer.alloc(XRSP_HEADER_SIZE);
38
+ header.writeUInt8(flags, 0);
39
+ header.writeUInt8(topic, 1);
40
+ header.writeUInt16LE(wordCount, 2);
41
+ header.writeUInt16LE(seq, 4);
42
+ header.writeUInt16LE(0, 6); // padding field
43
+
44
+ if (padLen === 0) {
45
+ return Buffer.concat([header, payload]);
46
+ }
47
+ return Buffer.concat([header, payload, Buffer.alloc(padLen)]);
48
+ }
49
+
50
+ /**
51
+ * Parse an XRSP header from the first 8 bytes of a buffer.
52
+ */
53
+ export function parseXrspHeader(data: Buffer): XrspHeader {
54
+ if (data.length < XRSP_HEADER_SIZE) {
55
+ throw new Error(
56
+ `XRSP header requires ${XRSP_HEADER_SIZE} bytes, got ${data.length}`,
57
+ );
58
+ }
59
+ return {
60
+ flags: data.readUInt8(0),
61
+ topic: data.readUInt8(1) & 0x3f,
62
+ wordCount: data.readUInt16LE(2),
63
+ seq: data.readUInt16LE(4),
64
+ padding: data.readUInt16LE(6),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Compute payload size from an XRSP header.
70
+ */
71
+ export function xrspPayloadSize(header: XrspHeader): number {
72
+ return Math.max(0, (header.wordCount - 1) * 4);
73
+ }