t2-demo-parser 1.0.3 → 2.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.
@@ -0,0 +1,36 @@
1
+ import type { GhostUpdate, PacketData } from "./types.js";
2
+ import type { ParsedData } from "./ClassRegistry.js";
3
+ /**
4
+ * Fold a sparse masked ghost update onto accumulated full state.
5
+ *
6
+ * Each masked wire section writes complete values for the fields it
7
+ * covers, so scalars and plain objects are last-write-wins. Arrays whose
8
+ * entries carry a numeric `index` (threads, images, sounds) are sparse:
9
+ * an update only includes changed entries, so they merge by index with
10
+ * entry replacement. (An images entry with `dataBlockId: 0` must replace
11
+ * rather than delete — "slot cleared" is meaningful state.) Positionally
12
+ * complete arrays (wheels) replace wholesale. Create-only fields survive
13
+ * because the create's data is the merge base.
14
+ */
15
+ export declare function mergeGhostParsedData(base: ParsedData, update: ParsedData): ParsedData;
16
+ /**
17
+ * Maintains one merged full ParsedData per live ghost by folding each
18
+ * packet's creates/updates/deletes (plus GhostAlwaysObjectEvent creates
19
+ * and EndGhosting clears). `toInitialGhosts()` then yields entries
20
+ * shaped like a demo recording's InitialBlockData.initialGhosts — the
21
+ * full-state ghost list a `.rec` starts with when recorded mid-match —
22
+ * for hydrating a late joiner.
23
+ */
24
+ export declare class GhostStateAccumulator {
25
+ private ghosts;
26
+ applyPacket(parsed: PacketData): void;
27
+ clear(): void;
28
+ size(): number;
29
+ /** Seed entries for `createLiveParser({ ghosts })`. */
30
+ getGhostSeeds(): Array<{
31
+ index: number;
32
+ classId: number;
33
+ }>;
34
+ toInitialGhosts(): GhostUpdate[];
35
+ }
36
+ //# sourceMappingURL=GhostStateAccumulator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GhostStateAccumulator.d.ts","sourceRoot":"","sources":["../src/GhostStateAccumulator.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AA0BrD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,UAAU,GACjB,UAAU,CAeZ;AAED;;;;;;;GAOG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,MAAM,CAAuC;IAErD,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAgDrC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,MAAM;IAId,uDAAuD;IACvD,aAAa,IAAI,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAO1D,eAAe,IAAI,WAAW,EAAE;CAYjC"}
@@ -0,0 +1,124 @@
1
+ import createDebug from "debug";
2
+ import { GhostMsgEndGhosting } from "./types.js";
3
+ const debug = createDebug("t2-demo-parser:ghost-state");
4
+ function isIndexedEntryArray(value) {
5
+ return (Array.isArray(value) &&
6
+ value.length > 0 &&
7
+ value.every((entry) => typeof entry === "object" &&
8
+ entry !== null &&
9
+ typeof entry.index === "number"));
10
+ }
11
+ /**
12
+ * Fold a sparse masked ghost update onto accumulated full state.
13
+ *
14
+ * Each masked wire section writes complete values for the fields it
15
+ * covers, so scalars and plain objects are last-write-wins. Arrays whose
16
+ * entries carry a numeric `index` (threads, images, sounds) are sparse:
17
+ * an update only includes changed entries, so they merge by index with
18
+ * entry replacement. (An images entry with `dataBlockId: 0` must replace
19
+ * rather than delete — "slot cleared" is meaningful state.) Positionally
20
+ * complete arrays (wheels) replace wholesale. Create-only fields survive
21
+ * because the create's data is the merge base.
22
+ */
23
+ export function mergeGhostParsedData(base, update) {
24
+ const merged = { ...base };
25
+ for (const [key, value] of Object.entries(update)) {
26
+ if (value === undefined)
27
+ continue;
28
+ const existing = merged[key];
29
+ if (isIndexedEntryArray(value) && isIndexedEntryArray(existing)) {
30
+ const byIndex = new Map();
31
+ for (const entry of existing)
32
+ byIndex.set(entry.index, entry);
33
+ for (const entry of value)
34
+ byIndex.set(entry.index, entry);
35
+ merged[key] = [...byIndex.values()].sort((a, b) => a.index - b.index);
36
+ }
37
+ else {
38
+ merged[key] = value;
39
+ }
40
+ }
41
+ return merged;
42
+ }
43
+ /**
44
+ * Maintains one merged full ParsedData per live ghost by folding each
45
+ * packet's creates/updates/deletes (plus GhostAlwaysObjectEvent creates
46
+ * and EndGhosting clears). `toInitialGhosts()` then yields entries
47
+ * shaped like a demo recording's InitialBlockData.initialGhosts — the
48
+ * full-state ghost list a `.rec` starts with when recorded mid-match —
49
+ * for hydrating a late joiner.
50
+ */
51
+ export class GhostStateAccumulator {
52
+ ghosts = new Map();
53
+ applyPacket(parsed) {
54
+ // Events apply before ghosts, matching packet layout (readEvents
55
+ // runs before readGhosts and its side effects alter ghost state).
56
+ for (const event of parsed.events) {
57
+ const data = event.parsedData;
58
+ if (!data)
59
+ continue;
60
+ if (data.type === "GhostingMessageEvent" &&
61
+ data.message === GhostMsgEndGhosting) {
62
+ this.clear();
63
+ }
64
+ else if (data.type === "GhostAlwaysObjectEvent") {
65
+ const ghostAlways = data;
66
+ if (typeof ghostAlways.classId === "number" && ghostAlways.objectData) {
67
+ this.ghosts.set(ghostAlways.ghostIndex, {
68
+ classId: ghostAlways.classId,
69
+ parsedData: structuredClone(ghostAlways.objectData),
70
+ });
71
+ }
72
+ }
73
+ }
74
+ for (const ghost of parsed.ghosts) {
75
+ if (ghost.type === "delete") {
76
+ this.ghosts.delete(ghost.index);
77
+ continue;
78
+ }
79
+ if (!ghost.parsedData)
80
+ continue;
81
+ if (ghost.type === "create" && typeof ghost.classId === "number") {
82
+ this.ghosts.set(ghost.index, {
83
+ classId: ghost.classId,
84
+ parsedData: structuredClone(ghost.parsedData),
85
+ });
86
+ }
87
+ else if (ghost.type === "update") {
88
+ const existing = this.ghosts.get(ghost.index);
89
+ if (!existing) {
90
+ // Divergence signal: an update for a ghost we never saw created.
91
+ debug("update for unknown ghost index %d", ghost.index);
92
+ continue;
93
+ }
94
+ existing.parsedData = mergeGhostParsedData(existing.parsedData, structuredClone(ghost.parsedData));
95
+ }
96
+ }
97
+ }
98
+ clear() {
99
+ this.ghosts.clear();
100
+ }
101
+ size() {
102
+ return this.ghosts.size;
103
+ }
104
+ /** Seed entries for `createLiveParser({ ghosts })`. */
105
+ getGhostSeeds() {
106
+ return [...this.ghosts.entries()].map(([index, ghost]) => ({
107
+ index,
108
+ classId: ghost.classId,
109
+ }));
110
+ }
111
+ toInitialGhosts() {
112
+ return [...this.ghosts.entries()]
113
+ .sort(([a], [b]) => a - b)
114
+ .map(([index, ghost]) => ({
115
+ index,
116
+ type: "create",
117
+ classId: ghost.classId,
118
+ updateBitsStart: 0,
119
+ updateBitsEnd: 0,
120
+ parsedData: structuredClone(ghost.parsedData),
121
+ }));
122
+ }
123
+ }
124
+ //# sourceMappingURL=GhostStateAccumulator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GhostStateAccumulator.js","sourceRoot":"","sources":["../src/GhostStateAccumulator.ts"],"names":[],"mappings":"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","sourcesContent":["import createDebug from \"debug\";\nimport { GhostMsgEndGhosting } from \"./types.js\";\nimport type { GhostUpdate, PacketData } from \"./types.js\";\nimport type { ParsedData } from \"./ClassRegistry.js\";\nimport type { GhostAlwaysObjectEventData } from \"./eventDataTypes.js\";\n\nconst debug = createDebug(\"t2-demo-parser:ghost-state\");\n\ninterface AccumulatedGhost {\n classId: number;\n parsedData: ParsedData;\n}\n\nfunction isIndexedEntryArray(value: unknown): value is Array<{\n index: number;\n [key: string]: unknown;\n}> {\n return (\n Array.isArray(value) &&\n value.length > 0 &&\n value.every(\n (entry) =>\n typeof entry === \"object\" &&\n entry !== null &&\n typeof (entry as { index?: unknown }).index === \"number\",\n )\n );\n}\n\n/**\n * Fold a sparse masked ghost update onto accumulated full state.\n *\n * Each masked wire section writes complete values for the fields it\n * covers, so scalars and plain objects are last-write-wins. Arrays whose\n * entries carry a numeric `index` (threads, images, sounds) are sparse:\n * an update only includes changed entries, so they merge by index with\n * entry replacement. (An images entry with `dataBlockId: 0` must replace\n * rather than delete — \"slot cleared\" is meaningful state.) Positionally\n * complete arrays (wheels) replace wholesale. Create-only fields survive\n * because the create's data is the merge base.\n */\nexport function mergeGhostParsedData(\n base: ParsedData,\n update: ParsedData,\n): ParsedData {\n const merged: ParsedData = { ...base };\n for (const [key, value] of Object.entries(update)) {\n if (value === undefined) continue;\n const existing = merged[key];\n if (isIndexedEntryArray(value) && isIndexedEntryArray(existing)) {\n const byIndex = new Map<number, { index: number }>();\n for (const entry of existing) byIndex.set(entry.index, entry);\n for (const entry of value) byIndex.set(entry.index, entry);\n merged[key] = [...byIndex.values()].sort((a, b) => a.index - b.index);\n } else {\n merged[key] = value;\n }\n }\n return merged;\n}\n\n/**\n * Maintains one merged full ParsedData per live ghost by folding each\n * packet's creates/updates/deletes (plus GhostAlwaysObjectEvent creates\n * and EndGhosting clears). `toInitialGhosts()` then yields entries\n * shaped like a demo recording's InitialBlockData.initialGhosts — the\n * full-state ghost list a `.rec` starts with when recorded mid-match —\n * for hydrating a late joiner.\n */\nexport class GhostStateAccumulator {\n private ghosts = new Map<number, AccumulatedGhost>();\n\n applyPacket(parsed: PacketData): void {\n // Events apply before ghosts, matching packet layout (readEvents\n // runs before readGhosts and its side effects alter ghost state).\n for (const event of parsed.events) {\n const data = event.parsedData;\n if (!data) continue;\n if (\n data.type === \"GhostingMessageEvent\" &&\n data.message === GhostMsgEndGhosting\n ) {\n this.clear();\n } else if (data.type === \"GhostAlwaysObjectEvent\") {\n const ghostAlways = data as GhostAlwaysObjectEventData;\n if (typeof ghostAlways.classId === \"number\" && ghostAlways.objectData) {\n this.ghosts.set(ghostAlways.ghostIndex, {\n classId: ghostAlways.classId,\n parsedData: structuredClone(ghostAlways.objectData),\n });\n }\n }\n }\n\n for (const ghost of parsed.ghosts) {\n if (ghost.type === \"delete\") {\n this.ghosts.delete(ghost.index);\n continue;\n }\n if (!ghost.parsedData) continue;\n if (ghost.type === \"create\" && typeof ghost.classId === \"number\") {\n this.ghosts.set(ghost.index, {\n classId: ghost.classId,\n parsedData: structuredClone(ghost.parsedData),\n });\n } else if (ghost.type === \"update\") {\n const existing = this.ghosts.get(ghost.index);\n if (!existing) {\n // Divergence signal: an update for a ghost we never saw created.\n debug(\"update for unknown ghost index %d\", ghost.index);\n continue;\n }\n existing.parsedData = mergeGhostParsedData(\n existing.parsedData,\n structuredClone(ghost.parsedData),\n );\n }\n }\n }\n\n clear(): void {\n this.ghosts.clear();\n }\n\n size(): number {\n return this.ghosts.size;\n }\n\n /** Seed entries for `createLiveParser({ ghosts })`. */\n getGhostSeeds(): Array<{ index: number; classId: number }> {\n return [...this.ghosts.entries()].map(([index, ghost]) => ({\n index,\n classId: ghost.classId,\n }));\n }\n\n toInitialGhosts(): GhostUpdate[] {\n return [...this.ghosts.entries()]\n .sort(([a], [b]) => a - b)\n .map(([index, ghost]) => ({\n index,\n type: \"create\" as const,\n classId: ghost.classId,\n updateBitsStart: 0,\n updateBitsEnd: 0,\n parsedData: structuredClone(ghost.parsedData),\n }));\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=GhostStateAccumulator.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GhostStateAccumulator.test.d.ts","sourceRoot":"","sources":["../src/GhostStateAccumulator.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { DemoParser } from "./DemoParser.js";
5
+ import { createLiveParser } from "./LiveParser.js";
6
+ import { GhostStateAccumulator, mergeGhostParsedData, } from "./GhostStateAccumulator.js";
7
+ import { BlockTypePacket, BlockTypeSendPacket } from "./types.js";
8
+ const DEMO_DIR = path.resolve(import.meta.dirname, "..", "data");
9
+ function demoExists(file) {
10
+ return fs.existsSync(path.join(DEMO_DIR, file));
11
+ }
12
+ describe("mergeGhostParsedData", () => {
13
+ it("overwrites scalars and preserves create-only fields", () => {
14
+ const merged = mergeGhostParsedData({
15
+ type: "Player",
16
+ isStatic: true,
17
+ health: 1,
18
+ position: { x: 0, y: 0, z: 0 },
19
+ }, { health: 0.5, position: { x: 1, y: 2, z: 3 } });
20
+ expect(merged).toEqual({
21
+ type: "Player",
22
+ isStatic: true,
23
+ health: 0.5,
24
+ position: { x: 1, y: 2, z: 3 },
25
+ });
26
+ });
27
+ it("merges indexed entry arrays by index with entry replacement", () => {
28
+ const merged = mergeGhostParsedData({
29
+ threads: [
30
+ { index: 0, sequence: 5, forward: true, atEnd: false },
31
+ { index: 1, sequence: 9, forward: true, atEnd: false },
32
+ ],
33
+ }, { threads: [{ index: 1, sequence: 12, forward: false, atEnd: true }] });
34
+ expect(merged.threads).toEqual([
35
+ { index: 0, sequence: 5, forward: true, atEnd: false },
36
+ { index: 1, sequence: 12, forward: false, atEnd: true },
37
+ ]);
38
+ });
39
+ it("retains images entries with dataBlockId 0 (slot clear)", () => {
40
+ const merged = mergeGhostParsedData({ images: [{ index: 2, dataBlockId: 40, firing: true }] }, { images: [{ index: 2, dataBlockId: 0 }] });
41
+ expect(merged.images).toEqual([{ index: 2, dataBlockId: 0 }]);
42
+ });
43
+ it("adds new indexed entries alongside existing ones", () => {
44
+ const merged = mergeGhostParsedData({ sounds: [{ index: 0, dataBlockId: 7 }] }, { sounds: [{ index: 3, dataBlockId: 8 }] });
45
+ expect(merged.sounds).toEqual([
46
+ { index: 0, dataBlockId: 7 },
47
+ { index: 3, dataBlockId: 8 },
48
+ ]);
49
+ });
50
+ it("replaces non-indexed arrays wholesale", () => {
51
+ const merged = mergeGhostParsedData({ wheels: [{ spring: 1 }, { spring: 2 }] }, { wheels: [{ spring: 3 }, { spring: 4 }] });
52
+ expect(merged.wheels).toEqual([{ spring: 3 }, { spring: 4 }]);
53
+ });
54
+ });
55
+ describe("EndGhosting side effects", () => {
56
+ it("clears ghosts but retains datablocks (connection-lifetime state)", () => {
57
+ const kit = createLiveParser({
58
+ dataBlocks: [[161, { shapeName: "armor.dts" }]],
59
+ ghosts: [{ index: 5, classId: 10 }],
60
+ });
61
+ // White-box: apply the EndGhosting side effect directly. The real
62
+ // client (netGhost.cc:706) deletes only local ghosts; datablocks must
63
+ // survive mission changes because the server's transmitDataBlocks
64
+ // skips datablocks already sent on the connection.
65
+ kit.packetParser.applyEventSideEffects({ type: "GhostingMessageEvent", message: 2 });
66
+ expect(kit.ghostTracker.size()).toBe(0);
67
+ expect(kit.packetParser.getDataBlockDataMap()?.get(161)).toEqual({
68
+ shapeName: "armor.dts",
69
+ });
70
+ });
71
+ it("GhostStateAccumulator clears accumulated ghosts on EndGhosting", () => {
72
+ const accumulator = new GhostStateAccumulator();
73
+ accumulator.applyPacket({
74
+ events: [],
75
+ ghosts: [
76
+ {
77
+ index: 1,
78
+ type: "create",
79
+ classId: 10,
80
+ updateBitsStart: 0,
81
+ updateBitsEnd: 0,
82
+ parsedData: { type: "Player" },
83
+ },
84
+ ],
85
+ });
86
+ expect(accumulator.size()).toBe(1);
87
+ accumulator.applyPacket({
88
+ events: [
89
+ {
90
+ classId: 20,
91
+ guaranteed: true,
92
+ dataBitsStart: 0,
93
+ dataBitsEnd: 0,
94
+ parsedData: { type: "GhostingMessageEvent", message: 2 },
95
+ },
96
+ ],
97
+ ghosts: [],
98
+ });
99
+ expect(accumulator.size()).toBe(0);
100
+ });
101
+ });
102
+ /**
103
+ * The load-bearing regression tests: export all cross-packet parser state
104
+ * at block K, seed a fresh parser stack with it, and verify both parsers
105
+ * produce deep-equal output for the remainder of the stream. This is
106
+ * exactly the watch-mode late-joiner scenario (relay exports, browser
107
+ * seeds) using demo files as a deterministic packet source.
108
+ */
109
+ const ROUND_TRIP_DEMOS = [
110
+ // Vehicle-heavy demo (wheels, mounts).
111
+ { file: "exogen_Harvester.rec", cutovers: [2_000, 12_000] },
112
+ { file: "uploads_6_SterIO_2025_LT_Pub_SH.rec", cutovers: [3_000, 20_000] },
113
+ // MPB (WheeledVehicle) created ~block 86k, wheel updates continue past
114
+ // the cutover — guards seeded parsing of the wheel section, whose length
115
+ // must not depend on state accumulated before the seed point.
116
+ {
117
+ file: "uploads_7_2025-04-26_21-30_Tacocat_CTFGame_MisadventureV2.rec",
118
+ cutovers: [90_000],
119
+ },
120
+ ];
121
+ /** Bound runtime: compare at most this many blocks after the cutover. */
122
+ const MAX_CONTINUE_BLOCKS = 10_000;
123
+ describe("seeded parser round-trip", () => {
124
+ for (const demo of ROUND_TRIP_DEMOS) {
125
+ for (const cutover of demo.cutovers) {
126
+ it(`${demo.file} @ block ${cutover}: seeded parser stays in lockstep`, { timeout: 120_000, skip: !demoExists(demo.file) }, async () => {
127
+ const parser = new DemoParser(fs.readFileSync(path.join(DEMO_DIR, demo.file)));
128
+ const { initialBlock } = await parser.load();
129
+ // Seed from the demo's initial ghosts, mirroring how the demo
130
+ // pipeline seeds its tracker. (A live relay connection has no
131
+ // initial block — it sees every create from connect onward.)
132
+ const accumulator = new GhostStateAccumulator();
133
+ accumulator.applyPacket({
134
+ events: [],
135
+ ghosts: initialBlock.initialGhosts,
136
+ });
137
+ for (let i = 0; i < cutover; i++) {
138
+ const block = parser.nextBlock();
139
+ if (!block)
140
+ break;
141
+ if (block.type === BlockTypePacket && block.parsed) {
142
+ accumulator.applyPacket(block.parsed);
143
+ }
144
+ }
145
+ const source = parser.getPacketParser();
146
+ const tracker = parser.getGhostTracker();
147
+ // The accumulator must track exactly the ghosts the parser knows.
148
+ const trackerEntries = [...tracker.getAllGhosts().entries()];
149
+ expect(accumulator.size()).toBe(trackerEntries.length);
150
+ const seedByIndex = new Map(accumulator
151
+ .getGhostSeeds()
152
+ .map((seed) => [seed.index, seed.classId]));
153
+ for (const [index, entry] of trackerEntries) {
154
+ expect(seedByIndex.get(index)).toBe(entry.classId);
155
+ }
156
+ const rejectedAtCutover = source.protocolRejected;
157
+ const seeded = createLiveParser({
158
+ dataBlocks: source.getDataBlockDataMap() ?? [],
159
+ ghosts: trackerEntries.map(([index, entry]) => ({
160
+ index,
161
+ classId: entry.classId,
162
+ })),
163
+ connectionProtocolState: source.getConnectionProtocolState(),
164
+ nextRecvEventSeq: source.getNextRecvEventSeq(),
165
+ compressionPoint: source.getCompressionPoint(),
166
+ pendingGuaranteedEvents: source.getPendingGuaranteedEvents(),
167
+ });
168
+ let compared = 0;
169
+ for (let i = 0; i < MAX_CONTINUE_BLOCKS; i++) {
170
+ const block = parser.nextBlock();
171
+ if (!block)
172
+ break;
173
+ if (block.type === BlockTypeSendPacket) {
174
+ seeded.packetParser.onSendPacketTrigger();
175
+ }
176
+ else if (block.type === BlockTypePacket) {
177
+ let seededParsed;
178
+ try {
179
+ seededParsed = seeded.packetParser.parsePacket(block.data);
180
+ }
181
+ catch {
182
+ seededParsed = undefined;
183
+ }
184
+ // Fast path: string compare; fall back to toEqual for a diff.
185
+ if (JSON.stringify(seededParsed) !== JSON.stringify(block.parsed)) {
186
+ expect(seededParsed).toEqual(block.parsed);
187
+ }
188
+ compared++;
189
+ }
190
+ }
191
+ expect(compared).toBeGreaterThan(1_000);
192
+ expect(seeded.packetParser.ghostsTrackerDiverged).toBe(0);
193
+ expect(seeded.packetParser.ghostsFailed).toBe(0);
194
+ // Both parsers see identical headers from identical state, so
195
+ // rejects (if any) must match the source parser's count delta.
196
+ expect(seeded.packetParser.protocolRejected).toBe(source.protocolRejected - rejectedAtCutover);
197
+ });
198
+ }
199
+ }
200
+ });
201
+ //# sourceMappingURL=GhostStateAccumulator.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GhostStateAccumulator.test.js","sourceRoot":"","sources":["../src/GhostStateAccumulator.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect } from \"vitest\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { DemoParser } from \"./DemoParser.js\";\nimport { createLiveParser } from \"./LiveParser.js\";\nimport {\n GhostStateAccumulator,\n mergeGhostParsedData,\n} from \"./GhostStateAccumulator.js\";\nimport { BlockTypePacket, BlockTypeSendPacket } from \"./types.js\";\nimport type { PacketData } from \"./types.js\";\n\nconst DEMO_DIR = path.resolve(import.meta.dirname, \"..\", \"data\");\n\nfunction demoExists(file: string): boolean {\n return fs.existsSync(path.join(DEMO_DIR, file));\n}\n\ndescribe(\"mergeGhostParsedData\", () => {\n it(\"overwrites scalars and preserves create-only fields\", () => {\n const merged = mergeGhostParsedData(\n {\n type: \"Player\",\n isStatic: true,\n health: 1,\n position: { x: 0, y: 0, z: 0 },\n },\n { health: 0.5, position: { x: 1, y: 2, z: 3 } },\n );\n expect(merged).toEqual({\n type: \"Player\",\n isStatic: true,\n health: 0.5,\n position: { x: 1, y: 2, z: 3 },\n });\n });\n\n it(\"merges indexed entry arrays by index with entry replacement\", () => {\n const merged = mergeGhostParsedData(\n {\n threads: [\n { index: 0, sequence: 5, forward: true, atEnd: false },\n { index: 1, sequence: 9, forward: true, atEnd: false },\n ],\n },\n { threads: [{ index: 1, sequence: 12, forward: false, atEnd: true }] },\n );\n expect(merged.threads).toEqual([\n { index: 0, sequence: 5, forward: true, atEnd: false },\n { index: 1, sequence: 12, forward: false, atEnd: true },\n ]);\n });\n\n it(\"retains images entries with dataBlockId 0 (slot clear)\", () => {\n const merged = mergeGhostParsedData(\n { images: [{ index: 2, dataBlockId: 40, firing: true }] },\n { images: [{ index: 2, dataBlockId: 0 }] },\n );\n expect(merged.images).toEqual([{ index: 2, dataBlockId: 0 }]);\n });\n\n it(\"adds new indexed entries alongside existing ones\", () => {\n const merged = mergeGhostParsedData(\n { sounds: [{ index: 0, dataBlockId: 7 }] },\n { sounds: [{ index: 3, dataBlockId: 8 }] },\n );\n expect(merged.sounds).toEqual([\n { index: 0, dataBlockId: 7 },\n { index: 3, dataBlockId: 8 },\n ]);\n });\n\n it(\"replaces non-indexed arrays wholesale\", () => {\n const merged = mergeGhostParsedData(\n { wheels: [{ spring: 1 }, { spring: 2 }] },\n { wheels: [{ spring: 3 }, { spring: 4 }] },\n );\n expect(merged.wheels).toEqual([{ spring: 3 }, { spring: 4 }]);\n });\n});\n\ndescribe(\"EndGhosting side effects\", () => {\n it(\"clears ghosts but retains datablocks (connection-lifetime state)\", () => {\n const kit = createLiveParser({\n dataBlocks: [[161, { shapeName: \"armor.dts\" }]],\n ghosts: [{ index: 5, classId: 10 }],\n });\n // White-box: apply the EndGhosting side effect directly. The real\n // client (netGhost.cc:706) deletes only local ghosts; datablocks must\n // survive mission changes because the server's transmitDataBlocks\n // skips datablocks already sent on the connection.\n (\n kit.packetParser as unknown as {\n applyEventSideEffects(data: Record<string, unknown>): void;\n }\n ).applyEventSideEffects({ type: \"GhostingMessageEvent\", message: 2 });\n expect(kit.ghostTracker.size()).toBe(0);\n expect(kit.packetParser.getDataBlockDataMap()?.get(161)).toEqual({\n shapeName: \"armor.dts\",\n });\n });\n\n it(\"GhostStateAccumulator clears accumulated ghosts on EndGhosting\", () => {\n const accumulator = new GhostStateAccumulator();\n accumulator.applyPacket({\n events: [],\n ghosts: [\n {\n index: 1,\n type: \"create\",\n classId: 10,\n updateBitsStart: 0,\n updateBitsEnd: 0,\n parsedData: { type: \"Player\" },\n },\n ],\n } as unknown as PacketData);\n expect(accumulator.size()).toBe(1);\n accumulator.applyPacket({\n events: [\n {\n classId: 20,\n guaranteed: true,\n dataBitsStart: 0,\n dataBitsEnd: 0,\n parsedData: { type: \"GhostingMessageEvent\", message: 2 },\n },\n ],\n ghosts: [],\n } as unknown as PacketData);\n expect(accumulator.size()).toBe(0);\n });\n});\n\n/**\n * The load-bearing regression tests: export all cross-packet parser state\n * at block K, seed a fresh parser stack with it, and verify both parsers\n * produce deep-equal output for the remainder of the stream. This is\n * exactly the watch-mode late-joiner scenario (relay exports, browser\n * seeds) using demo files as a deterministic packet source.\n */\nconst ROUND_TRIP_DEMOS = [\n // Vehicle-heavy demo (wheels, mounts).\n { file: \"exogen_Harvester.rec\", cutovers: [2_000, 12_000] },\n { file: \"uploads_6_SterIO_2025_LT_Pub_SH.rec\", cutovers: [3_000, 20_000] },\n // MPB (WheeledVehicle) created ~block 86k, wheel updates continue past\n // the cutover — guards seeded parsing of the wheel section, whose length\n // must not depend on state accumulated before the seed point.\n {\n file: \"uploads_7_2025-04-26_21-30_Tacocat_CTFGame_MisadventureV2.rec\",\n cutovers: [90_000],\n },\n];\n\n/** Bound runtime: compare at most this many blocks after the cutover. */\nconst MAX_CONTINUE_BLOCKS = 10_000;\n\ndescribe(\"seeded parser round-trip\", () => {\n for (const demo of ROUND_TRIP_DEMOS) {\n for (const cutover of demo.cutovers) {\n it(\n `${demo.file} @ block ${cutover}: seeded parser stays in lockstep`,\n { timeout: 120_000, skip: !demoExists(demo.file) },\n async () => {\n const parser = new DemoParser(\n fs.readFileSync(path.join(DEMO_DIR, demo.file)),\n );\n const { initialBlock } = await parser.load();\n\n // Seed from the demo's initial ghosts, mirroring how the demo\n // pipeline seeds its tracker. (A live relay connection has no\n // initial block — it sees every create from connect onward.)\n const accumulator = new GhostStateAccumulator();\n accumulator.applyPacket({\n events: [],\n ghosts: initialBlock.initialGhosts,\n } as unknown as PacketData);\n for (let i = 0; i < cutover; i++) {\n const block = parser.nextBlock();\n if (!block) break;\n if (block.type === BlockTypePacket && block.parsed) {\n accumulator.applyPacket(block.parsed as PacketData);\n }\n }\n\n const source = parser.getPacketParser();\n const tracker = parser.getGhostTracker();\n\n // The accumulator must track exactly the ghosts the parser knows.\n const trackerEntries = [...tracker.getAllGhosts().entries()];\n expect(accumulator.size()).toBe(trackerEntries.length);\n const seedByIndex = new Map(\n accumulator\n .getGhostSeeds()\n .map((seed) => [seed.index, seed.classId]),\n );\n for (const [index, entry] of trackerEntries) {\n expect(seedByIndex.get(index)).toBe(entry.classId);\n }\n\n const rejectedAtCutover = source.protocolRejected;\n\n const seeded = createLiveParser({\n dataBlocks: source.getDataBlockDataMap() ?? [],\n ghosts: trackerEntries.map(([index, entry]) => ({\n index,\n classId: entry.classId,\n })),\n connectionProtocolState: source.getConnectionProtocolState(),\n nextRecvEventSeq: source.getNextRecvEventSeq(),\n compressionPoint: source.getCompressionPoint(),\n pendingGuaranteedEvents: source.getPendingGuaranteedEvents(),\n });\n\n let compared = 0;\n for (let i = 0; i < MAX_CONTINUE_BLOCKS; i++) {\n const block = parser.nextBlock();\n if (!block) break;\n if (block.type === BlockTypeSendPacket) {\n seeded.packetParser.onSendPacketTrigger();\n } else if (block.type === BlockTypePacket) {\n let seededParsed: PacketData | undefined;\n try {\n seededParsed = seeded.packetParser.parsePacket(block.data);\n } catch {\n seededParsed = undefined;\n }\n // Fast path: string compare; fall back to toEqual for a diff.\n if (\n JSON.stringify(seededParsed) !== JSON.stringify(block.parsed)\n ) {\n expect(seededParsed).toEqual(block.parsed);\n }\n compared++;\n }\n }\n\n expect(compared).toBeGreaterThan(1_000);\n expect(seeded.packetParser.ghostsTrackerDiverged).toBe(0);\n expect(seeded.packetParser.ghostsFailed).toBe(0);\n // Both parsers see identical headers from identical state, so\n // rejects (if any) must match the source parser's count delta.\n expect(seeded.packetParser.protocolRejected).toBe(\n source.protocolRejected - rejectedAtCutover,\n );\n },\n );\n }\n }\n});\n"]}
@@ -1,16 +1,54 @@
1
1
  import { ClassRegistry } from "./ClassRegistry.js";
2
2
  import { GhostTracker } from "./GhostManager.js";
3
3
  import { PacketParser } from "./PacketParser.js";
4
+ import type { ConnectionProtocolState, NetEventInfo } from "./types.js";
5
+ import type { ParsedData } from "./ClassRegistry.js";
4
6
  export interface LiveParserKit {
5
7
  registry: ClassRegistry;
6
8
  ghostTracker: GhostTracker;
7
9
  packetParser: PacketParser;
8
10
  }
11
+ export interface LiveParserSeed {
12
+ /** objectId → parsed datablock data, copied into the parser's map. */
13
+ dataBlocks?: Iterable<[number, ParsedData]>;
14
+ /** Existing ghosts, so mid-stream updates aren't misread as creates. */
15
+ ghosts?: Iterable<{
16
+ index: number;
17
+ classId: number;
18
+ }>;
19
+ connectionProtocolState?: ConnectionProtocolState;
20
+ nextRecvEventSeq?: number;
21
+ compressionPoint?: {
22
+ x: number;
23
+ y: number;
24
+ z: number;
25
+ };
26
+ pendingGuaranteedEvents?: Array<{
27
+ absoluteSequenceNumber: number;
28
+ event: NetEventInfo;
29
+ }>;
30
+ }
31
+ /**
32
+ * Protocol state for a parser that passively observes the server→client
33
+ * stream while something else (e.g. a relay) owns the client→server side.
34
+ * `lastSendSeq` is set very high so ack validation (lastSendSeq <
35
+ * highestAck → reject) never fires when the server acks sequences the
36
+ * observer didn't send. The connect-sequence bit is taken from the first
37
+ * observed packet's header byte. Intended for the first packets of a
38
+ * connection: `lastSeqRecvd` starts at 0, so the 9-bit sequence window
39
+ * check rejects packets attached mid-stream (seed
40
+ * `connectionProtocolState` from the exporter in that case).
41
+ */
42
+ export declare function passiveObserverProtocolState(firstPacketByte: number): ConnectionProtocolState;
9
43
  /**
10
44
  * Create a parser stack for live server connections. Sets up the same
11
45
  * registry bindings as DemoParser but without requiring a demo file,
12
46
  * and includes a dataBlockDataMap for incremental datablock accumulation
13
47
  * via SimDataBlockEvent.
48
+ *
49
+ * With a seed, the stack resumes an in-progress stream from exported
50
+ * state (mirroring DemoParser.setupPacketParser), so a late joiner can
51
+ * continue parsing at a packet boundary in lockstep with the exporter.
14
52
  */
15
- export declare function createLiveParser(): LiveParserKit;
53
+ export declare function createLiveParser(seed?: LiveParserSeed): LiveParserKit;
16
54
  //# sourceMappingURL=LiveParser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"LiveParser.d.ts","sourceRoot":"","sources":["../src/LiveParser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAajD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAkBhD"}
1
+ {"version":3,"file":"LiveParser.d.ts","sourceRoot":"","sources":["../src/LiveParser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAYjD,OAAO,KAAK,EAAE,uBAAuB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAErD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,UAAU,CAAC,EAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;IAC5C,wEAAwE;IACxE,MAAM,CAAC,EAAE,QAAQ,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,uBAAuB,CAAC,EAAE,uBAAuB,CAAC;IAClD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gBAAgB,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,uBAAuB,CAAC,EAAE,KAAK,CAAC;QAC9B,sBAAsB,EAAE,MAAM,CAAC;QAC/B,KAAK,EAAE,YAAY,CAAC;KACrB,CAAC,CAAC;CACJ;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAC1C,eAAe,EAAE,MAAM,GACtB,uBAAuB,CAWzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,CAAC,EAAE,cAAc,GAAG,aAAa,CA0CrE"}
@@ -5,13 +5,40 @@ import { registerEventParsers } from "./EventParsers.js";
5
5
  import { registerGhostParsers } from "./GhostManager.js";
6
6
  import { registerDataBlockParsers } from "./DataBlockParsers.js";
7
7
  import { DataBlockClassFirst, DataBlockClassNames, NetObjectClassFirst, NetObjectClassNames, NetEventClassFirst, NetEventClassNames, } from "./types.js";
8
+ /**
9
+ * Protocol state for a parser that passively observes the server→client
10
+ * stream while something else (e.g. a relay) owns the client→server side.
11
+ * `lastSendSeq` is set very high so ack validation (lastSendSeq <
12
+ * highestAck → reject) never fires when the server acks sequences the
13
+ * observer didn't send. The connect-sequence bit is taken from the first
14
+ * observed packet's header byte. Intended for the first packets of a
15
+ * connection: `lastSeqRecvd` starts at 0, so the 9-bit sequence window
16
+ * check rejects packets attached mid-stream (seed
17
+ * `connectionProtocolState` from the exporter in that case).
18
+ */
19
+ export function passiveObserverProtocolState(firstPacketByte) {
20
+ return {
21
+ lastSeqRecvdAtSend: new Array(32).fill(0),
22
+ lastSeqRecvd: 0,
23
+ highestAckedSeq: 0,
24
+ lastSendSeq: 0x1fffffff,
25
+ ackMask: 0,
26
+ connectSequence: (firstPacketByte >> 1) & 1,
27
+ lastRecvAckAck: 0,
28
+ connectionEstablished: true,
29
+ };
30
+ }
8
31
  /**
9
32
  * Create a parser stack for live server connections. Sets up the same
10
33
  * registry bindings as DemoParser but without requiring a demo file,
11
34
  * and includes a dataBlockDataMap for incremental datablock accumulation
12
35
  * via SimDataBlockEvent.
36
+ *
37
+ * With a seed, the stack resumes an in-progress stream from exported
38
+ * state (mirroring DemoParser.setupPacketParser), so a late joiner can
39
+ * continue parsing at a packet boundary in lockstep with the exporter.
13
40
  */
14
- export function createLiveParser() {
41
+ export function createLiveParser(seed) {
15
42
  const registry = new ClassRegistry();
16
43
  const ghostTracker = new GhostTracker();
17
44
  registerEventParsers(registry);
@@ -21,8 +48,23 @@ export function createLiveParser() {
21
48
  registry.bindDeterministicGhosts(NetObjectClassNames, NetObjectClassFirst);
22
49
  registry.bindDeterministicEvents(NetEventClassNames, NetEventClassFirst);
23
50
  const dataBlockDataMap = new Map();
51
+ if (seed?.dataBlocks) {
52
+ for (const [objectId, data] of seed.dataBlocks) {
53
+ dataBlockDataMap.set(objectId, data);
54
+ }
55
+ }
56
+ if (seed?.ghosts) {
57
+ for (const ghost of seed.ghosts) {
58
+ const parserEntry = registry.getGhostParser(ghost.classId);
59
+ ghostTracker.createGhost(ghost.index, ghost.classId, parserEntry?.name ?? `unknown_${ghost.classId}`);
60
+ }
61
+ }
24
62
  const packetParser = new PacketParser(registry, ghostTracker, {
25
63
  dataBlockDataMap,
64
+ connectionProtocolState: seed?.connectionProtocolState,
65
+ nextRecvEventSeq: seed?.nextRecvEventSeq,
66
+ compressionPoint: seed?.compressionPoint,
67
+ pendingGuaranteedEvents: seed?.pendingGuaranteedEvents,
26
68
  });
27
69
  return { registry, ghostTracker, packetParser };
28
70
  }
@@ -1 +1 @@
1
- {"version":3,"file":"LiveParser.js","sourceRoot":"","sources":["../src/LiveParser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAQpB;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,QAAQ,GAAG,IAAI,aAAa,EAAE,CAAC;IACrC,MAAM,YAAY,GAAG,IAAI,YAAY,EAAE,CAAC;IAExC,oBAAoB,CAAC,QAAQ,CAAC,CAAC;IAC/B,oBAAoB,CAAC,QAAQ,CAAC,CAAC;IAC/B,wBAAwB,CAAC,QAAQ,CAAC,CAAC;IAEnC,QAAQ,CAAC,2BAA2B,CAAC,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;IAC/E,QAAQ,CAAC,uBAAuB,CAAC,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;IAC3E,QAAQ,CAAC,uBAAuB,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAAC;IAEzE,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAmC,CAAC;IACpE,MAAM,YAAY,GAAG,IAAI,YAAY,CAAC,QAAQ,EAAE,YAAY,EAAE;QAC5D,gBAAgB;KACjB,CAAC,CAAC;IAEH,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AAClD,CAAC","sourcesContent":["import { ClassRegistry } from \"./ClassRegistry.js\";\nimport { GhostTracker } from \"./GhostManager.js\";\nimport { PacketParser } from \"./PacketParser.js\";\nimport { registerEventParsers } from \"./EventParsers.js\";\nimport { registerGhostParsers } from \"./GhostManager.js\";\nimport { registerDataBlockParsers } from \"./DataBlockParsers.js\";\nimport {\n DataBlockClassFirst,\n DataBlockClassNames,\n NetObjectClassFirst,\n NetObjectClassNames,\n NetEventClassFirst,\n NetEventClassNames,\n} from \"./types.js\";\n\nexport interface LiveParserKit {\n registry: ClassRegistry;\n ghostTracker: GhostTracker;\n packetParser: PacketParser;\n}\n\n/**\n * Create a parser stack for live server connections. Sets up the same\n * registry bindings as DemoParser but without requiring a demo file,\n * and includes a dataBlockDataMap for incremental datablock accumulation\n * via SimDataBlockEvent.\n */\nexport function createLiveParser(): LiveParserKit {\n const registry = new ClassRegistry();\n const ghostTracker = new GhostTracker();\n\n registerEventParsers(registry);\n registerGhostParsers(registry);\n registerDataBlockParsers(registry);\n\n registry.bindDeterministicDataBlocks(DataBlockClassNames, DataBlockClassFirst);\n registry.bindDeterministicGhosts(NetObjectClassNames, NetObjectClassFirst);\n registry.bindDeterministicEvents(NetEventClassNames, NetEventClassFirst);\n\n const dataBlockDataMap = new Map<number, Record<string, unknown>>();\n const packetParser = new PacketParser(registry, ghostTracker, {\n dataBlockDataMap,\n });\n\n return { registry, ghostTracker, packetParser };\n}\n"]}
1
+ {"version":3,"file":"LiveParser.js","sourceRoot":"","sources":["../src/LiveParser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAwBpB;;;;;;;;;;GAUG;AACH,MAAM,UAAU,4BAA4B,CAC1C,eAAuB;IAEvB,OAAO;QACL,kBAAkB,EAAE,IAAI,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,YAAY,EAAE,CAAC;QACf,eAAe,EAAE,CAAC;QAClB,WAAW,EAAE,UAAU;QACvB,OAAO,EAAE,CAAC;QACV,eAAe,EAAE,CAAC,eAAe,IAAI,CAAC,CAAC,GAAG,CAAC;QAC3C,cAAc,EAAE,CAAC;QACjB,qBAAqB,EAAE,IAAI;KAC5B,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqB;IACpD,MAAM,QAAQ,GAAG,IAAI,aAAa,EAAE,CAAC;IACrC,MAAM,YAAY,GAAG,IAAI,YAAY,EAAE,CAAC;IAExC,oBAAoB,CAAC,QAAQ,CAAC,CAAC;IAC/B,oBAAoB,CAAC,QAAQ,CAAC,CAAC;IAC/B,wBAAwB,CAAC,QAAQ,CAAC,CAAC;IAEnC,QAAQ,CAAC,2BAA2B,CAClC,mBAAmB,EACnB,mBAAmB,CACpB,CAAC;IACF,QAAQ,CAAC,uBAAuB,CAAC,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;IAC3E,QAAQ,CAAC,uBAAuB,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAAC;IAEzE,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAsB,CAAC;IACvD,IAAI,IAAI,EAAE,UAAU,EAAE,CAAC;QACrB,KAAK,MAAM,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YAC/C,gBAAgB,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACvC,CAAC;IACH,CAAC;IAED,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChC,MAAM,WAAW,GAAG,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAC3D,YAAY,CAAC,WAAW,CACtB,KAAK,CAAC,KAAK,EACX,KAAK,CAAC,OAAO,EACb,WAAW,EAAE,IAAI,IAAI,WAAW,KAAK,CAAC,OAAO,EAAE,CAChD,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,YAAY,GAAG,IAAI,YAAY,CAAC,QAAQ,EAAE,YAAY,EAAE;QAC5D,gBAAgB;QAChB,uBAAuB,EAAE,IAAI,EAAE,uBAAuB;QACtD,gBAAgB,EAAE,IAAI,EAAE,gBAAgB;QACxC,gBAAgB,EAAE,IAAI,EAAE,gBAAgB;QACxC,uBAAuB,EAAE,IAAI,EAAE,uBAAuB;KACvD,CAAC,CAAC;IAEH,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AAClD,CAAC","sourcesContent":["import { ClassRegistry } from \"./ClassRegistry.js\";\nimport { GhostTracker } from \"./GhostManager.js\";\nimport { PacketParser } from \"./PacketParser.js\";\nimport { registerEventParsers } from \"./EventParsers.js\";\nimport { registerGhostParsers } from \"./GhostManager.js\";\nimport { registerDataBlockParsers } from \"./DataBlockParsers.js\";\nimport {\n DataBlockClassFirst,\n DataBlockClassNames,\n NetObjectClassFirst,\n NetObjectClassNames,\n NetEventClassFirst,\n NetEventClassNames,\n} from \"./types.js\";\nimport type { ConnectionProtocolState, NetEventInfo } from \"./types.js\";\nimport type { ParsedData } from \"./ClassRegistry.js\";\n\nexport interface LiveParserKit {\n registry: ClassRegistry;\n ghostTracker: GhostTracker;\n packetParser: PacketParser;\n}\n\nexport interface LiveParserSeed {\n /** objectId → parsed datablock data, copied into the parser's map. */\n dataBlocks?: Iterable<[number, ParsedData]>;\n /** Existing ghosts, so mid-stream updates aren't misread as creates. */\n ghosts?: Iterable<{ index: number; classId: number }>;\n connectionProtocolState?: ConnectionProtocolState;\n nextRecvEventSeq?: number;\n compressionPoint?: { x: number; y: number; z: number };\n pendingGuaranteedEvents?: Array<{\n absoluteSequenceNumber: number;\n event: NetEventInfo;\n }>;\n}\n\n/**\n * Protocol state for a parser that passively observes the server→client\n * stream while something else (e.g. a relay) owns the client→server side.\n * `lastSendSeq` is set very high so ack validation (lastSendSeq <\n * highestAck → reject) never fires when the server acks sequences the\n * observer didn't send. The connect-sequence bit is taken from the first\n * observed packet's header byte. Intended for the first packets of a\n * connection: `lastSeqRecvd` starts at 0, so the 9-bit sequence window\n * check rejects packets attached mid-stream (seed\n * `connectionProtocolState` from the exporter in that case).\n */\nexport function passiveObserverProtocolState(\n firstPacketByte: number,\n): ConnectionProtocolState {\n return {\n lastSeqRecvdAtSend: new Array(32).fill(0),\n lastSeqRecvd: 0,\n highestAckedSeq: 0,\n lastSendSeq: 0x1fffffff,\n ackMask: 0,\n connectSequence: (firstPacketByte >> 1) & 1,\n lastRecvAckAck: 0,\n connectionEstablished: true,\n };\n}\n\n/**\n * Create a parser stack for live server connections. Sets up the same\n * registry bindings as DemoParser but without requiring a demo file,\n * and includes a dataBlockDataMap for incremental datablock accumulation\n * via SimDataBlockEvent.\n *\n * With a seed, the stack resumes an in-progress stream from exported\n * state (mirroring DemoParser.setupPacketParser), so a late joiner can\n * continue parsing at a packet boundary in lockstep with the exporter.\n */\nexport function createLiveParser(seed?: LiveParserSeed): LiveParserKit {\n const registry = new ClassRegistry();\n const ghostTracker = new GhostTracker();\n\n registerEventParsers(registry);\n registerGhostParsers(registry);\n registerDataBlockParsers(registry);\n\n registry.bindDeterministicDataBlocks(\n DataBlockClassNames,\n DataBlockClassFirst,\n );\n registry.bindDeterministicGhosts(NetObjectClassNames, NetObjectClassFirst);\n registry.bindDeterministicEvents(NetEventClassNames, NetEventClassFirst);\n\n const dataBlockDataMap = new Map<number, ParsedData>();\n if (seed?.dataBlocks) {\n for (const [objectId, data] of seed.dataBlocks) {\n dataBlockDataMap.set(objectId, data);\n }\n }\n\n if (seed?.ghosts) {\n for (const ghost of seed.ghosts) {\n const parserEntry = registry.getGhostParser(ghost.classId);\n ghostTracker.createGhost(\n ghost.index,\n ghost.classId,\n parserEntry?.name ?? `unknown_${ghost.classId}`,\n );\n }\n }\n\n const packetParser = new PacketParser(registry, ghostTracker, {\n dataBlockDataMap,\n connectionProtocolState: seed?.connectionProtocolState,\n nextRecvEventSeq: seed?.nextRecvEventSeq,\n compressionPoint: seed?.compressionPoint,\n pendingGuaranteedEvents: seed?.pendingGuaranteedEvents,\n });\n\n return { registry, ghostTracker, packetParser };\n}\n"]}
@@ -1,4 +1,4 @@
1
- import type { ConnectionProtocolState, PacketData } from "./types.js";
1
+ import type { ConnectionProtocolState, PacketData, NetEventInfo } from "./types.js";
2
2
  import type { ClassRegistry, ParsedData } from "./ClassRegistry.js";
3
3
  import type { GhostTracker } from "./GhostManager.js";
4
4
  /**
@@ -14,7 +14,6 @@ export declare class PacketParser {
14
14
  private registry;
15
15
  private ghostTracker;
16
16
  private compressionPoint;
17
- private controlParserByGhostIndex;
18
17
  private dataBlockDataMap?;
19
18
  private lastSeqRecvdAtSend;
20
19
  private lastSeqRecvd;
@@ -46,6 +45,15 @@ export declare class PacketParser {
46
45
  dataBlockDataMap?: Map<number, ParsedData>;
47
46
  connectionProtocolState?: ConnectionProtocolState;
48
47
  nextRecvEventSeq?: number;
48
+ compressionPoint?: {
49
+ x: number;
50
+ y: number;
51
+ z: number;
52
+ };
53
+ pendingGuaranteedEvents?: Array<{
54
+ absoluteSequenceNumber: number;
55
+ event: NetEventInfo;
56
+ }>;
49
57
  });
50
58
  getCompressionPoint(): {
51
59
  x: number;
@@ -56,6 +64,19 @@ export declare class PacketParser {
56
64
  private getConnectionContext;
57
65
  private _setNextRecvEventSeq;
58
66
  setConnectionProtocolState(state: ConnectionProtocolState): void;
67
+ /**
68
+ * Export the current protocol window state. Together with
69
+ * `getNextRecvEventSeq`, `getPendingGuaranteedEvents`,
70
+ * `getCompressionPoint`, `getDataBlockDataMap`, and the ghost tracker
71
+ * contents, this captures all cross-packet parser state, so an
72
+ * identically seeded parser continues the stream in lockstep.
73
+ */
74
+ getConnectionProtocolState(): ConnectionProtocolState;
75
+ getNextRecvEventSeq(): number;
76
+ getPendingGuaranteedEvents(): Array<{
77
+ absoluteSequenceNumber: number;
78
+ event: NetEventInfo;
79
+ }>;
59
80
  /**
60
81
  * Emulate ConnectionProtocol::buildSendPacketHeader (FUN_0043d2d0, data path).
61
82
  * Demo block type 1 indicates a local send-packet trigger.