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,943 @@
1
+ import { inflate } from "fflate";
2
+ import createDebug from "debug";
3
+ import { BitStream } from "./BitStream.js";
4
+ import { PacketParser } from "./PacketParser.js";
5
+ import { ClassRegistry } from "./ClassRegistry.js";
6
+ import { GhostTracker, registerGhostParsers } from "./GhostManager.js";
7
+ import { registerEventParsers } from "./EventParsers.js";
8
+ import { registerDataBlockParsers } from "./DataBlockParsers.js";
9
+ import { BlockTypePacket, BlockTypeSendPacket, BlockTypeMove, BlockTypeInfo, MaxTriggerKeys, NetEventClassBitSize, NetEventClassFirst, NetObjectClassBitSize, NetObjectClassFirst, GhostIdBitSize, DataBlockClassFirst, DataBlockClassNames, NetObjectClassNames, NetEventClassNames, SimDBEventObjectIdBits, SimDBEventClassIdBits, SimDBEventIndexBits, SimDBEventTotalBits, } from "./types.js";
10
+ const debug = createDebug("t2-demo-parser");
11
+ const debugInitial = createDebug("t2-demo-parser:initial");
12
+ const debugBlocks = createDebug("t2-demo-parser:blocks");
13
+ export class DemoParser {
14
+ buffer;
15
+ view;
16
+ offset;
17
+ registry;
18
+ ghostTracker;
19
+ packetParser;
20
+ // Stepping API state
21
+ _loaded = false;
22
+ _header;
23
+ _initialBlock;
24
+ _decompressedData;
25
+ _decompressedView;
26
+ _blockStreamOffset = 0;
27
+ _blockCount;
28
+ _blockCursor = 0;
29
+ constructor(buffer) {
30
+ this.buffer = buffer;
31
+ this.view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
32
+ this.offset = 0;
33
+ this.registry = new ClassRegistry();
34
+ this.ghostTracker = new GhostTracker();
35
+ // Register parser catalogs
36
+ registerEventParsers(this.registry);
37
+ registerGhostParsers(this.registry);
38
+ registerDataBlockParsers(this.registry);
39
+ // Bind DataBlock parsers deterministically using the known class name
40
+ // mapping derived from binary analysis of the Tribes 2 executable.
41
+ // ClassIds are assigned alphabetically (C strcmp) starting at
42
+ // DataBlockClassFirst (128).
43
+ const { bound: dbBound, missing: dbMissing } = this.registry.bindDeterministicDataBlocks(DataBlockClassNames, DataBlockClassFirst);
44
+ if (dbMissing.length > 0) {
45
+ debug("DataBlock binding: %d/%d bound, missing parsers: %s", dbBound, DataBlockClassNames.length, dbMissing.join(", "));
46
+ }
47
+ // Bind ghost (NetObject) parsers deterministically using the known
48
+ // class name mapping derived from binary analysis. The 53 NetObject
49
+ // classes are sorted alphabetically (C strcmp) and assigned sequential
50
+ // classIds starting at NetObjectClassFirst (0).
51
+ const { bound: ghostBound, missing: ghostMissing } = this.registry.bindDeterministicGhosts(NetObjectClassNames, NetObjectClassFirst);
52
+ if (ghostMissing.length > 0) {
53
+ debug("Ghost binding: %d/%d bound, missing parsers: %s", ghostBound, NetObjectClassNames.length, ghostMissing.join(", "));
54
+ }
55
+ // Bind event (NetEvent) parsers deterministically using the known
56
+ // class name mapping derived from binary analysis. The 26 NetEvent
57
+ // classes are sorted alphabetically (C strcmp) and assigned sequential
58
+ // classIds starting at NetEventClassFirst (255).
59
+ const { bound: eventBound, missing: eventMissing } = this.registry.bindDeterministicEvents(NetEventClassNames, NetEventClassFirst);
60
+ if (eventMissing.length > 0) {
61
+ debug("Event binding: %d/%d bound, missing parsers: %s", eventBound, NetEventClassNames.length, eventMissing.join(", "));
62
+ }
63
+ this.packetParser = new PacketParser(this.registry, this.ghostTracker);
64
+ }
65
+ getRegistry() {
66
+ return this.registry;
67
+ }
68
+ getGhostTracker() {
69
+ return this.ghostTracker;
70
+ }
71
+ getPacketParser() {
72
+ return this.packetParser;
73
+ }
74
+ // --- Stepping API getters ---
75
+ get loaded() {
76
+ return this._loaded;
77
+ }
78
+ get header() {
79
+ if (!this._loaded)
80
+ throw new Error("must call load() first");
81
+ return this._header;
82
+ }
83
+ get initialBlock() {
84
+ if (!this._loaded)
85
+ throw new Error("must call load() first");
86
+ return this._initialBlock;
87
+ }
88
+ get blockCount() {
89
+ if (!this._loaded)
90
+ throw new Error("must call load() first");
91
+ if (this._blockCount === undefined) {
92
+ // Lazy scan: walk decompressed buffer counting U16 headers
93
+ const data = this._decompressedData;
94
+ const view = this._decompressedView;
95
+ let count = 0;
96
+ let off = 0;
97
+ while (off + 2 <= data.length) {
98
+ const typeSize = view.getUint16(off, true);
99
+ const size = typeSize & 0xfff;
100
+ off += 2 + size;
101
+ if (off > data.length)
102
+ break;
103
+ count++;
104
+ }
105
+ this._blockCount = count;
106
+ }
107
+ return this._blockCount;
108
+ }
109
+ get blockCursor() {
110
+ if (!this._loaded)
111
+ throw new Error("must call load() first");
112
+ return this._blockCursor;
113
+ }
114
+ // --- Stepping API methods ---
115
+ /**
116
+ * Async load: parses header, initial block (DataBlocks, events,
117
+ * ghosts, control object, mission name), and asynchronously decompresses
118
+ * the block stream. No block indexing — blocks are read lazily via nextBlock().
119
+ *
120
+ * Idempotent — second call returns cached result.
121
+ */
122
+ async load() {
123
+ if (this._loaded) {
124
+ return {
125
+ header: this._header,
126
+ initialBlock: this._initialBlock,
127
+ };
128
+ }
129
+ // Phase 1: header + initial block
130
+ const header = this.readHeader();
131
+ debug('header: "%s" version=0x%s length=%dms (%smin) initialBlockSize=%d', header.identString, header.protocolVersion.toString(16), header.demoLengthMs, (header.demoLengthMs / 1000 / 60).toFixed(1), header.initialBlockSize);
132
+ const initialBlockData = this.buffer.subarray(this.offset, this.offset + header.initialBlockSize);
133
+ const initialBlock = this.readInitialBlock(initialBlockData);
134
+ this.offset += header.initialBlockSize;
135
+ // Phase 2: async decompress block stream
136
+ const compressedData = this.buffer.subarray(this.offset);
137
+ debug("compressed block stream: %d bytes", compressedData.length);
138
+ const decompressedData = await new Promise((resolve, reject) => {
139
+ inflate(compressedData, (err, data) => {
140
+ if (err)
141
+ reject(err);
142
+ else
143
+ resolve(data);
144
+ });
145
+ });
146
+ debug("decompressed block stream: %d bytes", decompressedData.length);
147
+ this._decompressedData = decompressedData;
148
+ this._decompressedView = new DataView(decompressedData.buffer, decompressedData.byteOffset, decompressedData.byteLength);
149
+ // Phase 3: set up PacketParser with seeded ghost tracker
150
+ this.setupPacketParser(initialBlock);
151
+ // Cache results
152
+ this._header = header;
153
+ this._initialBlock = initialBlock;
154
+ this._blockStreamOffset = 0;
155
+ this._blockCursor = 0;
156
+ this._loaded = true;
157
+ return { header, initialBlock };
158
+ }
159
+ /**
160
+ * Read and parse the next block from the decompressed buffer.
161
+ * Returns the parsed block, or undefined when all blocks are exhausted.
162
+ * Each block is transient — only one in memory at a time.
163
+ * Throws if load() has not been called.
164
+ */
165
+ nextBlock() {
166
+ if (!this._loaded)
167
+ throw new Error("must call load() first");
168
+ const data = this._decompressedData;
169
+ const view = this._decompressedView;
170
+ const off = this._blockStreamOffset;
171
+ if (off + 2 > data.length)
172
+ return undefined;
173
+ const typeSize = view.getUint16(off, true);
174
+ const type = typeSize >> 12;
175
+ const size = typeSize & 0xfff;
176
+ if (off + 2 + size > data.length) {
177
+ debugBlocks("block %d: size %d would exceed decompressed data (offset=%d remaining=%d), stopping", this._blockCursor, size, off + 2, data.length - off - 2);
178
+ return undefined;
179
+ }
180
+ const blockData = data.subarray(off + 2, off + 2 + size);
181
+ this._blockStreamOffset = off + 2 + size;
182
+ const block = {
183
+ index: this._blockCursor,
184
+ type,
185
+ size,
186
+ data: blockData,
187
+ };
188
+ this._blockCursor++;
189
+ if (type === BlockTypePacket) {
190
+ try {
191
+ block.parsed = this.packetParser.parsePacket(blockData);
192
+ }
193
+ catch {
194
+ // Skip unparseable packets
195
+ }
196
+ }
197
+ else if (type === BlockTypeSendPacket) {
198
+ this.packetParser.onSendPacketTrigger();
199
+ }
200
+ else if (type === BlockTypeMove && size === 64) {
201
+ try {
202
+ block.parsed = this.readRawMove(blockData);
203
+ }
204
+ catch {
205
+ // Skip unparseable moves
206
+ }
207
+ }
208
+ else if (type === BlockTypeInfo && size === 8) {
209
+ try {
210
+ block.parsed = this.readInfoBlock(blockData);
211
+ }
212
+ catch {
213
+ // Skip unparseable info blocks
214
+ }
215
+ }
216
+ return block;
217
+ }
218
+ /**
219
+ * Reset stepping state: resets stream offset and cursor to 0,
220
+ * clears cached block count, and re-initializes the PacketParser
221
+ * with a fresh ghost tracker seeded from the initial block.
222
+ * Throws if load() has not been called.
223
+ */
224
+ reset() {
225
+ if (!this._loaded)
226
+ throw new Error("must call load() first");
227
+ this._blockStreamOffset = 0;
228
+ this._blockCursor = 0;
229
+ this._blockCount = undefined;
230
+ // Fresh ghost tracker + packet parser
231
+ this.setupPacketParser(this._initialBlock);
232
+ }
233
+ /**
234
+ * Fast-forward through N blocks, processing each but not returning them.
235
+ * Returns the number of blocks actually processed (may be less than count
236
+ * if the stream is exhausted).
237
+ * Throws if load() has not been called.
238
+ */
239
+ processBlocks(count) {
240
+ if (!this._loaded)
241
+ throw new Error("must call load() first");
242
+ let processed = 0;
243
+ for (let i = 0; i < count; i++) {
244
+ if (!this.nextBlock())
245
+ break;
246
+ processed++;
247
+ }
248
+ return processed;
249
+ }
250
+ /**
251
+ * Set up a fresh PacketParser seeded from the initial block's ghosts
252
+ * and with the DataBlock data map for ghost parsers. Replaces
253
+ * this.ghostTracker and this.packetParser.
254
+ */
255
+ setupPacketParser(initialBlock) {
256
+ // Build DataBlock data map (objectId → parsed data)
257
+ const dataBlockDataMap = new Map();
258
+ for (const [objectId, db] of initialBlock.dataBlocks) {
259
+ dataBlockDataMap.set(objectId, db.data);
260
+ }
261
+ // Seed ghost tracker from initial block ghosts
262
+ const gt = new GhostTracker();
263
+ for (const ghost of initialBlock.initialGhosts) {
264
+ if (ghost.type !== "create" || ghost.classId === undefined)
265
+ continue;
266
+ const parserEntry = this.registry.getGhostParser(ghost.classId);
267
+ gt.createGhost(ghost.index, ghost.classId, parserEntry?.name ?? `unknown_${ghost.classId}`);
268
+ }
269
+ const pp = new PacketParser(this.registry, gt, {
270
+ dataBlockDataMap,
271
+ connectionProtocolState: initialBlock.connectionState,
272
+ nextRecvEventSeq: initialBlock.nextRecvEventSeq,
273
+ });
274
+ this.ghostTracker = gt;
275
+ this.packetParser = pp;
276
+ }
277
+ /**
278
+ * Full parse: async load + drain all blocks.
279
+ */
280
+ async parseFullDemo() {
281
+ const { header, initialBlock } = await this.load();
282
+ const blocks = [];
283
+ let block;
284
+ while ((block = this.nextBlock()))
285
+ blocks.push(block);
286
+ return { header, initialBlock, blocks };
287
+ }
288
+ readHeader() {
289
+ // Read the identification string: U8 length + string
290
+ const strLen = this.view.getUint8(this.offset);
291
+ this.offset += 1;
292
+ const identString = new TextDecoder("ascii").decode(this.buffer.subarray(this.offset, this.offset + strLen));
293
+ this.offset += strLen;
294
+ // U32 protocol version
295
+ const protocolVersion = this.view.getUint32(this.offset, true);
296
+ this.offset += 4;
297
+ // U32 demo length in ms
298
+ const demoLengthMs = this.view.getUint32(this.offset, true);
299
+ this.offset += 4;
300
+ // U32 initial block size
301
+ const initialBlockSize = this.view.getUint32(this.offset, true);
302
+ this.offset += 4;
303
+ return { identString, protocolVersion, demoLengthMs, initialBlockSize };
304
+ }
305
+ /** Parse the initial block: DataBlocks, scores, targets, connection state,
306
+ * events, ghosts, control object, and mission name. */
307
+ readInitialBlock(data) {
308
+ const bs = new BitStream(data);
309
+ // --- A. Tagged string table (1024 entries) ---
310
+ const taggedStrings = new Map();
311
+ for (let i = 0; i < 1024; i++) {
312
+ if (bs.readFlag()) {
313
+ taggedStrings.set(i, bs.readString());
314
+ }
315
+ }
316
+ debugInitial("after tagged strings bit=%d count=%d", bs.getCurPos(), taggedStrings.size);
317
+ // --- B.1 U32 datablockCount ---
318
+ const expectedDataBlockCount = bs.readU32();
319
+ // --- B.2 DataBlock loop ---
320
+ const dataBlockHeaders = [];
321
+ const dataBlocks = new Map();
322
+ let dataBlockCount = 0;
323
+ while (bs.readFlag()) {
324
+ dataBlockCount++;
325
+ const modified = bs.readFlag();
326
+ if (!modified) {
327
+ continue;
328
+ }
329
+ const objectId = bs.readInt(SimDBEventObjectIdBits);
330
+ const classId = bs.readInt(SimDBEventClassIdBits) + DataBlockClassFirst;
331
+ const index = bs.readInt(SimDBEventIndexBits);
332
+ const total = bs.readInt(SimDBEventTotalBits);
333
+ const dataBitsStart = bs.getCurPos();
334
+ dataBlockHeaders.push({ objectId, classId, index, total, dataBitsStart });
335
+ const parserEntry = this.registry.getDataBlockParser(classId);
336
+ if (parserEntry) {
337
+ const parsedData = parserEntry.unpackData(bs);
338
+ dataBlocks.set(objectId, {
339
+ classId,
340
+ className: parserEntry.name,
341
+ objectId,
342
+ data: parsedData,
343
+ });
344
+ }
345
+ else {
346
+ const className = classId >= DataBlockClassFirst &&
347
+ classId < DataBlockClassFirst + DataBlockClassNames.length
348
+ ? DataBlockClassNames[classId - DataBlockClassFirst]
349
+ : `unknown(${classId})`;
350
+ throw new Error(`No parser for DataBlock classId ${classId} (${className}) at bit ${dataBitsStart}`);
351
+ }
352
+ }
353
+ debug("all %d/%d DataBlocks parsed (%d payloads), bit position after DataBlocks: %d", dataBlockCount, expectedDataBlockCount, dataBlocks.size, bs.getCurPos());
354
+ // --- B.3 $firstPerson (U8 boolean from GameConnection::writeDemoStartBlock) ---
355
+ const firstPerson = bs.readU8() !== 0;
356
+ // --- B.4 6× U32 connection fields ---
357
+ // @0x8384, @0x8388, @0x83f8, @0x83fc, @0x8400, @0x8434
358
+ const connectionFields = [];
359
+ for (let i = 0; i < 6; i++)
360
+ connectionFields.push(bs.readU32());
361
+ // --- B.5 16× U32 state array ---
362
+ // @0x83b8 + i*4, i=0..15
363
+ const stateArray = [];
364
+ for (let i = 0; i < 16; i++)
365
+ stateArray.push(bs.readU32());
366
+ // --- B.6 U32 score entry count ---
367
+ const scoreCount = bs.readU32();
368
+ // --- B.7 Score entries × count (FUN_00601800) ---
369
+ const scoreEntries = [];
370
+ for (let i = 0; i < scoreCount; i++) {
371
+ scoreEntries.push(this.readScoreEntry(bs));
372
+ }
373
+ debugInitial("after score entries bit=%d scoreCount=%d", bs.getCurPos(), scoreCount);
374
+ // B.8: FUN_005fb130 — clears internal state, no bitstream I/O
375
+ // --- B.8 DemoValues ---
376
+ const demoValues = this.readDemoValues(bs);
377
+ debugInitial("after demo values bit=%d demoValues=%d", bs.getCurPos(), demoValues.length);
378
+ // --- B.9 Complex TargetManager (FUN_00670660) ---
379
+ const { sensorGroupColors, targets: targetEntries } = this.readComplexTargetManager(bs);
380
+ debugInitial("after complex target manager bit=%d targets=%d sensorGroupColors=%d", bs.getCurPos(), targetEntries.length, sensorGroupColors.length);
381
+ // --- B.10 Parent: NetConnection::readDemoStartBlock (FUN_00588260) ---
382
+ // B.10a ConnectionProtocol (FUN_0043d820)
383
+ const connectionState = this.readConnectionProtocol(bs);
384
+ debugInitial("after connection protocol bit=%d lastRecv=%d highestAck=%d lastSend=%d connected=%s", bs.getCurPos(), connectionState.lastSeqRecvd, connectionState.highestAckedSeq, connectionState.lastSendSeq, connectionState.connectionEstablished);
385
+ // B.10b RTT, B.10c packet loss
386
+ const roundTripTime = bs.readF32();
387
+ const packetLoss = bs.readF32();
388
+ debugInitial("after RTT/loss bit=%d rtt=%d loss=%d", bs.getCurPos(), roundTripTime, packetLoss);
389
+ // B.10d PathManager (FUN_00591ce0)
390
+ const pathManager = this.readPathManager(bs);
391
+ debugInitial("after path manager bit=%d entries=%d", bs.getCurPos(), pathManager.length);
392
+ // B.10e Notify count only (FUN_00588260).
393
+ // The reader allocates notify nodes in memory but does not consume
394
+ // per-notify records from the bitstream in this phase.
395
+ const notifyCount = bs.readU32();
396
+ debugInitial("after notify count bit=%d notifyCount=%d", bs.getCurPos(), notifyCount);
397
+ // --- B.10f through B.15: events, ghosts, control object, mission ---
398
+ // Uses a temporary ghost tracker for initial-block ghost parsing only.
399
+ const totalBits = bs.getBuffer().length * 8;
400
+ const ibGhostTracker = new GhostTracker();
401
+ const savedGhostTracker = this.ghostTracker;
402
+ this.ghostTracker = ibGhostTracker;
403
+ let initialEvents = [];
404
+ let nextRecvEventSeq = 0;
405
+ let ghostingSequence = 0;
406
+ let initialGhosts = [];
407
+ let controlObjectGhostIndex = -1;
408
+ let controlObjectData;
409
+ let missionName = "";
410
+ let missionCRC = 0;
411
+ let phase2Error;
412
+ try {
413
+ debugInitial("phase2 start bit=%d remaining=%d", bs.getCurPos(), totalBits - bs.getCurPos());
414
+ // B.10f Events
415
+ ({ nextRecvEventSeq, events: initialEvents } = this.readEventStartBlock(bs));
416
+ debugInitial("after initial events bit=%d count=%d", bs.getCurPos(), initialEvents.length);
417
+ // B.10g Ghosts
418
+ const ghostResult = this.readGhostStartBlock(bs, dataBlocks);
419
+ ghostingSequence = ghostResult.ghostingSequence;
420
+ initialGhosts = ghostResult.ghosts;
421
+ debugInitial("after initial ghosts bit=%d count=%d seq=%d", bs.getCurPos(), initialGhosts.length, ghostingSequence);
422
+ // B.11 controlObjectGhostIndex
423
+ controlObjectGhostIndex = bs.readS32();
424
+ debugInitial("after control ghost index bit=%d control=%d", bs.getCurPos(), controlObjectGhostIndex);
425
+ // B.12 If != -1: controlObject readPacketData
426
+ if (controlObjectGhostIndex !== -1) {
427
+ const ghost = ibGhostTracker.getGhost(controlObjectGhostIndex);
428
+ if (ghost) {
429
+ const parser = this.registry.getGhostParser(ghost.classId);
430
+ if (parser?.readPacketData) {
431
+ const conn = {
432
+ compressionPoint: { x: 0, y: 0, z: 0 },
433
+ ghostTracker: ibGhostTracker,
434
+ };
435
+ controlObjectData = parser.readPacketData(bs, conn);
436
+ debugInitial("after control readPacketData bit=%d parser=%s", bs.getCurPos(), parser.name);
437
+ }
438
+ }
439
+ }
440
+ // B.13 $MissionName
441
+ missionName = bs.readString();
442
+ // B.14 mMissionCRC
443
+ missionCRC = bs.readU32();
444
+ // Byte-align (validate) before SimpleTargetManagers
445
+ bs.setCurPos(((bs.getCurPos() + 7) >> 3) << 3);
446
+ // B.15 Simple TargetManager ×2
447
+ this.readSimpleTargetManager(bs);
448
+ this.readSimpleTargetManager(bs);
449
+ debugInitial('after sequential tail bit=%d mission="%s" CRC=0x%s', bs.getCurPos(), missionName, missionCRC.toString(16));
450
+ }
451
+ catch (e) {
452
+ phase2Error = e instanceof Error ? e.message : String(e);
453
+ }
454
+ finally {
455
+ this.ghostTracker = savedGhostTracker;
456
+ }
457
+ const remaining = totalBits - bs.getCurPos();
458
+ const missionPrintableRatio = missionName.length > 0
459
+ ? missionName
460
+ .split("")
461
+ .filter((c) => {
462
+ const code = c.charCodeAt(0);
463
+ return code >= 0x20 && code <= 0x7e;
464
+ }).length / missionName.length
465
+ : 1;
466
+ const phase2Valid = missionName.length > 0 &&
467
+ missionPrintableRatio >= 0.8 &&
468
+ phase2Error === undefined;
469
+ debug('initial block: events=%d ghosts=%d ghostingSeq=%d controlObj=%d mission="%s" CRC=0x%s valid=%s%s', initialEvents.length, initialGhosts.length, ghostingSequence, controlObjectGhostIndex, missionName, missionCRC.toString(16), phase2Valid, phase2Error ? ` error=${phase2Error}` : "");
470
+ return {
471
+ taggedStrings,
472
+ dataBlockHeaders,
473
+ dataBlockCount,
474
+ dataBlocks,
475
+ firstPerson,
476
+ connectionFields,
477
+ stateArray,
478
+ scoreEntries,
479
+ demoValues,
480
+ sensorGroupColors,
481
+ targetEntries,
482
+ connectionState,
483
+ roundTripTime,
484
+ packetLoss,
485
+ pathManager,
486
+ notifyCount,
487
+ nextRecvEventSeq,
488
+ ghostingSequence,
489
+ initialGhosts,
490
+ initialEvents,
491
+ controlObjectGhostIndex,
492
+ controlObjectData,
493
+ missionName,
494
+ missionCRC,
495
+ phase2TrailingBits: remaining,
496
+ phase2Valid,
497
+ phase2Error,
498
+ };
499
+ }
500
+ /**
501
+ * Read a score entry from FUN_00601800.
502
+ * Format: 3 conditional U16s + 3 U6s + 1 flag + 6 flags.
503
+ */
504
+ readScoreEntry(bs) {
505
+ const clientId = bs.readFlag() ? bs.readInt(16) : 0;
506
+ const teamId = bs.readFlag() ? bs.readInt(16) : 0;
507
+ const score = bs.readFlag() ? bs.readInt(16) : 0;
508
+ const field0 = bs.readInt(6);
509
+ const field1 = bs.readInt(6);
510
+ const field2 = bs.readInt(6);
511
+ // FUN_006014e0: post-processing, no stream reads
512
+ const isBot = bs.readFlag();
513
+ const triggerFlags = [];
514
+ for (let i = 0; i < 6; i++)
515
+ triggerFlags.push(bs.readFlag());
516
+ return { clientId, teamId, score, field0, field1, field2, isBot, triggerFlags };
517
+ }
518
+ /**
519
+ * Read DemoValues from FUN_005fb5c0 lines 388966-388980.
520
+ * Format: while(readFlag()) { readString(value) }
521
+ * Variable names are derived from index ($DemoValue_0, $DemoValue_1, etc.).
522
+ */
523
+ readDemoValues(bs) {
524
+ const values = [];
525
+ while (bs.readFlag()) {
526
+ values.push(bs.readString());
527
+ }
528
+ return values;
529
+ }
530
+ /**
531
+ * Read Complex TargetManager from FUN_00670660.
532
+ *
533
+ * Phase 1: 4×U8 initial data
534
+ * Phase 2: 32×32 grid — each cell: readFlag + if true: 4×U8
535
+ * Phase 3: 512 target entries with conditional fields
536
+ */
537
+ readComplexTargetManager(bs) {
538
+ // Phase 1: FUN_0043efe0 — read 4 bytes (4×U8)
539
+ bs.readU8();
540
+ bs.readU8();
541
+ bs.readU8();
542
+ bs.readU8();
543
+ // Phase 2: 32×32 sensor group color grid (IFF colors).
544
+ // sensorGroupColors[group][targetGroup] = RGBA determines how targetGroup
545
+ // appears to group (e.g., red for enemy, green for friendly).
546
+ const sensorGroupColors = [];
547
+ for (let group = 0; group < 32; group++) {
548
+ for (let targetGroup = 0; targetGroup < 32; targetGroup++) {
549
+ if (bs.readFlag()) {
550
+ sensorGroupColors.push({
551
+ group,
552
+ targetGroup,
553
+ r: bs.readU8(),
554
+ g: bs.readU8(),
555
+ b: bs.readU8(),
556
+ a: bs.readU8(),
557
+ });
558
+ }
559
+ }
560
+ }
561
+ // Phase 3: 512 target entries
562
+ const targets = [];
563
+ for (let i = 0; i < 512; i++) {
564
+ if (!bs.readFlag())
565
+ continue; // Target not active
566
+ const entry = {
567
+ targetId: i,
568
+ sensorGroup: 0,
569
+ targetData: 0,
570
+ damageLevel: 0,
571
+ };
572
+ // Conditional sensor data
573
+ if (bs.readFlag()) {
574
+ entry.sensorData = bs.readU32(); // F32 sensorData
575
+ }
576
+ // Conditional voice map data
577
+ if (bs.readFlag()) {
578
+ entry.voiceMapData = bs.readU32(); // F32 voiceMapData
579
+ }
580
+ // 5 conditional strings: name, skin, skinPref, voice, typeDescription
581
+ if (bs.readFlag())
582
+ entry.name = bs.readString();
583
+ if (bs.readFlag())
584
+ entry.skin = bs.readString();
585
+ if (bs.readFlag())
586
+ entry.skinPref = bs.readString();
587
+ if (bs.readFlag())
588
+ entry.voice = bs.readString();
589
+ if (bs.readFlag())
590
+ entry.typeDescription = bs.readString();
591
+ // Always-read fields
592
+ entry.sensorGroup = bs.readInt(5); // team/type
593
+ entry.targetData = bs.readInt(9); // target data
594
+ // Targets >= 32 have additional DataBlock reference
595
+ if (i >= 32) {
596
+ if (bs.readFlag()) {
597
+ entry.dataBlockRef = bs.readInt(11); // DataBlock reference (FUN_00436d10: readClassId)
598
+ }
599
+ // Virtual call notification — no bitstream I/O
600
+ }
601
+ // Damage level: readFloat(7) — always read for all active targets
602
+ entry.damageLevel = bs.readFloat(7);
603
+ targets.push(entry);
604
+ }
605
+ return { sensorGroupColors, targets };
606
+ }
607
+ /**
608
+ * Read PathManager from FUN_00591ce0.
609
+ * Decompiled format:
610
+ * - U32 entryCount
611
+ * - repeat entryCount:
612
+ * - U32 entryId
613
+ * - U32 recordCount
614
+ * - repeat recordCount:
615
+ * - U32 field0
616
+ * - U32 field1
617
+ * - U32 field2
618
+ * - U32 auxField
619
+ */
620
+ readPathManager(bs) {
621
+ const entries = [];
622
+ const entryCount = bs.readU32();
623
+ for (let i = 0; i < entryCount; i++) {
624
+ const entryId = bs.readU32();
625
+ const recordCount = bs.readU32();
626
+ const records = [];
627
+ for (let j = 0; j < recordCount; j++) {
628
+ records.push({
629
+ field0: bs.readU32(),
630
+ field1: bs.readU32(),
631
+ field2: bs.readU32(),
632
+ auxField: bs.readU32(),
633
+ });
634
+ }
635
+ entries.push({ entryId, records });
636
+ }
637
+ return entries;
638
+ }
639
+ /**
640
+ * Read Simple TargetManager from FUN_006021b0.
641
+ * Format: U8 flag + 4×U32 = 136 bits total.
642
+ */
643
+ readSimpleTargetManager(bs) {
644
+ bs.readU8(); // _read(1) = 8 bits
645
+ bs.readU32(); // 4× _read(4)
646
+ bs.readU32();
647
+ bs.readU32();
648
+ bs.readU32();
649
+ }
650
+ readConnectionProtocol(bs) {
651
+ const lastSeqRecvdAtSend = [];
652
+ for (let i = 0; i < 32; i++) {
653
+ lastSeqRecvdAtSend.push(bs.readU32());
654
+ }
655
+ const lastSeqRecvd = bs.readU32();
656
+ const highestAckedSeq = bs.readU32();
657
+ const lastSendSeq = bs.readU32();
658
+ const ackMask = bs.readU32();
659
+ const connectSequence = bs.readU32();
660
+ const lastRecvAckAck = bs.readU32();
661
+ const connectionEstablished = bs.readBool();
662
+ return {
663
+ lastSeqRecvdAtSend,
664
+ lastSeqRecvd,
665
+ highestAckedSeq,
666
+ lastSendSeq,
667
+ ackMask,
668
+ connectSequence,
669
+ lastRecvAckAck,
670
+ connectionEstablished,
671
+ };
672
+ }
673
+ readEventStartBlock(bs) {
674
+ const nextRecvEventSeq = bs.readU32();
675
+ const events = [];
676
+ debugInitial("event block: nextRecvEventSeq=%d bit=%d", nextRecvEventSeq, bs.getCurPos());
677
+ while (bs.readFlag()) {
678
+ const classId = bs.readInt(NetEventClassBitSize) + NetEventClassFirst;
679
+ const dataBitsStart = bs.getCurPos();
680
+ // Try to parse the event payload using the registry
681
+ const parserEntry = this.registry.getEventParser(classId);
682
+ let parsedData;
683
+ if (parserEntry) {
684
+ try {
685
+ const conn = {
686
+ compressionPoint: { x: 0, y: 0, z: 0 },
687
+ ghostTracker: this.ghostTracker,
688
+ getDataBlockParser: (cid) => this.registry.getDataBlockParser(cid),
689
+ };
690
+ parsedData = parserEntry.unpack(bs, conn);
691
+ }
692
+ catch {
693
+ // Can't parse — stop here
694
+ events.push({
695
+ classId,
696
+ guaranteed: true,
697
+ dataBitsStart,
698
+ dataBitsEnd: dataBitsStart,
699
+ });
700
+ break;
701
+ }
702
+ }
703
+ else {
704
+ // No parser — can't advance past this event
705
+ events.push({
706
+ classId,
707
+ guaranteed: true,
708
+ dataBitsStart,
709
+ dataBitsEnd: dataBitsStart,
710
+ });
711
+ break;
712
+ }
713
+ events.push({
714
+ classId,
715
+ guaranteed: true,
716
+ dataBitsStart,
717
+ dataBitsEnd: bs.getCurPos(),
718
+ parsedData,
719
+ });
720
+ debugInitial(" event classId=%d bits=%d", classId, bs.getCurPos() - dataBitsStart);
721
+ }
722
+ return { nextRecvEventSeq, events };
723
+ }
724
+ readGhostStartBlock(bs, dataBlocks) {
725
+ const ghostingSequence = bs.readU32();
726
+ const ghosts = [];
727
+ debugInitial("ghost block: seq=%d bit=%d", ghostingSequence, bs.getCurPos());
728
+ const ghostCatalog = this.registry.getGhostCatalog();
729
+ const totalBits = bs.getBuffer().length * 8;
730
+ // Build DataBlock data lookup for ghost parsers that need it
731
+ // (e.g., WheeledVehicle needs shape name to determine wheel count).
732
+ const dataBlockDataMap = new Map();
733
+ for (const [objectId, db] of dataBlocks) {
734
+ dataBlockDataMap.set(objectId, db.data);
735
+ }
736
+ while (bs.readFlag()) {
737
+ if (bs.isError())
738
+ break;
739
+ const index = bs.readInt(GhostIdBitSize);
740
+ const classId = bs.readInt(NetObjectClassBitSize) + NetObjectClassFirst;
741
+ const updateBitsStart = bs.getCurPos();
742
+ // Build ordered list of parser candidates.
743
+ // Registry binding (from deterministic classId) is preferred over
744
+ // DataBlock-based identification because the classId is authoritative
745
+ // while the DataBlock type may be a base class (e.g., StaticShapeData
746
+ // used by a BeaconObject, which extends StaticShape with extra fields).
747
+ const candidates = [];
748
+ const seen = new Set();
749
+ // Peek at DataBlock flag to identify ghost via DataBlock type
750
+ const { entry: dbEntry } = this.identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog);
751
+ // Candidate 1: registry binding (deterministic classId)
752
+ const regEntry = this.registry.getGhostParser(classId);
753
+ if (regEntry) {
754
+ candidates.push({ entry: regEntry, method: "registry" });
755
+ seen.add(regEntry);
756
+ }
757
+ // Candidate 2: DataBlock-based identification (fallback)
758
+ if (dbEntry && !seen.has(dbEntry)) {
759
+ candidates.push({ entry: dbEntry, method: "datablock" });
760
+ seen.add(dbEntry);
761
+ }
762
+ // Try each candidate with alignment validation
763
+ const connOverrides = {
764
+ getDataBlockData: (objectId) => dataBlockDataMap.get(objectId),
765
+ getDataBlockParser: (cid) => this.registry.getDataBlockParser(cid),
766
+ };
767
+ let parsed = false;
768
+ for (const { entry, method } of candidates) {
769
+ const isTrusted = method === "registry";
770
+ const result = this.tryGhostParser(bs, entry, updateBitsStart, totalBits, false, connOverrides, isTrusted);
771
+ if (result !== false) {
772
+ this.ghostTracker.createGhost(index, classId, entry.name);
773
+ debugInitial(" ghost idx=%d classId=%d parser=%s bits=%d via=%s", index, classId, entry.name, bs.getCurPos() - updateBitsStart, method);
774
+ ghosts.push({
775
+ index,
776
+ type: "create",
777
+ classId,
778
+ updateBitsStart,
779
+ updateBitsEnd: bs.getCurPos(),
780
+ parsedData: result,
781
+ });
782
+ parsed = true;
783
+ break;
784
+ }
785
+ }
786
+ if (parsed)
787
+ continue;
788
+ // No candidate worked — stop parsing ghosts
789
+ debugInitial(" ghost idx=%d classId=%d NO PARSER (stopping at bit=%d, remaining=%d)", index, classId, updateBitsStart, totalBits - updateBitsStart);
790
+ break;
791
+ }
792
+ debugInitial("ghost loop ended at bit=%d remaining=%d count=%d", bs.getCurPos(), totalBits - bs.getCurPos(), ghosts.length);
793
+ return { ghostingSequence, ghosts };
794
+ }
795
+ /**
796
+ * Try parsing a ghost with a given parser and validate alignment.
797
+ * Returns true if parsing succeeded and alignment is valid.
798
+ * On success, the BitStream is positioned after the parsed ghost data.
799
+ * On failure, the BitStream is restored to updateBitsStart.
800
+ */
801
+ tryGhostParser(bs, entry, updateBitsStart, totalBits, silent = false, connOverrides, trusted = false) {
802
+ const savedPos = bs.savePos();
803
+ if (!silent) {
804
+ debugInitial(" try %s: startBit=%d", entry.name, updateBitsStart);
805
+ }
806
+ try {
807
+ const parsedData = entry.unpackUpdate(bs, true, {
808
+ compressionPoint: { x: 0, y: 0, z: 0 },
809
+ ghostTracker: this.ghostTracker,
810
+ ...connOverrides,
811
+ });
812
+ const bitsConsumed = bs.getCurPos() - updateBitsStart;
813
+ const remaining = totalBits - bs.getCurPos();
814
+ if (bs.isError() || (!trusted && bitsConsumed < 3)) {
815
+ if (!silent) {
816
+ debugInitial(" reject %s: bits=%d isError=%s", entry.name, bitsConsumed, bs.isError());
817
+ }
818
+ bs.restorePos(savedPos);
819
+ return false;
820
+ }
821
+ // Validate alignment: if substantial data remains, the next
822
+ // continuation flag must be 1 (more ghosts in the snapshot).
823
+ if (remaining > 1000) {
824
+ const peekPos = bs.getCurPos();
825
+ const nextFlag = bs.readFlag();
826
+ bs.setCurPos(peekPos);
827
+ if (!nextFlag) {
828
+ if (!silent) {
829
+ debugInitial(" reject %s: bits=%d misaligned (remaining=%d)", entry.name, bitsConsumed, remaining);
830
+ }
831
+ bs.restorePos(savedPos);
832
+ return false;
833
+ }
834
+ }
835
+ return parsedData ?? {};
836
+ }
837
+ catch (e) {
838
+ if (!silent) {
839
+ debugInitial(" reject %s: error at bit=%d: %s", entry.name, bs.getCurPos(), e instanceof Error ? e.message : String(e));
840
+ }
841
+ bs.restorePos(savedPos);
842
+ return false;
843
+ }
844
+ }
845
+ /**
846
+ * Peek at the GameBase DataBlock prefix to identify a ghost's parser.
847
+ * GameBase subclass ghost creates always start with:
848
+ * flag(1b) + if flag: DataBlockId(11b)
849
+ * The DataBlock type (e.g., "PlayerData") maps to the ghost parser
850
+ * (e.g., "Player") by stripping the "Data" suffix.
851
+ *
852
+ * Returns { entry, dbFlag } where entry is the parser (if found) and
853
+ * dbFlag indicates whether the first bit was 1 (DataBlock flag set).
854
+ * In the initial block (mask=0xFFFFFFFF), dbFlag=false means the ghost
855
+ * is definitely NOT a GameBase subclass.
856
+ * The BitStream position is always restored after peeking.
857
+ */
858
+ identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog) {
859
+ if (!dataBlocks)
860
+ return { entry: undefined, dbFlag: false };
861
+ const savedPos = bs.savePos();
862
+ let entry;
863
+ let dbFlag = false;
864
+ try {
865
+ dbFlag = bs.readFlag();
866
+ if (dbFlag) {
867
+ const dbId = bs.readInt(11);
868
+ const db = dataBlocks.get(dbId);
869
+ if (db) {
870
+ const ghostName = db.className.replace(/Data$/, "");
871
+ entry = ghostCatalog.get(ghostName);
872
+ if (!entry) {
873
+ debugInitial(" identifyGhostViaDataBlock: dbId=%d className=%s ghostName=%s (no ghost parser)", dbId, db.className, ghostName);
874
+ }
875
+ }
876
+ else {
877
+ debugInitial(" identifyGhostViaDataBlock: dbId=%d (no DataBlock found)", dbId);
878
+ }
879
+ }
880
+ else {
881
+ debugInitial(" identifyGhostViaDataBlock: DataBlock flag=0");
882
+ }
883
+ }
884
+ catch {
885
+ // Ignore errors during peek
886
+ }
887
+ bs.restorePos(savedPos);
888
+ return { entry, dbFlag };
889
+ }
890
+ /**
891
+ * Parse a raw 64-byte Move struct from a type 2 block.
892
+ *
893
+ * Layout (all little-endian):
894
+ * S32 px, py, pz (12 bytes, offsets 0-11)
895
+ * U32 pyaw, ppitch, proll (12 bytes, offsets 12-23)
896
+ * F32 x, y, z (12 bytes, offsets 24-35)
897
+ * F32 yaw, pitch, roll (12 bytes, offsets 36-47)
898
+ * U32 id (4 bytes, offset 48)
899
+ * U32 sendCount (4 bytes, offset 52)
900
+ * bool freeLook (1 byte, offset 56)
901
+ * bool trigger[6] (6 bytes, offsets 57-62)
902
+ * padding (1 byte, offset 63)
903
+ */
904
+ readRawMove(data) {
905
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
906
+ const px = dv.getInt32(0, true);
907
+ const py = dv.getInt32(4, true);
908
+ const pz = dv.getInt32(8, true);
909
+ const pyaw = dv.getUint32(12, true);
910
+ const ppitch = dv.getUint32(16, true);
911
+ const proll = dv.getUint32(20, true);
912
+ const x = dv.getFloat32(24, true);
913
+ const y = dv.getFloat32(28, true);
914
+ const z = dv.getFloat32(32, true);
915
+ const yaw = dv.getFloat32(36, true);
916
+ const pitch = dv.getFloat32(40, true);
917
+ const roll = dv.getFloat32(44, true);
918
+ const id = dv.getUint32(48, true);
919
+ const sendCount = dv.getUint32(52, true);
920
+ const freeLook = data[56] !== 0;
921
+ const trigger = [];
922
+ for (let i = 0; i < MaxTriggerKeys; i++) {
923
+ trigger.push(data[57 + i] !== 0);
924
+ }
925
+ return {
926
+ px, py, pz, pyaw, ppitch, proll,
927
+ x, y, z, yaw, pitch, roll,
928
+ id, sendCount, freeLook, trigger,
929
+ };
930
+ }
931
+ /**
932
+ * Parse an 8-byte info block (type 3).
933
+ * Contains U32 + F32 (observed: always 1 + 120.0).
934
+ */
935
+ readInfoBlock(data) {
936
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
937
+ return {
938
+ value1: dv.getUint32(0, true),
939
+ value2: dv.getFloat32(4, true),
940
+ };
941
+ }
942
+ }
943
+ //# sourceMappingURL=DemoParser.js.map