@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,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;