t2-demo-parser 2.1.0 → 3.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/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 +51 -76
  16. package/dist/DataBlockParsers.js.map +1 -1
  17. package/dist/DemoParser.d.ts +86 -23
  18. package/dist/DemoParser.d.ts.map +1 -1
  19. package/dist/DemoParser.js +409 -264
  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 +111 -112
  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
@@ -1,15 +1,31 @@
1
- import { inflate } from "fflate";
1
+ 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;
@@ -26,40 +42,36 @@ export class DemoParser {
26
42
  _blockStreamOffset = 0;
27
43
  _blockCount;
28
44
  _blockCursor = 0;
29
- constructor(buffer) {
45
+ // Incremental mode: the compressed block stream arrives in chunks via
46
+ // push() and is inflated as it goes; nextBlock() treats the end of the
47
+ // decompressed data as a frontier (more may arrive) until finish().
48
+ _incremental;
49
+ _complete = true;
50
+ _inflator;
51
+ /** The zlib stream announced its own end (final deflate block seen) —
52
+ * later bytes are trailing garbage and further pushes would throw. */
53
+ _streamEnded = false;
54
+ /** Backing store for the growing decompressed stream (incremental
55
+ * mode). `_decompressedData` is always a length-exact subarray of
56
+ * this, so every existing `.length`-based read stays correct. */
57
+ _backing;
58
+ _decompressedLength = 0;
59
+ // Buffered-time scan state (see bufferedMoveTicks).
60
+ _scanOffset = 0;
61
+ _bufferedMoveTicks = 0;
62
+ _ignoreProtocolVersion;
63
+ _haltOnFault;
64
+ constructor(buffer, options) {
30
65
  this.buffer = buffer;
31
66
  this.view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
32
67
  this.offset = 0;
33
- this.registry = new ClassRegistry();
68
+ this._incremental = options?.incremental === true;
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();
34
74
  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
75
  this.packetParser = new PacketParser(this.registry, this.ghostTracker);
64
76
  }
65
77
  getRegistry() {
@@ -90,22 +102,35 @@ export class DemoParser {
90
102
  throw new Error("must call load() first");
91
103
  if (this._blockCount === undefined) {
92
104
  // Lazy scan: walk decompressed buffer counting U16 headers
93
- const data = this._decompressedData;
94
- const view = this._decompressedView;
95
105
  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;
106
+ this._walkBlockHeaders(0, () => {
103
107
  count++;
104
- }
108
+ });
105
109
  this._blockCount = count;
106
110
  }
107
111
  return this._blockCount;
108
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
+ }
109
134
  get blockCursor() {
110
135
  if (!this._loaded)
111
136
  throw new Error("must call load() first");
@@ -129,23 +154,52 @@ export class DemoParser {
129
154
  // Phase 1: header + initial block
130
155
  const header = this.readHeader();
131
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);
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}`);
162
+ }
132
163
  const initialBlockData = this.buffer.subarray(this.offset, this.offset + header.initialBlockSize);
133
164
  const initialBlock = this.readInitialBlock(initialBlockData);
134
165
  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);
166
+ if (this._incremental) {
167
+ // Phase 2 (incremental): streaming inflate. Compressed bytes
168
+ // arrive via push(); whatever tail the constructor buffer already
169
+ // holds past the initial block is the first chunk. fflate's
170
+ // Inflate emits decompressed output synchronously during push.
171
+ this._complete = false;
172
+ this._decompressedLength = 0;
173
+ this._backing = new Uint8Array(1 << 20);
174
+ this._decompressedData = this._backing.subarray(0, 0);
175
+ this._decompressedView = new DataView(this._backing.buffer, 0, 0);
176
+ this._inflator = new Inflate((chunk, final) => {
177
+ this._appendDecompressed(chunk);
178
+ if (final)
179
+ this._streamEnded = true;
144
180
  });
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);
181
+ const tail = this.buffer.subarray(this.offset);
182
+ // The prefix buffer has served its purpose (header + initial block
183
+ // are parsed); don't retain arbitrary compressed tail bytes twice.
184
+ if (tail.length > 0)
185
+ this._inflator.push(tail);
186
+ }
187
+ else {
188
+ // Phase 2: async decompress block stream
189
+ const compressedData = this.buffer.subarray(this.offset);
190
+ debug("compressed block stream: %d bytes", compressedData.length);
191
+ const decompressedData = await new Promise((resolve, reject) => {
192
+ inflate(compressedData, (err, data) => {
193
+ if (err)
194
+ reject(err);
195
+ else
196
+ resolve(data);
197
+ });
198
+ });
199
+ debug("decompressed block stream: %d bytes", decompressedData.length);
200
+ this._decompressedData = decompressedData;
201
+ this._decompressedView = new DataView(decompressedData.buffer, decompressedData.byteOffset, decompressedData.byteLength);
202
+ }
149
203
  // Phase 3: set up PacketParser with seeded ghost tracker
150
204
  this.setupPacketParser(initialBlock);
151
205
  // Cache results
@@ -156,6 +210,143 @@ export class DemoParser {
156
210
  this._loaded = true;
157
211
  return { header, initialBlock };
158
212
  }
213
+ /**
214
+ * Parse just the fixed-size demo header from a byte prefix, without
215
+ * constructing a parser. Throws RangeError when `bytes` is too short —
216
+ * callers streaming a download retry as more data arrives. The
217
+ * returned `byteLength` is where the initial block begins; the block
218
+ * stream begins at `byteLength + header.initialBlockSize`.
219
+ */
220
+ static peekHeader(bytes) {
221
+ if (bytes.length < 1)
222
+ throw new RangeError("incomplete header");
223
+ const strLen = bytes[0];
224
+ const byteLength = 1 + strLen + 12;
225
+ if (bytes.length < byteLength)
226
+ throw new RangeError("incomplete header");
227
+ const identString = new TextDecoder("ascii").decode(bytes.subarray(1, 1 + strLen));
228
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
229
+ const protocolVersion = view.getUint32(1 + strLen, true);
230
+ const demoLengthMs = view.getUint32(1 + strLen + 4, true);
231
+ const initialBlockSize = view.getUint32(1 + strLen + 8, true);
232
+ return {
233
+ header: { identString, protocolVersion, demoLengthMs, initialBlockSize },
234
+ byteLength,
235
+ };
236
+ }
237
+ /**
238
+ * Incremental mode only: feed the next chunk of raw (compressed) block
239
+ * stream bytes as they arrive. Inflation happens synchronously; any
240
+ * blocks completed by this chunk become readable via nextBlock().
241
+ * The chunk is not retained.
242
+ */
243
+ push(chunk) {
244
+ if (!this._incremental)
245
+ throw new Error("not an incremental parser");
246
+ if (!this._loaded)
247
+ throw new Error("must call load() first");
248
+ if (this._complete)
249
+ throw new Error("already finished");
250
+ if (chunk.length === 0 || this._streamEnded)
251
+ return;
252
+ this._inflator.push(chunk);
253
+ }
254
+ /**
255
+ * Incremental mode only: signal that the download is complete. Flushes
256
+ * the inflator; after this, running out of blocks means the true end
257
+ * of the demo rather than the frontier.
258
+ */
259
+ finish() {
260
+ if (!this._incremental)
261
+ throw new Error("not an incremental parser");
262
+ if (!this._loaded)
263
+ throw new Error("must call load() first");
264
+ if (this._complete)
265
+ return;
266
+ if (!this._streamEnded) {
267
+ try {
268
+ this._inflator.push(new Uint8Array(0), true);
269
+ }
270
+ catch (err) {
271
+ // A truncated zlib stream: the blocks inflated so far remain
272
+ // valid (nextBlock bounds-checks the tail) — degrade to a
273
+ // shorter demo rather than failing the whole load.
274
+ debug("finish(): inflate flush failed: %o", err);
275
+ }
276
+ }
277
+ this._inflator = undefined;
278
+ this._complete = true;
279
+ // Trim: the doubling backing store can overshoot by up to 2× —
280
+ // release the slack now that the final length is known.
281
+ if (this._backing && this._backing.length > this._decompressedLength) {
282
+ const exact = this._backing.slice(0, this._decompressedLength);
283
+ this._backing = exact;
284
+ this._decompressedData = exact;
285
+ this._decompressedView = new DataView(exact.buffer, exact.byteOffset, exact.byteLength);
286
+ }
287
+ }
288
+ /**
289
+ * False only in incremental mode before finish(): nextBlock()
290
+ * returning undefined then means "frontier — more data may arrive",
291
+ * not the end of the demo.
292
+ */
293
+ get isComplete() {
294
+ return this._complete;
295
+ }
296
+ /**
297
+ * Bytes of decompressed block stream available so far. Grows during
298
+ * an incremental feed; consumers that latched an "out of blocks"
299
+ * state can compare against it to know new data has arrived.
300
+ */
301
+ get decompressedByteLength() {
302
+ return this._incremental
303
+ ? this._decompressedLength
304
+ : (this._decompressedData?.length ?? 0);
305
+ }
306
+ /**
307
+ * Move-tick blocks in the decompressed stream so far — each is one
308
+ * fixed 32ms simulation tick, so this measures buffered DEMO TIME
309
+ * exactly (compressed bytes don't: quiet stretches pack far denser).
310
+ * Independent of the read cursor; unaffected by reset(). Maintained
311
+ * incrementally as data arrives; a full lazy scan on first access
312
+ * covers the one-shot mode.
313
+ */
314
+ get bufferedMoveTicks() {
315
+ this._scanBufferedTicks();
316
+ return this._bufferedMoveTicks;
317
+ }
318
+ _scanBufferedTicks() {
319
+ let ticks = this._bufferedMoveTicks;
320
+ this._scanOffset = this._walkBlockHeaders(this._scanOffset, (type) => {
321
+ if (type === BlockTypeMove)
322
+ ticks++;
323
+ });
324
+ this._bufferedMoveTicks = ticks;
325
+ }
326
+ _appendDecompressed(chunk) {
327
+ if (chunk.length === 0)
328
+ return;
329
+ const needed = this._decompressedLength + chunk.length;
330
+ let backing = this._backing;
331
+ if (needed > backing.length) {
332
+ let capacity = backing.length;
333
+ while (capacity < needed)
334
+ capacity *= 2;
335
+ const grown = new Uint8Array(capacity);
336
+ grown.set(backing.subarray(0, this._decompressedLength));
337
+ backing = grown;
338
+ this._backing = grown;
339
+ }
340
+ backing.set(chunk, this._decompressedLength);
341
+ this._decompressedLength = needed;
342
+ // Refresh the exact-length views; subarray shares memory, so this is
343
+ // cheap and keeps every `.length`-based reader (nextBlock,
344
+ // blockCount) correct without touching them.
345
+ this._decompressedData = backing.subarray(0, needed);
346
+ this._decompressedView = new DataView(backing.buffer, 0, needed);
347
+ // The lazily-cached block count no longer covers the new bytes.
348
+ this._blockCount = undefined;
349
+ }
159
350
  /**
160
351
  * Read and parse the next block from the decompressed buffer.
161
352
  * Returns the parsed block, or undefined when all blocks are exhausted.
@@ -186,32 +377,26 @@ export class DemoParser {
186
377
  data: blockData,
187
378
  };
188
379
  this._blockCursor++;
189
- if (type === BlockTypePacket) {
190
- 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) {
191
385
  block.parsed = this.packetParser.parsePacket(blockData);
192
386
  }
193
- catch {
194
- // Skip unparseable packets
387
+ else if (type === BlockTypeSendPacket) {
388
+ this.packetParser.onSendPacketTrigger();
195
389
  }
196
- }
197
- else if (type === BlockTypeSendPacket) {
198
- this.packetParser.onSendPacketTrigger();
199
- }
200
- else if (type === BlockTypeMove && size === 64) {
201
- try {
390
+ else if (type === BlockTypeMove && size === 64) {
202
391
  block.parsed = this.readRawMove(blockData);
203
392
  }
204
- catch {
205
- // Skip unparseable moves
206
- }
207
- }
208
- else if (type === BlockTypeInfo && size === 8) {
209
- try {
393
+ else if (type === BlockTypeInfo && size === 8) {
210
394
  block.parsed = this.readInfoBlock(blockData);
211
395
  }
212
- catch {
213
- // Skip unparseable info blocks
214
- }
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);
215
400
  }
216
401
  return block;
217
402
  }
@@ -271,6 +456,20 @@ export class DemoParser {
271
456
  connectionProtocolState: initialBlock.connectionState,
272
457
  nextRecvEventSeq: initialBlock.nextRecvEventSeq,
273
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,
274
473
  });
275
474
  this.ghostTracker = gt;
276
475
  this.packetParser = pp;
@@ -286,22 +485,25 @@ export class DemoParser {
286
485
  blocks.push(block);
287
486
  return { header, initialBlock, blocks };
288
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
+ */
289
493
  readHeader() {
290
- // Read the identification string: U8 length + string
291
- const strLen = this.view.getUint8(this.offset);
292
- this.offset += 1;
293
- const identString = new TextDecoder("ascii").decode(this.buffer.subarray(this.offset, this.offset + strLen));
294
- this.offset += strLen;
295
- // U32 protocol version
296
- const protocolVersion = this.view.getUint32(this.offset, true);
297
- this.offset += 4;
298
- // U32 demo length in ms
299
- const demoLengthMs = this.view.getUint32(this.offset, true);
300
- this.offset += 4;
301
- // U32 initial block size
302
- const initialBlockSize = this.view.getUint32(this.offset, true);
303
- this.offset += 4;
304
- 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;
305
507
  }
306
508
  /** Parse the initial block: DataBlocks, scores, targets, connection state,
307
509
  * events, ghosts, control object, and mission name. */
@@ -365,7 +567,8 @@ export class DemoParser {
365
567
  for (let i = 0; i < 16; i++)
366
568
  stateArray.push(bs.readU32());
367
569
  // --- B.6 U32 score entry count ---
368
- 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");
369
572
  // --- B.7 Score entries × count (FUN_00601800) ---
370
573
  const scoreEntries = [];
371
574
  for (let i = 0; i < scoreCount; i++) {
@@ -394,6 +597,22 @@ export class DemoParser {
394
597
  // The reader allocates notify nodes in memory but does not consume
395
598
  // per-notify records from the bitstream in this phase.
396
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
+ }
397
616
  debugInitial("after notify count bit=%d notifyCount=%d", bs.getCurPos(), notifyCount);
398
617
  // --- B.10f through B.15: events, ghosts, control object, mission ---
399
618
  // Uses a temporary ghost tracker for initial-block ghost parsing only.
@@ -421,41 +640,50 @@ export class DemoParser {
421
640
  const ghostResult = this.readGhostStartBlock(bs, dataBlocks);
422
641
  ghostingSequence = ghostResult.ghostingSequence;
423
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
+ }
424
650
  debugInitial("after initial ghosts bit=%d count=%d seq=%d", bs.getCurPos(), initialGhosts.length, ghostingSequence);
425
651
  // B.11 controlObjectGhostIndex
426
652
  controlObjectGhostIndex = bs.readS32();
427
653
  debugInitial("after control ghost index bit=%d control=%d", bs.getCurPos(), controlObjectGhostIndex);
428
654
  // B.12 If != -1: controlObject readPacketData
429
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.
430
659
  const ghost = ibGhostTracker.getGhost(controlObjectGhostIndex);
431
- if (ghost) {
432
- const parser = this.registry.getGhostParser(ghost.classId);
433
- if (parser?.readPacketData) {
434
- // getGhostParser enables the nested vehicle readPacketData
435
- // when the recorder was piloting at recording start — without
436
- // it those bytes go unread and every later read in the
437
- // initial block (mission name, CRC) is misaligned.
438
- const conn = {
439
- compressionPoint: { x: 0, y: 0, z: 0 },
440
- ghostTracker: ibGhostTracker,
441
- getGhostParser: (classId) => this.registry.getGhostParser(classId),
442
- };
443
- controlObjectData = parser.readPacketData(bs, conn);
444
- // The control object's readPacketData establishes the
445
- // connection's compression point (its position) — carry it
446
- // into the packet parser seed.
447
- initialCompressionPoint = conn.compressionPoint;
448
- debugInitial("after control readPacketData bit=%d parser=%s", bs.getCurPos(), parser.name);
449
- }
660
+ if (!ghost) {
661
+ throw new Error(`control object ghost ${controlObjectGhostIndex} not found among initial ghosts`);
450
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);
451
680
  }
452
681
  // B.13 $MissionName
453
682
  missionName = bs.readString();
454
683
  // B.14 mMissionCRC
455
684
  missionCRC = bs.readU32();
456
- // Byte-align (validate) before SimpleTargetManagers
457
- bs.setCurPos(((bs.getCurPos() + 7) >> 3) << 3);
458
- // 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.
459
687
  this.readSimpleTargetManager(bs);
460
688
  this.readSimpleTargetManager(bs);
461
689
  debugInitial('after sequential tail bit=%d mission="%s" CRC=0x%s', bs.getCurPos(), missionName, missionCRC.toString(16));
@@ -477,6 +705,9 @@ export class DemoParser {
477
705
  missionPrintableRatio >= 0.8 &&
478
706
  phase2Error === undefined;
479
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
+ }
480
711
  return {
481
712
  taggedStrings,
482
713
  dataBlockHeaders,
@@ -506,6 +737,7 @@ export class DemoParser {
506
737
  phase2TrailingBits: remaining,
507
738
  phase2Valid,
508
739
  phase2Error,
740
+ warnings,
509
741
  };
510
742
  }
511
743
  /**
@@ -639,10 +871,10 @@ export class DemoParser {
639
871
  */
640
872
  readPathManager(bs) {
641
873
  const entries = [];
642
- const entryCount = bs.readU32();
874
+ const entryCount = readCheckedCount(bs, 64, "PathManager entry count");
643
875
  for (let i = 0; i < entryCount; i++) {
644
876
  const entryId = bs.readU32();
645
- const recordCount = bs.readU32();
877
+ const recordCount = readCheckedCount(bs, 128, "PathManager record count");
646
878
  const records = [];
647
879
  for (let j = 0; j < recordCount; j++) {
648
880
  records.push({
@@ -747,10 +979,9 @@ export class DemoParser {
747
979
  const ghostingSequence = bs.readU32();
748
980
  const ghosts = [];
749
981
  debugInitial("ghost block: seq=%d bit=%d", ghostingSequence, bs.getCurPos());
750
- const ghostCatalog = this.registry.getGhostCatalog();
751
982
  const totalBits = bs.getBuffer().length * 8;
752
- // Build DataBlock data lookup for ghost parsers that need it
753
- // (e.g., WheeledVehicle needs shape name to determine wheel count).
983
+ // DataBlock data lookup for ghost parsers that resolve datablock
984
+ // fields during unpack.
754
985
  const dataBlockDataMap = new Map();
755
986
  for (const [objectId, db] of dataBlocks) {
756
987
  dataBlockDataMap.set(objectId, db.data);
@@ -761,154 +992,66 @@ export class DemoParser {
761
992
  const index = bs.readInt(GhostIdBitSize);
762
993
  const classId = bs.readInt(NetObjectClassBitSize) + NetObjectClassFirst;
763
994
  const updateBitsStart = bs.getCurPos();
764
- // Build ordered list of parser candidates.
765
- // Registry binding (from deterministic classId) is preferred over
766
- // DataBlock-based identification because the classId is authoritative
767
- // while the DataBlock type may be a base class (e.g., StaticShapeData
768
- // used by a BeaconObject, which extends StaticShape with extra fields).
769
- const candidates = [];
770
- const seen = new Set();
771
- // Peek at DataBlock flag to identify ghost via DataBlock type
772
- const { entry: dbEntry } = this.identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog);
773
- // Candidate 1: registry binding (deterministic classId)
774
- const regEntry = this.registry.getGhostParser(classId);
775
- if (regEntry) {
776
- candidates.push({ entry: regEntry, method: "registry" });
777
- seen.add(regEntry);
778
- }
779
- // Candidate 2: DataBlock-based identification (fallback)
780
- if (dbEntry && !seen.has(dbEntry)) {
781
- candidates.push({ entry: dbEntry, method: "datablock" });
782
- 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;
783
1013
  }
784
- // Try each candidate with alignment validation
785
- const connOverrides = {
786
- getDataBlockData: (objectId) => dataBlockDataMap.get(objectId),
787
- getDataBlockParser: (cid) => this.registry.getDataBlockParser(cid),
788
- };
789
- let parsed = false;
790
- for (const { entry, method } of candidates) {
791
- const isTrusted = method === "registry";
792
- const result = this.tryGhostParser(bs, entry, updateBitsStart, totalBits, false, connOverrides, isTrusted);
793
- if (result !== false) {
794
- this.ghostTracker.createGhost(index, classId, entry.name);
795
- debugInitial(" ghost idx=%d classId=%d parser=%s bits=%d via=%s", index, classId, entry.name, bs.getCurPos() - updateBitsStart, method);
796
- ghosts.push({
797
- index,
798
- type: "create",
799
- classId,
800
- updateBitsStart,
801
- updateBitsEnd: bs.getCurPos(),
802
- parsedData: result,
803
- });
804
- parsed = true;
805
- 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");
806
1025
  }
807
1026
  }
808
- if (parsed)
809
- continue;
810
- // No candidate worked stop parsing ghosts
811
- debugInitial(" ghost idx=%d classId=%d NO PARSER (stopping at bit=%d, remaining=%d)", index, classId, updateBitsStart, totalBits - updateBitsStart);
812
- 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
+ });
813
1051
  }
814
1052
  debugInitial("ghost loop ended at bit=%d remaining=%d count=%d", bs.getCurPos(), totalBits - bs.getCurPos(), ghosts.length);
815
1053
  return { ghostingSequence, ghosts };
816
1054
  }
817
- /**
818
- * Try parsing a ghost with a given parser and validate alignment.
819
- * Returns true if parsing succeeded and alignment is valid.
820
- * On success, the BitStream is positioned after the parsed ghost data.
821
- * On failure, the BitStream is restored to updateBitsStart.
822
- */
823
- tryGhostParser(bs, entry, updateBitsStart, totalBits, silent = false, connOverrides, trusted = false) {
824
- const savedPos = bs.savePos();
825
- if (!silent) {
826
- debugInitial(" try %s: startBit=%d", entry.name, updateBitsStart);
827
- }
828
- try {
829
- const parsedData = entry.unpackUpdate(bs, true, {
830
- compressionPoint: { x: 0, y: 0, z: 0 },
831
- ghostTracker: this.ghostTracker,
832
- ...connOverrides,
833
- });
834
- const bitsConsumed = bs.getCurPos() - updateBitsStart;
835
- const remaining = totalBits - bs.getCurPos();
836
- if (bs.isError() || (!trusted && bitsConsumed < 3)) {
837
- if (!silent) {
838
- debugInitial(" reject %s: bits=%d isError=%s", entry.name, bitsConsumed, bs.isError());
839
- }
840
- bs.restorePos(savedPos);
841
- return false;
842
- }
843
- // Validate alignment: if substantial data remains, the next
844
- // continuation flag must be 1 (more ghosts in the snapshot).
845
- if (remaining > 1000) {
846
- const peekPos = bs.getCurPos();
847
- const nextFlag = bs.readFlag();
848
- bs.setCurPos(peekPos);
849
- if (!nextFlag) {
850
- if (!silent) {
851
- debugInitial(" reject %s: bits=%d misaligned (remaining=%d)", entry.name, bitsConsumed, remaining);
852
- }
853
- bs.restorePos(savedPos);
854
- return false;
855
- }
856
- }
857
- return parsedData ?? {};
858
- }
859
- catch (e) {
860
- if (!silent) {
861
- debugInitial(" reject %s: error at bit=%d: %s", entry.name, bs.getCurPos(), e instanceof Error ? e.message : String(e));
862
- }
863
- bs.restorePos(savedPos);
864
- return false;
865
- }
866
- }
867
- /**
868
- * Peek at the GameBase DataBlock prefix to identify a ghost's parser.
869
- * GameBase subclass ghost creates always start with:
870
- * flag(1b) + if flag: DataBlockId(11b)
871
- * The DataBlock type (e.g., "PlayerData") maps to the ghost parser
872
- * (e.g., "Player") by stripping the "Data" suffix.
873
- *
874
- * Returns { entry, dbFlag } where entry is the parser (if found) and
875
- * dbFlag indicates whether the first bit was 1 (DataBlock flag set).
876
- * In the initial block (mask=0xFFFFFFFF), dbFlag=false means the ghost
877
- * is definitely NOT a GameBase subclass.
878
- * The BitStream position is always restored after peeking.
879
- */
880
- identifyGhostViaDataBlock(bs, dataBlocks, ghostCatalog) {
881
- if (!dataBlocks)
882
- return { entry: undefined, dbFlag: false };
883
- const savedPos = bs.savePos();
884
- let entry;
885
- let dbFlag = false;
886
- try {
887
- dbFlag = bs.readFlag();
888
- if (dbFlag) {
889
- const dbId = bs.readInt(11);
890
- const db = dataBlocks.get(dbId);
891
- if (db) {
892
- const ghostName = db.className.replace(/Data$/, "");
893
- entry = ghostCatalog.get(ghostName);
894
- if (!entry) {
895
- debugInitial(" identifyGhostViaDataBlock: dbId=%d className=%s ghostName=%s (no ghost parser)", dbId, db.className, ghostName);
896
- }
897
- }
898
- else {
899
- debugInitial(" identifyGhostViaDataBlock: dbId=%d (no DataBlock found)", dbId);
900
- }
901
- }
902
- else {
903
- debugInitial(" identifyGhostViaDataBlock: DataBlock flag=0");
904
- }
905
- }
906
- catch {
907
- // Ignore errors during peek
908
- }
909
- bs.restorePos(savedPos);
910
- return { entry, dbFlag };
911
- }
912
1055
  /**
913
1056
  * Parse a raw 64-byte Move struct from a type 2 block.
914
1057
  *
@@ -970,8 +1113,10 @@ export class DemoParser {
970
1113
  readInfoBlock(data) {
971
1114
  const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
972
1115
  return {
973
- value1: dv.getUint32(0, true),
974
- 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),
975
1120
  };
976
1121
  }
977
1122
  }