t2-demo-parser 1.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +823 -0
  3. package/dist/BitStream.d.ts +143 -0
  4. package/dist/BitStream.d.ts.map +1 -0
  5. package/dist/BitStream.js +369 -0
  6. package/dist/BitStream.js.map +1 -0
  7. package/dist/BitStream.test.d.ts +2 -0
  8. package/dist/BitStream.test.d.ts.map +1 -0
  9. package/dist/BitStream.test.js +419 -0
  10. package/dist/BitStream.test.js.map +1 -0
  11. package/dist/ClassRegistry.d.ts +100 -0
  12. package/dist/ClassRegistry.d.ts.map +1 -0
  13. package/dist/ClassRegistry.js +118 -0
  14. package/dist/ClassRegistry.js.map +1 -0
  15. package/dist/ClassRegistry.test.d.ts +2 -0
  16. package/dist/ClassRegistry.test.d.ts.map +1 -0
  17. package/dist/ClassRegistry.test.js +83 -0
  18. package/dist/ClassRegistry.test.js.map +1 -0
  19. package/dist/DataBlockParsers.d.ts +3 -0
  20. package/dist/DataBlockParsers.d.ts.map +1 -0
  21. package/dist/DataBlockParsers.js +2041 -0
  22. package/dist/DataBlockParsers.js.map +1 -0
  23. package/dist/DemoParser.d.ts +155 -0
  24. package/dist/DemoParser.d.ts.map +1 -0
  25. package/dist/DemoParser.js +943 -0
  26. package/dist/DemoParser.js.map +1 -0
  27. package/dist/DemoParser.test.d.ts +2 -0
  28. package/dist/DemoParser.test.d.ts.map +1 -0
  29. package/dist/DemoParser.test.js +472 -0
  30. package/dist/DemoParser.test.js.map +1 -0
  31. package/dist/EventParsers.d.ts +3 -0
  32. package/dist/EventParsers.d.ts.map +1 -0
  33. package/dist/EventParsers.js +603 -0
  34. package/dist/EventParsers.js.map +1 -0
  35. package/dist/GhostManager.d.ts +13 -0
  36. package/dist/GhostManager.d.ts.map +1 -0
  37. package/dist/GhostManager.js +2002 -0
  38. package/dist/GhostManager.js.map +1 -0
  39. package/dist/GhostTracker.test.d.ts +2 -0
  40. package/dist/GhostTracker.test.d.ts.map +1 -0
  41. package/dist/GhostTracker.test.js +64 -0
  42. package/dist/GhostTracker.test.js.map +1 -0
  43. package/dist/HuffmanProcessor.d.ts +12 -0
  44. package/dist/HuffmanProcessor.d.ts.map +1 -0
  45. package/dist/HuffmanProcessor.js +163 -0
  46. package/dist/HuffmanProcessor.js.map +1 -0
  47. package/dist/LiveParser.d.ts +16 -0
  48. package/dist/LiveParser.d.ts.map +1 -0
  49. package/dist/LiveParser.js +29 -0
  50. package/dist/LiveParser.js.map +1 -0
  51. package/dist/PacketParser.d.ts +104 -0
  52. package/dist/PacketParser.d.ts.map +1 -0
  53. package/dist/PacketParser.js +713 -0
  54. package/dist/PacketParser.js.map +1 -0
  55. package/dist/Timeline.d.ts +101 -0
  56. package/dist/Timeline.d.ts.map +1 -0
  57. package/dist/Timeline.js +251 -0
  58. package/dist/Timeline.js.map +1 -0
  59. package/dist/cli.d.ts +3 -0
  60. package/dist/cli.d.ts.map +1 -0
  61. package/dist/cli.js +372 -0
  62. package/dist/cli.js.map +1 -0
  63. package/dist/dataBlockDataTypes.d.ts +877 -0
  64. package/dist/dataBlockDataTypes.d.ts.map +1 -0
  65. package/dist/dataBlockDataTypes.js +2 -0
  66. package/dist/dataBlockDataTypes.js.map +1 -0
  67. package/dist/dataTypes.d.ts +31 -0
  68. package/dist/dataTypes.d.ts.map +1 -0
  69. package/dist/dataTypes.js +5 -0
  70. package/dist/dataTypes.js.map +1 -0
  71. package/dist/dump.d.ts +3 -0
  72. package/dist/dump.d.ts.map +1 -0
  73. package/dist/dump.js +36 -0
  74. package/dist/dump.js.map +1 -0
  75. package/dist/eventDataTypes.d.ts +199 -0
  76. package/dist/eventDataTypes.d.ts.map +1 -0
  77. package/dist/eventDataTypes.js +2 -0
  78. package/dist/eventDataTypes.js.map +1 -0
  79. package/dist/ghostDataTypes.d.ts +573 -0
  80. package/dist/ghostDataTypes.d.ts.map +1 -0
  81. package/dist/ghostDataTypes.js +2 -0
  82. package/dist/ghostDataTypes.js.map +1 -0
  83. package/dist/index.d.ts +17 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +9 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/types.d.ts +261 -0
  88. package/dist/types.d.ts.map +1 -0
  89. package/dist/types.js +204 -0
  90. package/dist/types.js.map +1 -0
  91. package/package.json +46 -0
@@ -0,0 +1,2002 @@
1
+ import { MaxTriggerKeys } from "./types.js";
2
+ // ============================================================
3
+ // Ghost Tracker — tracks ghost lifecycle (create/update/delete)
4
+ // ============================================================
5
+ export class GhostTracker {
6
+ ghosts = new Map();
7
+ getGhost(index) {
8
+ return this.ghosts.get(index);
9
+ }
10
+ hasGhost(index) {
11
+ return this.ghosts.has(index);
12
+ }
13
+ createGhost(index, classId, className) {
14
+ const entry = { classId, className, state: {} };
15
+ this.ghosts.set(index, entry);
16
+ return entry;
17
+ }
18
+ deleteGhost(index) {
19
+ this.ghosts.delete(index);
20
+ }
21
+ getAllGhosts() {
22
+ return this.ghosts;
23
+ }
24
+ size() {
25
+ return this.ghosts.size;
26
+ }
27
+ clear() {
28
+ this.ghosts.clear();
29
+ }
30
+ }
31
+ // ============================================================
32
+ // Shared parsing helpers
33
+ // ============================================================
34
+ function readMove(bs) {
35
+ const pyaw = bs.readFlag() ? bs.readInt(16) : 0;
36
+ const ppitch = bs.readFlag() ? bs.readInt(16) : 0;
37
+ const proll = bs.readFlag() ? bs.readInt(16) : 0;
38
+ const px = bs.readInt(6);
39
+ const py = bs.readInt(6);
40
+ const pz = bs.readInt(6);
41
+ const freeLook = bs.readFlag();
42
+ const trigger = [];
43
+ for (let i = 0; i < MaxTriggerKeys; i++) {
44
+ trigger.push(bs.readFlag());
45
+ }
46
+ return { pyaw, ppitch, proll, px, py, pz, freeLook, trigger };
47
+ }
48
+ /**
49
+ * Read a packed ColorF: 4 × U8 (32 bits total), each converted to [0..1] float.
50
+ * From decompiled binary: FUN_0043f040 reads 4 bytes, each × (1/255).
51
+ */
52
+ function readPackedColorF(bs) {
53
+ return {
54
+ r: bs.readInt(8) / 255,
55
+ g: bs.readInt(8) / 255,
56
+ b: bs.readInt(8) / 255,
57
+ a: bs.readInt(8) / 255,
58
+ };
59
+ }
60
+ /**
61
+ * Tribes 2 object/datablock references are serialized via FUN_00436ce0/FUN_00436d10
62
+ * as raw 11-bit object ids (nextPow2(0x800) -> bitCount 11).
63
+ */
64
+ function readObjectRef11(bs) {
65
+ return bs.readInt(11);
66
+ }
67
+ // ============================================================
68
+ // GameBase::unpackUpdate (base layer for all game objects)
69
+ // ============================================================
70
+ function readGameBaseUpdate(bs, _isInitial, _conn) {
71
+ const result = {};
72
+ // GameBase::unpackUpdate (FUN_005e3360)
73
+ // DataBlockMask
74
+ if (bs.readFlag()) {
75
+ result.dataBlockId = readObjectRef11(bs);
76
+ }
77
+ // TargetMask
78
+ if (bs.readFlag()) {
79
+ // Optional explicit target id; otherwise remains -1.
80
+ const hasTargetId = bs.readFlag();
81
+ result.targetId = hasTargetId ? bs.readInt(9) : -1;
82
+ }
83
+ return result;
84
+ }
85
+ // ============================================================
86
+ // ShapeBase::unpackUpdate (layer 2 — damage, threads, images, etc.)
87
+ // ============================================================
88
+ function readShapeBaseUpdate(bs, isInitial, conn) {
89
+ const result = readGameBaseUpdate(bs, isInitial, conn);
90
+ // ShapeBase::unpackUpdate (FUN_005ef0e0)
91
+ // Combined gate for all remaining ShapeBase masks.
92
+ if (!bs.readFlag()) {
93
+ return result;
94
+ }
95
+ // DamageMask
96
+ if (bs.readFlag()) {
97
+ result.damageLevel = bs.readFloat(6); // DamageLevelBits=6
98
+ result.damageState = bs.readInt(2); // NumDamageStateBits=2
99
+ result.blowApart = bs.readFlag();
100
+ result.damageDir = bs.readNormalVector(8);
101
+ }
102
+ // SoundMask (4 sound slots)
103
+ if (bs.readFlag()) {
104
+ const sounds = [];
105
+ for (let i = 0; i < 4; i++) {
106
+ if (bs.readFlag()) {
107
+ const playing = bs.readFlag();
108
+ const sound = {
109
+ index: i,
110
+ playing,
111
+ };
112
+ if (playing) {
113
+ sound.profileId = readObjectRef11(bs);
114
+ }
115
+ sounds.push(sound);
116
+ }
117
+ }
118
+ if (sounds.length > 0) {
119
+ result.sounds = sounds;
120
+ }
121
+ }
122
+ // ThreadMask (script animation threads, 4 slots)
123
+ if (bs.readFlag()) {
124
+ const threads = [];
125
+ for (let i = 0; i < 4; i++) {
126
+ if (bs.readFlag()) {
127
+ threads.push({
128
+ index: i,
129
+ sequence: bs.readInt(5),
130
+ state: bs.readInt(2),
131
+ forward: bs.readFlag(),
132
+ atEnd: bs.readFlag(),
133
+ });
134
+ }
135
+ }
136
+ if (threads.length > 0) {
137
+ result.threads = threads;
138
+ }
139
+ }
140
+ // ImageMask (8 mounted image slots)
141
+ let imageSkinDirty = false;
142
+ if (bs.readFlag()) {
143
+ const images = [];
144
+ for (let i = 0; i < 8; i++) {
145
+ if (bs.readFlag()) {
146
+ const img = { index: i };
147
+ // Optional image datablock reference.
148
+ if (bs.readFlag()) {
149
+ img.dataBlockId = readObjectRef11(bs);
150
+ }
151
+ else {
152
+ img.dataBlockId = 0;
153
+ }
154
+ // FUN_00588870 payload: hasSkin -> (tagIndex|string).
155
+ if (bs.readFlag()) {
156
+ if (bs.readFlag()) {
157
+ img.skinTagIndex = bs.readInt(10);
158
+ imageSkinDirty = true;
159
+ }
160
+ else {
161
+ img.skinName = bs.readString();
162
+ imageSkinDirty = true;
163
+ }
164
+ }
165
+ // Per-slot state flags + 3-bit fire count.
166
+ // The retail Tribes 2 binary (FUN_005ef0e0) packs these in a different
167
+ // order than the V12 SDK source code. Verified via Ghidra decompilation:
168
+ // the binary stores to offsets 0x3ac (triggerDown), 0x398 (loaded),
169
+ // 0x3ad (ammo), 0x3af (wet), 0x3ae (target), confirmed by matching the
170
+ // setImage(slot, db, skin, loaded, ammo, triggerDown) call signature.
171
+ img.triggerDown = bs.readFlag();
172
+ img.loaded = bs.readFlag();
173
+ img.ammo = bs.readFlag();
174
+ img.wet = bs.readFlag();
175
+ img.target = bs.readFlag();
176
+ img.fireCount = bs.readInt(3);
177
+ // Mounted-image firing bit (FUN_005ef0e0):
178
+ // Read when (this+0x18 & 8)==0, i.e., when the object is NOT
179
+ // "properly added" to the scene. Initial block creates and packet
180
+ // stream ghost creates are NOT properly added (isInitial=true),
181
+ // but packet stream ghost updates ARE properly added (isInitial=false).
182
+ if (isInitial) {
183
+ img.imageExtraFlag = bs.readFlag();
184
+ }
185
+ images.push(img);
186
+ }
187
+ }
188
+ if (images.length > 0) {
189
+ result.images = images;
190
+ }
191
+ }
192
+ // CloakMask section (ShapeBase, binary-verified FUN_005ef0e0).
193
+ // Combined mask: ShieldMask | CloakMask | InvincibleMask (0x1c0).
194
+ if (bs.readFlag()) {
195
+ // CloakMask sub-section.
196
+ const hasCloakData = bs.readFlag();
197
+ if (hasCloakData) {
198
+ result.cloaked = bs.readFlag(); // mCloaked (stealth pack active)
199
+ result.isControlled = bs.readFlag(); // bool(getControllingClient())
200
+ const fading = bs.readFlag(); // mFading && mFadeElapsedTime >= mFadeDelay
201
+ result.fading = fading;
202
+ if (fading) {
203
+ result.fadeOut = bs.readFlag(); // mFadeOut (true=fading out, false=fading in)
204
+ result.fadeTime = bs.readF32(); // mFadeTime (duration in seconds)
205
+ }
206
+ else {
207
+ result.fadeVal = bs.readFlag(); // mFadeVal == 1.0 (true=visible, false=hidden)
208
+ }
209
+ }
210
+ const hasStateB = bs.readFlag();
211
+ if (hasStateB) {
212
+ const useStateBFlag = bs.readFlag();
213
+ if (useStateBFlag) {
214
+ const stateBMode = bs.readFlag();
215
+ result.stateBMode = stateBMode;
216
+ if (stateBMode) {
217
+ result.energyPackOn = true;
218
+ }
219
+ else {
220
+ result.energyPackOn = false;
221
+ }
222
+ }
223
+ else {
224
+ result.shieldNormal = bs.readNormalVector(8);
225
+ result.energyPercent = bs.readFloat(5);
226
+ }
227
+ }
228
+ if (bs.readFlag()) {
229
+ result.stateValue1 = bs.readU32();
230
+ result.stateValue2 = bs.readU32();
231
+ }
232
+ }
233
+ if (imageSkinDirty) {
234
+ result.imageSkinDirty = true;
235
+ }
236
+ // MountedMask
237
+ if (bs.readFlag()) {
238
+ if (bs.readFlag()) {
239
+ // Mounting
240
+ result.mountObject = bs.readInt(10);
241
+ result.mountNode = bs.readInt(5); // NumMountPointBits=5
242
+ }
243
+ else {
244
+ result.mountObject = -1; // Unmounting
245
+ }
246
+ }
247
+ return result;
248
+ }
249
+ // ============================================================
250
+ // Player ghost parser
251
+ // ============================================================
252
+ function playerUnpackUpdate(bs, isInitial, conn) {
253
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
254
+ // ImpactMask (only on non-initial updates)
255
+ if (bs.readFlag()) {
256
+ result.impactSound = bs.readInt(3); // ImpactBits=3
257
+ }
258
+ // ActionMask — action animation
259
+ if (bs.readFlag()) {
260
+ result.action = bs.readInt(8); // ActionAnimBits=8
261
+ result.actionHoldAtEnd = bs.readFlag();
262
+ result.actionAtEnd = bs.readFlag();
263
+ result.actionFirstPerson = bs.readFlag();
264
+ if (!result.actionAtEnd) {
265
+ if (bs.readFlag()) {
266
+ result.actionAnimPos = bs.readSignedFloat(6);
267
+ }
268
+ }
269
+ }
270
+ // ActionMask — arm animation
271
+ if (bs.readFlag()) {
272
+ result.armAction = bs.readInt(8); // ActionAnimBits=8
273
+ }
274
+ // Control object shortcut — if controlled by this client, skip rest
275
+ if (bs.readFlag()) {
276
+ return result;
277
+ }
278
+ // MoveMask
279
+ if (bs.readFlag()) {
280
+ result.actionState = bs.readInt(3); // NumStateBits=3
281
+ if (bs.readFlag()) {
282
+ // RecoverState
283
+ result.recoverTicks = bs.readInt(7); // RecoverDelayBits=7
284
+ }
285
+ // FUN_005db2d0: two state flags immediately before compressed position.
286
+ result.moveFlag0 = bs.readFlag();
287
+ result.moveFlag1 = bs.readFlag();
288
+ result.position = bs.readCompressedPoint(conn.compressionPoint);
289
+ // Velocity
290
+ if (bs.readFlag()) {
291
+ // Binary (0x5db850): readInt(13) FIRST, then readNormalVector(10)
292
+ const velMag = bs.readInt(13) / 32.0;
293
+ const velDir = bs.readNormalVector(10);
294
+ result.velocity = {
295
+ x: velDir.x * velMag,
296
+ y: velDir.y * velMag,
297
+ z: velDir.z * velMag,
298
+ };
299
+ }
300
+ else {
301
+ result.velocity = { x: 0, y: 0, z: 0 };
302
+ }
303
+ result.headX = bs.readSignedFloat(6); // * maxLookAngle
304
+ result.headZ = bs.readSignedFloat(6); // * maxLookAngle
305
+ result.rotationZ = bs.readFloat(7) * 2 * Math.PI;
306
+ result.move = readMove(bs);
307
+ result.allowWarp = bs.readFlag();
308
+ }
309
+ // Energy (always present after MoveMask section)
310
+ result.energy = bs.readFloat(5); // EnergyLevelBits=5
311
+ return result;
312
+ }
313
+ function playerReadPacketData(bs, conn) {
314
+ const result = {};
315
+ // ShapeBase::readPacketData
316
+ result.energyLevel = bs.readF32();
317
+ result.rechargeRate = bs.readF32();
318
+ // Player::readPacketData
319
+ result.actionState = bs.readInt(3); // NumStateBits=3
320
+ if (bs.readFlag()) {
321
+ // RecoverState
322
+ result.recoverTicks = bs.readInt(7);
323
+ }
324
+ if (bs.readFlag()) {
325
+ result.jumpDelay = bs.readInt(7); // JumpDelayBits=7
326
+ }
327
+ if (bs.readFlag()) {
328
+ // Not mounted — read full position/velocity
329
+ const pos = {
330
+ x: bs.readF32(),
331
+ y: bs.readF32(),
332
+ z: bs.readF32(),
333
+ };
334
+ result.position = pos;
335
+ // Update compression point (this IS the compression point)
336
+ conn.compressionPoint = pos;
337
+ result.velocity = {
338
+ x: bs.readF32(),
339
+ y: bs.readF32(),
340
+ z: bs.readF32(),
341
+ };
342
+ result.jumpSurfaceLastContact = bs.readInt(4);
343
+ }
344
+ result.headX = bs.readF32();
345
+ result.headZ = bs.readF32();
346
+ result.rotationZ = bs.readF32();
347
+ if (bs.readFlag()) {
348
+ // Has control object (e.g., vehicle being piloted)
349
+ // Binary FUN_005dab20: readInt(10) + resolveGhost + obj->readPacketData(conn, stream)
350
+ const controlGhostIndex = bs.readInt(10);
351
+ result.controlObjectGhost = controlGhostIndex;
352
+ // Recursively call the control object's readPacketData.
353
+ // Look up the ghost class and find its readPacketData parser.
354
+ const controlGhost = conn.ghostTracker.getGhost(controlGhostIndex);
355
+ const controlParser = controlGhost
356
+ ? conn.getGhostParser?.(controlGhost.classId)
357
+ : undefined;
358
+ if (controlParser?.readPacketData) {
359
+ const prevGhostIndex = conn.currentGhostIndex;
360
+ conn.currentGhostIndex = controlGhostIndex;
361
+ result.controlObjectData = controlParser.readPacketData(bs, conn);
362
+ conn.currentGhostIndex = prevGhostIndex;
363
+ }
364
+ }
365
+ result.disableMove = bs.readFlag();
366
+ result.pilot = bs.readFlag();
367
+ return result;
368
+ }
369
+ // ============================================================
370
+ // Vehicle ghost parser
371
+ // ============================================================
372
+ function vehicleUnpackUpdate(bs, isInitial, conn) {
373
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
374
+ result.jetting = bs.readFlag();
375
+ // Control object shortcut — server writes true and returns early,
376
+ // meaning no further Vehicle data follows in the stream.
377
+ if (bs.readFlag()) {
378
+ result._controlledEarlyReturn = true;
379
+ return result;
380
+ }
381
+ // Steering
382
+ result.steeringYaw = bs.readFloat(9);
383
+ result.steeringPitch = bs.readFloat(9);
384
+ result.move = readMove(bs);
385
+ result.frozen = bs.readFlag();
386
+ // PositionMask
387
+ if (bs.readFlag()) {
388
+ result.position = bs.readCompressedPoint(conn.compressionPoint);
389
+ // Angular position as quaternion (4x F32)
390
+ result.angPosition = {
391
+ x: bs.readF32(),
392
+ y: bs.readF32(),
393
+ z: bs.readF32(),
394
+ w: bs.readF32(),
395
+ };
396
+ // Linear momentum (3x F32)
397
+ result.linMomentum = bs.readPoint3F();
398
+ // Angular momentum (3x F32)
399
+ result.angMomentum = bs.readPoint3F();
400
+ }
401
+ // EnergyMask
402
+ if (bs.readFlag()) {
403
+ result.energy = bs.readFloat(8);
404
+ }
405
+ return result;
406
+ }
407
+ function vehicleReadPacketData(bs, conn) {
408
+ // Vehicle::readPacketData (binary FUN_0060d740)
409
+ // ShapeBase::readPacketData → GameBase::readPacketData (empty) + energy + recharge
410
+ const result = {};
411
+ result.energyLevel = bs.readF32();
412
+ result.rechargeRate = bs.readF32();
413
+ // Vehicle-specific rigid body state
414
+ result.steering = { x: bs.readF32(), y: bs.readF32() };
415
+ const linPos = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
416
+ result.linPosition = linPos;
417
+ result.angPosition = {
418
+ x: bs.readF32(),
419
+ y: bs.readF32(),
420
+ z: bs.readF32(),
421
+ w: bs.readF32(),
422
+ };
423
+ result.linMomentum = bs.readPoint3F();
424
+ result.angMomentum = bs.readPoint3F();
425
+ result.disableMove = bs.readFlag();
426
+ result.frozen = bs.readFlag();
427
+ conn.compressionPoint = linPos;
428
+ return result;
429
+ }
430
+ function wheeledVehicleReadPacketData(bs, conn) {
431
+ // WheeledVehicle::readPacketData (binary FUN_00615840)
432
+ // Calls Parent::readPacketData (Vehicle) + braking flag + per-wheel data
433
+ const result = vehicleReadPacketData(bs, conn);
434
+ result.braking = bs.readFlag();
435
+ // wheelCount × 3 F32 (avel, Dy, Dx per wheel)
436
+ // Use ghost wheel count cache (populated during ghost creates)
437
+ let wheelCount = 4;
438
+ const ghostIdx = conn.currentGhostIndex;
439
+ if (ghostIdx !== undefined) {
440
+ const cached = ghostWheelCountCache.get(ghostIdx);
441
+ if (cached !== undefined) {
442
+ wheelCount = cached;
443
+ }
444
+ }
445
+ const wheels = [];
446
+ for (let i = 0; i < wheelCount; i++) {
447
+ wheels.push({
448
+ avel: bs.readF32(),
449
+ dy: bs.readF32(),
450
+ dx: bs.readF32(),
451
+ });
452
+ }
453
+ result.wheels = wheels;
454
+ return result;
455
+ }
456
+ // ============================================================
457
+ // FlyingVehicle ghost parser
458
+ // ============================================================
459
+ function flyingVehicleUnpackUpdate(bs, isInitial, conn) {
460
+ const result = vehicleUnpackUpdate(bs, isInitial, conn);
461
+ // FlyingVehicle has its own control gate flag (separate from Vehicle's).
462
+ // Retail binary removed the V12 ThrustMask wrapper but kept the per-subclass
463
+ // control check: if true, no FlyingVehicle-specific data follows.
464
+ if (bs.readFlag()) {
465
+ return result;
466
+ }
467
+ result.createHeightOn = bs.readFlag();
468
+ result.thrustDirection = bs.readInt(3); // NumThrustBits=3
469
+ return result;
470
+ }
471
+ // ============================================================
472
+ // HoverVehicle ghost parser
473
+ // ============================================================
474
+ function hoverVehicleUnpackUpdate(bs, isInitial, conn) {
475
+ const result = vehicleUnpackUpdate(bs, isInitial, conn);
476
+ result.thrustDirection = bs.readInt(3); // NumThrustBits=3
477
+ return result;
478
+ }
479
+ // ============================================================
480
+ // Item ghost parser
481
+ // ============================================================
482
+ function itemUnpackUpdate(bs, isInitial, conn) {
483
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
484
+ // InitialUpdateMask
485
+ if (bs.readFlag()) {
486
+ result.rotate = bs.readFlag();
487
+ result.isStatic = bs.readFlag();
488
+ result.collideable = bs.readFlag();
489
+ if (bs.readFlag()) {
490
+ result.scale = bs.readPoint3F();
491
+ }
492
+ }
493
+ // ThrowSrcMask
494
+ if (bs.readFlag()) {
495
+ result.collisionObject = bs.readInt(10);
496
+ }
497
+ // RotationMask (only if !rotate)
498
+ if (bs.readFlag()) {
499
+ const zSign = bs.readFlag() ? -1 : 1;
500
+ const angle = bs.readF32();
501
+ result.rotation = { zSign, angle };
502
+ }
503
+ // PositionMask
504
+ if (bs.readFlag()) {
505
+ result.position = bs.readPoint3F();
506
+ const atRest = bs.readFlag();
507
+ result.atRest = atRest;
508
+ if (!atRest) {
509
+ result.velocity = bs.readPoint3F();
510
+ }
511
+ result.warp = bs.readFlag();
512
+ }
513
+ return result;
514
+ }
515
+ // ============================================================
516
+ // StaticShape ghost parser
517
+ // ============================================================
518
+ function staticShapeUnpackUpdate(bs, isInitial, conn) {
519
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
520
+ // PositionMask
521
+ if (bs.readFlag()) {
522
+ // FUN_00602da0 -> FUN_0043c4c0 reads compressed affine transform
523
+ // (position + quaternion xyz + sign bit), not a full MatrixF.
524
+ const transform = bs.readAffineTransform();
525
+ result.transform = transform;
526
+ result.position = transform.position;
527
+ result.scale = bs.readPoint3F();
528
+ }
529
+ // Power state (always present)
530
+ result.powered = bs.readFlag();
531
+ return result;
532
+ }
533
+ function scopeAlwaysShapeUnpackUpdate(bs, isInitial, conn) {
534
+ // ScopeAlwaysShape vtable +0x4c -> FUN_00602da0 (same as StaticShape).
535
+ return staticShapeUnpackUpdate(bs, isInitial, conn);
536
+ }
537
+ function markerUnpackUpdate(bs, _isInitial, _conn) {
538
+ // Marker::unpackUpdate (simPath.cc): Parent::unpackUpdate (SceneObject,
539
+ // no data) then reads just a Point3F position.
540
+ const position = bs.readPoint3F();
541
+ return { position };
542
+ }
543
+ function simpleNetObjectUnpackUpdate(bs, _isInitial, _conn) {
544
+ // SimpleNetObject vtable +0x4c -> FUN_005c5220 (single string).
545
+ return { message: bs.readString() };
546
+ }
547
+ function beaconObjectUnpackUpdate(bs, isInitial, conn) {
548
+ // BeaconObject vtable +0x4c -> FUN_006a3ae0:
549
+ // StaticShape payload + optional 2-bit beacon type.
550
+ const result = staticShapeUnpackUpdate(bs, isInitial, conn);
551
+ if (bs.readFlag()) {
552
+ result.beaconType = bs.readInt(2);
553
+ }
554
+ return result;
555
+ }
556
+ function missionMarkerUnpackUpdate(bs, isInitial, conn) {
557
+ // MissionMarker extends ShapeBase (confirmed by V12 source and empirical
558
+ // testing). The Ghidra decompilation misidentified the parent call — the
559
+ // real parent is ShapeBase::unpackUpdate, not an empty stub.
560
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
561
+ // MissionMarker's own field: PositionMask
562
+ if (bs.readFlag()) {
563
+ const transform = bs.readAffineTransform();
564
+ result.transform = transform;
565
+ result.position = transform.position;
566
+ result.scale = bs.readPoint3F();
567
+ }
568
+ return result;
569
+ }
570
+ function debrisUnpackUpdate(bs, isInitial, conn) {
571
+ // Debris vtable +0x4c -> FUN_006844d0.
572
+ // Confirmed in decompiled binary: calls parent GameBase::unpackUpdate first.
573
+ const result = readGameBaseUpdate(bs, isInitial, conn);
574
+ // 6 × F32
575
+ result.value0 = bs.readF32();
576
+ result.value1 = bs.readF32();
577
+ result.value2 = bs.readF32();
578
+ result.value3 = bs.readF32();
579
+ result.value4 = bs.readF32();
580
+ result.value5 = bs.readF32();
581
+ // 4 × bool (readBool = 8-bit)
582
+ result.bool0 = bs.readBool();
583
+ result.bool1 = bs.readBool();
584
+ result.bool2 = bs.readBool();
585
+ result.bool3 = bs.readBool();
586
+ // 6 × F32
587
+ result.value6 = bs.readF32();
588
+ result.value7 = bs.readF32();
589
+ result.value8 = bs.readF32();
590
+ result.value9 = bs.readF32();
591
+ result.value10 = bs.readF32();
592
+ result.value11 = bs.readF32();
593
+ // 2 × bool
594
+ result.bool4 = bs.readBool();
595
+ result.bool5 = bs.readBool();
596
+ // 3 × F32
597
+ result.value12 = bs.readF32();
598
+ result.value13 = bs.readF32();
599
+ result.value14 = bs.readF32();
600
+ // 1 × bool
601
+ result.bool6 = bs.readBool();
602
+ // 2 × string
603
+ result.string0 = bs.readString();
604
+ result.string1 = bs.readString();
605
+ // 3 conditional object references
606
+ const refs = [];
607
+ for (let i = 0; i < 2; i++) {
608
+ refs.push(bs.readFlag() ? readObjectRef11(bs) : -1);
609
+ }
610
+ result.objectRefs = refs;
611
+ result.objectRef2 = bs.readFlag() ? readObjectRef11(bs) : -1;
612
+ return result;
613
+ }
614
+ function projectileUnpackUpdate(bs, isInitial, conn) {
615
+ // Projectile vtable +0x4c -> FUN_005e3360 (GameBase only).
616
+ return readGameBaseUpdate(bs, isInitial, conn);
617
+ }
618
+ // ============================================================
619
+ // Projectile ghost parsers
620
+ // ============================================================
621
+ function bombProjectileUnpackUpdate(bs, _isInitial, _conn) {
622
+ // BombProjectile vtable +0x4c -> FUN_00636050.
623
+ const result = {};
624
+ // Parent GameBase::unpackUpdate
625
+ if (bs.readFlag()) {
626
+ result.dataBlockId = readObjectRef11(bs);
627
+ }
628
+ if (bs.readFlag()) {
629
+ const hasTargetId = bs.readFlag();
630
+ result.targetId = hasTargetId ? bs.readInt(9) : -1;
631
+ }
632
+ if (!bs.readFlag()) {
633
+ if (bs.readFlag()) {
634
+ result.position = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
635
+ result.velocity = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
636
+ }
637
+ if (!bs.readFlag()) {
638
+ return result;
639
+ }
640
+ result.endPoint = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
641
+ result.endNormal = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
642
+ return result;
643
+ }
644
+ result.position = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
645
+ result.velocity = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
646
+ result.currTick = bs.readInt(12); // nextPow2(0x1000) -> 12 bits
647
+ if (bs.readFlag()) {
648
+ result.resetFlag = true; // FUN_00636ae0 side-effect-only
649
+ }
650
+ if (bs.readFlag()) {
651
+ result.explodePoint = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
652
+ result.explodeNormal = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
653
+ }
654
+ if (bs.readFlag()) {
655
+ result.sourceObject = bs.readInt(11); // nextPow2(0x401) -> 11 bits
656
+ result.sourceSlot = bs.readInt(3); // nextPow2(8) -> 3 bits
657
+ }
658
+ else {
659
+ result.sourceObject = -1;
660
+ result.sourceSlot = -1;
661
+ }
662
+ if (bs.readFlag()) {
663
+ result.vehicleObject = bs.readInt(11); // nextPow2(0x401) -> 11 bits
664
+ }
665
+ else {
666
+ result.vehicleObject = 0;
667
+ }
668
+ return result;
669
+ }
670
+ function grenadeUnpackUpdate(bs, isInitial, conn) {
671
+ // Parent: Projectile → GameBase (Projectile has no override)
672
+ const result = readGameBaseUpdate(bs, isInitial, conn);
673
+ if (bs.readFlag()) {
674
+ // InitialUpdateMask
675
+ result.position = bs.readPoint3F();
676
+ result.velocity = bs.readPoint3F();
677
+ result.currTick = bs.readRangedU32(0, 4095);
678
+ result.quickSplash = bs.readFlag();
679
+ if (bs.readFlag()) {
680
+ result.explodePoint = bs.readPoint3F();
681
+ result.explodeNormal = bs.readPoint3F();
682
+ }
683
+ if (bs.readFlag()) {
684
+ result.sourceObject = bs.readRangedU32(0, 1024);
685
+ result.sourceSlot = bs.readRangedU32(0, 7);
686
+ }
687
+ if (bs.readFlag()) {
688
+ result.vehicleObject = bs.readRangedU32(0, 1024);
689
+ }
690
+ }
691
+ else {
692
+ // Non-initial
693
+ if (bs.readFlag()) {
694
+ // BounceMask
695
+ result.position = bs.readPoint3F();
696
+ result.velocity = bs.readPoint3F();
697
+ }
698
+ if (bs.readFlag()) {
699
+ // ExplosionMask
700
+ result.explodePoint = bs.readPoint3F();
701
+ result.explodeNormal = bs.readPoint3F();
702
+ }
703
+ }
704
+ return result;
705
+ }
706
+ function seekerUnpackUpdate(bs, isInitial, conn) {
707
+ // SeekerProjectile::unpackUpdate in build 25034 is FUN_0063c010
708
+ // (vtable PTR_FUN_007ae54c + 0x4c), with parent GameBase::unpackUpdate.
709
+ const result = readGameBaseUpdate(bs, isInitial, conn);
710
+ const fullStateMask = bs.readFlag();
711
+ if (!fullStateMask) {
712
+ // 0x0063c010 non-full path
713
+ const isExplosion = bs.readFlag();
714
+ if (isExplosion) {
715
+ // FUN_00639370: explode(position, normal) — SeekerProjectile detonation.
716
+ result.explodePosition = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
717
+ result.explodeNormal = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
718
+ return result;
719
+ }
720
+ result.position = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
721
+ result.velocity = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
722
+ const hasTargetInfo = bs.readFlag();
723
+ if (hasTargetInfo) {
724
+ const hasTargetGhost = bs.readFlag();
725
+ if (!hasTargetGhost) {
726
+ result.targetDirection = {
727
+ x: bs.readF32(),
728
+ y: bs.readF32(),
729
+ z: bs.readF32(),
730
+ };
731
+ result.targetMode = 1;
732
+ }
733
+ else {
734
+ // getBitCount(getNextPow2(0x401)) -> 11 bits
735
+ result.targetGhost = bs.readInt(11);
736
+ result.targetMode = 0;
737
+ }
738
+ }
739
+ else {
740
+ result.targetMode = 2;
741
+ }
742
+ return result;
743
+ }
744
+ // 0x0063c010 full-state path
745
+ result.position = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
746
+ result.velocity = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
747
+ result.orientation = { x: bs.readF32(), y: bs.readF32(), z: bs.readF32() };
748
+ // Optional source object + slot
749
+ if (bs.readFlag()) {
750
+ result.sourceObject = bs.readInt(11); // getBitCount(getNextPow2(0x401))
751
+ result.sourceSlot = bs.readInt(3); // getBitCount(getNextPow2(8))
752
+ }
753
+ else {
754
+ result.sourceObject = -1;
755
+ result.sourceSlot = -1;
756
+ }
757
+ // Optional target descriptor
758
+ if (bs.readFlag()) {
759
+ const hasTargetGhost = bs.readFlag();
760
+ if (!hasTargetGhost) {
761
+ result.targetDirection = {
762
+ x: bs.readF32(),
763
+ y: bs.readF32(),
764
+ z: bs.readF32(),
765
+ };
766
+ result.targetMode = 1;
767
+ }
768
+ else {
769
+ result.targetGhost = bs.readInt(11); // getBitCount(getNextPow2(0x401))
770
+ result.targetMode = 0;
771
+ }
772
+ }
773
+ else {
774
+ result.targetMode = 2;
775
+ }
776
+ // Final timeout/alive flag branch in FUN_0063c010.
777
+ result.timeoutReset = bs.readFlag();
778
+ return result;
779
+ }
780
+ // ============================================================
781
+ // Turret ghost parser
782
+ // ============================================================
783
+ function turretUnpackUpdate(bs, isInitial, conn) {
784
+ // Turret extends StaticShape (confirmed in decompiled binary FUN_00655f60
785
+ // calls FUN_00602da0 = StaticShape::unpackUpdate as parent)
786
+ const result = staticShapeUnpackUpdate(bs, isInitial, conn);
787
+ // Capacitor energy (Tribes 2 specific — not in V12/TorqueSDK)
788
+ if (bs.readFlag()) {
789
+ result.capacitorEnergy = bs.readFloat(8);
790
+ }
791
+ // Control client shortcut
792
+ if (bs.readFlag()) {
793
+ return result;
794
+ }
795
+ // Barrel rotation state
796
+ if (bs.readFlag()) {
797
+ result.phi = bs.readFloat(10);
798
+ result.theta = bs.readFloat(10);
799
+ result.activationLevel = bs.readFloat(8);
800
+ }
801
+ return result;
802
+ }
803
+ // ============================================================
804
+ // InteriorInstance ghost parser (partial — can't parse light groups without resource)
805
+ // ============================================================
806
+ function interiorUnpackUpdate(bs, _isInitial, _conn) {
807
+ const result = {};
808
+ if (bs.readFlag()) {
809
+ // InitMask — full initial state
810
+ result.crc = bs.readU32();
811
+ result.interiorFile = bs.readString();
812
+ result.showTerrainInside = bs.readFlag();
813
+ result.transform = bs.readMatrixF();
814
+ result.scale = bs.readPoint3F();
815
+ result.alarmState = bs.readFlag();
816
+ result.skinBase = bs.readString();
817
+ if (bs.readFlag()) {
818
+ result.audioProfileId = readObjectRef11(bs);
819
+ }
820
+ if (bs.readFlag()) {
821
+ result.audioEnvironmentId = readObjectRef11(bs);
822
+ }
823
+ }
824
+ else {
825
+ // Normal update
826
+ if (bs.readFlag()) {
827
+ // TransformMask
828
+ result.transform = bs.readMatrixF();
829
+ result.scale = bs.readPoint3F();
830
+ }
831
+ result.alarmState = bs.readFlag();
832
+ // LightUpdateGrouper — iterates over bits 3-10 (8 slots), each slot
833
+ // containing a dirty flag + N active flags (one per light in that group).
834
+ // The loop exits at the first bit position with 0 keys. We don't know
835
+ // the interior resource so we can't determine key counts. For interiors
836
+ // with 0 animated lights (the common case), no bits are written here.
837
+ // We assume 0 and continue to the remaining masks.
838
+ // SkinBaseMask
839
+ if (bs.readFlag()) {
840
+ result.skinBase = bs.readString();
841
+ }
842
+ // AudioMask
843
+ if (bs.readFlag()) {
844
+ if (bs.readFlag()) {
845
+ result.audioProfileId = readObjectRef11(bs);
846
+ }
847
+ if (bs.readFlag()) {
848
+ result.audioEnvironmentId = readObjectRef11(bs);
849
+ }
850
+ }
851
+ }
852
+ return result;
853
+ }
854
+ // ============================================================
855
+ // Camera ghost parser (simple)
856
+ // ============================================================
857
+ function cameraUnpackUpdate(bs, isInitial, conn) {
858
+ // Camera::unpackUpdate (FUN_005cc8c0):
859
+ // 1) ShapeBase::unpackUpdate (FUN_005ef0e0)
860
+ // 2) if (control object shortcut flag) return
861
+ // 3) if (camera update flag) read 5 x F32
862
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
863
+ // Controlled by local connection: no additional payload in this update.
864
+ if (bs.readFlag()) {
865
+ return result;
866
+ }
867
+ // Camera-specific payload (5 floats in retail Tribes2.exe).
868
+ if (bs.readFlag()) {
869
+ result.posX = bs.readF32();
870
+ result.posY = bs.readF32();
871
+ result.posZ = bs.readF32();
872
+ result.fovOrDist = bs.readF32();
873
+ result.orbitParam = bs.readF32();
874
+ }
875
+ return result;
876
+ }
877
+ function cameraReadPacketData(bs, conn) {
878
+ const result = {};
879
+ // Camera::readPacketData (FUN_005cc530 / FUN_005cc650):
880
+ // Parent::readPacketData + absolute camera transform basis + mode-specific orbit payload.
881
+ result.energyLevel = bs.readF32();
882
+ result.rechargeRate = bs.readF32();
883
+ const pos = {
884
+ x: bs.readF32(),
885
+ y: bs.readF32(),
886
+ z: bs.readF32(),
887
+ };
888
+ result.position = pos;
889
+ result.rotX = bs.readF32();
890
+ result.rotZ = bs.readF32();
891
+ // writeRangedU32(mode, 0, 4) -> 3 bits.
892
+ const cameraMode = bs.readInt(3);
893
+ result.cameraMode = cameraMode;
894
+ // Modes 3/4 include orbit-distance payload.
895
+ if (cameraMode === 3 || cameraMode === 4) {
896
+ result.minOrbitDist = bs.readF32();
897
+ result.maxOrbitDist = bs.readF32();
898
+ result.curOrbitDist = bs.readF32();
899
+ // OrbitObjectMode: observing flag + always-read 10-bit target ghost index.
900
+ if (cameraMode === 3) {
901
+ result.observingClientObject = bs.readFlag();
902
+ result.orbitObjectGhostIndex = bs.readInt(10);
903
+ }
904
+ // OrbitPointMode: NetConnection::readCompressed using current compression point.
905
+ if (cameraMode === 4) {
906
+ result.orbitPoint = bs.readCompressedPoint(conn.compressionPoint, 0.01);
907
+ }
908
+ }
909
+ // The camera packet updates the connection compression point to absolute camera pos.
910
+ conn.compressionPoint = pos;
911
+ return result;
912
+ }
913
+ // ============================================================
914
+ // LinearProjectile ghost parser
915
+ // ============================================================
916
+ function linearProjectileUnpackUpdate(bs, isInitial, conn) {
917
+ // Parent: LinearProjectile → Projectile → GameBase
918
+ const result = readGameBaseUpdate(bs, isInitial, conn);
919
+ if (bs.readFlag()) {
920
+ // InitialUpdateMask
921
+ if (bs.readFlag()) {
922
+ // Hidden/already exploded (mHidden=true): initial create of finished projectile
923
+ // Binary: sets mHidden=1, reads pos+direction, computes endpoint, reads decal flag
924
+ result.hidden = true;
925
+ result.explodePosition = bs.readCompressedPoint(conn.compressionPoint);
926
+ result.explodeNormal = bs.readNormalVector(14); // binary: 0xe
927
+ result.endedWithDecal = bs.readFlag();
928
+ }
929
+ else {
930
+ // Live projectile initial update
931
+ result.position = bs.readCompressedPoint(conn.compressionPoint);
932
+ result.direction = bs.readNormalVector(14); // binary: 0xe
933
+ result.currTick = bs.readRangedU32(0, 511); // nextPow2(0x200)=9 bits
934
+ if (bs.readFlag()) {
935
+ result.sourceObject = bs.readInt(10); // nextPow2(0x400)=10 bits
936
+ result.sourceSlot = bs.readRangedU32(0, 7); // nextPow2(8)=3 bits
937
+ if (bs.readFlag()) {
938
+ result.excessVel = bs.readRangedU32(0, 255); // nextPow2(0x100)=8 bits
939
+ result.excessDir = bs.readNormalVector(7);
940
+ }
941
+ }
942
+ if (bs.readFlag()) {
943
+ result.vehicleObject = bs.readInt(10); // nextPow2(0x400)=10 bits
944
+ }
945
+ }
946
+ }
947
+ else {
948
+ // Non-initial: explosion notification
949
+ result.explodePosition = bs.readCompressedPoint(conn.compressionPoint);
950
+ result.explodeNormal = bs.readNormalVector(14); // binary: 0xe
951
+ result.endedWithDecal = bs.readFlag();
952
+ }
953
+ return result;
954
+ }
955
+ // ============================================================
956
+ // ELFProjectile ghost parser
957
+ // ============================================================
958
+ function elfProjectileUnpackUpdate(bs, isInitial, conn) {
959
+ // Parent: ELFProjectile → GameBase
960
+ const result = readGameBaseUpdate(bs, isInitial, conn);
961
+ if (bs.readFlag()) {
962
+ if (bs.readFlag()) {
963
+ result.sourceObject = bs.readRangedU32(0, 1024);
964
+ result.sourceSlot = bs.readRangedU32(0, 7);
965
+ result.targetObject = bs.readRangedU32(0, 1024);
966
+ }
967
+ }
968
+ return result;
969
+ }
970
+ // ============================================================
971
+ // RepairProjectile ghost parser
972
+ // ============================================================
973
+ function repairProjectileUnpackUpdate(bs, isInitial, conn) {
974
+ // Parent: RepairProjectile → GameBase
975
+ const result = readGameBaseUpdate(bs, isInitial, conn);
976
+ if (bs.readFlag()) {
977
+ // InitialUpdateMask
978
+ if (bs.readFlag()) {
979
+ result.sourceObject = bs.readRangedU32(0, 1024);
980
+ result.sourceSlot = bs.readRangedU32(0, 7);
981
+ result.repairingObject = bs.readRangedU32(0, 1024);
982
+ }
983
+ }
984
+ // Non-initial update: no data
985
+ return result;
986
+ }
987
+ // ============================================================
988
+ // TargetProjectile ghost parser
989
+ // ============================================================
990
+ function targetProjectileUnpackUpdate(bs, isInitial, conn) {
991
+ // Parent: TargetProjectile → Projectile → GameBase
992
+ const result = readGameBaseUpdate(bs, isInitial, conn);
993
+ if (bs.readFlag()) {
994
+ // InitialUpdateMask
995
+ result.initialPosition = bs.readPoint3F();
996
+ result.endPos = bs.readPoint3F();
997
+ result.truncated = bs.readFlag();
998
+ if (bs.readFlag()) {
999
+ result.sourceObject = bs.readRangedU32(0, 1024);
1000
+ result.sourceSlot = bs.readRangedU32(0, 7);
1001
+ result.clientOwned = bs.readFlag();
1002
+ }
1003
+ }
1004
+ else {
1005
+ // Swing update
1006
+ if (bs.readFlag()) {
1007
+ result.sourceObject = bs.readRangedU32(0, 1024);
1008
+ result.sourceSlot = bs.readRangedU32(0, 7);
1009
+ result.clientOwned = bs.readFlag();
1010
+ }
1011
+ else {
1012
+ result.initialPosition = bs.readPoint3F();
1013
+ }
1014
+ result.endPos = bs.readPoint3F();
1015
+ result.truncated = bs.readFlag();
1016
+ }
1017
+ return result;
1018
+ }
1019
+ // ============================================================
1020
+ // WayPoint ghost parser
1021
+ // ============================================================
1022
+ function wayPointUnpackUpdate(bs, isInitial, conn) {
1023
+ // WayPoint extends MissionMarker (V12 source: missionMarker.h)
1024
+ const result = missionMarkerUnpackUpdate(bs, isInitial, conn);
1025
+ // WayPoint additions
1026
+ if (bs.readFlag()) {
1027
+ result.name = bs.readString();
1028
+ }
1029
+ if (bs.readFlag()) {
1030
+ result.teamId = bs.readS32();
1031
+ }
1032
+ if (bs.readFlag()) {
1033
+ result.hidden = bs.readFlag();
1034
+ }
1035
+ return result;
1036
+ }
1037
+ // ============================================================
1038
+ // SpawnSphere ghost parser
1039
+ // ============================================================
1040
+ function spawnSphereUnpackUpdate(bs, isInitial, conn) {
1041
+ // SpawnSphere extends MissionMarker (V12 source: missionMarker.h)
1042
+ const result = missionMarkerUnpackUpdate(bs, isInitial, conn);
1043
+ // SpawnSphere additions
1044
+ if (bs.readFlag()) {
1045
+ result.radius = bs.readF32();
1046
+ result.sphereWeight = bs.readF32();
1047
+ result.indoorWeight = bs.readF32();
1048
+ result.outdoorWeight = bs.readF32();
1049
+ }
1050
+ return result;
1051
+ }
1052
+ // ============================================================
1053
+ // ForceFieldBare ghost parser
1054
+ // ============================================================
1055
+ function forceFieldBareUnpackUpdate(bs, _isInitial, _conn) {
1056
+ // ForceFieldBare::unpackUpdate (FUN_00676d30):
1057
+ // GameBase parent, then two-level transform flags, then StateChangeMask.
1058
+ const result = readGameBaseUpdate(bs, _isInitial, _conn);
1059
+ // InitialUpdateMask flag — if true, reads transform+scale (initial path)
1060
+ if (bs.readFlag()) {
1061
+ result.transform = bs.readAffineTransform();
1062
+ result.scale = bs.readPoint3F();
1063
+ }
1064
+ else {
1065
+ // Non-initial: TransformMask flag
1066
+ if (bs.readFlag()) {
1067
+ result.transform = bs.readAffineTransform();
1068
+ result.scale = bs.readPoint3F();
1069
+ }
1070
+ }
1071
+ // StateChangeMask
1072
+ if (bs.readFlag()) {
1073
+ const state = bs.readInt(2); // 0=Open, 1=Opening, 2=Closing, 3=Closed
1074
+ result.state = state;
1075
+ if (state === 3) {
1076
+ // Closed: position = 0 (not read from stream)
1077
+ result.position = 0;
1078
+ }
1079
+ else if (state === 0) {
1080
+ // Open: position = datablock fadeMS (not read from stream)
1081
+ }
1082
+ else {
1083
+ // Opening (1) or Closing (2): read position from stream
1084
+ result.position = bs.readU32();
1085
+ }
1086
+ }
1087
+ return result;
1088
+ }
1089
+ // ============================================================
1090
+ // TSStatic ghost parser
1091
+ // ============================================================
1092
+ function tsStaticUnpackUpdate(bs, _isInitial, _conn) {
1093
+ const result = {};
1094
+ // TSStatic::unpackUpdate (FUN_00690580) has no update-mask flags:
1095
+ // it always serializes MatrixF transform, Point3F scale, and shapeName.
1096
+ result.transform = bs.readMatrixF();
1097
+ result.scale = bs.readPoint3F();
1098
+ result.shapeName = bs.readString();
1099
+ return result;
1100
+ }
1101
+ // ============================================================
1102
+ // TerrainBlock ghost parser
1103
+ // ============================================================
1104
+ function terrainBlockUnpackUpdate(bs, _isInitial, _conn) {
1105
+ // TerrainBlock extends SceneObject (NOT GameBase).
1106
+ // V12 source: terrain/terrData.cc lines 910-941
1107
+ // Format:
1108
+ // flag(InitMask) → U32 CRC + readString(terrFileName)
1109
+ // + readString(detailTextureName) + U32 squareSize
1110
+ // + U32 count + U32[count] emptySquareRuns
1111
+ // else → flag(EmptyMask) → U32 count + U32[count] emptySquareRuns
1112
+ const result = {};
1113
+ if (bs.readFlag()) {
1114
+ // InitMask
1115
+ result.crc = bs.readU32();
1116
+ result.terrFileName = bs.readString();
1117
+ result.detailTextureName = bs.readString();
1118
+ result.squareSize = bs.readU32();
1119
+ const size = bs.readU32();
1120
+ const emptySquareRuns = [];
1121
+ for (let i = 0; i < size; i++) {
1122
+ emptySquareRuns.push(bs.readU32());
1123
+ }
1124
+ result.emptySquareRuns = emptySquareRuns;
1125
+ result.emptySquareRunCount = size;
1126
+ }
1127
+ else {
1128
+ // Normal update
1129
+ if (bs.readFlag()) {
1130
+ // EmptyMask
1131
+ const size = bs.readU32();
1132
+ const emptySquareRuns = [];
1133
+ for (let i = 0; i < size; i++) {
1134
+ emptySquareRuns.push(bs.readU32());
1135
+ }
1136
+ result.emptySquareRuns = emptySquareRuns;
1137
+ result.emptySquareRunCount = size;
1138
+ }
1139
+ }
1140
+ return result;
1141
+ }
1142
+ // ============================================================
1143
+ // Sun ghost parser
1144
+ // ============================================================
1145
+ function sunUnpackUpdate(bs, _isInitial, _conn) {
1146
+ // Binary FUN_005b1620: TWO separate readFlag blocks.
1147
+ // Block 1: 5 texture/environment map name strings
1148
+ // Block 2: 19 F32 values (direction + color + ambient + extra light properties)
1149
+ const result = {};
1150
+ // Block 1: texture names (5 × readSTString)
1151
+ if (bs.readFlag()) {
1152
+ const textures = [];
1153
+ for (let i = 0; i < 5; i++) {
1154
+ textures.push(bs.readString());
1155
+ }
1156
+ result.textures = textures;
1157
+ }
1158
+ // Block 2: light properties (19 × F32)
1159
+ if (bs.readFlag()) {
1160
+ const values = [];
1161
+ for (let i = 0; i < 19; i++) {
1162
+ values.push(bs.readF32());
1163
+ }
1164
+ result.direction = { x: values[0], y: values[1], z: values[2] };
1165
+ result.color = { r: values[3], g: values[4], b: values[5], a: values[6] };
1166
+ result.ambient = { r: values[7], g: values[8], b: values[9], a: values[10] };
1167
+ // Remaining 8 values are additional light properties in build 25034
1168
+ result.extraLightProps = values.slice(11);
1169
+ }
1170
+ return result;
1171
+ }
1172
+ // ============================================================
1173
+ // Sky ghost parser
1174
+ // ============================================================
1175
+ function skyUnpackUpdate(bs, _isInitial, _conn) {
1176
+ const result = {};
1177
+ // Decompiled from FUN_005ab580 (Sky::unpackUpdate) / FUN_005abfd0 (packUpdate).
1178
+ // This layout is significantly larger than Torque-era Sky examples and must
1179
+ // stay aligned with the retail Tribes 2 binary.
1180
+ // InitialUpdateMask
1181
+ if (bs.readFlag()) {
1182
+ result.materialList = bs.readString();
1183
+ result.fogColor = { r: bs.readF32(), g: bs.readF32(), b: bs.readF32() };
1184
+ const fogVolumeCount = bs.readU32();
1185
+ if (fogVolumeCount > 64) {
1186
+ throw new Error(`Invalid Sky fogVolumeCount: ${fogVolumeCount}`);
1187
+ }
1188
+ result.fogVolumeCount = fogVolumeCount;
1189
+ // bool fields are read via Stream::read(1) in the binary (byte-sized bool)
1190
+ result.useSkyTextures = bs.readBool();
1191
+ result.renderBottomTexture = bs.readBool();
1192
+ result.skySolidColor = { r: bs.readF32(), g: bs.readF32(), b: bs.readF32() };
1193
+ result.windEffectPrecipitation = bs.readBool();
1194
+ const fogVolumes = [];
1195
+ for (let i = 0; i < fogVolumeCount; i++) {
1196
+ // V12 sky.cc writes: visibleDistance, minHeight, maxHeight, color
1197
+ fogVolumes.push({
1198
+ visibleDistance: bs.readF32(),
1199
+ minHeight: bs.readF32(),
1200
+ maxHeight: bs.readF32(),
1201
+ color: { r: bs.readF32(), g: bs.readF32(), b: bs.readF32() },
1202
+ });
1203
+ }
1204
+ result.fogVolumes = fogVolumes;
1205
+ const cloudLayers = [];
1206
+ for (let i = 0; i < 3; i++) {
1207
+ cloudLayers.push({
1208
+ texture: bs.readString(),
1209
+ heightPercent: bs.readF32(),
1210
+ speed: bs.readF32(),
1211
+ });
1212
+ }
1213
+ result.cloudLayers = cloudLayers;
1214
+ // Initial wind velocity vector
1215
+ result.windVelocity = bs.readPoint3F();
1216
+ result.stormCurrent = bs.readF32();
1217
+ if (bs.readFlag()) {
1218
+ result.stormInit = {
1219
+ startPct: bs.readF32(),
1220
+ duration: bs.readF32(),
1221
+ indexOrMode: bs.readF32(),
1222
+ startTime: bs.readF32(),
1223
+ targetPct: bs.readF32(),
1224
+ };
1225
+ }
1226
+ }
1227
+ // StormCloudsShow mask
1228
+ if (bs.readFlag()) {
1229
+ result.stormCloudsOn = bs.readBool();
1230
+ }
1231
+ // StormFogShow mask
1232
+ if (bs.readFlag()) {
1233
+ result.stormFogOn = bs.readBool();
1234
+ }
1235
+ // Distance mask (visible/fog distances)
1236
+ if (bs.readFlag()) {
1237
+ result.visibleDistance = bs.readF32();
1238
+ result.fogDistance = bs.readF32();
1239
+ }
1240
+ // Storm/falloff mask
1241
+ if (bs.readFlag()) {
1242
+ result.stormType = bs.readF32();
1243
+ result.stormMagnitude = bs.readF32();
1244
+ }
1245
+ // Storm timeline mask
1246
+ if (bs.readFlag()) {
1247
+ result.stormTimeline = {
1248
+ startPct: bs.readF32(),
1249
+ duration: bs.readF32(),
1250
+ indexOrMode: bs.readF32(),
1251
+ };
1252
+ }
1253
+ // Storm cloud profile mask
1254
+ if (bs.readFlag()) {
1255
+ result.stormCloudProfile = {
1256
+ enabled: bs.readF32(),
1257
+ value0: bs.readF32(),
1258
+ value1: bs.readF32(),
1259
+ value2: bs.readF32(),
1260
+ };
1261
+ }
1262
+ // Wind velocity update mask
1263
+ if (bs.readFlag()) {
1264
+ result.windVelocity = bs.readPoint3F();
1265
+ }
1266
+ return result;
1267
+ }
1268
+ // ============================================================
1269
+ // Lightning ghost parser (GameBase subclass)
1270
+ // ============================================================
1271
+ function lightningUnpackUpdate(bs, isInitial, conn) {
1272
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1273
+ if (bs.readFlag()) {
1274
+ // InitialUpdateMask — only sent on create
1275
+ result.position = bs.readPoint3F();
1276
+ result.scale = bs.readPoint3F();
1277
+ result.strikeWidth = bs.readF32();
1278
+ result.chanceToHitTarget = bs.readF32();
1279
+ result.strikeRadius = bs.readF32();
1280
+ result.boltStartRadius = bs.readF32();
1281
+ result.color = { r: bs.readF32(), g: bs.readF32(), b: bs.readF32() };
1282
+ result.fadeColor = { r: bs.readF32(), g: bs.readF32(), b: bs.readF32() };
1283
+ result.useFog = bs.readInt(8) !== 0; // bool read as U8
1284
+ result.strikesPerMinute = bs.readF32();
1285
+ }
1286
+ return result;
1287
+ }
1288
+ // ============================================================
1289
+ // WaterBlock ghost parser (SceneObject subclass, NO GameBase)
1290
+ // ============================================================
1291
+ const WC_NUM_SUBMERGE_TEX = 2;
1292
+ function waterBlockUnpackUpdate(bs, _isInitial, _conn) {
1293
+ const result = {};
1294
+ // No flag checks — always sends everything
1295
+ result.transform = bs.readAffineTransform();
1296
+ result.scale = bs.readPoint3F();
1297
+ result.surfaceName = bs.readString();
1298
+ result.envMapName = bs.readString();
1299
+ const submergeNames = [];
1300
+ for (let i = 0; i < WC_NUM_SUBMERGE_TEX; i++) {
1301
+ submergeNames.push(bs.readString());
1302
+ }
1303
+ result.submergeNames = submergeNames;
1304
+ result.liquidType = bs.readS32();
1305
+ result.density = bs.readF32();
1306
+ result.viscosity = bs.readF32();
1307
+ result.waveMagnitude = bs.readF32();
1308
+ result.surfaceOpacity = bs.readF32();
1309
+ result.envMapIntensity = bs.readF32();
1310
+ result.removeWetEdges = bs.readInt(8) !== 0; // bool as U8
1311
+ if (bs.readFlag()) {
1312
+ result.audioEnvironmentId = readObjectRef11(bs);
1313
+ }
1314
+ return result;
1315
+ }
1316
+ // ============================================================
1317
+ // MissionArea ghost parser (NetObject subclass)
1318
+ // ============================================================
1319
+ function missionAreaUnpackUpdate(bs, _isInitial, _conn) {
1320
+ const result = {};
1321
+ if (bs.readFlag()) {
1322
+ // RectI: point.x, point.y, extent.x, extent.y
1323
+ result.area = {
1324
+ x: bs.readS32(),
1325
+ y: bs.readS32(),
1326
+ w: bs.readS32(),
1327
+ h: bs.readS32(),
1328
+ };
1329
+ result.flightCeiling = bs.readF32();
1330
+ result.flightCeilingRange = bs.readF32();
1331
+ }
1332
+ return result;
1333
+ }
1334
+ // ============================================================
1335
+ // Splash ghost parser (GameBase subclass)
1336
+ // ============================================================
1337
+ function splashUnpackUpdate(bs, isInitial, conn) {
1338
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1339
+ if (bs.readFlag()) {
1340
+ result.position = bs.readPoint3F();
1341
+ }
1342
+ return result;
1343
+ }
1344
+ // ============================================================
1345
+ // Shockwave ghost parser (GameBase subclass)
1346
+ // ============================================================
1347
+ function shockwaveUnpackUpdate(bs, isInitial, conn) {
1348
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1349
+ if (bs.readFlag()) {
1350
+ result.position = bs.readPoint3F();
1351
+ result.normal = bs.readPoint3F();
1352
+ }
1353
+ return result;
1354
+ }
1355
+ // ============================================================
1356
+ // FireballAtmosphere ghost parser (GameBase subclass)
1357
+ // ============================================================
1358
+ function fireballAtmosphereUnpackUpdate(bs, isInitial, conn) {
1359
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1360
+ if (bs.readFlag()) {
1361
+ result.dropRadius = bs.readF32();
1362
+ result.dropsPerMinute = bs.readF32();
1363
+ result.maxDropAngle = bs.readF32();
1364
+ result.minDropAngle = bs.readF32();
1365
+ result.startVelocity = bs.readF32();
1366
+ result.dropHeight = bs.readF32();
1367
+ result.dropDir = bs.readPoint3F();
1368
+ }
1369
+ return result;
1370
+ }
1371
+ // ============================================================
1372
+ // VehicleBlocker ghost parser (SceneObject subclass, NO GameBase)
1373
+ // ============================================================
1374
+ function vehicleBlockerUnpackUpdate(bs, _isInitial, _conn) {
1375
+ // VehicleBlocker instance vtable is 0x00796ca4. Slot +0x4c resolves to
1376
+ // FUN_005b9d40, which always reads:
1377
+ // 16 x U32 (MatrixF)
1378
+ // 6 x U32 (two Point3F corners)
1379
+ // No flags are present in this payload.
1380
+ const result = {};
1381
+ result.transform = bs.readMatrixF();
1382
+ result.boundsMin = bs.readPoint3F();
1383
+ result.boundsMax = bs.readPoint3F();
1384
+ return result;
1385
+ }
1386
+ // ============================================================
1387
+ // ParticleEmissionDummy ghost parser (GameBase subclass)
1388
+ // ============================================================
1389
+ function particleEmissionDummyUnpackUpdate(bs, isInitial, conn) {
1390
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1391
+ // Always writes transform + scale + optional datablock
1392
+ result.transform = bs.readMatrixF();
1393
+ result.scale = bs.readPoint3F();
1394
+ if (bs.readFlag()) {
1395
+ result.emitterDatablockId = readObjectRef11(bs);
1396
+ }
1397
+ return result;
1398
+ }
1399
+ // ============================================================
1400
+ // Precipitation ghost parser (GameBase subclass)
1401
+ // ============================================================
1402
+ function precipitationUnpackUpdate(bs, isInitial, conn) {
1403
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1404
+ // Mask order from decompiled binary FUN_00681260:
1405
+ // InitMask, StormShowMask, StormMask, PercentageMask
1406
+ // (Note: V12 source has different order; binary is authoritative)
1407
+ // InitMask
1408
+ if (bs.readFlag()) {
1409
+ result.percentage = bs.readF32();
1410
+ const colorCount = bs.readS32();
1411
+ result.colorCount = colorCount;
1412
+ if (colorCount < 0 || colorCount > 3) {
1413
+ throw new Error(`Invalid precipitation colorCount: ${colorCount}`);
1414
+ }
1415
+ // Colors use packed ColorF (FUN_0043f040): 4×U8 = 32 bits each, NOT 4×F32
1416
+ const colors = [];
1417
+ for (let i = 0; i < colorCount; i++) {
1418
+ colors.push(readPackedColorF(bs));
1419
+ }
1420
+ result.colors = colors;
1421
+ result.offsetSpeed = bs.readF32();
1422
+ result.minVelocity = bs.readF32();
1423
+ result.maxVelocity = bs.readF32();
1424
+ result.maxDrops = bs.readS32();
1425
+ result.maxRadius = bs.readF32();
1426
+ // Inner flag: storm initialization data (3 F32s)
1427
+ if (bs.readFlag()) {
1428
+ result.stormLastTime = bs.readF32();
1429
+ result.stormTime = bs.readF32();
1430
+ result.stormEndPercentage = bs.readF32();
1431
+ }
1432
+ }
1433
+ // StormShowMask
1434
+ if (bs.readFlag()) {
1435
+ result.stormPrecipitationOn = bs.readBool();
1436
+ }
1437
+ // StormMask
1438
+ if (bs.readFlag()) {
1439
+ result.stormTime = bs.readF32();
1440
+ result.stormEndPercentage = bs.readF32();
1441
+ }
1442
+ // PercentageMask
1443
+ if (bs.readFlag()) {
1444
+ result.percentageUpdate = bs.readF32();
1445
+ }
1446
+ return result;
1447
+ }
1448
+ // ============================================================
1449
+ // SniperProjectile ghost parser
1450
+ // ============================================================
1451
+ function sniperProjectileUnpackUpdate(bs, isInitial, conn) {
1452
+ // Parent: SniperProjectile → LinearProjectile → Projectile → GameBase
1453
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1454
+ if (bs.readFlag()) {
1455
+ // InitialUpdateMask
1456
+ result.energyPercentage = bs.readFloat(7);
1457
+ result.initialPosition = bs.readPoint3F();
1458
+ result.endPos = bs.readPoint3F();
1459
+ result.truncated = bs.readFlag();
1460
+ result.hitWater = bs.readFlag();
1461
+ if (bs.readFlag()) {
1462
+ result.sourceObject = bs.readRangedU32(0, 1024);
1463
+ result.sourceSlot = bs.readRangedU32(0, 7);
1464
+ result.clientOwned = bs.readFlag();
1465
+ }
1466
+ }
1467
+ else {
1468
+ // Swing update
1469
+ if (bs.readFlag()) {
1470
+ result.sourceObject = bs.readRangedU32(0, 1024);
1471
+ result.sourceSlot = bs.readRangedU32(0, 7);
1472
+ result.clientOwned = bs.readFlag();
1473
+ }
1474
+ else {
1475
+ result.initialPosition = bs.readPoint3F();
1476
+ }
1477
+ result.endPos = bs.readPoint3F();
1478
+ result.truncated = bs.readFlag();
1479
+ }
1480
+ return result;
1481
+ }
1482
+ // ============================================================
1483
+ // ShockLanceProjectile ghost parser
1484
+ // ============================================================
1485
+ function shockLanceProjectileUnpackUpdate(bs, isInitial, conn) {
1486
+ // Parent: ShockLanceProjectile → Projectile → GameBase
1487
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1488
+ // Target (always written)
1489
+ if (bs.readFlag()) {
1490
+ result.targetObject = bs.readRangedU32(0, 1024);
1491
+ }
1492
+ // Initial update
1493
+ if (bs.readFlag()) {
1494
+ result.start = bs.readPoint3F();
1495
+ result.end = bs.readPoint3F();
1496
+ result.hitObject = bs.readFlag();
1497
+ if (bs.readFlag()) {
1498
+ result.sourceObject = bs.readRangedU32(0, 1024);
1499
+ result.sourceSlot = bs.readRangedU32(0, 7);
1500
+ }
1501
+ }
1502
+ return result;
1503
+ }
1504
+ // ============================================================
1505
+ // WheeledVehicle ghost parser
1506
+ // ============================================================
1507
+ /**
1508
+ * Determine wheel count from a WheeledVehicleData shape name.
1509
+ * In standard Tribes 2, the only WheeledVehicle is the MPB/Jericho (6 wheels).
1510
+ * Wildcat and Beowulf are HoverVehicleData, NOT WheeledVehicleData.
1511
+ * wheelCount is computed at runtime from shape ground#/spring# node pairs.
1512
+ * Falls back to 6 (the only standard WheeledVehicle) if shape unknown.
1513
+ */
1514
+ function getWheelCountFromShape(shapeName) {
1515
+ if (!shapeName)
1516
+ return 6;
1517
+ const lower = shapeName.toLowerCase();
1518
+ // MPB (vehicle_land_mpbase.dts) has 6 wheels: Ground0-5 + Spring0-5
1519
+ if (lower.includes("mpb") || lower.includes("mpbase"))
1520
+ return 6;
1521
+ // Default to 6 for unknown shapes (standard T2 only has the MPB)
1522
+ return 6;
1523
+ }
1524
+ /** Cache of dataBlockId → wheelCount for WheeledVehicle ghosts. */
1525
+ const wheelCountCache = new Map();
1526
+ /** Cache of ghostIndex → wheelCount for WheeledVehicle updates (where dbId is absent). */
1527
+ const ghostWheelCountCache = new Map();
1528
+ function wheeledVehicleUnpackUpdate(bs, isInitial, conn) {
1529
+ const result = vehicleUnpackUpdate(bs, isInitial, conn);
1530
+ // Always read braking/wheels. Binary (FUN_00615a70) checks
1531
+ // param_1[0x2097]==in_ECX (GameConnection.mControlObject == this vehicle)
1532
+ // but in demo recordings mControlObject is always the Player, never
1533
+ // a vehicle, so this condition is always false.
1534
+ {
1535
+ result.braking = bs.readFlag();
1536
+ if (bs.readFlag()) {
1537
+ // Determine wheel count from the DataBlock's shape name.
1538
+ // On creates, dataBlockId is available (DataBlockMask set).
1539
+ // On updates, use ghost-index cache populated during create.
1540
+ let wheelCount = 4;
1541
+ const dbId = result.dataBlockId;
1542
+ const ghostIdx = conn.currentGhostIndex;
1543
+ if (dbId !== undefined) {
1544
+ const cached = wheelCountCache.get(dbId);
1545
+ if (cached !== undefined) {
1546
+ wheelCount = cached;
1547
+ }
1548
+ else if (conn.getDataBlockData) {
1549
+ const dbData = conn.getDataBlockData(dbId);
1550
+ if (dbData) {
1551
+ const shapeName = dbData.shapeName;
1552
+ if (typeof shapeName === "string") {
1553
+ wheelCount = getWheelCountFromShape(shapeName);
1554
+ }
1555
+ wheelCountCache.set(dbId, wheelCount);
1556
+ }
1557
+ }
1558
+ // Cache by ghost index for future updates
1559
+ if (ghostIdx !== undefined) {
1560
+ ghostWheelCountCache.set(ghostIdx, wheelCount);
1561
+ }
1562
+ }
1563
+ else if (ghostIdx !== undefined) {
1564
+ // UPDATE: look up by ghost index
1565
+ const cached = ghostWheelCountCache.get(ghostIdx);
1566
+ if (cached !== undefined) {
1567
+ wheelCount = cached;
1568
+ }
1569
+ }
1570
+ const wheels = [];
1571
+ for (let i = 0; i < wheelCount; i++) {
1572
+ wheels.push({
1573
+ avel: bs.readF32(),
1574
+ dy: bs.readF32(),
1575
+ dx: bs.readF32(),
1576
+ });
1577
+ }
1578
+ result.wheels = wheels;
1579
+ }
1580
+ }
1581
+ return result;
1582
+ }
1583
+ // ============================================================
1584
+ // Trigger ghost parser
1585
+ // ============================================================
1586
+ function triggerUnpackUpdate(bs, _isInitial, _conn) {
1587
+ // Trigger::unpackUpdate (0x0061bab0):
1588
+ // Parent chain reads 0 bits (confirmed via r2ghidra — the parent call at
1589
+ // 0x005e2840 chains to 0x00436e00 which is empty).
1590
+ // Then reads 4 raw bytes (U32 tickPeriodMS, stored at this+0x44).
1591
+ // The V12 source has a full polyhedron (transform, points, planes, edges)
1592
+ // but the Tribes 2 build 25034 binary strips all of that.
1593
+ return { tickPeriodMS: bs.readU32() };
1594
+ }
1595
+ // ============================================================
1596
+ // PhysicalZone ghost parser (SceneObject subclass)
1597
+ // ============================================================
1598
+ function physicalZoneUnpackUpdate(bs, _isInitial, _conn) {
1599
+ const result = {};
1600
+ if (bs.readFlag()) {
1601
+ // Initial update — raw MatrixF (16×F32) + Point3F scale
1602
+ result.transform = bs.readMatrixF();
1603
+ result.scale = bs.readPoint3F();
1604
+ // Polyhedron points
1605
+ const numPoints = bs.readU32();
1606
+ if (numPoints > Math.floor(bs.getRemainingBits() / 96)) {
1607
+ throw new Error(`Invalid physicalZone point count: ${numPoints}`);
1608
+ }
1609
+ const points = [];
1610
+ for (let i = 0; i < numPoints; i++) {
1611
+ points.push(bs.readPoint3F());
1612
+ }
1613
+ result.points = points;
1614
+ // Polyhedron planes
1615
+ const numPlanes = bs.readU32();
1616
+ if (numPlanes > Math.floor(bs.getRemainingBits() / 128)) {
1617
+ throw new Error(`Invalid physicalZone plane count: ${numPlanes}`);
1618
+ }
1619
+ const planes = [];
1620
+ for (let i = 0; i < numPlanes; i++) {
1621
+ planes.push({
1622
+ x: bs.readF32(),
1623
+ y: bs.readF32(),
1624
+ z: bs.readF32(),
1625
+ d: bs.readF32(),
1626
+ });
1627
+ }
1628
+ result.planes = planes;
1629
+ // Polyhedron edges
1630
+ const numEdges = bs.readU32();
1631
+ if (numEdges > Math.floor(bs.getRemainingBits() / 128)) {
1632
+ throw new Error(`Invalid physicalZone edge count: ${numEdges}`);
1633
+ }
1634
+ const edges = [];
1635
+ for (let i = 0; i < numEdges; i++) {
1636
+ edges.push({
1637
+ face0: bs.readU32(),
1638
+ face1: bs.readU32(),
1639
+ vertex0: bs.readU32(),
1640
+ vertex1: bs.readU32(),
1641
+ });
1642
+ }
1643
+ result.edges = edges;
1644
+ result.velocityMod = bs.readF32();
1645
+ result.gravityMod = bs.readF32();
1646
+ result.appliedForce = bs.readPoint3F();
1647
+ result.active = bs.readFlag();
1648
+ }
1649
+ else {
1650
+ // Non-initial update: just active flag
1651
+ result.active = bs.readFlag();
1652
+ }
1653
+ return result;
1654
+ }
1655
+ // ============================================================
1656
+ // AudioEmitter ghost parser (SceneObject subclass)
1657
+ // ============================================================
1658
+ function audioEmitterUnpackUpdate(bs, _isInitial, _conn) {
1659
+ const result = {};
1660
+ // Initial update flag
1661
+ result.initialUpdate = bs.readFlag();
1662
+ // Transform
1663
+ if (bs.readFlag()) {
1664
+ result.transform = bs.readAffineTransform();
1665
+ }
1666
+ // Profile
1667
+ if (bs.readFlag()) {
1668
+ if (bs.readFlag()) {
1669
+ result.audioProfileId = readObjectRef11(bs);
1670
+ }
1671
+ }
1672
+ // Description
1673
+ if (bs.readFlag()) {
1674
+ if (bs.readFlag()) {
1675
+ result.audioDescriptionId = readObjectRef11(bs);
1676
+ }
1677
+ }
1678
+ // Filename
1679
+ if (bs.readFlag()) {
1680
+ result.filename = bs.readString();
1681
+ }
1682
+ // UseProfileDescription
1683
+ if (bs.readFlag()) {
1684
+ result.useProfileDescription = bs.readFlag();
1685
+ }
1686
+ // Volume
1687
+ if (bs.readFlag()) {
1688
+ result.volume = bs.readF32();
1689
+ }
1690
+ // IsLooping
1691
+ if (bs.readFlag()) {
1692
+ result.isLooping = bs.readFlag();
1693
+ }
1694
+ // Is3D
1695
+ if (bs.readFlag()) {
1696
+ result.is3D = bs.readFlag();
1697
+ }
1698
+ // MinDistance
1699
+ if (bs.readFlag()) {
1700
+ result.minDistance = bs.readF32();
1701
+ }
1702
+ // MaxDistance
1703
+ if (bs.readFlag()) {
1704
+ result.maxDistance = bs.readF32();
1705
+ }
1706
+ // ConeInsideAngle (S32 in engine — field type 1)
1707
+ if (bs.readFlag()) {
1708
+ result.coneInsideAngle = bs.readS32();
1709
+ }
1710
+ // ConeOutsideAngle (S32 in engine — field type 1)
1711
+ if (bs.readFlag()) {
1712
+ result.coneOutsideAngle = bs.readS32();
1713
+ }
1714
+ // ConeOutsideVolume
1715
+ if (bs.readFlag()) {
1716
+ result.coneOutsideVolume = bs.readF32();
1717
+ }
1718
+ // ConeVector
1719
+ if (bs.readFlag()) {
1720
+ result.coneVector = bs.readPoint3F();
1721
+ }
1722
+ // LoopCount (S32 in engine — field type 1)
1723
+ if (bs.readFlag()) {
1724
+ result.loopCount = bs.readS32();
1725
+ }
1726
+ // MinLoopGap (S32 in engine — field type 1, milliseconds)
1727
+ if (bs.readFlag()) {
1728
+ result.minLoopGap = bs.readS32();
1729
+ }
1730
+ // MaxLoopGap (S32 in engine — field type 1, milliseconds)
1731
+ if (bs.readFlag()) {
1732
+ result.maxLoopGap = bs.readS32();
1733
+ }
1734
+ // AudioType (S32/enum in engine — field type 9, written as 4 raw bytes)
1735
+ if (bs.readFlag()) {
1736
+ result.audioType = bs.readS32();
1737
+ }
1738
+ // OutsideAmbient
1739
+ if (bs.readFlag()) {
1740
+ result.outsideAmbient = bs.readFlag();
1741
+ }
1742
+ return result;
1743
+ }
1744
+ // ============================================================
1745
+ // StationFXPersonal ghost parser (GameBase subclass)
1746
+ // ============================================================
1747
+ function stationFXPersonalUnpackUpdate(bs, isInitial, conn) {
1748
+ const result = readGameBaseUpdate(bs, isInitial, conn);
1749
+ if (bs.readFlag()) {
1750
+ // InitialUpdateMask
1751
+ if (bs.readFlag()) {
1752
+ result.stationObject = bs.readRangedU32(0, 1024);
1753
+ }
1754
+ }
1755
+ return result;
1756
+ }
1757
+ // ============================================================
1758
+ // AIObjective ghost parser (AIObjective::unpackUpdate @ FUN_0047dae0)
1759
+ // ============================================================
1760
+ function aiObjectiveUnpackUpdate(bs, isInitial, conn) {
1761
+ // AIObjective::unpackUpdate calls FUN_0066a8b0 (ShapeBase-derived),
1762
+ // then reads one additional flag.
1763
+ const result = readShapeBaseUpdate(bs, isInitial, conn);
1764
+ if (bs.readFlag()) {
1765
+ result.transform = bs.readAffineTransform();
1766
+ result.scale = bs.readPoint3F();
1767
+ }
1768
+ result.unknownFlag = bs.readFlag();
1769
+ return result;
1770
+ }
1771
+ // ============================================================
1772
+ // Register all ghost parsers into the registry
1773
+ // ============================================================
1774
+ export function registerGhostParsers(registry) {
1775
+ registry.catalogGhost({
1776
+ name: "AIObjective",
1777
+ unpackUpdate: aiObjectiveUnpackUpdate,
1778
+ });
1779
+ registry.catalogGhost({
1780
+ name: "BeaconObject",
1781
+ unpackUpdate: beaconObjectUnpackUpdate,
1782
+ });
1783
+ registry.catalogGhost({
1784
+ name: "BombProjectile",
1785
+ unpackUpdate: bombProjectileUnpackUpdate,
1786
+ });
1787
+ registry.catalogGhost({
1788
+ name: "Player",
1789
+ unpackUpdate: playerUnpackUpdate,
1790
+ readPacketData: playerReadPacketData,
1791
+ });
1792
+ registry.catalogGhost({
1793
+ name: "Debris",
1794
+ unpackUpdate: debrisUnpackUpdate,
1795
+ });
1796
+ registry.catalogGhost({
1797
+ name: "GameBase",
1798
+ unpackUpdate: readGameBaseUpdate,
1799
+ });
1800
+ registry.catalogGhost({
1801
+ name: "ShapeBase",
1802
+ unpackUpdate: readShapeBaseUpdate,
1803
+ readPacketData: vehicleReadPacketData,
1804
+ });
1805
+ registry.catalogGhost({
1806
+ name: "Vehicle",
1807
+ unpackUpdate: vehicleUnpackUpdate,
1808
+ readPacketData: vehicleReadPacketData,
1809
+ });
1810
+ registry.catalogGhost({
1811
+ name: "FlyingVehicle",
1812
+ unpackUpdate: flyingVehicleUnpackUpdate,
1813
+ readPacketData: vehicleReadPacketData,
1814
+ });
1815
+ registry.catalogGhost({
1816
+ name: "HoverVehicle",
1817
+ unpackUpdate: hoverVehicleUnpackUpdate,
1818
+ readPacketData: vehicleReadPacketData,
1819
+ });
1820
+ registry.catalogGhost({
1821
+ name: "Item",
1822
+ unpackUpdate: itemUnpackUpdate,
1823
+ });
1824
+ registry.catalogGhost({
1825
+ name: "Marker",
1826
+ unpackUpdate: markerUnpackUpdate,
1827
+ });
1828
+ registry.catalogGhost({
1829
+ name: "MissionMarker",
1830
+ unpackUpdate: missionMarkerUnpackUpdate,
1831
+ });
1832
+ registry.catalogGhost({
1833
+ name: "StaticShape",
1834
+ unpackUpdate: staticShapeUnpackUpdate,
1835
+ });
1836
+ registry.catalogGhost({
1837
+ name: "Projectile",
1838
+ unpackUpdate: projectileUnpackUpdate,
1839
+ });
1840
+ registry.catalogGhost({
1841
+ name: "ScopeAlwaysShape",
1842
+ unpackUpdate: scopeAlwaysShapeUnpackUpdate,
1843
+ });
1844
+ registry.catalogGhost({
1845
+ name: "GrenadeProjectile",
1846
+ unpackUpdate: grenadeUnpackUpdate,
1847
+ });
1848
+ registry.catalogGhost({
1849
+ name: "SimpleNetObject",
1850
+ unpackUpdate: simpleNetObjectUnpackUpdate,
1851
+ });
1852
+ registry.catalogGhost({
1853
+ name: "SeekerProjectile",
1854
+ unpackUpdate: seekerUnpackUpdate,
1855
+ });
1856
+ registry.catalogGhost({
1857
+ name: "Turret",
1858
+ unpackUpdate: turretUnpackUpdate,
1859
+ });
1860
+ registry.catalogGhost({
1861
+ name: "InteriorInstance",
1862
+ unpackUpdate: interiorUnpackUpdate,
1863
+ });
1864
+ registry.catalogGhost({
1865
+ name: "Camera",
1866
+ unpackUpdate: cameraUnpackUpdate,
1867
+ readPacketData: cameraReadPacketData,
1868
+ });
1869
+ registry.catalogGhost({
1870
+ name: "LinearProjectile",
1871
+ unpackUpdate: linearProjectileUnpackUpdate,
1872
+ });
1873
+ registry.catalogGhost({
1874
+ name: "ELFProjectile",
1875
+ unpackUpdate: elfProjectileUnpackUpdate,
1876
+ });
1877
+ registry.catalogGhost({
1878
+ name: "RepairProjectile",
1879
+ unpackUpdate: repairProjectileUnpackUpdate,
1880
+ });
1881
+ registry.catalogGhost({
1882
+ name: "TargetProjectile",
1883
+ unpackUpdate: targetProjectileUnpackUpdate,
1884
+ });
1885
+ // TracerProjectile resolves through LinearProjectile vtables in the retail binary.
1886
+ registry.catalogGhost({
1887
+ name: "TracerProjectile",
1888
+ unpackUpdate: linearProjectileUnpackUpdate,
1889
+ });
1890
+ registry.catalogGhost({
1891
+ name: "WayPoint",
1892
+ unpackUpdate: wayPointUnpackUpdate,
1893
+ });
1894
+ registry.catalogGhost({
1895
+ name: "SpawnSphere",
1896
+ unpackUpdate: spawnSphereUnpackUpdate,
1897
+ });
1898
+ registry.catalogGhost({
1899
+ name: "ForceFieldBare",
1900
+ unpackUpdate: forceFieldBareUnpackUpdate,
1901
+ });
1902
+ registry.catalogGhost({
1903
+ name: "TSStatic",
1904
+ unpackUpdate: tsStaticUnpackUpdate,
1905
+ });
1906
+ registry.catalogGhost({
1907
+ name: "TerrainBlock",
1908
+ unpackUpdate: terrainBlockUnpackUpdate,
1909
+ });
1910
+ registry.catalogGhost({
1911
+ name: "Sun",
1912
+ unpackUpdate: sunUnpackUpdate,
1913
+ });
1914
+ registry.catalogGhost({
1915
+ name: "Sky",
1916
+ unpackUpdate: skyUnpackUpdate,
1917
+ });
1918
+ registry.catalogGhost({
1919
+ name: "Lightning",
1920
+ unpackUpdate: lightningUnpackUpdate,
1921
+ });
1922
+ registry.catalogGhost({
1923
+ name: "WaterBlock",
1924
+ unpackUpdate: waterBlockUnpackUpdate,
1925
+ });
1926
+ registry.catalogGhost({
1927
+ name: "MissionArea",
1928
+ unpackUpdate: missionAreaUnpackUpdate,
1929
+ });
1930
+ registry.catalogGhost({
1931
+ name: "Splash",
1932
+ unpackUpdate: splashUnpackUpdate,
1933
+ });
1934
+ registry.catalogGhost({
1935
+ name: "Shockwave",
1936
+ unpackUpdate: shockwaveUnpackUpdate,
1937
+ });
1938
+ registry.catalogGhost({
1939
+ name: "FireballAtmosphere",
1940
+ unpackUpdate: fireballAtmosphereUnpackUpdate,
1941
+ });
1942
+ registry.catalogGhost({
1943
+ name: "VehicleBlocker",
1944
+ unpackUpdate: vehicleBlockerUnpackUpdate,
1945
+ });
1946
+ registry.catalogGhost({
1947
+ name: "ParticleEmissionDummy",
1948
+ unpackUpdate: particleEmissionDummyUnpackUpdate,
1949
+ });
1950
+ registry.catalogGhost({
1951
+ name: "Precipitation",
1952
+ unpackUpdate: precipitationUnpackUpdate,
1953
+ });
1954
+ // Projectile aliases — same wire format as GrenadeProjectile
1955
+ registry.catalogGhost({
1956
+ name: "EnergyProjectile",
1957
+ unpackUpdate: grenadeUnpackUpdate,
1958
+ });
1959
+ registry.catalogGhost({
1960
+ name: "FlareProjectile",
1961
+ unpackUpdate: grenadeUnpackUpdate,
1962
+ });
1963
+ registry.catalogGhost({
1964
+ name: "LinearFlareProjectile",
1965
+ unpackUpdate: linearProjectileUnpackUpdate,
1966
+ });
1967
+ registry.catalogGhost({
1968
+ name: "SniperProjectile",
1969
+ unpackUpdate: sniperProjectileUnpackUpdate,
1970
+ });
1971
+ registry.catalogGhost({
1972
+ name: "ShockLanceProjectile",
1973
+ unpackUpdate: shockLanceProjectileUnpackUpdate,
1974
+ });
1975
+ registry.catalogGhost({
1976
+ name: "WheeledVehicle",
1977
+ unpackUpdate: wheeledVehicleUnpackUpdate,
1978
+ readPacketData: wheeledVehicleReadPacketData,
1979
+ });
1980
+ registry.catalogGhost({
1981
+ name: "Trigger",
1982
+ unpackUpdate: triggerUnpackUpdate,
1983
+ });
1984
+ registry.catalogGhost({
1985
+ name: "PhysicalZone",
1986
+ unpackUpdate: physicalZoneUnpackUpdate,
1987
+ });
1988
+ registry.catalogGhost({
1989
+ name: "AudioEmitter",
1990
+ unpackUpdate: audioEmitterUnpackUpdate,
1991
+ });
1992
+ registry.catalogGhost({
1993
+ name: "StationFXPersonal",
1994
+ unpackUpdate: stationFXPersonalUnpackUpdate,
1995
+ });
1996
+ // StationFXVehicle has identical wire format to StationFXPersonal
1997
+ registry.catalogGhost({
1998
+ name: "StationFXVehicle",
1999
+ unpackUpdate: stationFXPersonalUnpackUpdate,
2000
+ });
2001
+ }
2002
+ //# sourceMappingURL=GhostManager.js.map