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.
- 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 +51 -76
- package/dist/DataBlockParsers.js.map +1 -1
- package/dist/DemoParser.d.ts +86 -23
- package/dist/DemoParser.d.ts.map +1 -1
- package/dist/DemoParser.js +409 -264
- 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 +111 -112
- 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/DemoParser.js
CHANGED
|
@@ -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 {
|
|
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;
|
|
@@ -26,40 +42,36 @@ export class DemoParser {
|
|
|
26
42
|
_blockStreamOffset = 0;
|
|
27
43
|
_blockCount;
|
|
28
44
|
_blockCursor = 0;
|
|
29
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
457
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
753
|
-
//
|
|
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
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (
|
|
794
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
974
|
-
|
|
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
|
}
|