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.
- package/README.md +170 -7
- package/dist/BitStream.d.ts +14 -3
- package/dist/BitStream.d.ts.map +1 -1
- package/dist/BitStream.js +28 -6
- package/dist/BitStream.js.map +1 -1
- package/dist/BitWriter.d.ts +29 -0
- package/dist/BitWriter.d.ts.map +1 -0
- package/dist/BitWriter.js +76 -0
- package/dist/BitWriter.js.map +1 -0
- package/dist/ClassRegistry.d.ts +23 -28
- package/dist/ClassRegistry.d.ts.map +1 -1
- package/dist/ClassRegistry.js +75 -84
- package/dist/ClassRegistry.js.map +1 -1
- package/dist/DataBlockParsers.d.ts.map +1 -1
- package/dist/DataBlockParsers.js +60 -85
- package/dist/DataBlockParsers.js.map +1 -1
- package/dist/DemoParser.d.ts +23 -22
- package/dist/DemoParser.d.ts.map +1 -1
- package/dist/DemoParser.js +218 -266
- package/dist/DemoParser.js.map +1 -1
- package/dist/EventParsers.d.ts.map +1 -1
- package/dist/EventParsers.js +87 -58
- package/dist/EventParsers.js.map +1 -1
- package/dist/GhostManager.d.ts +7 -1
- package/dist/GhostManager.d.ts.map +1 -1
- package/dist/GhostManager.js +231 -146
- package/dist/GhostManager.js.map +1 -1
- package/dist/HuffmanProcessor.d.ts +15 -0
- package/dist/HuffmanProcessor.d.ts.map +1 -1
- package/dist/HuffmanProcessor.js +45 -0
- package/dist/HuffmanProcessor.js.map +1 -1
- package/dist/LiveParser.d.ts +20 -1
- package/dist/LiveParser.d.ts.map +1 -1
- package/dist/LiveParser.js +31 -12
- package/dist/LiveParser.js.map +1 -1
- package/dist/PacketParser.d.ts +22 -2
- package/dist/PacketParser.d.ts.map +1 -1
- package/dist/PacketParser.js +111 -48
- package/dist/PacketParser.js.map +1 -1
- package/dist/Timeline.d.ts +5 -2
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +25 -12
- package/dist/Timeline.js.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/dataBlockDataTypes.d.ts +118 -117
- package/dist/dataBlockDataTypes.d.ts.map +1 -1
- package/dist/dataBlockDataTypes.js.map +1 -1
- package/dist/defaultRegistry.d.ts +9 -0
- package/dist/defaultRegistry.d.ts.map +1 -0
- package/dist/defaultRegistry.js +43 -0
- package/dist/defaultRegistry.js.map +1 -0
- package/dist/eventDataTypes.d.ts +74 -72
- package/dist/eventDataTypes.d.ts.map +1 -1
- package/dist/eventDataTypes.js.map +1 -1
- package/dist/ghostDataTypes.d.ts +125 -131
- package/dist/ghostDataTypes.d.ts.map +1 -1
- package/dist/ghostDataTypes.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +51 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/package.json +4 -1
- package/dist/BitStream.test.d.ts +0 -2
- package/dist/BitStream.test.d.ts.map +0 -1
- package/dist/BitStream.test.js +0 -419
- package/dist/BitStream.test.js.map +0 -1
- package/dist/ClassRegistry.test.d.ts +0 -2
- package/dist/ClassRegistry.test.d.ts.map +0 -1
- package/dist/ClassRegistry.test.js +0 -83
- package/dist/ClassRegistry.test.js.map +0 -1
- package/dist/DataBlockParsers.test.d.ts +0 -2
- package/dist/DataBlockParsers.test.d.ts.map +0 -1
- package/dist/DataBlockParsers.test.js +0 -294
- package/dist/DataBlockParsers.test.js.map +0 -1
- package/dist/DemoParser.test.d.ts +0 -2
- package/dist/DemoParser.test.d.ts.map +0 -1
- package/dist/DemoParser.test.js +0 -472
- package/dist/DemoParser.test.js.map +0 -1
- package/dist/GhostStateAccumulator.test.d.ts +0 -2
- package/dist/GhostStateAccumulator.test.d.ts.map +0 -1
- package/dist/GhostStateAccumulator.test.js +0 -201
- package/dist/GhostStateAccumulator.test.js.map +0 -1
- package/dist/GhostTracker.test.d.ts +0 -2
- package/dist/GhostTracker.test.d.ts.map +0 -1
- package/dist/GhostTracker.test.js +0 -64
- package/dist/GhostTracker.test.js.map +0 -1
- package/dist/IncrementalParser.test.d.ts +0 -2
- package/dist/IncrementalParser.test.d.ts.map +0 -1
- package/dist/IncrementalParser.test.js +0 -226
- package/dist/IncrementalParser.test.js.map +0 -1
package/dist/DemoParser.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import { GhostTracker
|
|
7
|
-
import {
|
|
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.
|
|
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
|
-
|
|
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.
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
650
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
946
|
-
//
|
|
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
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (
|
|
987
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
}
|