@lagless/desync-diagnostics 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +26 -0
  2. package/out-tsc/index.d.ts +13 -0
  3. package/out-tsc/index.d.ts.map +1 -0
  4. package/out-tsc/lib/attach.d.ts +5 -0
  5. package/out-tsc/lib/attach.d.ts.map +1 -0
  6. package/out-tsc/lib/diagnostics-collector.d.ts +41 -0
  7. package/out-tsc/lib/diagnostics-collector.d.ts.map +1 -0
  8. package/out-tsc/lib/diagnostics-protocol.d.ts +52 -0
  9. package/out-tsc/lib/diagnostics-protocol.d.ts.map +1 -0
  10. package/out-tsc/lib/divergence-analysis.d.ts +21 -0
  11. package/out-tsc/lib/divergence-analysis.d.ts.map +1 -0
  12. package/out-tsc/lib/hash-bytes.d.ts +6 -0
  13. package/out-tsc/lib/hash-bytes.d.ts.map +1 -0
  14. package/out-tsc/lib/performance-profiler.d.ts +43 -0
  15. package/out-tsc/lib/performance-profiler.d.ts.map +1 -0
  16. package/out-tsc/lib/report-generator.d.ts +55 -0
  17. package/out-tsc/lib/report-generator.d.ts.map +1 -0
  18. package/out-tsc/lib/types.d.ts +39 -0
  19. package/out-tsc/lib/types.d.ts.map +1 -0
  20. package/out-tsc/lib/use-desync-diagnostics.d.ts +9 -0
  21. package/out-tsc/lib/use-desync-diagnostics.d.ts.map +1 -0
  22. package/package.json +38 -0
  23. package/src/index.ts +26 -0
  24. package/src/lib/attach.ts +10 -0
  25. package/src/lib/diagnostics-collector.spec.ts +354 -0
  26. package/src/lib/diagnostics-collector.ts +210 -0
  27. package/src/lib/diagnostics-protocol.ts +44 -0
  28. package/src/lib/divergence-analysis.spec.ts +178 -0
  29. package/src/lib/divergence-analysis.ts +179 -0
  30. package/src/lib/hash-bytes.spec.ts +29 -0
  31. package/src/lib/hash-bytes.ts +11 -0
  32. package/src/lib/performance-profiler.spec.ts +412 -0
  33. package/src/lib/performance-profiler.ts +245 -0
  34. package/src/lib/report-generator.spec.ts +147 -0
  35. package/src/lib/report-generator.ts +117 -0
  36. package/src/lib/types.ts +41 -0
  37. package/src/lib/use-desync-diagnostics.ts +101 -0
  38. package/tsconfig.json +21 -0
  39. package/tsconfig.tsbuildinfo +1 -0
  40. package/vite.config.ts +20 -0
@@ -0,0 +1,178 @@
1
+ import { analyzeDivergence } from './divergence-analysis.js';
2
+ import type { DiagnosticsReport } from './report-generator.js';
3
+
4
+ function makeReport(
5
+ playerSlot: number,
6
+ timeline: Array<{ tick: number; hash: number; physicsHash: number; wasRollback?: boolean }>,
7
+ rollbacks?: Array<{ atSimTick: number; rollbackToTick: number; timestamp: number }>,
8
+ ): DiagnosticsReport {
9
+ return {
10
+ version: 2,
11
+ generatedAt: new Date().toISOString(),
12
+ playerSlot,
13
+ config: { fps: 60, maxPlayers: 4, frameLength: 16.67, snapshotRate: 1, maxEntities: 1000 },
14
+ summary: {
15
+ totalTicks: timeline.length,
16
+ totalRollbacks: rollbacks?.length ?? 0,
17
+ firstDivergenceTick: null,
18
+ verifiedTickGapCount: 0,
19
+ latestPhysicsHash: timeline.length > 0 ? timeline[timeline.length - 1].physicsHash : 0,
20
+ oldestTick: timeline.length > 0 ? timeline[0].tick : 0,
21
+ newestTick: timeline.length > 0 ? timeline[timeline.length - 1].tick : 0,
22
+ },
23
+ timeline: timeline.map((t) => ({
24
+ tick: t.tick,
25
+ hash: t.hash,
26
+ physicsHash: t.physicsHash,
27
+ velocityHash: 0,
28
+ verifiedTick: t.tick - 1,
29
+ wasRollback: t.wasRollback ?? false,
30
+ inputCountBySlot: [0, 0, 0, 0],
31
+ })),
32
+ rollbacks: rollbacks ?? [],
33
+ inputHistory: {},
34
+ };
35
+ }
36
+
37
+ describe('analyzeDivergence', () => {
38
+ it('should return no divergence when all clients match', () => {
39
+ const timeline = [
40
+ { tick: 1, hash: 100, physicsHash: 1000 },
41
+ { tick: 2, hash: 200, physicsHash: 2000 },
42
+ { tick: 3, hash: 300, physicsHash: 3000 },
43
+ ];
44
+ const result = analyzeDivergence([
45
+ makeReport(0, timeline),
46
+ makeReport(1, timeline),
47
+ ]);
48
+
49
+ expect(result.firstEcsDivergenceTick).toBeNull();
50
+ expect(result.firstPhysicsDivergenceTick).toBeNull();
51
+ });
52
+
53
+ it('should find first ECS divergence tick', () => {
54
+ const result = analyzeDivergence([
55
+ makeReport(0, [
56
+ { tick: 1, hash: 100, physicsHash: 1000 },
57
+ { tick: 2, hash: 200, physicsHash: 2000 },
58
+ { tick: 3, hash: 999, physicsHash: 3000 },
59
+ ]),
60
+ makeReport(1, [
61
+ { tick: 1, hash: 100, physicsHash: 1000 },
62
+ { tick: 2, hash: 200, physicsHash: 2000 },
63
+ { tick: 3, hash: 300, physicsHash: 3000 },
64
+ ]),
65
+ ]);
66
+
67
+ expect(result.firstEcsDivergenceTick).toBe(3);
68
+ expect(result.firstPhysicsDivergenceTick).toBeNull();
69
+ });
70
+
71
+ it('should find first physics divergence tick', () => {
72
+ const result = analyzeDivergence([
73
+ makeReport(0, [
74
+ { tick: 1, hash: 100, physicsHash: 1000 },
75
+ { tick: 2, hash: 200, physicsHash: 9999 },
76
+ ]),
77
+ makeReport(1, [
78
+ { tick: 1, hash: 100, physicsHash: 1000 },
79
+ { tick: 2, hash: 200, physicsHash: 2000 },
80
+ ]),
81
+ ]);
82
+
83
+ expect(result.firstEcsDivergenceTick).toBeNull();
84
+ expect(result.firstPhysicsDivergenceTick).toBe(2);
85
+ });
86
+
87
+ it('should use LAST occurrence of a tick in timeline (rollback resimulation)', () => {
88
+ // Client 0 has tick 2 twice — original (hash=200) and resimulated (hash=250)
89
+ // The final hash is 250, which matches client 1's hash of 250
90
+ const result = analyzeDivergence([
91
+ makeReport(0, [
92
+ { tick: 1, hash: 100, physicsHash: 1000 },
93
+ { tick: 2, hash: 200, physicsHash: 2000 }, // original — stale
94
+ { tick: 3, hash: 300, physicsHash: 3000 },
95
+ { tick: 2, hash: 250, physicsHash: 2500, wasRollback: true }, // resimulated — final
96
+ { tick: 3, hash: 350, physicsHash: 3500, wasRollback: true },
97
+ ]),
98
+ makeReport(1, [
99
+ { tick: 1, hash: 100, physicsHash: 1000 },
100
+ { tick: 2, hash: 250, physicsHash: 2500 },
101
+ { tick: 3, hash: 350, physicsHash: 3500 },
102
+ ]),
103
+ ]);
104
+
105
+ expect(result.firstEcsDivergenceTick).toBeNull();
106
+ expect(result.firstPhysicsDivergenceTick).toBeNull();
107
+ });
108
+
109
+ it('should build checkpoint comparison at specified interval', () => {
110
+ const timeline = Array.from({ length: 120 }, (_, i) => ({
111
+ tick: i + 1,
112
+ hash: (i + 1) * 10,
113
+ physicsHash: (i + 1) * 100,
114
+ }));
115
+
116
+ const result = analyzeDivergence([
117
+ makeReport(0, timeline),
118
+ makeReport(1, timeline),
119
+ ], 60);
120
+
121
+ // Checkpoint at tick 60 and 120
122
+ expect(result.checkpointComparison.length).toBe(2);
123
+ expect(result.checkpointComparison[0].tick).toBe(60);
124
+ expect(result.checkpointComparison[0].ecsMatch).toBe(true);
125
+ expect(result.checkpointComparison[0].physicsMatch).toBe(true);
126
+ expect(result.checkpointComparison[1].tick).toBe(120);
127
+ });
128
+
129
+ it('should detect rollback overlap windows', () => {
130
+ const timeline = Array.from({ length: 20 }, (_, i) => ({
131
+ tick: i + 1,
132
+ hash: (i + 1) * 10,
133
+ physicsHash: (i + 1) * 100,
134
+ }));
135
+
136
+ const result = analyzeDivergence([
137
+ makeReport(0, timeline, [
138
+ { atSimTick: 15, rollbackToTick: 10, timestamp: 1000 },
139
+ ]),
140
+ makeReport(1, timeline, [
141
+ { atSimTick: 16, rollbackToTick: 12, timestamp: 1001 },
142
+ ]),
143
+ ]);
144
+
145
+ // Overlap: client 0 resims 10-15, client 1 resims 12-16 → overlap is 12-15
146
+ expect(result.rollbackOverlapWindows.length).toBeGreaterThan(0);
147
+ const window = result.rollbackOverlapWindows[0];
148
+ expect(window.startTick).toBe(12);
149
+ expect(window.endTick).toBe(15);
150
+ expect(window.affectedSlots).toContain(0);
151
+ expect(window.affectedSlots).toContain(1);
152
+ });
153
+
154
+ it('should return no divergence for a single client', () => {
155
+ const result = analyzeDivergence([
156
+ makeReport(0, [
157
+ { tick: 1, hash: 100, physicsHash: 1000 },
158
+ ]),
159
+ ]);
160
+
161
+ expect(result.firstEcsDivergenceTick).toBeNull();
162
+ expect(result.firstPhysicsDivergenceTick).toBeNull();
163
+ expect(result.checkpointComparison).toHaveLength(0);
164
+ expect(result.rollbackOverlapWindows).toHaveLength(0);
165
+ });
166
+
167
+ it('should handle clients with empty timelines', () => {
168
+ const result = analyzeDivergence([
169
+ makeReport(0, []),
170
+ makeReport(1, []),
171
+ ]);
172
+
173
+ expect(result.firstEcsDivergenceTick).toBeNull();
174
+ expect(result.firstPhysicsDivergenceTick).toBeNull();
175
+ expect(result.checkpointComparison).toHaveLength(0);
176
+ expect(result.rollbackOverlapWindows).toHaveLength(0);
177
+ });
178
+ });
@@ -0,0 +1,179 @@
1
+ import type { DiagnosticsReport } from './report-generator.js';
2
+
3
+ export interface CheckpointComparison {
4
+ tick: number;
5
+ ecsHashes: Record<number, number>;
6
+ physicsHashes: Record<number, number>;
7
+ ecsMatch: boolean;
8
+ physicsMatch: boolean;
9
+ }
10
+
11
+ export interface RollbackOverlapWindow {
12
+ startTick: number;
13
+ endTick: number;
14
+ affectedSlots: number[];
15
+ }
16
+
17
+ export interface DivergenceAnalysis {
18
+ firstEcsDivergenceTick: number | null;
19
+ firstPhysicsDivergenceTick: number | null;
20
+ checkpointComparison: CheckpointComparison[];
21
+ rollbackOverlapWindows: RollbackOverlapWindow[];
22
+ }
23
+
24
+ interface FinalHashEntry {
25
+ hash: number;
26
+ physicsHash: number;
27
+ }
28
+
29
+ /**
30
+ * Build a map of tick → final hash for a client's timeline.
31
+ * For ticks that appear multiple times (rollback resimulation),
32
+ * the LAST occurrence is the correct "final" state.
33
+ */
34
+ function buildFinalHashMap(report: DiagnosticsReport): Map<number, FinalHashEntry> {
35
+ const map = new Map<number, FinalHashEntry>();
36
+ for (const record of report.timeline) {
37
+ map.set(record.tick, { hash: record.hash, physicsHash: record.physicsHash });
38
+ }
39
+ return map;
40
+ }
41
+
42
+ const DEFAULT_CHECKPOINT_INTERVAL = 60;
43
+
44
+ export function analyzeDivergence(
45
+ clients: DiagnosticsReport[],
46
+ checkpointInterval = DEFAULT_CHECKPOINT_INTERVAL,
47
+ ): DivergenceAnalysis {
48
+ if (clients.length < 2) {
49
+ return {
50
+ firstEcsDivergenceTick: null,
51
+ firstPhysicsDivergenceTick: null,
52
+ checkpointComparison: [],
53
+ rollbackOverlapWindows: [],
54
+ };
55
+ }
56
+
57
+ const finalHashMaps = clients.map((c) => buildFinalHashMap(c));
58
+
59
+ // Collect all ticks present in ALL clients
60
+ const allTicks = new Set<number>();
61
+ for (const [tick] of finalHashMaps[0]) {
62
+ if (finalHashMaps.every((m) => m.has(tick))) {
63
+ allTicks.add(tick);
64
+ }
65
+ }
66
+
67
+ const sortedTicks = [...allTicks].sort((a, b) => a - b);
68
+
69
+ // Find first divergence ticks
70
+ let firstEcsDivergenceTick: number | null = null;
71
+ let firstPhysicsDivergenceTick: number | null = null;
72
+
73
+ for (const tick of sortedTicks) {
74
+ if (firstEcsDivergenceTick === null) {
75
+ const ecsHashes = finalHashMaps.map((m) => m.get(tick)!.hash);
76
+ if (!ecsHashes.every((h) => h === ecsHashes[0])) {
77
+ firstEcsDivergenceTick = tick;
78
+ }
79
+ }
80
+ if (firstPhysicsDivergenceTick === null) {
81
+ const physicsHashes = finalHashMaps.map((m) => m.get(tick)!.physicsHash);
82
+ if (!physicsHashes.every((h) => h === physicsHashes[0])) {
83
+ firstPhysicsDivergenceTick = tick;
84
+ }
85
+ }
86
+ if (firstEcsDivergenceTick !== null && firstPhysicsDivergenceTick !== null) {
87
+ break;
88
+ }
89
+ }
90
+
91
+ // Build checkpoint comparison at interval
92
+ const checkpointComparison: CheckpointComparison[] = [];
93
+ for (const tick of sortedTicks) {
94
+ if (tick % checkpointInterval !== 0) continue;
95
+
96
+ const ecsHashes: Record<number, number> = {};
97
+ const physicsHashes: Record<number, number> = {};
98
+ for (let i = 0; i < clients.length; i++) {
99
+ const entry = finalHashMaps[i].get(tick)!;
100
+ ecsHashes[clients[i].playerSlot] = entry.hash;
101
+ physicsHashes[clients[i].playerSlot] = entry.physicsHash;
102
+ }
103
+
104
+ const ecsValues = Object.values(ecsHashes);
105
+ const physicsValues = Object.values(physicsHashes);
106
+
107
+ checkpointComparison.push({
108
+ tick,
109
+ ecsHashes,
110
+ physicsHashes,
111
+ ecsMatch: ecsValues.every((h) => h === ecsValues[0]),
112
+ physicsMatch: physicsValues.every((h) => h === physicsValues[0]),
113
+ });
114
+ }
115
+
116
+ // Find rollback overlap windows
117
+ const rollbackOverlapWindows = findRollbackOverlaps(clients);
118
+
119
+ return {
120
+ firstEcsDivergenceTick,
121
+ firstPhysicsDivergenceTick,
122
+ checkpointComparison,
123
+ rollbackOverlapWindows,
124
+ };
125
+ }
126
+
127
+ interface RollbackRange {
128
+ slot: number;
129
+ startTick: number;
130
+ endTick: number;
131
+ }
132
+
133
+ function findRollbackOverlaps(clients: DiagnosticsReport[]): RollbackOverlapWindow[] {
134
+ // Collect all rollback ranges from all clients
135
+ const ranges: RollbackRange[] = [];
136
+ for (const client of clients) {
137
+ for (const rb of client.rollbacks) {
138
+ ranges.push({
139
+ slot: client.playerSlot,
140
+ startTick: rb.rollbackToTick,
141
+ endTick: rb.atSimTick,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Find overlapping ranges between different slots
147
+ const windows: RollbackOverlapWindow[] = [];
148
+ for (let i = 0; i < ranges.length; i++) {
149
+ for (let j = i + 1; j < ranges.length; j++) {
150
+ if (ranges[i].slot === ranges[j].slot) continue;
151
+
152
+ const overlapStart = Math.max(ranges[i].startTick, ranges[j].startTick);
153
+ const overlapEnd = Math.min(ranges[i].endTick, ranges[j].endTick);
154
+
155
+ if (overlapStart < overlapEnd) {
156
+ // Check if this window is already covered
157
+ const existing = windows.find(
158
+ (w) => w.startTick === overlapStart && w.endTick === overlapEnd,
159
+ );
160
+ if (existing) {
161
+ if (!existing.affectedSlots.includes(ranges[i].slot)) {
162
+ existing.affectedSlots.push(ranges[i].slot);
163
+ }
164
+ if (!existing.affectedSlots.includes(ranges[j].slot)) {
165
+ existing.affectedSlots.push(ranges[j].slot);
166
+ }
167
+ } else {
168
+ windows.push({
169
+ startTick: overlapStart,
170
+ endTick: overlapEnd,
171
+ affectedSlots: [ranges[i].slot, ranges[j].slot],
172
+ });
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ return windows.sort((a, b) => a.startTick - b.startTick);
179
+ }
@@ -0,0 +1,29 @@
1
+ import { hashBytes } from './hash-bytes.js';
2
+
3
+ describe('hashBytes', () => {
4
+ it('should return 0 for empty input', () => {
5
+ expect(hashBytes(new Uint8Array(0))).toBe(0);
6
+ });
7
+
8
+ it('should produce consistent hash for same input', () => {
9
+ const data = new Uint8Array([1, 2, 3, 4, 5]);
10
+ const hash1 = hashBytes(data);
11
+ const hash2 = hashBytes(data);
12
+ expect(hash1).toBe(hash2);
13
+ expect(hash1).not.toBe(0);
14
+ });
15
+
16
+ it('should produce different hashes for different input', () => {
17
+ const a = new Uint8Array([1, 2, 3]);
18
+ const b = new Uint8Array([3, 2, 1]);
19
+ expect(hashBytes(a)).not.toBe(hashBytes(b));
20
+ });
21
+
22
+ it('should return a 32-bit unsigned integer', () => {
23
+ const data = new Uint8Array(1000);
24
+ for (let i = 0; i < data.length; i++) data[i] = i & 0xFF;
25
+ const hash = hashBytes(data);
26
+ expect(hash).toBeGreaterThanOrEqual(0);
27
+ expect(hash).toBeLessThanOrEqual(0xFFFFFFFF);
28
+ });
29
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Polynomial hash (same algorithm as Mem.getHash) but using direct Uint8Array indexing
3
+ * instead of DataView for better performance on large buffers (e.g. Rapier snapshots).
4
+ */
5
+ export function hashBytes(data: Uint8Array): number {
6
+ let hash = 0;
7
+ for (let i = 0; i < data.length; i++) {
8
+ hash = (hash * 31 + data[i]) >>> 0;
9
+ }
10
+ return hash;
11
+ }