@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,412 @@
1
+ import { PerformanceProfiler } from './performance-profiler.js';
2
+ import type { IECSSystem } from '@lagless/core';
3
+
4
+ function createMockSystems(): IECSSystem[] {
5
+ return [
6
+ {
7
+ update(_tick: number) {
8
+ // Simulate ~0.1ms of work
9
+ const start = performance.now();
10
+ while (performance.now() - start < 0.1) { /* spin */ }
11
+ },
12
+ constructor: { name: 'PhysicsSystem' } as unknown as { name: string },
13
+ } as unknown as IECSSystem,
14
+ {
15
+ update(_tick: number) {
16
+ // Simulate ~0.05ms of work
17
+ const start = performance.now();
18
+ while (performance.now() - start < 0.05) { /* spin */ }
19
+ },
20
+ constructor: { name: 'MovementSystem' } as unknown as { name: string },
21
+ } as unknown as IECSSystem,
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * Creates a mock runner that simulates the ECSSimulation tick loop.
27
+ * The simulate() method runs all systems, and saveSnapshot() is a no-op.
28
+ * addTickHandler() registers handlers called at the end of each tick.
29
+ */
30
+ function createMockRunner(systems: IECSSystem[], opts?: { snapshotRate?: number }) {
31
+ const tickHandlers = new Set<(tick: number) => void>();
32
+ const snapshotRate = opts?.snapshotRate ?? 5;
33
+
34
+ const simulation = {
35
+ registeredSystems: systems as ReadonlyArray<IECSSystem>,
36
+ addTickHandler(handler: (tick: number) => void): () => void {
37
+ tickHandlers.add(handler);
38
+ return () => { tickHandlers.delete(handler); };
39
+ },
40
+ simulate(tick: number): void {
41
+ for (const sys of systems) {
42
+ sys.update(tick);
43
+ }
44
+ },
45
+ saveSnapshot(_tick: number): void {
46
+ // Simulate ~0.05ms of snapshot work
47
+ const start = performance.now();
48
+ while (performance.now() - start < 0.05) { /* spin */ }
49
+ },
50
+ };
51
+
52
+ /**
53
+ * Runs one full tick: simulate → extra work → conditionally saveSnapshot → tick handlers.
54
+ * Mirrors ECSSimulation.simulationTicks() loop body.
55
+ */
56
+ function runTick(tick: number): void {
57
+ (simulation as any).simulate(tick);
58
+ // Simulate non-system work (hash tracking, signals) ~0.02ms
59
+ const start = performance.now();
60
+ while (performance.now() - start < 0.02) { /* spin */ }
61
+ // Conditionally save snapshot
62
+ if (snapshotRate > 0 && tick % snapshotRate === 0) {
63
+ (simulation as any).saveSnapshot(tick);
64
+ }
65
+ // Call tick handlers
66
+ for (const handler of tickHandlers) handler(tick);
67
+ }
68
+
69
+ return {
70
+ Simulation: simulation,
71
+ runTick,
72
+ } as any;
73
+ }
74
+
75
+ describe('PerformanceProfiler', () => {
76
+ it('should attach and wrap system update methods', () => {
77
+ const systems = createMockSystems();
78
+ const originalUpdates = systems.map((s) => s.update);
79
+ const profiler = new PerformanceProfiler();
80
+ const runner = createMockRunner(systems);
81
+
82
+ profiler.attach(runner);
83
+
84
+ // update methods should be wrapped (different references)
85
+ for (let i = 0; i < systems.length; i++) {
86
+ expect(systems[i].update).not.toBe(originalUpdates[i]);
87
+ }
88
+
89
+ profiler.dispose();
90
+ });
91
+
92
+ it('should detach and restore original update methods', () => {
93
+ const systems = createMockSystems();
94
+ const originalUpdates = systems.map((s) => s.update);
95
+ const profiler = new PerformanceProfiler();
96
+ const runner = createMockRunner(systems);
97
+
98
+ profiler.attach(runner);
99
+ profiler.detach();
100
+
101
+ for (let i = 0; i < systems.length; i++) {
102
+ expect(systems[i].update).toBe(originalUpdates[i]);
103
+ }
104
+
105
+ profiler.dispose();
106
+ });
107
+
108
+ it('should collect per-system timing stats', () => {
109
+ const systems = createMockSystems();
110
+ const profiler = new PerformanceProfiler();
111
+ const runner = createMockRunner(systems);
112
+
113
+ profiler.attach(runner);
114
+
115
+ // Run full ticks through the mock tick loop
116
+ for (let tick = 1; tick <= 5; tick++) {
117
+ runner.runTick(tick);
118
+ }
119
+
120
+ const stats = profiler.getStats();
121
+ expect(stats.systems).toHaveLength(2);
122
+ expect(stats.systems[0].name).toBe('PhysicsSystem');
123
+ expect(stats.systems[1].name).toBe('MovementSystem');
124
+
125
+ // Each system should have positive timing values
126
+ for (const sys of stats.systems) {
127
+ expect(sys.latest).toBeGreaterThan(0);
128
+ expect(sys.min).toBeGreaterThan(0);
129
+ expect(sys.max).toBeGreaterThanOrEqual(sys.min);
130
+ expect(sys.avg).toBeGreaterThan(0);
131
+ }
132
+
133
+ profiler.dispose();
134
+ });
135
+
136
+ it('should compute aggregate tick time', () => {
137
+ const systems = createMockSystems();
138
+ const profiler = new PerformanceProfiler();
139
+ const runner = createMockRunner(systems);
140
+
141
+ profiler.attach(runner);
142
+
143
+ for (let tick = 1; tick <= 3; tick++) {
144
+ runner.runTick(tick);
145
+ }
146
+
147
+ const stats = profiler.getStats();
148
+ // Tick time should be positive
149
+ expect(stats.tickTime.latest).toBeGreaterThan(0);
150
+ expect(stats.tickTime.avg).toBeGreaterThan(0);
151
+ expect(stats.tickTime.min).toBeGreaterThan(0);
152
+ expect(stats.tickTime.max).toBeGreaterThanOrEqual(stats.tickTime.min);
153
+
154
+ profiler.dispose();
155
+ });
156
+
157
+ it('should return zeroed stats when no ticks have run', () => {
158
+ const systems = createMockSystems();
159
+ const profiler = new PerformanceProfiler();
160
+ const runner = createMockRunner(systems);
161
+
162
+ profiler.attach(runner);
163
+
164
+ const stats = profiler.getStats();
165
+ expect(stats.systems).toHaveLength(2);
166
+ for (const sys of stats.systems) {
167
+ expect(sys.latest).toBe(0);
168
+ expect(sys.min).toBe(0);
169
+ expect(sys.max).toBe(0);
170
+ expect(sys.avg).toBe(0);
171
+ }
172
+ expect(stats.tickTime.latest).toBe(0);
173
+
174
+ profiler.dispose();
175
+ });
176
+
177
+ it('should still call original update when wrapped', () => {
178
+ let callCount = 0;
179
+ const systems: IECSSystem[] = [
180
+ {
181
+ update() { callCount++; },
182
+ constructor: { name: 'TestSystem' } as unknown as { name: string },
183
+ } as unknown as IECSSystem,
184
+ ];
185
+ const profiler = new PerformanceProfiler();
186
+ const runner = createMockRunner(systems);
187
+
188
+ profiler.attach(runner);
189
+ systems[0].update(1);
190
+ systems[0].update(2);
191
+
192
+ expect(callCount).toBe(2);
193
+
194
+ profiler.dispose();
195
+ });
196
+
197
+ it('should handle rolling window correctly', () => {
198
+ let duration = 0.1;
199
+ const systems: IECSSystem[] = [
200
+ {
201
+ update() {
202
+ const start = performance.now();
203
+ while (performance.now() - start < duration) { /* spin */ }
204
+ },
205
+ constructor: { name: 'VaryingSystem' } as unknown as { name: string },
206
+ } as unknown as IECSSystem,
207
+ ];
208
+ const profiler = new PerformanceProfiler(8); // small window
209
+ const runner = createMockRunner(systems);
210
+
211
+ profiler.attach(runner);
212
+
213
+ // Run 8 ticks with 0.1ms duration
214
+ for (let i = 1; i <= 8; i++) {
215
+ runner.runTick(i);
216
+ }
217
+
218
+ const stats1 = profiler.getStats();
219
+ const avg1 = stats1.systems[0].avg;
220
+
221
+ // Run 8 more ticks with 0.3ms duration — old values should be overwritten
222
+ duration = 0.3;
223
+ for (let i = 9; i <= 16; i++) {
224
+ runner.runTick(i);
225
+ }
226
+
227
+ const stats2 = profiler.getStats();
228
+ // Average should now be higher since all window entries are ~0.3ms
229
+ expect(stats2.systems[0].avg).toBeGreaterThan(avg1);
230
+
231
+ profiler.dispose();
232
+ });
233
+
234
+ // --- NEW TESTS for tick time and snapshot time ---
235
+
236
+ it('should measure real total tick time greater than sum of system times', () => {
237
+ const systems = createMockSystems();
238
+ const profiler = new PerformanceProfiler();
239
+ const runner = createMockRunner(systems, { snapshotRate: 1 });
240
+
241
+ profiler.attach(runner);
242
+
243
+ // Run 10 ticks (with snapshot every tick to include snapshot overhead)
244
+ for (let tick = 1; tick <= 10; tick++) {
245
+ runner.runTick(tick);
246
+ }
247
+
248
+ const stats = profiler.getStats();
249
+ const systemSum = stats.systems.reduce((sum, s) => sum + s.avg, 0);
250
+
251
+ // Real tick time should be greater than system sum (includes non-system work + snapshot)
252
+ expect(stats.tickTime.avg).toBeGreaterThan(systemSum);
253
+
254
+ profiler.dispose();
255
+ });
256
+
257
+ it('should include snapshotTime in stats', () => {
258
+ const systems = createMockSystems();
259
+ const profiler = new PerformanceProfiler();
260
+ const runner = createMockRunner(systems, { snapshotRate: 1 });
261
+
262
+ profiler.attach(runner);
263
+
264
+ // Run ticks — snapshot happens every tick with snapshotRate=1
265
+ for (let tick = 1; tick <= 5; tick++) {
266
+ runner.runTick(tick);
267
+ }
268
+
269
+ const stats = profiler.getStats();
270
+ expect(stats.snapshotTime).toBeDefined();
271
+ expect(stats.snapshotTime.latest).toBeGreaterThan(0);
272
+ expect(stats.snapshotTime.min).toBeGreaterThan(0);
273
+ expect(stats.snapshotTime.max).toBeGreaterThanOrEqual(stats.snapshotTime.min);
274
+ expect(stats.snapshotTime.avg).toBeGreaterThan(0);
275
+
276
+ profiler.dispose();
277
+ });
278
+
279
+ it('should record snapshot time only when snapshot happens', () => {
280
+ const systems = createMockSystems();
281
+ const profiler = new PerformanceProfiler();
282
+ // snapshotRate=5 means snapshot on tick 5, 10, 15, ...
283
+ const runner = createMockRunner(systems, { snapshotRate: 5 });
284
+
285
+ profiler.attach(runner);
286
+
287
+ // Run 4 ticks — no snapshot should happen
288
+ for (let tick = 1; tick <= 4; tick++) {
289
+ runner.runTick(tick);
290
+ }
291
+
292
+ let stats = profiler.getStats();
293
+ expect(stats.snapshotTime.latest).toBe(0);
294
+ expect(stats.snapshotTime.avg).toBe(0);
295
+
296
+ // Run tick 5 — snapshot should happen
297
+ runner.runTick(5);
298
+
299
+ stats = profiler.getStats();
300
+ expect(stats.snapshotTime.latest).toBeGreaterThan(0);
301
+ expect(stats.snapshotTime.avg).toBeGreaterThan(0);
302
+
303
+ profiler.dispose();
304
+ });
305
+
306
+ it('should detach and restore simulate() and saveSnapshot()', () => {
307
+ const systems = createMockSystems();
308
+ const profiler = new PerformanceProfiler();
309
+ const runner = createMockRunner(systems);
310
+
311
+ const originalSimulate = runner.Simulation.simulate;
312
+ const originalSaveSnapshot = runner.Simulation.saveSnapshot;
313
+
314
+ profiler.attach(runner);
315
+
316
+ // Methods should be monkey-patched
317
+ expect(runner.Simulation.simulate).not.toBe(originalSimulate);
318
+ expect(runner.Simulation.saveSnapshot).not.toBe(originalSaveSnapshot);
319
+
320
+ profiler.detach();
321
+
322
+ // Methods should be restored
323
+ expect(runner.Simulation.simulate).toBe(originalSimulate);
324
+ expect(runner.Simulation.saveSnapshot).toBe(originalSaveSnapshot);
325
+
326
+ profiler.dispose();
327
+ });
328
+
329
+ // --- overheadTime tests ---
330
+
331
+ it('should include overheadTime in stats reflecting non-system non-snapshot work', () => {
332
+ const systems = createMockSystems();
333
+ const profiler = new PerformanceProfiler();
334
+ // snapshotRate=0 means no snapshots — overhead = tickTime - simulateTime
335
+ const runner = createMockRunner(systems, { snapshotRate: 0 });
336
+
337
+ profiler.attach(runner);
338
+
339
+ for (let tick = 1; tick <= 10; tick++) {
340
+ runner.runTick(tick);
341
+ }
342
+
343
+ const stats = profiler.getStats();
344
+ // The mock tick loop has ~0.02ms of non-system work between simulate and tick handlers
345
+ expect(stats.overheadTime).toBeDefined();
346
+ expect(stats.overheadTime.avg).toBeGreaterThan(0);
347
+ expect(stats.overheadTime.min).toBeGreaterThanOrEqual(0);
348
+ expect(stats.overheadTime.max).toBeGreaterThanOrEqual(stats.overheadTime.min);
349
+
350
+ profiler.dispose();
351
+ });
352
+
353
+ it('should return zeroed overheadTime when no ticks have run', () => {
354
+ const systems = createMockSystems();
355
+ const profiler = new PerformanceProfiler();
356
+ const runner = createMockRunner(systems);
357
+
358
+ profiler.attach(runner);
359
+
360
+ const stats = profiler.getStats();
361
+ expect(stats.overheadTime.latest).toBe(0);
362
+ expect(stats.overheadTime.avg).toBe(0);
363
+
364
+ profiler.dispose();
365
+ });
366
+
367
+ it('should reset overheadTime state on detach', () => {
368
+ const systems = createMockSystems();
369
+ const profiler = new PerformanceProfiler();
370
+ const runner = createMockRunner(systems);
371
+
372
+ profiler.attach(runner);
373
+
374
+ for (let tick = 1; tick <= 5; tick++) {
375
+ runner.runTick(tick);
376
+ }
377
+
378
+ profiler.detach();
379
+
380
+ const stats = profiler.getStats();
381
+ expect(stats.overheadTime.latest).toBe(0);
382
+ expect(stats.overheadTime.avg).toBe(0);
383
+
384
+ profiler.dispose();
385
+ });
386
+
387
+ it('should remove tick handler on detach', () => {
388
+ const systems = createMockSystems();
389
+ const profiler = new PerformanceProfiler();
390
+ const runner = createMockRunner(systems);
391
+
392
+ profiler.attach(runner);
393
+
394
+ // Run some ticks
395
+ for (let tick = 1; tick <= 3; tick++) {
396
+ runner.runTick(tick);
397
+ }
398
+
399
+ profiler.detach();
400
+
401
+ // Run more ticks — tick time should NOT accumulate
402
+ for (let tick = 4; tick <= 6; tick++) {
403
+ runner.runTick(tick);
404
+ }
405
+
406
+ // After detach, getStats should return zeroed (entries cleared)
407
+ const stats = profiler.getStats();
408
+ expect(stats.tickTime.latest).toBe(0);
409
+
410
+ profiler.dispose();
411
+ });
412
+ });
@@ -0,0 +1,245 @@
1
+ import type { ECSRunner, ECSSimulation, IECSSystem } from '@lagless/core';
2
+
3
+ export interface TimingStats {
4
+ latest: number;
5
+ min: number;
6
+ max: number;
7
+ avg: number;
8
+ }
9
+
10
+ export interface SystemTimingStats extends TimingStats {
11
+ name: string;
12
+ }
13
+
14
+ export interface PerformanceStats {
15
+ tickTime: TimingStats;
16
+ snapshotTime: TimingStats;
17
+ overheadTime: TimingStats;
18
+ systems: SystemTimingStats[];
19
+ }
20
+
21
+ interface SystemEntry {
22
+ name: string;
23
+ original: (tick: number) => void;
24
+ system: IECSSystem;
25
+ buffer: Float64Array;
26
+ writeIndex: number;
27
+ count: number;
28
+ }
29
+
30
+ /** Runtime shape of ECSSimulation with protected methods accessible via monkey-patching */
31
+ interface SimulationRuntime {
32
+ simulate: (tick: number) => void;
33
+ saveSnapshot: (tick: number) => void;
34
+ }
35
+
36
+ const DEFAULT_WINDOW_SIZE = 600;
37
+ const ZERO_TIMING: TimingStats = { latest: 0, min: 0, max: 0, avg: 0 };
38
+
39
+ function computeBufferStats(buffer: Float64Array, writeIndex: number, count: number, windowSize: number): TimingStats {
40
+ if (count === 0) return { ...ZERO_TIMING };
41
+ let min = Infinity;
42
+ let max = -Infinity;
43
+ let sum = 0;
44
+ for (let i = 0; i < count; i++) {
45
+ const idx = (writeIndex - count + i + windowSize) % windowSize;
46
+ const v = buffer[idx];
47
+ if (v < min) min = v;
48
+ if (v > max) max = v;
49
+ sum += v;
50
+ }
51
+ const latestIdx = (writeIndex - 1 + windowSize) % windowSize;
52
+ return {
53
+ latest: buffer[latestIdx],
54
+ min,
55
+ max,
56
+ avg: sum / count,
57
+ };
58
+ }
59
+
60
+ export class PerformanceProfiler {
61
+ private readonly _windowSize: number;
62
+ private _entries: SystemEntry[] = [];
63
+ private _attached = false;
64
+
65
+ // Tick time ring buffer
66
+ private _tickTimeBuffer!: Float64Array;
67
+ private _tickTimeWriteIndex = 0;
68
+ private _tickTimeCount = 0;
69
+ private _tickStartTime = 0;
70
+
71
+ // Snapshot time ring buffer
72
+ private _snapshotTimeBuffer!: Float64Array;
73
+ private _snapshotTimeWriteIndex = 0;
74
+ private _snapshotTimeCount = 0;
75
+
76
+ // Overhead time ring buffer (tickTime - simulateElapsed - snapshotElapsed)
77
+ private _overheadTimeBuffer!: Float64Array;
78
+ private _overheadTimeWriteIndex = 0;
79
+ private _overheadTimeCount = 0;
80
+
81
+ // Per-tick elapsed tracking for overhead computation
82
+ private _lastSimulateElapsed = -1; // sentinel: -1 = no data yet (first-tick guard)
83
+ private _lastSnapshotElapsed = 0;
84
+
85
+ // Cleanup references
86
+ private _originalSimulate: ((tick: number) => void) | null = null;
87
+ private _originalSaveSnapshot: ((tick: number) => void) | null = null;
88
+ private _removeTickHandler: (() => void) | null = null;
89
+ private _simulation: ECSSimulation | null = null;
90
+
91
+ constructor(windowSize = DEFAULT_WINDOW_SIZE) {
92
+ this._windowSize = windowSize;
93
+ }
94
+
95
+ public attach(runner: ECSRunner): void {
96
+ if (this._attached) return;
97
+ this._attached = true;
98
+
99
+ const simulation = runner.Simulation;
100
+ this._simulation = simulation;
101
+ const sim = simulation as unknown as SimulationRuntime;
102
+ const systems = simulation.registeredSystems;
103
+ this._entries = [];
104
+
105
+ // Initialize ring buffers
106
+ this._tickTimeBuffer = new Float64Array(this._windowSize);
107
+ this._tickTimeWriteIndex = 0;
108
+ this._tickTimeCount = 0;
109
+ this._snapshotTimeBuffer = new Float64Array(this._windowSize);
110
+ this._snapshotTimeWriteIndex = 0;
111
+ this._snapshotTimeCount = 0;
112
+ this._overheadTimeBuffer = new Float64Array(this._windowSize);
113
+ this._overheadTimeWriteIndex = 0;
114
+ this._overheadTimeCount = 0;
115
+ this._lastSimulateElapsed = -1;
116
+ this._lastSnapshotElapsed = 0;
117
+
118
+ // Monkey-patch per-system update methods
119
+ for (const system of systems) {
120
+ const name = system.constructor.name;
121
+ const original = system.update;
122
+ const entry: SystemEntry = {
123
+ name,
124
+ original,
125
+ system,
126
+ buffer: new Float64Array(this._windowSize),
127
+ writeIndex: 0,
128
+ count: 0,
129
+ };
130
+ this._entries.push(entry);
131
+
132
+ system.update = (tick: number) => {
133
+ const start = performance.now();
134
+ original.call(system, tick);
135
+ const elapsed = performance.now() - start;
136
+ entry.buffer[entry.writeIndex % this._windowSize] = elapsed;
137
+ entry.writeIndex++;
138
+ entry.count = Math.min(entry.count + 1, this._windowSize);
139
+ };
140
+ }
141
+
142
+ // Monkey-patch simulate() to record tick start time
143
+ const originalSimulate = sim.simulate;
144
+ this._originalSimulate = originalSimulate;
145
+ sim.simulate = (tick: number) => {
146
+ this._lastSnapshotElapsed = 0; // Reset before saveSnapshot() might or might not run this tick
147
+ this._tickStartTime = performance.now();
148
+ originalSimulate.call(simulation, tick);
149
+ this._lastSimulateElapsed = performance.now() - this._tickStartTime;
150
+ };
151
+
152
+ // Monkey-patch saveSnapshot() to measure snapshot time
153
+ const originalSaveSnapshot = sim.saveSnapshot;
154
+ this._originalSaveSnapshot = originalSaveSnapshot;
155
+ sim.saveSnapshot = (tick: number) => {
156
+ const start = performance.now();
157
+ originalSaveSnapshot.call(simulation, tick);
158
+ const elapsed = performance.now() - start;
159
+ this._lastSnapshotElapsed = elapsed;
160
+ this._snapshotTimeBuffer[this._snapshotTimeWriteIndex % this._windowSize] = elapsed;
161
+ this._snapshotTimeWriteIndex++;
162
+ this._snapshotTimeCount = Math.min(this._snapshotTimeCount + 1, this._windowSize);
163
+ };
164
+
165
+ // Add tick handler to compute total tick time and overhead
166
+ this._removeTickHandler = simulation.addTickHandler(() => {
167
+ const elapsed = performance.now() - this._tickStartTime;
168
+ this._tickTimeBuffer[this._tickTimeWriteIndex % this._windowSize] = elapsed;
169
+ this._tickTimeWriteIndex++;
170
+ this._tickTimeCount = Math.min(this._tickTimeCount + 1, this._windowSize);
171
+
172
+ // Compute overhead: tickTime - simulateElapsed - snapshotElapsed
173
+ // Skip first tick after attach (sentinel: _lastSimulateElapsed === -1)
174
+ if (this._lastSimulateElapsed >= 0) {
175
+ const overhead = Math.max(0, elapsed - this._lastSimulateElapsed - this._lastSnapshotElapsed);
176
+ this._overheadTimeBuffer[this._overheadTimeWriteIndex % this._windowSize] = overhead;
177
+ this._overheadTimeWriteIndex++;
178
+ this._overheadTimeCount = Math.min(this._overheadTimeCount + 1, this._windowSize);
179
+ }
180
+ });
181
+ }
182
+
183
+ public detach(): void {
184
+ if (!this._attached) return;
185
+
186
+ // Restore per-system update methods
187
+ for (const entry of this._entries) {
188
+ entry.system.update = entry.original;
189
+ }
190
+ this._entries = [];
191
+
192
+ // Restore simulate() and saveSnapshot()
193
+ const sim = this._simulation as unknown as SimulationRuntime;
194
+ if (sim && this._originalSimulate) {
195
+ sim.simulate = this._originalSimulate;
196
+ }
197
+ if (sim && this._originalSaveSnapshot) {
198
+ sim.saveSnapshot = this._originalSaveSnapshot;
199
+ }
200
+
201
+ // Remove tick handler
202
+ if (this._removeTickHandler) {
203
+ this._removeTickHandler();
204
+ this._removeTickHandler = null;
205
+ }
206
+
207
+ this._originalSimulate = null;
208
+ this._originalSaveSnapshot = null;
209
+ this._simulation = null;
210
+ this._tickTimeWriteIndex = 0;
211
+ this._tickTimeCount = 0;
212
+ this._snapshotTimeWriteIndex = 0;
213
+ this._snapshotTimeCount = 0;
214
+ this._overheadTimeWriteIndex = 0;
215
+ this._overheadTimeCount = 0;
216
+ this._lastSimulateElapsed = -1;
217
+ this._lastSnapshotElapsed = 0;
218
+ this._attached = false;
219
+ }
220
+
221
+ public getStats(): PerformanceStats {
222
+ const systems: SystemTimingStats[] = this._entries.map((entry) => {
223
+ const stats = computeBufferStats(entry.buffer, entry.writeIndex, entry.count, this._windowSize);
224
+ return { name: entry.name, ...stats };
225
+ });
226
+
227
+ const tickTime = computeBufferStats(
228
+ this._tickTimeBuffer, this._tickTimeWriteIndex, this._tickTimeCount, this._windowSize,
229
+ );
230
+
231
+ const snapshotTime = computeBufferStats(
232
+ this._snapshotTimeBuffer, this._snapshotTimeWriteIndex, this._snapshotTimeCount, this._windowSize,
233
+ );
234
+
235
+ const overheadTime = computeBufferStats(
236
+ this._overheadTimeBuffer, this._overheadTimeWriteIndex, this._overheadTimeCount, this._windowSize,
237
+ );
238
+
239
+ return { tickTime, snapshotTime, overheadTime, systems };
240
+ }
241
+
242
+ public dispose(): void {
243
+ this.detach();
244
+ }
245
+ }