t2-demo-parser 2.2.0 → 3.0.1

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 (95) hide show
  1. package/README.md +170 -7
  2. package/dist/BitStream.d.ts +14 -3
  3. package/dist/BitStream.d.ts.map +1 -1
  4. package/dist/BitStream.js +28 -6
  5. package/dist/BitStream.js.map +1 -1
  6. package/dist/BitWriter.d.ts +29 -0
  7. package/dist/BitWriter.d.ts.map +1 -0
  8. package/dist/BitWriter.js +76 -0
  9. package/dist/BitWriter.js.map +1 -0
  10. package/dist/ClassRegistry.d.ts +23 -28
  11. package/dist/ClassRegistry.d.ts.map +1 -1
  12. package/dist/ClassRegistry.js +75 -84
  13. package/dist/ClassRegistry.js.map +1 -1
  14. package/dist/DataBlockParsers.d.ts.map +1 -1
  15. package/dist/DataBlockParsers.js +60 -85
  16. package/dist/DataBlockParsers.js.map +1 -1
  17. package/dist/DemoParser.d.ts +23 -22
  18. package/dist/DemoParser.d.ts.map +1 -1
  19. package/dist/DemoParser.js +218 -266
  20. package/dist/DemoParser.js.map +1 -1
  21. package/dist/EventParsers.d.ts.map +1 -1
  22. package/dist/EventParsers.js +87 -58
  23. package/dist/EventParsers.js.map +1 -1
  24. package/dist/GhostManager.d.ts +7 -1
  25. package/dist/GhostManager.d.ts.map +1 -1
  26. package/dist/GhostManager.js +231 -146
  27. package/dist/GhostManager.js.map +1 -1
  28. package/dist/HuffmanProcessor.d.ts +15 -0
  29. package/dist/HuffmanProcessor.d.ts.map +1 -1
  30. package/dist/HuffmanProcessor.js +45 -0
  31. package/dist/HuffmanProcessor.js.map +1 -1
  32. package/dist/LiveParser.d.ts +20 -1
  33. package/dist/LiveParser.d.ts.map +1 -1
  34. package/dist/LiveParser.js +31 -12
  35. package/dist/LiveParser.js.map +1 -1
  36. package/dist/PacketParser.d.ts +22 -2
  37. package/dist/PacketParser.d.ts.map +1 -1
  38. package/dist/PacketParser.js +111 -48
  39. package/dist/PacketParser.js.map +1 -1
  40. package/dist/Timeline.d.ts +5 -2
  41. package/dist/Timeline.d.ts.map +1 -1
  42. package/dist/Timeline.js +25 -12
  43. package/dist/Timeline.js.map +1 -1
  44. package/dist/cli.js +3 -0
  45. package/dist/cli.js.map +1 -1
  46. package/dist/dataBlockDataTypes.d.ts +118 -117
  47. package/dist/dataBlockDataTypes.d.ts.map +1 -1
  48. package/dist/dataBlockDataTypes.js.map +1 -1
  49. package/dist/defaultRegistry.d.ts +9 -0
  50. package/dist/defaultRegistry.d.ts.map +1 -0
  51. package/dist/defaultRegistry.js +43 -0
  52. package/dist/defaultRegistry.js.map +1 -0
  53. package/dist/eventDataTypes.d.ts +74 -72
  54. package/dist/eventDataTypes.d.ts.map +1 -1
  55. package/dist/eventDataTypes.js.map +1 -1
  56. package/dist/ghostDataTypes.d.ts +125 -131
  57. package/dist/ghostDataTypes.d.ts.map +1 -1
  58. package/dist/ghostDataTypes.js.map +1 -1
  59. package/dist/index.d.ts +6 -4
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +4 -2
  62. package/dist/index.js.map +1 -1
  63. package/dist/types.d.ts +51 -2
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/types.js +6 -0
  66. package/dist/types.js.map +1 -1
  67. package/package.json +4 -1
  68. package/dist/BitStream.test.d.ts +0 -2
  69. package/dist/BitStream.test.d.ts.map +0 -1
  70. package/dist/BitStream.test.js +0 -419
  71. package/dist/BitStream.test.js.map +0 -1
  72. package/dist/ClassRegistry.test.d.ts +0 -2
  73. package/dist/ClassRegistry.test.d.ts.map +0 -1
  74. package/dist/ClassRegistry.test.js +0 -83
  75. package/dist/ClassRegistry.test.js.map +0 -1
  76. package/dist/DataBlockParsers.test.d.ts +0 -2
  77. package/dist/DataBlockParsers.test.d.ts.map +0 -1
  78. package/dist/DataBlockParsers.test.js +0 -294
  79. package/dist/DataBlockParsers.test.js.map +0 -1
  80. package/dist/DemoParser.test.d.ts +0 -2
  81. package/dist/DemoParser.test.d.ts.map +0 -1
  82. package/dist/DemoParser.test.js +0 -472
  83. package/dist/DemoParser.test.js.map +0 -1
  84. package/dist/GhostStateAccumulator.test.d.ts +0 -2
  85. package/dist/GhostStateAccumulator.test.d.ts.map +0 -1
  86. package/dist/GhostStateAccumulator.test.js +0 -201
  87. package/dist/GhostStateAccumulator.test.js.map +0 -1
  88. package/dist/GhostTracker.test.d.ts +0 -2
  89. package/dist/GhostTracker.test.d.ts.map +0 -1
  90. package/dist/GhostTracker.test.js +0 -64
  91. package/dist/GhostTracker.test.js.map +0 -1
  92. package/dist/IncrementalParser.test.d.ts +0 -2
  93. package/dist/IncrementalParser.test.d.ts.map +0 -1
  94. package/dist/IncrementalParser.test.js +0 -226
  95. package/dist/IncrementalParser.test.js.map +0 -1
@@ -2,14 +2,30 @@ import { inflate, Inflate } from "fflate";
2
2
  import createDebug from "debug";
3
3
  import { BitStream } from "./BitStream.js";
4
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";
5
+ import { createDefaultRegistry } from "./defaultRegistry.js";
6
+ import { GhostTracker } from "./GhostManager.js";
7
+ import { BlockTypePacket, BlockTypeSendPacket, BlockTypeMove, BlockTypeInfo, DemoIdentString, DemoProtocolVersion, MaxTriggerKeys, NetEventClassBitSize, NetEventClassFirst, NetObjectClassBitSize, NetObjectClassFirst, GhostIdBitSize, DataBlockClassFirst, DataBlockClassNames, SimDBEventObjectIdBits, SimDBEventClassIdBits, SimDBEventIndexBits, SimDBEventTotalBits, } from "./types.js";
10
8
  const debug = createDebug("t2-demo-parser");
9
+ /**
10
+ * Sequence numbers assigned to ordered events carried in the demo start
11
+ * block: past every 32-bit sequence, so they stay queued without ever
12
+ * matching `nextRecvEventSeq` (see setupPacketParser).
13
+ */
14
+ const START_BLOCK_EVENT_SEQ_BASE = 0x1_0000_0000;
11
15
  const debugInitial = createDebug("t2-demo-parser:initial");
12
16
  const debugBlocks = createDebug("t2-demo-parser:blocks");
17
+ /**
18
+ * Read a U32 count and reject it unless `minBitsPerEntry × count` bits
19
+ * remain: exhausted reads return 0 without throwing, so a corrupt count
20
+ * would otherwise spin allocating until memory ran out.
21
+ */
22
+ function readCheckedCount(bs, minBitsPerEntry, what) {
23
+ const count = bs.readU32();
24
+ if (count > bs.getRemainingBits() / minBitsPerEntry) {
25
+ throw new Error(`Invalid ${what}: ${count}`);
26
+ }
27
+ return count;
28
+ }
13
29
  export class DemoParser {
14
30
  buffer;
15
31
  view;
@@ -43,41 +59,19 @@ export class DemoParser {
43
59
  // Buffered-time scan state (see bufferedMoveTicks).
44
60
  _scanOffset = 0;
45
61
  _bufferedMoveTicks = 0;
62
+ _ignoreProtocolVersion;
63
+ _haltOnFault;
46
64
  constructor(buffer, options) {
47
65
  this.buffer = buffer;
48
66
  this.view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
49
67
  this.offset = 0;
50
68
  this._incremental = options?.incremental === true;
51
- this.registry = new ClassRegistry();
69
+ this._ignoreProtocolVersion = options?.ignoreProtocolVersion === true;
70
+ this._haltOnFault = options?.haltOnFault;
71
+ // Parser catalogs bound to their deterministic classIds (derived from
72
+ // binary analysis of the Tribes 2 executable).
73
+ this.registry = createDefaultRegistry();
52
74
  this.ghostTracker = new GhostTracker();
53
- // Register parser catalogs
54
- registerEventParsers(this.registry);
55
- registerGhostParsers(this.registry);
56
- registerDataBlockParsers(this.registry);
57
- // Bind DataBlock parsers deterministically using the known class name
58
- // mapping derived from binary analysis of the Tribes 2 executable.
59
- // ClassIds are assigned alphabetically (C strcmp) starting at
60
- // DataBlockClassFirst (128).
61
- const { bound: dbBound, missing: dbMissing } = this.registry.bindDeterministicDataBlocks(DataBlockClassNames, DataBlockClassFirst);
62
- if (dbMissing.length > 0) {
63
- debug("DataBlock binding: %d/%d bound, missing parsers: %s", dbBound, DataBlockClassNames.length, dbMissing.join(", "));
64
- }
65
- // Bind ghost (NetObject) parsers deterministically using the known
66
- // class name mapping derived from binary analysis. The 53 NetObject
67
- // classes are sorted alphabetically (C strcmp) and assigned sequential
68
- // classIds starting at NetObjectClassFirst (0).
69
- const { bound: ghostBound, missing: ghostMissing } = this.registry.bindDeterministicGhosts(NetObjectClassNames, NetObjectClassFirst);
70
- if (ghostMissing.length > 0) {
71
- debug("Ghost binding: %d/%d bound, missing parsers: %s", ghostBound, NetObjectClassNames.length, ghostMissing.join(", "));
72
- }
73
- // Bind event (NetEvent) parsers deterministically using the known
74
- // class name mapping derived from binary analysis. The 26 NetEvent
75
- // classes are sorted alphabetically (C strcmp) and assigned sequential
76
- // classIds starting at NetEventClassFirst (255).
77
- const { bound: eventBound, missing: eventMissing } = this.registry.bindDeterministicEvents(NetEventClassNames, NetEventClassFirst);
78
- if (eventMissing.length > 0) {
79
- debug("Event binding: %d/%d bound, missing parsers: %s", eventBound, NetEventClassNames.length, eventMissing.join(", "));
80
- }
81
75
  this.packetParser = new PacketParser(this.registry, this.ghostTracker);
82
76
  }
83
77
  getRegistry() {
@@ -108,22 +102,35 @@ export class DemoParser {
108
102
  throw new Error("must call load() first");
109
103
  if (this._blockCount === undefined) {
110
104
  // Lazy scan: walk decompressed buffer counting U16 headers
111
- const data = this._decompressedData;
112
- const view = this._decompressedView;
113
105
  let count = 0;
114
- let off = 0;
115
- while (off + 2 <= data.length) {
116
- const typeSize = view.getUint16(off, true);
117
- const size = typeSize & 0xfff;
118
- off += 2 + size;
119
- if (off > data.length)
120
- break;
106
+ this._walkBlockHeaders(0, () => {
121
107
  count++;
122
- }
108
+ });
123
109
  this._blockCount = count;
124
110
  }
125
111
  return this._blockCount;
126
112
  }
113
+ /**
114
+ * Walk complete block headers from byte offset `from`, calling `visit`
115
+ * for each; returns the offset of the first incomplete block (or the
116
+ * end of the data).
117
+ */
118
+ _walkBlockHeaders(from, visit) {
119
+ const data = this._decompressedData;
120
+ const view = this._decompressedView;
121
+ if (!data || !view)
122
+ return from;
123
+ let off = from;
124
+ while (off + 2 <= data.length) {
125
+ const typeSize = view.getUint16(off, true);
126
+ const size = typeSize & 0xfff;
127
+ if (off + 2 + size > data.length)
128
+ break;
129
+ visit(typeSize >> 12, size);
130
+ off += 2 + size;
131
+ }
132
+ return off;
133
+ }
127
134
  get blockCursor() {
128
135
  if (!this._loaded)
129
136
  throw new Error("must call load() first");
@@ -147,9 +154,11 @@ export class DemoParser {
147
154
  // Phase 1: header + initial block
148
155
  const header = this.readHeader();
149
156
  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);
150
- if (this._incremental &&
151
- this.buffer.length < this.offset + header.initialBlockSize) {
152
- throw new RangeError(`incremental parser needs the full initial block up front: have ${this.buffer.length - this.offset} bytes, need ${header.initialBlockSize}`);
157
+ if (this.buffer.length < this.offset + header.initialBlockSize) {
158
+ if (this._incremental) {
159
+ throw new RangeError(`incremental parser needs the full initial block up front: have ${this.buffer.length - this.offset} bytes, need ${header.initialBlockSize}`);
160
+ }
161
+ throw new RangeError(`truncated demo: initial block needs ${header.initialBlockSize} bytes, have ${this.buffer.length - this.offset}`);
153
162
  }
154
163
  const initialBlockData = this.buffer.subarray(this.offset, this.offset + header.initialBlockSize);
155
164
  const initialBlock = this.readInitialBlock(initialBlockData);
@@ -307,22 +316,11 @@ export class DemoParser {
307
316
  return this._bufferedMoveTicks;
308
317
  }
309
318
  _scanBufferedTicks() {
310
- const data = this._decompressedData;
311
- const view = this._decompressedView;
312
- if (!data || !view)
313
- return;
314
- let off = this._scanOffset;
315
319
  let ticks = this._bufferedMoveTicks;
316
- while (off + 2 <= data.length) {
317
- const typeSize = view.getUint16(off, true);
318
- const size = typeSize & 0xfff;
319
- if (off + 2 + size > data.length)
320
- break;
321
- if (typeSize >> 12 === BlockTypeMove)
320
+ this._scanOffset = this._walkBlockHeaders(this._scanOffset, (type) => {
321
+ if (type === BlockTypeMove)
322
322
  ticks++;
323
- off += 2 + size;
324
- }
325
- this._scanOffset = off;
323
+ });
326
324
  this._bufferedMoveTicks = ticks;
327
325
  }
328
326
  _appendDecompressed(chunk) {
@@ -379,32 +377,26 @@ export class DemoParser {
379
377
  data: blockData,
380
378
  };
381
379
  this._blockCursor++;
382
- if (type === BlockTypePacket) {
383
- try {
380
+ // PacketParser reports problems through parseFault rather than
381
+ // throwing; anything that does escape is a parser bug, and the raw
382
+ // block plus the error text is kept for diagnosis.
383
+ try {
384
+ if (type === BlockTypePacket) {
384
385
  block.parsed = this.packetParser.parsePacket(blockData);
385
386
  }
386
- catch {
387
- // Skip unparseable packets
387
+ else if (type === BlockTypeSendPacket) {
388
+ this.packetParser.onSendPacketTrigger();
388
389
  }
389
- }
390
- else if (type === BlockTypeSendPacket) {
391
- this.packetParser.onSendPacketTrigger();
392
- }
393
- else if (type === BlockTypeMove && size === 64) {
394
- try {
390
+ else if (type === BlockTypeMove && size === 64) {
395
391
  block.parsed = this.readRawMove(blockData);
396
392
  }
397
- catch {
398
- // Skip unparseable moves
399
- }
400
- }
401
- else if (type === BlockTypeInfo && size === 8) {
402
- try {
393
+ else if (type === BlockTypeInfo && size === 8) {
403
394
  block.parsed = this.readInfoBlock(blockData);
404
395
  }
405
- catch {
406
- // Skip unparseable info blocks
407
- }
396
+ }
397
+ catch (e) {
398
+ block.parseError = e instanceof Error ? e.message : String(e);
399
+ debugBlocks("block %d (type %d, %d bytes) threw: %s", block.index, type, size, block.parseError);
408
400
  }
409
401
  return block;
410
402
  }
@@ -464,6 +456,20 @@ export class DemoParser {
464
456
  connectionProtocolState: initialBlock.connectionState,
465
457
  nextRecvEventSeq: initialBlock.nextRecvEventSeq,
466
458
  compressionPoint: initialBlock.initialCompressionPoint,
459
+ // NetConnection::eventReadStartBlock (FUN_00583ac0) appends the
460
+ // start block's in-flight ordered events to the wait queue without
461
+ // a sequence number (the field is never written; NetEvent's
462
+ // constructor leaves it uninitialized), so in the engine they can
463
+ // only ever dispatch by accident of heap contents. The deterministic
464
+ // stand-in keeps them queued behind every possible real sequence
465
+ // number, where they neither dispatch nor block dispatch.
466
+ pendingGuaranteedEvents: initialBlock.initialEvents
467
+ .filter((event) => !event.failed)
468
+ .map((event, i) => ({
469
+ absoluteSequenceNumber: START_BLOCK_EVENT_SEQ_BASE + i,
470
+ event,
471
+ })),
472
+ haltOnFault: this._haltOnFault,
467
473
  });
468
474
  this.ghostTracker = gt;
469
475
  this.packetParser = pp;
@@ -479,22 +485,25 @@ export class DemoParser {
479
485
  blocks.push(block);
480
486
  return { header, initialBlock, blocks };
481
487
  }
488
+ /**
489
+ * Read and validate the header the way GameConnection's demo playback
490
+ * does: the ident string must match and the protocol version must be
491
+ * the one this build speaks (unless `ignoreProtocolVersion` is set).
492
+ */
482
493
  readHeader() {
483
- // Read the identification string: U8 length + string
484
- const strLen = this.view.getUint8(this.offset);
485
- this.offset += 1;
486
- const identString = new TextDecoder("ascii").decode(this.buffer.subarray(this.offset, this.offset + strLen));
487
- this.offset += strLen;
488
- // U32 protocol version
489
- const protocolVersion = this.view.getUint32(this.offset, true);
490
- this.offset += 4;
491
- // U32 demo length in ms
492
- const demoLengthMs = this.view.getUint32(this.offset, true);
493
- this.offset += 4;
494
- // U32 initial block size
495
- const initialBlockSize = this.view.getUint32(this.offset, true);
496
- this.offset += 4;
497
- return { identString, protocolVersion, demoLengthMs, initialBlockSize };
494
+ const { header, byteLength } = DemoParser.peekHeader(this.buffer.subarray(this.offset));
495
+ this.offset += byteLength;
496
+ if (header.identString !== DemoIdentString) {
497
+ throw new Error(`not a Tribes 2 recording: ident string ${JSON.stringify(header.identString)}`);
498
+ }
499
+ if (header.protocolVersion !== DemoProtocolVersion) {
500
+ const detail = `protocol version 0x${header.protocolVersion.toString(16)} (expected 0x${DemoProtocolVersion.toString(16)})`;
501
+ if (!this._ignoreProtocolVersion) {
502
+ throw new Error(`unsupported demo ${detail}; the game rejects it too. Pass { ignoreProtocolVersion: true } to try anyway.`);
503
+ }
504
+ debug("parsing demo with unexpected %s", detail);
505
+ }
506
+ return header;
498
507
  }
499
508
  /** Parse the initial block: DataBlocks, scores, targets, connection state,
500
509
  * events, ghosts, control object, and mission name. */
@@ -558,7 +567,8 @@ export class DemoParser {
558
567
  for (let i = 0; i < 16; i++)
559
568
  stateArray.push(bs.readU32());
560
569
  // --- B.6 U32 score entry count ---
561
- const scoreCount = bs.readU32();
570
+ // A score entry is at least 3 + 18 + 1 + 6 = 28 bits (FUN_00601800).
571
+ const scoreCount = readCheckedCount(bs, 28, "score entry count");
562
572
  // --- B.7 Score entries × count (FUN_00601800) ---
563
573
  const scoreEntries = [];
564
574
  for (let i = 0; i < scoreCount; i++) {
@@ -587,6 +597,22 @@ export class DemoParser {
587
597
  // The reader allocates notify nodes in memory but does not consume
588
598
  // per-notify records from the bitstream in this phase.
589
599
  const notifyCount = bs.readU32();
600
+ // Engine invariant (verified in build 25034): the start block carries
601
+ // one PacketNotify per in-flight packet, i.e. exactly
602
+ // lastSendSeq - highestAckedSeq of them. checkPacketSend
603
+ // (FUN_005877e0) refuses to queue a notify while windowFull
604
+ // (FUN_0043d720: lastSendSeq - highestAckedSeq > 0x1d), and
605
+ // handleNotify (FUN_005874d0) pops the queue head with no null check on
606
+ // every newly acked sequence, so a mismatched seed crashes playback on
607
+ // the first ack. Every Tribes2.exe recording satisfies this exactly.
608
+ const warnings = [];
609
+ const inFlight = (connectionState.lastSendSeq - connectionState.highestAckedSeq) >>> 0;
610
+ if (notifyCount !== inFlight) {
611
+ warnings.push(`notify count ${notifyCount} does not match in-flight packets ` +
612
+ `(lastSendSeq ${connectionState.lastSendSeq} - highestAckedSeq ` +
613
+ `${connectionState.highestAckedSeq} = ${inFlight}); Tribes2.exe ` +
614
+ `dereferences an empty notify queue on the first ack`);
615
+ }
590
616
  debugInitial("after notify count bit=%d notifyCount=%d", bs.getCurPos(), notifyCount);
591
617
  // --- B.10f through B.15: events, ghosts, control object, mission ---
592
618
  // Uses a temporary ghost tracker for initial-block ghost parsing only.
@@ -614,41 +640,50 @@ export class DemoParser {
614
640
  const ghostResult = this.readGhostStartBlock(bs, dataBlocks);
615
641
  ghostingSequence = ghostResult.ghostingSequence;
616
642
  initialGhosts = ghostResult.ghosts;
643
+ const lastInitialGhost = initialGhosts[initialGhosts.length - 1];
644
+ if (lastInitialGhost?.failed) {
645
+ // The engine sets "Invalid packet." here and drops the connection
646
+ // once the start block is done; the fields below are still read,
647
+ // misaligned, exactly as it reads them.
648
+ phase2Error ??= `initial ghost ${lastInitialGhost.index} (classId ${lastInitialGhost.classId}) failed: ${lastInitialGhost.error}`;
649
+ }
617
650
  debugInitial("after initial ghosts bit=%d count=%d seq=%d", bs.getCurPos(), initialGhosts.length, ghostingSequence);
618
651
  // B.11 controlObjectGhostIndex
619
652
  controlObjectGhostIndex = bs.readS32();
620
653
  debugInitial("after control ghost index bit=%d control=%d", bs.getCurPos(), controlObjectGhostIndex);
621
654
  // B.12 If != -1: controlObject readPacketData
622
655
  if (controlObjectGhostIndex !== -1) {
656
+ // The engine resolves the ghost and calls its readPacketData with
657
+ // no fallback; if we cannot, every later read in the initial
658
+ // block (mission name, CRC) would be misaligned, so fail phase 2.
623
659
  const ghost = ibGhostTracker.getGhost(controlObjectGhostIndex);
624
- if (ghost) {
625
- const parser = this.registry.getGhostParser(ghost.classId);
626
- if (parser?.readPacketData) {
627
- // getGhostParser enables the nested vehicle readPacketData
628
- // when the recorder was piloting at recording start — without
629
- // it those bytes go unread and every later read in the
630
- // initial block (mission name, CRC) is misaligned.
631
- const conn = {
632
- compressionPoint: { x: 0, y: 0, z: 0 },
633
- ghostTracker: ibGhostTracker,
634
- getGhostParser: (classId) => this.registry.getGhostParser(classId),
635
- };
636
- controlObjectData = parser.readPacketData(bs, conn);
637
- // The control object's readPacketData establishes the
638
- // connection's compression point (its position) — carry it
639
- // into the packet parser seed.
640
- initialCompressionPoint = conn.compressionPoint;
641
- debugInitial("after control readPacketData bit=%d parser=%s", bs.getCurPos(), parser.name);
642
- }
660
+ if (!ghost) {
661
+ throw new Error(`control object ghost ${controlObjectGhostIndex} not found among initial ghosts`);
643
662
  }
663
+ const parser = this.registry.getGhostParser(ghost.classId);
664
+ if (!parser?.readPacketData) {
665
+ throw new Error(`control object ghost ${controlObjectGhostIndex} (${ghost.className}) has no readPacketData parser`);
666
+ }
667
+ // getGhostParser enables the nested vehicle readPacketData when
668
+ // the recorder was piloting at recording start.
669
+ const conn = {
670
+ compressionPoint: { x: 0, y: 0, z: 0 },
671
+ ghostTracker: ibGhostTracker,
672
+ getGhostParser: (classId) => this.registry.getGhostParser(classId),
673
+ };
674
+ controlObjectData = parser.readPacketData(bs, conn);
675
+ // The control object's readPacketData establishes the
676
+ // connection's compression point (its position) — carry it
677
+ // into the packet parser seed.
678
+ initialCompressionPoint = conn.compressionPoint;
679
+ debugInitial("after control readPacketData bit=%d parser=%s", bs.getCurPos(), parser.name);
644
680
  }
645
681
  // B.13 $MissionName
646
682
  missionName = bs.readString();
647
683
  // B.14 mMissionCRC
648
684
  missionCRC = bs.readU32();
649
- // Byte-align (validate) before SimpleTargetManagers
650
- bs.setCurPos(((bs.getCurPos() + 7) >> 3) << 3);
651
- // B.15 Simple TargetManager ×2
685
+ // B.15 Simple TargetManager ×2. FUN_006021b0 reads U8 + 4×U32 at the
686
+ // current bit position; the binary does not byte-align here.
652
687
  this.readSimpleTargetManager(bs);
653
688
  this.readSimpleTargetManager(bs);
654
689
  debugInitial('after sequential tail bit=%d mission="%s" CRC=0x%s', bs.getCurPos(), missionName, missionCRC.toString(16));
@@ -670,6 +705,9 @@ export class DemoParser {
670
705
  missionPrintableRatio >= 0.8 &&
671
706
  phase2Error === undefined;
672
707
  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}` : "");
708
+ for (const warning of warnings) {
709
+ debug("initial block warning: %s", warning);
710
+ }
673
711
  return {
674
712
  taggedStrings,
675
713
  dataBlockHeaders,
@@ -699,6 +737,7 @@ export class DemoParser {
699
737
  phase2TrailingBits: remaining,
700
738
  phase2Valid,
701
739
  phase2Error,
740
+ warnings,
702
741
  };
703
742
  }
704
743
  /**
@@ -832,10 +871,10 @@ export class DemoParser {
832
871
  */
833
872
  readPathManager(bs) {
834
873
  const entries = [];
835
- const entryCount = bs.readU32();
874
+ const entryCount = readCheckedCount(bs, 64, "PathManager entry count");
836
875
  for (let i = 0; i < entryCount; i++) {
837
876
  const entryId = bs.readU32();
838
- const recordCount = bs.readU32();
877
+ const recordCount = readCheckedCount(bs, 128, "PathManager record count");
839
878
  const records = [];
840
879
  for (let j = 0; j < recordCount; j++) {
841
880
  records.push({
@@ -940,10 +979,9 @@ export class DemoParser {
940
979
  const ghostingSequence = bs.readU32();
941
980
  const ghosts = [];
942
981
  debugInitial("ghost block: seq=%d bit=%d", ghostingSequence, bs.getCurPos());
943
- const ghostCatalog = this.registry.getGhostCatalog();
944
982
  const totalBits = bs.getBuffer().length * 8;
945
- // Build DataBlock data lookup for ghost parsers that need it
946
- // (e.g., WheeledVehicle needs shape name to determine wheel count).
983
+ // DataBlock data lookup for ghost parsers that resolve datablock
984
+ // fields during unpack.
947
985
  const dataBlockDataMap = new Map();
948
986
  for (const [objectId, db] of dataBlocks) {
949
987
  dataBlockDataMap.set(objectId, db.data);
@@ -954,154 +992,66 @@ export class DemoParser {
954
992
  const index = bs.readInt(GhostIdBitSize);
955
993
  const classId = bs.readInt(NetObjectClassBitSize) + NetObjectClassFirst;
956
994
  const updateBitsStart = bs.getCurPos();
957
- // Build ordered list of parser candidates.
958
- // Registry binding (from deterministic classId) is preferred over
959
- // DataBlock-based identification because the classId is authoritative
960
- // while the DataBlock type may be a base class (e.g., StaticShapeData
961
- // used by a BeaconObject, which extends StaticShape with extra fields).
962
- const candidates = [];
963
- const seen = new Set();
964
- // Peek at DataBlock flag to identify ghost via DataBlock type
965
- const { entry: dbEntry } = this.identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog);
966
- // Candidate 1: registry binding (deterministic classId)
967
- const regEntry = this.registry.getGhostParser(classId);
968
- if (regEntry) {
969
- candidates.push({ entry: regEntry, method: "registry" });
970
- seen.add(regEntry);
971
- }
972
- // Candidate 2: DataBlock-based identification (fallback)
973
- if (dbEntry && !seen.has(dbEntry)) {
974
- candidates.push({ entry: dbEntry, method: "datablock" });
975
- seen.add(dbEntry);
995
+ // NetConnection::ghostReadStartBlock (FUN_00585220): create the
996
+ // object from the class id, call its unpackUpdate, register it.
997
+ // An unknown class id or a failed registration is "Invalid packet."
998
+ // the engine stops reading ghosts and the connection is dead.
999
+ // There is no second candidate and no plausibility check.
1000
+ const entry = this.registry.getGhostParser(classId);
1001
+ if (!entry) {
1002
+ debugInitial(" ghost idx=%d classId=%d NO CLASS (stopping at bit=%d)", index, classId, updateBitsStart);
1003
+ ghosts.push({
1004
+ index,
1005
+ type: "create",
1006
+ classId,
1007
+ updateBitsStart,
1008
+ updateBitsEnd: updateBitsStart,
1009
+ failed: true,
1010
+ error: `no ghost class bound to classId ${classId}`,
1011
+ });
1012
+ break;
976
1013
  }
977
- // Try each candidate with alignment validation
978
- const connOverrides = {
979
- getDataBlockData: (objectId) => dataBlockDataMap.get(objectId),
980
- getDataBlockParser: (cid) => this.registry.getDataBlockParser(cid),
981
- };
982
- let parsed = false;
983
- for (const { entry, method } of candidates) {
984
- const isTrusted = method === "registry";
985
- const result = this.tryGhostParser(bs, entry, updateBitsStart, totalBits, false, connOverrides, isTrusted);
986
- if (result !== false) {
987
- this.ghostTracker.createGhost(index, classId, entry.name);
988
- debugInitial(" ghost idx=%d classId=%d parser=%s bits=%d via=%s", index, classId, entry.name, bs.getCurPos() - updateBitsStart, method);
989
- ghosts.push({
990
- index,
991
- type: "create",
992
- classId,
993
- updateBitsStart,
994
- updateBitsEnd: bs.getCurPos(),
995
- parsedData: result,
996
- });
997
- parsed = true;
998
- break;
1014
+ let parsedData;
1015
+ try {
1016
+ parsedData =
1017
+ entry.unpackUpdate(bs, true, {
1018
+ compressionPoint: { x: 0, y: 0, z: 0 },
1019
+ ghostTracker: this.ghostTracker,
1020
+ getDataBlockData: (objectId) => dataBlockDataMap.get(objectId),
1021
+ getDataBlockParser: (cid) => this.registry.getDataBlockParser(cid),
1022
+ }) ?? {};
1023
+ if (bs.isError()) {
1024
+ throw new Error("ran past the end of the initial block");
999
1025
  }
1000
1026
  }
1001
- if (parsed)
1002
- continue;
1003
- // No candidate worked stop parsing ghosts
1004
- debugInitial(" ghost idx=%d classId=%d NO PARSER (stopping at bit=%d, remaining=%d)", index, classId, updateBitsStart, totalBits - updateBitsStart);
1005
- break;
1027
+ catch (e) {
1028
+ const error = `${entry.name}: ${e instanceof Error ? e.message : String(e)}`;
1029
+ debugInitial(" ghost idx=%d classId=%d parser=%s FAILED at bit=%d: %s", index, classId, entry.name, bs.getCurPos(), error);
1030
+ ghosts.push({
1031
+ index,
1032
+ type: "create",
1033
+ classId,
1034
+ updateBitsStart,
1035
+ updateBitsEnd: bs.getCurPos(),
1036
+ failed: true,
1037
+ error,
1038
+ });
1039
+ break;
1040
+ }
1041
+ this.ghostTracker.createGhost(index, classId, entry.name);
1042
+ debugInitial(" ghost idx=%d classId=%d parser=%s bits=%d", index, classId, entry.name, bs.getCurPos() - updateBitsStart);
1043
+ ghosts.push({
1044
+ index,
1045
+ type: "create",
1046
+ classId,
1047
+ updateBitsStart,
1048
+ updateBitsEnd: bs.getCurPos(),
1049
+ parsedData,
1050
+ });
1006
1051
  }
1007
1052
  debugInitial("ghost loop ended at bit=%d remaining=%d count=%d", bs.getCurPos(), totalBits - bs.getCurPos(), ghosts.length);
1008
1053
  return { ghostingSequence, ghosts };
1009
1054
  }
1010
- /**
1011
- * Try parsing a ghost with a given parser and validate alignment.
1012
- * Returns true if parsing succeeded and alignment is valid.
1013
- * On success, the BitStream is positioned after the parsed ghost data.
1014
- * On failure, the BitStream is restored to updateBitsStart.
1015
- */
1016
- tryGhostParser(bs, entry, updateBitsStart, totalBits, silent = false, connOverrides, trusted = false) {
1017
- const savedPos = bs.savePos();
1018
- if (!silent) {
1019
- debugInitial(" try %s: startBit=%d", entry.name, updateBitsStart);
1020
- }
1021
- try {
1022
- const parsedData = entry.unpackUpdate(bs, true, {
1023
- compressionPoint: { x: 0, y: 0, z: 0 },
1024
- ghostTracker: this.ghostTracker,
1025
- ...connOverrides,
1026
- });
1027
- const bitsConsumed = bs.getCurPos() - updateBitsStart;
1028
- const remaining = totalBits - bs.getCurPos();
1029
- if (bs.isError() || (!trusted && bitsConsumed < 3)) {
1030
- if (!silent) {
1031
- debugInitial(" reject %s: bits=%d isError=%s", entry.name, bitsConsumed, bs.isError());
1032
- }
1033
- bs.restorePos(savedPos);
1034
- return false;
1035
- }
1036
- // Validate alignment: if substantial data remains, the next
1037
- // continuation flag must be 1 (more ghosts in the snapshot).
1038
- if (remaining > 1000) {
1039
- const peekPos = bs.getCurPos();
1040
- const nextFlag = bs.readFlag();
1041
- bs.setCurPos(peekPos);
1042
- if (!nextFlag) {
1043
- if (!silent) {
1044
- debugInitial(" reject %s: bits=%d misaligned (remaining=%d)", entry.name, bitsConsumed, remaining);
1045
- }
1046
- bs.restorePos(savedPos);
1047
- return false;
1048
- }
1049
- }
1050
- return parsedData ?? {};
1051
- }
1052
- catch (e) {
1053
- if (!silent) {
1054
- debugInitial(" reject %s: error at bit=%d: %s", entry.name, bs.getCurPos(), e instanceof Error ? e.message : String(e));
1055
- }
1056
- bs.restorePos(savedPos);
1057
- return false;
1058
- }
1059
- }
1060
- /**
1061
- * Peek at the GameBase DataBlock prefix to identify a ghost's parser.
1062
- * GameBase subclass ghost creates always start with:
1063
- * flag(1b) + if flag: DataBlockId(11b)
1064
- * The DataBlock type (e.g., "PlayerData") maps to the ghost parser
1065
- * (e.g., "Player") by stripping the "Data" suffix.
1066
- *
1067
- * Returns { entry, dbFlag } where entry is the parser (if found) and
1068
- * dbFlag indicates whether the first bit was 1 (DataBlock flag set).
1069
- * In the initial block (mask=0xFFFFFFFF), dbFlag=false means the ghost
1070
- * is definitely NOT a GameBase subclass.
1071
- * The BitStream position is always restored after peeking.
1072
- */
1073
- identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog) {
1074
- if (!dataBlocks)
1075
- return { entry: undefined, dbFlag: false };
1076
- const savedPos = bs.savePos();
1077
- let entry;
1078
- let dbFlag = false;
1079
- try {
1080
- dbFlag = bs.readFlag();
1081
- if (dbFlag) {
1082
- const dbId = bs.readInt(11);
1083
- const db = dataBlocks.get(dbId);
1084
- if (db) {
1085
- const ghostName = db.className.replace(/Data$/, "");
1086
- entry = ghostCatalog.get(ghostName);
1087
- if (!entry) {
1088
- debugInitial(" identifyGhostViaDataBlock: dbId=%d className=%s ghostName=%s (no ghost parser)", dbId, db.className, ghostName);
1089
- }
1090
- }
1091
- else {
1092
- debugInitial(" identifyGhostViaDataBlock: dbId=%d (no DataBlock found)", dbId);
1093
- }
1094
- }
1095
- else {
1096
- debugInitial(" identifyGhostViaDataBlock: DataBlock flag=0");
1097
- }
1098
- }
1099
- catch {
1100
- // Ignore errors during peek
1101
- }
1102
- bs.restorePos(savedPos);
1103
- return { entry, dbFlag };
1104
- }
1105
1055
  /**
1106
1056
  * Parse a raw 64-byte Move struct from a type 2 block.
1107
1057
  *
@@ -1163,8 +1113,10 @@ export class DemoParser {
1163
1113
  readInfoBlock(data) {
1164
1114
  const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
1165
1115
  return {
1166
- value1: dv.getUint32(0, true),
1167
- value2: dv.getFloat32(4, true),
1116
+ // GameConnection::handleRecordedBlock (FUN_005fb170) case 3 reads
1117
+ // only byte 0 as firstPerson and the F32 at +4 as the camera FOV.
1118
+ firstPerson: data[0] !== 0,
1119
+ cameraFov: dv.getFloat32(4, true),
1168
1120
  };
1169
1121
  }
1170
1122
  }