@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.
- package/LICENSE +26 -0
- package/out-tsc/index.d.ts +13 -0
- package/out-tsc/index.d.ts.map +1 -0
- package/out-tsc/lib/attach.d.ts +5 -0
- package/out-tsc/lib/attach.d.ts.map +1 -0
- package/out-tsc/lib/diagnostics-collector.d.ts +41 -0
- package/out-tsc/lib/diagnostics-collector.d.ts.map +1 -0
- package/out-tsc/lib/diagnostics-protocol.d.ts +52 -0
- package/out-tsc/lib/diagnostics-protocol.d.ts.map +1 -0
- package/out-tsc/lib/divergence-analysis.d.ts +21 -0
- package/out-tsc/lib/divergence-analysis.d.ts.map +1 -0
- package/out-tsc/lib/hash-bytes.d.ts +6 -0
- package/out-tsc/lib/hash-bytes.d.ts.map +1 -0
- package/out-tsc/lib/performance-profiler.d.ts +43 -0
- package/out-tsc/lib/performance-profiler.d.ts.map +1 -0
- package/out-tsc/lib/report-generator.d.ts +55 -0
- package/out-tsc/lib/report-generator.d.ts.map +1 -0
- package/out-tsc/lib/types.d.ts +39 -0
- package/out-tsc/lib/types.d.ts.map +1 -0
- package/out-tsc/lib/use-desync-diagnostics.d.ts +9 -0
- package/out-tsc/lib/use-desync-diagnostics.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +26 -0
- package/src/lib/attach.ts +10 -0
- package/src/lib/diagnostics-collector.spec.ts +354 -0
- package/src/lib/diagnostics-collector.ts +210 -0
- package/src/lib/diagnostics-protocol.ts +44 -0
- package/src/lib/divergence-analysis.spec.ts +178 -0
- package/src/lib/divergence-analysis.ts +179 -0
- package/src/lib/hash-bytes.spec.ts +29 -0
- package/src/lib/hash-bytes.ts +11 -0
- package/src/lib/performance-profiler.spec.ts +412 -0
- package/src/lib/performance-profiler.ts +245 -0
- package/src/lib/report-generator.spec.ts +147 -0
- package/src/lib/report-generator.ts +117 -0
- package/src/lib/types.ts +41 -0
- package/src/lib/use-desync-diagnostics.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +20 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { DiagnosticsCollector } from './diagnostics-collector.js';
|
|
2
|
+
import type { DiagnosticsConfig } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ─── Mocks ──────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function createMockRunner(options?: { maxPlayers?: number }) {
|
|
7
|
+
const maxPlayers = options?.maxPlayers ?? 4;
|
|
8
|
+
let tickHandler: ((tick: number) => void) | null = null;
|
|
9
|
+
let rollbackHandler: ((tick: number) => void) | null = null;
|
|
10
|
+
let currentTick = 0;
|
|
11
|
+
let hashAtTickMap = new Map<number, number>();
|
|
12
|
+
let memHash = 0;
|
|
13
|
+
let verifiedTick = -1;
|
|
14
|
+
const rpcsByTick = new Map<number, Array<{ meta: { playerSlot: number }; inputId: number }>>();
|
|
15
|
+
|
|
16
|
+
const runner = {
|
|
17
|
+
Config: { maxPlayers },
|
|
18
|
+
Simulation: {
|
|
19
|
+
tick: 0,
|
|
20
|
+
addTickHandler: (fn: (tick: number) => void) => {
|
|
21
|
+
tickHandler = fn;
|
|
22
|
+
return () => { tickHandler = null; };
|
|
23
|
+
},
|
|
24
|
+
addRollbackHandler: (fn: (tick: number) => void) => {
|
|
25
|
+
rollbackHandler = fn;
|
|
26
|
+
return () => { rollbackHandler = null; };
|
|
27
|
+
},
|
|
28
|
+
getHashAtTick: (tick: number) => hashAtTickMap.get(tick),
|
|
29
|
+
mem: {
|
|
30
|
+
getHash: () => memHash,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
InputProviderInstance: {
|
|
34
|
+
get verifiedTick() { return verifiedTick; },
|
|
35
|
+
rpcHistory: {
|
|
36
|
+
getRPCsAtTick: (tick: number) => rpcsByTick.get(tick) ?? [],
|
|
37
|
+
getRPCCountAtTick: (tick: number) => (rpcsByTick.get(tick) ?? []).length,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
runner: runner as unknown as Parameters<typeof DiagnosticsCollector['prototype']['dispose']> extends [] ? any : any,
|
|
44
|
+
fireTick: (tick: number) => {
|
|
45
|
+
currentTick = tick;
|
|
46
|
+
runner.Simulation.tick = tick;
|
|
47
|
+
tickHandler?.(tick);
|
|
48
|
+
},
|
|
49
|
+
fireRollback: (tick: number) => {
|
|
50
|
+
// Simulate real ECSSimulation behavior: tick is restored BEFORE handler fires
|
|
51
|
+
runner.Simulation.tick = tick;
|
|
52
|
+
rollbackHandler?.(tick);
|
|
53
|
+
},
|
|
54
|
+
setHashAtTick: (tick: number, hash: number) => hashAtTickMap.set(tick, hash),
|
|
55
|
+
setMemHash: (hash: number) => { memHash = hash; },
|
|
56
|
+
setVerifiedTick: (vt: number) => { verifiedTick = vt; },
|
|
57
|
+
setRPCsAtTick: (tick: number, rpcs: Array<{ meta: { playerSlot: number }; inputId: number }>) => {
|
|
58
|
+
rpcsByTick.set(tick, rpcs);
|
|
59
|
+
},
|
|
60
|
+
get tickHandler() { return tickHandler; },
|
|
61
|
+
get rollbackHandler() { return rollbackHandler; },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Tests ──────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('DiagnosticsCollector', () => {
|
|
68
|
+
it('should record tick data via tick handler', () => {
|
|
69
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
70
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
71
|
+
|
|
72
|
+
setHashAtTick(1, 0xABCD);
|
|
73
|
+
setVerifiedTick(0);
|
|
74
|
+
fireTick(1);
|
|
75
|
+
|
|
76
|
+
expect(collector.count).toBe(1);
|
|
77
|
+
|
|
78
|
+
const timeline = collector.getTimeline();
|
|
79
|
+
expect(timeline).toHaveLength(1);
|
|
80
|
+
expect(timeline[0].tick).toBe(1);
|
|
81
|
+
expect(timeline[0].hash).toBe(0xABCD);
|
|
82
|
+
expect(timeline[0].verifiedTick).toBe(0);
|
|
83
|
+
expect(timeline[0].wasRollback).toBe(false);
|
|
84
|
+
|
|
85
|
+
collector.dispose();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should fall back to mem.getHash() when hashHistory misses', () => {
|
|
89
|
+
const { runner, fireTick, setMemHash, setVerifiedTick } = createMockRunner();
|
|
90
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
91
|
+
|
|
92
|
+
setMemHash(0x1234);
|
|
93
|
+
setVerifiedTick(0);
|
|
94
|
+
fireTick(1);
|
|
95
|
+
|
|
96
|
+
const timeline = collector.getTimeline();
|
|
97
|
+
expect(timeline[0].hash).toBe(0x1234);
|
|
98
|
+
|
|
99
|
+
collector.dispose();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should record input counts per player slot', () => {
|
|
103
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick, setRPCsAtTick } = createMockRunner({ maxPlayers: 4 });
|
|
104
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
105
|
+
|
|
106
|
+
setHashAtTick(5, 0);
|
|
107
|
+
setVerifiedTick(4);
|
|
108
|
+
setRPCsAtTick(5, [
|
|
109
|
+
{ meta: { playerSlot: 0 }, inputId: 1 },
|
|
110
|
+
{ meta: { playerSlot: 0 }, inputId: 2 },
|
|
111
|
+
{ meta: { playerSlot: 1 }, inputId: 1 },
|
|
112
|
+
]);
|
|
113
|
+
fireTick(5);
|
|
114
|
+
|
|
115
|
+
const timeline = collector.getTimeline();
|
|
116
|
+
expect(timeline[0].inputCountBySlot[0]).toBe(2);
|
|
117
|
+
expect(timeline[0].inputCountBySlot[1]).toBe(1);
|
|
118
|
+
expect(timeline[0].inputCountBySlot[2]).toBe(0);
|
|
119
|
+
expect(timeline[0].inputCountBySlot[3]).toBe(0);
|
|
120
|
+
|
|
121
|
+
collector.dispose();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should wrap ring buffer when full', () => {
|
|
125
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
126
|
+
const bufferSize = 5;
|
|
127
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize });
|
|
128
|
+
|
|
129
|
+
setVerifiedTick(0);
|
|
130
|
+
for (let t = 1; t <= 8; t++) {
|
|
131
|
+
setHashAtTick(t, t * 100);
|
|
132
|
+
fireTick(t);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Buffer should contain ticks 4-8 (last 5)
|
|
136
|
+
expect(collector.count).toBe(5);
|
|
137
|
+
const timeline = collector.getTimeline();
|
|
138
|
+
expect(timeline.map(r => r.tick)).toEqual([4, 5, 6, 7, 8]);
|
|
139
|
+
expect(timeline.map(r => r.hash)).toEqual([400, 500, 600, 700, 800]);
|
|
140
|
+
|
|
141
|
+
collector.dispose();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should record rollback events', () => {
|
|
145
|
+
const { runner, fireTick, fireRollback, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
146
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
147
|
+
|
|
148
|
+
setVerifiedTick(0);
|
|
149
|
+
// Simulate ticks 1-10
|
|
150
|
+
for (let t = 1; t <= 10; t++) {
|
|
151
|
+
setHashAtTick(t, t);
|
|
152
|
+
fireTick(t);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rollback to tick 5
|
|
156
|
+
fireRollback(5);
|
|
157
|
+
|
|
158
|
+
const rollbacks = collector.getRollbacks();
|
|
159
|
+
expect(rollbacks).toHaveLength(1);
|
|
160
|
+
expect(rollbacks[0].atSimTick).toBe(10);
|
|
161
|
+
expect(rollbacks[0].rollbackToTick).toBe(5);
|
|
162
|
+
expect(rollbacks[0].timestamp).toBeGreaterThan(0);
|
|
163
|
+
|
|
164
|
+
const stats = collector.getStats();
|
|
165
|
+
expect(stats.totalRollbacks).toBe(1);
|
|
166
|
+
expect(stats.lastRollbackTick).toBe(5);
|
|
167
|
+
|
|
168
|
+
collector.dispose();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should mark re-simulated ticks as wasRollback=true', () => {
|
|
172
|
+
const { runner, fireTick, fireRollback, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
173
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
174
|
+
|
|
175
|
+
setVerifiedTick(0);
|
|
176
|
+
// Initial simulation: ticks 1-10
|
|
177
|
+
for (let t = 1; t <= 10; t++) {
|
|
178
|
+
setHashAtTick(t, t);
|
|
179
|
+
fireTick(t);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Rollback to tick 8 (atSimTick=10)
|
|
183
|
+
fireRollback(8);
|
|
184
|
+
|
|
185
|
+
// Re-simulate ticks 8, 9, 10 (these should be marked as rollback)
|
|
186
|
+
for (let t = 8; t <= 10; t++) {
|
|
187
|
+
setHashAtTick(t, t + 1000); // different hash post-rollback
|
|
188
|
+
fireTick(t);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Then simulate tick 11 (should NOT be marked as rollback)
|
|
192
|
+
setHashAtTick(11, 11);
|
|
193
|
+
fireTick(11);
|
|
194
|
+
|
|
195
|
+
const timeline = collector.getTimeline();
|
|
196
|
+
// Find tick 8, 9, 10, 11 entries (the latest ones)
|
|
197
|
+
const tick8 = timeline.find(r => r.tick === 8 && r.hash === 1008);
|
|
198
|
+
const tick9 = timeline.find(r => r.tick === 9 && r.hash === 1009);
|
|
199
|
+
const tick10 = timeline.find(r => r.tick === 10 && r.hash === 1010);
|
|
200
|
+
const tick11 = timeline.find(r => r.tick === 11);
|
|
201
|
+
|
|
202
|
+
expect(tick8?.wasRollback).toBe(true);
|
|
203
|
+
expect(tick9?.wasRollback).toBe(true);
|
|
204
|
+
expect(tick10?.wasRollback).toBe(true);
|
|
205
|
+
expect(tick11?.wasRollback).toBe(false);
|
|
206
|
+
|
|
207
|
+
collector.dispose();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should detect verifiedTick gaps', () => {
|
|
211
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
212
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
213
|
+
|
|
214
|
+
setHashAtTick(1, 1);
|
|
215
|
+
setVerifiedTick(0);
|
|
216
|
+
fireTick(1);
|
|
217
|
+
|
|
218
|
+
setHashAtTick(2, 2);
|
|
219
|
+
setVerifiedTick(1); // +1, no gap
|
|
220
|
+
fireTick(2);
|
|
221
|
+
|
|
222
|
+
setHashAtTick(3, 3);
|
|
223
|
+
setVerifiedTick(5); // +4, gap!
|
|
224
|
+
fireTick(3);
|
|
225
|
+
|
|
226
|
+
setHashAtTick(4, 4);
|
|
227
|
+
setVerifiedTick(6); // +1, no gap
|
|
228
|
+
fireTick(4);
|
|
229
|
+
|
|
230
|
+
const stats = collector.getStats();
|
|
231
|
+
expect(stats.verifiedTickGapCount).toBe(1);
|
|
232
|
+
|
|
233
|
+
collector.dispose();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should provide correct stats', () => {
|
|
237
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
238
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
239
|
+
|
|
240
|
+
setVerifiedTick(0);
|
|
241
|
+
for (let t = 1; t <= 5; t++) {
|
|
242
|
+
setHashAtTick(t, t * 10);
|
|
243
|
+
fireTick(t);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const stats = collector.getStats();
|
|
247
|
+
expect(stats.ticksRecorded).toBe(5);
|
|
248
|
+
expect(stats.totalRollbacks).toBe(0);
|
|
249
|
+
expect(stats.latestHash).toBe(50);
|
|
250
|
+
expect(stats.oldestTick).toBe(1);
|
|
251
|
+
expect(stats.newestTick).toBe(5);
|
|
252
|
+
|
|
253
|
+
collector.dispose();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should dispose and stop recording', () => {
|
|
257
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick, tickHandler } = createMockRunner();
|
|
258
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
259
|
+
|
|
260
|
+
setVerifiedTick(0);
|
|
261
|
+
setHashAtTick(1, 1);
|
|
262
|
+
fireTick(1);
|
|
263
|
+
expect(collector.count).toBe(1);
|
|
264
|
+
|
|
265
|
+
collector.dispose();
|
|
266
|
+
|
|
267
|
+
// Handler should be removed
|
|
268
|
+
expect(tickHandler).toBeNull();
|
|
269
|
+
|
|
270
|
+
// Double dispose should be safe
|
|
271
|
+
collector.dispose();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should cap rollback events at maxRollbackEvents', () => {
|
|
275
|
+
const { runner, fireTick, fireRollback, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
276
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100, maxRollbackEvents: 3 });
|
|
277
|
+
|
|
278
|
+
setVerifiedTick(0);
|
|
279
|
+
for (let t = 1; t <= 5; t++) {
|
|
280
|
+
setHashAtTick(t, t);
|
|
281
|
+
fireTick(t);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Fire 4 rollbacks — only last 3 should remain
|
|
285
|
+
for (let i = 0; i < 4; i++) {
|
|
286
|
+
fireRollback(i + 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const rollbacks = collector.getRollbacks();
|
|
290
|
+
expect(rollbacks).toHaveLength(3);
|
|
291
|
+
expect(rollbacks[0].rollbackToTick).toBe(2);
|
|
292
|
+
expect(rollbacks[2].rollbackToTick).toBe(4);
|
|
293
|
+
|
|
294
|
+
collector.dispose();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should handle empty buffer stats gracefully', () => {
|
|
298
|
+
const { runner } = createMockRunner();
|
|
299
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
300
|
+
|
|
301
|
+
const stats = collector.getStats();
|
|
302
|
+
expect(stats.ticksRecorded).toBe(0);
|
|
303
|
+
expect(stats.latestHash).toBe(0);
|
|
304
|
+
expect(stats.oldestTick).toBe(0);
|
|
305
|
+
expect(stats.newestTick).toBe(0);
|
|
306
|
+
|
|
307
|
+
const timeline = collector.getTimeline();
|
|
308
|
+
expect(timeline).toHaveLength(0);
|
|
309
|
+
|
|
310
|
+
collector.dispose();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should record physicsHash when physicsHashFn is provided', () => {
|
|
314
|
+
let callCount = 0;
|
|
315
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
316
|
+
const collector = new DiagnosticsCollector(runner, {
|
|
317
|
+
bufferSize: 100,
|
|
318
|
+
physicsHashFn: () => {
|
|
319
|
+
callCount++;
|
|
320
|
+
return 0xDEAD;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
setHashAtTick(1, 0xABCD);
|
|
325
|
+
setVerifiedTick(0);
|
|
326
|
+
fireTick(1);
|
|
327
|
+
|
|
328
|
+
const timeline = collector.getTimeline();
|
|
329
|
+
expect(timeline[0].physicsHash).toBe(0xDEAD);
|
|
330
|
+
expect(callCount).toBe(1);
|
|
331
|
+
|
|
332
|
+
const stats = collector.getStats();
|
|
333
|
+
expect(stats.latestPhysicsHash).toBe(0xDEAD);
|
|
334
|
+
|
|
335
|
+
collector.dispose();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should record physicsHash as 0 when physicsHashFn is not provided', () => {
|
|
339
|
+
const { runner, fireTick, setHashAtTick, setVerifiedTick } = createMockRunner();
|
|
340
|
+
const collector = new DiagnosticsCollector(runner, { bufferSize: 100 });
|
|
341
|
+
|
|
342
|
+
setHashAtTick(1, 0xABCD);
|
|
343
|
+
setVerifiedTick(0);
|
|
344
|
+
fireTick(1);
|
|
345
|
+
|
|
346
|
+
const timeline = collector.getTimeline();
|
|
347
|
+
expect(timeline[0].physicsHash).toBe(0);
|
|
348
|
+
|
|
349
|
+
const stats = collector.getStats();
|
|
350
|
+
expect(stats.latestPhysicsHash).toBe(0);
|
|
351
|
+
|
|
352
|
+
collector.dispose();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ECSRunner } from '@lagless/core';
|
|
2
|
+
import type { DiagnosticsConfig, TickRecord, RollbackEvent, DiagnosticsStats } from './types.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BUFFER_SIZE = 18000; // 5 min at 60fps
|
|
5
|
+
const DEFAULT_MAX_ROLLBACK_EVENTS = 1000;
|
|
6
|
+
|
|
7
|
+
export class DiagnosticsCollector {
|
|
8
|
+
private readonly _runner: ECSRunner;
|
|
9
|
+
private readonly _maxPlayers: number;
|
|
10
|
+
private readonly _bufferSize: number;
|
|
11
|
+
|
|
12
|
+
// Ring buffer — pre-allocated typed arrays
|
|
13
|
+
private readonly _ticks: Uint32Array;
|
|
14
|
+
private readonly _hashes: Uint32Array;
|
|
15
|
+
private readonly _physicsHashes: Uint32Array;
|
|
16
|
+
private readonly _velocityHashes: Uint32Array;
|
|
17
|
+
private readonly _verifiedTicks: Int32Array;
|
|
18
|
+
private readonly _wasRollback: Uint8Array;
|
|
19
|
+
private readonly _inputCounts: Uint8Array; // [bufferSize * maxPlayers]
|
|
20
|
+
|
|
21
|
+
private readonly _physicsHashFn: (() => number) | null;
|
|
22
|
+
private readonly _velocityHashFn: (() => number) | null;
|
|
23
|
+
|
|
24
|
+
private _head = 0; // next write index
|
|
25
|
+
private _count = 0; // number of valid entries
|
|
26
|
+
|
|
27
|
+
// Rollback events ring buffer
|
|
28
|
+
private readonly _maxRollbackEvents: number;
|
|
29
|
+
private readonly _rollbackEvents: RollbackEvent[] = [];
|
|
30
|
+
private _totalRollbacks = 0;
|
|
31
|
+
private _lastRollbackTick = 0;
|
|
32
|
+
|
|
33
|
+
// Rollback re-simulation tracking
|
|
34
|
+
private _isResimulating = false;
|
|
35
|
+
private _preRollbackTick = 0;
|
|
36
|
+
private _lastTickSeen = 0;
|
|
37
|
+
|
|
38
|
+
// VerifiedTick gap detection
|
|
39
|
+
private _prevVerifiedTick = -1;
|
|
40
|
+
private _verifiedTickGapCount = 0;
|
|
41
|
+
|
|
42
|
+
// Handler cleanup
|
|
43
|
+
private _removeTickHandler: (() => void) | null = null;
|
|
44
|
+
private _removeRollbackHandler: (() => void) | null = null;
|
|
45
|
+
private _disposed = false;
|
|
46
|
+
|
|
47
|
+
constructor(runner: ECSRunner, config?: DiagnosticsConfig) {
|
|
48
|
+
this._runner = runner;
|
|
49
|
+
this._maxPlayers = runner.Config.maxPlayers;
|
|
50
|
+
this._bufferSize = config?.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
51
|
+
this._maxRollbackEvents = config?.maxRollbackEvents ?? DEFAULT_MAX_ROLLBACK_EVENTS;
|
|
52
|
+
|
|
53
|
+
this._physicsHashFn = config?.physicsHashFn ?? null;
|
|
54
|
+
this._velocityHashFn = config?.velocityHashFn ?? null;
|
|
55
|
+
|
|
56
|
+
this._ticks = new Uint32Array(this._bufferSize);
|
|
57
|
+
this._hashes = new Uint32Array(this._bufferSize);
|
|
58
|
+
this._physicsHashes = new Uint32Array(this._bufferSize);
|
|
59
|
+
this._velocityHashes = new Uint32Array(this._bufferSize);
|
|
60
|
+
this._verifiedTicks = new Int32Array(this._bufferSize);
|
|
61
|
+
this._wasRollback = new Uint8Array(this._bufferSize);
|
|
62
|
+
this._inputCounts = new Uint8Array(this._bufferSize * this._maxPlayers);
|
|
63
|
+
|
|
64
|
+
this._removeRollbackHandler = runner.Simulation.addRollbackHandler((tick) => {
|
|
65
|
+
this._onRollback(tick);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this._removeTickHandler = runner.Simulation.addTickHandler((tick) => {
|
|
69
|
+
this._onTick(tick);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Public API ──────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
public get runner(): ECSRunner {
|
|
76
|
+
return this._runner;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public get bufferSize(): number {
|
|
80
|
+
return this._bufferSize;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public get count(): number {
|
|
84
|
+
return this._count;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public getTimeline(): TickRecord[] {
|
|
88
|
+
const result: TickRecord[] = [];
|
|
89
|
+
const start = this._count < this._bufferSize ? 0 : this._head;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < this._count; i++) {
|
|
92
|
+
const idx = (start + i) % this._bufferSize;
|
|
93
|
+
const slotOffset = idx * this._maxPlayers;
|
|
94
|
+
result.push({
|
|
95
|
+
tick: this._ticks[idx],
|
|
96
|
+
hash: this._hashes[idx],
|
|
97
|
+
physicsHash: this._physicsHashes[idx],
|
|
98
|
+
velocityHash: this._velocityHashes[idx],
|
|
99
|
+
verifiedTick: this._verifiedTicks[idx],
|
|
100
|
+
wasRollback: this._wasRollback[idx] !== 0,
|
|
101
|
+
inputCountBySlot: this._inputCounts.slice(slotOffset, slotOffset + this._maxPlayers),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public getRollbacks(): ReadonlyArray<RollbackEvent> {
|
|
109
|
+
return this._rollbackEvents;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public getStats(): DiagnosticsStats {
|
|
113
|
+
const oldestIdx = this._count < this._bufferSize ? 0 : this._head;
|
|
114
|
+
const newestIdx = this._count === 0 ? 0 : (this._head - 1 + this._bufferSize) % this._bufferSize;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ticksRecorded: this._count,
|
|
118
|
+
totalRollbacks: this._totalRollbacks,
|
|
119
|
+
lastRollbackTick: this._lastRollbackTick,
|
|
120
|
+
verifiedTickGapCount: this._verifiedTickGapCount,
|
|
121
|
+
latestHash: this._count > 0 ? this._hashes[newestIdx] : 0,
|
|
122
|
+
latestPhysicsHash: this._count > 0 ? this._physicsHashes[newestIdx] : 0,
|
|
123
|
+
latestVelocityHash: this._count > 0 ? this._velocityHashes[newestIdx] : 0,
|
|
124
|
+
oldestTick: this._count > 0 ? this._ticks[oldestIdx] : 0,
|
|
125
|
+
newestTick: this._count > 0 ? this._ticks[newestIdx] : 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public dispose(): void {
|
|
130
|
+
if (this._disposed) return;
|
|
131
|
+
this._disposed = true;
|
|
132
|
+
this._removeTickHandler?.();
|
|
133
|
+
this._removeRollbackHandler?.();
|
|
134
|
+
this._removeTickHandler = null;
|
|
135
|
+
this._removeRollbackHandler = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Private handlers ────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
private _onRollback(tick: number): void {
|
|
141
|
+
// Use _lastTickSeen (tracked in _onTick) because Simulation.tick is already
|
|
142
|
+
// restored to the rollback target when the rollback handler fires.
|
|
143
|
+
this._preRollbackTick = this._lastTickSeen;
|
|
144
|
+
this._isResimulating = true;
|
|
145
|
+
this._totalRollbacks++;
|
|
146
|
+
this._lastRollbackTick = tick;
|
|
147
|
+
|
|
148
|
+
const event: RollbackEvent = {
|
|
149
|
+
atSimTick: this._preRollbackTick,
|
|
150
|
+
rollbackToTick: tick,
|
|
151
|
+
timestamp: performance.now(),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (this._rollbackEvents.length >= this._maxRollbackEvents) {
|
|
155
|
+
this._rollbackEvents.shift();
|
|
156
|
+
}
|
|
157
|
+
this._rollbackEvents.push(event);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private _onTick(tick: number): void {
|
|
161
|
+
if (this._isResimulating && tick > this._preRollbackTick) {
|
|
162
|
+
this._isResimulating = false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sim = this._runner.Simulation;
|
|
166
|
+
const provider = this._runner.InputProviderInstance;
|
|
167
|
+
|
|
168
|
+
// Get hash — prefer hashHistory (already computed), fall back to direct computation
|
|
169
|
+
let hash = sim.getHashAtTick(tick);
|
|
170
|
+
if (hash === undefined) {
|
|
171
|
+
hash = sim.mem.getHash();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get verifiedTick
|
|
175
|
+
const verifiedTick = provider.verifiedTick;
|
|
176
|
+
|
|
177
|
+
// Detect verifiedTick gaps (jumps of >1 between consecutive records)
|
|
178
|
+
if (this._prevVerifiedTick >= 0 && verifiedTick > this._prevVerifiedTick + 1) {
|
|
179
|
+
this._verifiedTickGapCount++;
|
|
180
|
+
}
|
|
181
|
+
this._prevVerifiedTick = verifiedTick;
|
|
182
|
+
|
|
183
|
+
// Count inputs per slot at this tick
|
|
184
|
+
const rpcs = provider.rpcHistory.getRPCsAtTick(tick);
|
|
185
|
+
const slotOffset = this._head * this._maxPlayers;
|
|
186
|
+
|
|
187
|
+
// Zero the slot region
|
|
188
|
+
this._inputCounts.fill(0, slotOffset, slotOffset + this._maxPlayers);
|
|
189
|
+
for (const rpc of rpcs) {
|
|
190
|
+
if (rpc.meta.playerSlot < this._maxPlayers) {
|
|
191
|
+
this._inputCounts[slotOffset + rpc.meta.playerSlot]++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Write to ring buffer
|
|
196
|
+
this._ticks[this._head] = tick;
|
|
197
|
+
this._hashes[this._head] = hash;
|
|
198
|
+
this._physicsHashes[this._head] = this._physicsHashFn ? this._physicsHashFn() : 0;
|
|
199
|
+
this._velocityHashes[this._head] = this._velocityHashFn ? this._velocityHashFn() : 0;
|
|
200
|
+
this._verifiedTicks[this._head] = verifiedTick;
|
|
201
|
+
this._wasRollback[this._head] = this._isResimulating ? 1 : 0;
|
|
202
|
+
|
|
203
|
+
this._head = (this._head + 1) % this._bufferSize;
|
|
204
|
+
if (this._count < this._bufferSize) {
|
|
205
|
+
this._count++;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this._lastTickSeen = tick;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DiagnosticsReport } from './report-generator.js';
|
|
2
|
+
|
|
3
|
+
// ─── Child → Parent (game iframe → dev-player) ──────────────
|
|
4
|
+
|
|
5
|
+
export interface DiagnosticsSummaryMessage {
|
|
6
|
+
type: 'dev-bridge:diagnostics-summary';
|
|
7
|
+
instanceId: string;
|
|
8
|
+
rollbackCount: number;
|
|
9
|
+
lastRollbackTick: number;
|
|
10
|
+
verifiedTickGapCount: number;
|
|
11
|
+
ticksRecorded: number;
|
|
12
|
+
latestHash: number;
|
|
13
|
+
latestPhysicsHash: number;
|
|
14
|
+
latestVelocityHash: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiagnosticsReportMessage {
|
|
18
|
+
type: 'dev-bridge:diagnostics-report';
|
|
19
|
+
instanceId: string;
|
|
20
|
+
report: DiagnosticsReport;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PerformanceStatsMessage {
|
|
24
|
+
type: 'dev-bridge:performance-stats';
|
|
25
|
+
instanceId: string;
|
|
26
|
+
tickTime: { latest: number; min: number; max: number; avg: number };
|
|
27
|
+
snapshotTime: { latest: number; min: number; max: number; avg: number };
|
|
28
|
+
overheadTime: { latest: number; min: number; max: number; avg: number };
|
|
29
|
+
systems: Array<{ name: string; latest: number; min: number; max: number; avg: number }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DiagnosticsChildMessage =
|
|
33
|
+
| DiagnosticsSummaryMessage
|
|
34
|
+
| DiagnosticsReportMessage
|
|
35
|
+
| PerformanceStatsMessage;
|
|
36
|
+
|
|
37
|
+
// ─── Parent → Child (dev-player → game iframe) ──────────────
|
|
38
|
+
|
|
39
|
+
export interface RequestDiagnosticsReportMessage {
|
|
40
|
+
type: 'dev-bridge:request-diagnostics-report';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DiagnosticsParentMessage =
|
|
44
|
+
| RequestDiagnosticsReportMessage;
|