File size: 23,502 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
// The one Cesium-bound piece: it turns "draw N satellites with these
// components" into a reconcile, and the render loop into frame samples.

import type { JulianDate } from "@cesium/engine";

import { useCesiumStore } from "../../stores/cesium";
import { useSatStore } from "../../stores/sat";
import type { CesiumController } from "../CesiumController";
import type { DesiredScene } from "../SatelliteManager";
import type { BenchmarkTarget, FootprintSample, MeasureOptions, SceneApplied, SceneRequest } from "./benchmarkRunner";
import { FrameSampler, type FrameSample } from "./frameSampler";

declare global {
  /**
   * `performance.measureUserAgentSpecificMemory()`, absent from the TypeScript dom
   * lib because it is not Baseline. Declared here rather than cast at the call
   * site so the shape is stated once.
   */
  interface MemoryMeasurement {
    bytes: number;
    breakdown: Array<{ bytes: number; types: string[]; attribution: Array<{ url: string; scope: string }> }>;
  }

  interface Performance {
    measureUserAgentSpecificMemory?: () => Promise<MemoryMeasurement>;
    // Chrome only. The comment here used to claim 5 MB buckets unless started
    // with --enable-precise-memory-info; measured on Chrome in 2026 that is not
    // so — eight consecutive reads gave eight distinct non-round values with and
    // without the flag. What makes a single read useless is not granularity but
    // uncollected garbage. See FrameSample.heap.
    memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
  }
}

/** About two seconds at 60 fps — enough for the live readout to be steady. */
const LIVE_WINDOW_FRAMES = 120;

const BYTES_PER_MB = 1024 * 1024;

/** A GPU query result is usually a frame or two away; give up rather than leak. */
const GPU_QUERY_POLL_MS = 4;
const GPU_QUERY_MAX_POLLS = 250;

export interface LiveSnapshot {
  frames: FrameSample;
  satellitesVisible: number;
  componentsDrawn: string[];
  clockMultiplier: number;
  entities: number;
  primitives: number;
}

export interface TargetOptions {
  /**
   * Restrict the sweep to satellites carrying this tag. Unset means the whole
   * loaded catalog, which is what makes the counts reachable on any route
   * rather than only where a big enough group happens to be configured.
   */
  tag?: string;
  /**
   * Place a ground station, which switches pass prediction on for every
   * satellite. Off by default: it is a large cost that has nothing to do with
   * drawing, so it belongs in its own run rather than in every row.
   */
  groundStation?: { lat: number; lon: number };
}

/**
 * How long to let the app go quiet before reading the footprint.
 *
 * Long enough for the sample-window top-ups already in flight to land and their
 * buffers to become collectable. Six seconds was where the reading stopped moving:
 * the same scene read 550 MB at +6 s and 544 MB at +16 s, against 1044 and 1295 MB
 * with the clock running.
 */
const FOOTPRINT_QUIESCE_MS = 6000;

const wait = (ms: number, signal: AbortSignal): Promise<void> =>
  new Promise((resolve) => {
    if (signal.aborted || ms <= 0) {
      resolve();
      return;
    }
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    function onAbort(): void {
      clearTimeout(timer);
      resolve();
    }
    signal.addEventListener("abort", onAbort, { once: true });
  });

/**
 * Wait for `count` presented frames — but never forever.
 *
 * A hidden tab does not throttle requestAnimationFrame, it suspends it, so
 * without the timeout a sweep started and then backgrounded wedges on step one
 * and never reports anything. The timeout means it carries on instead, and the
 * `frames` column is what says the sample was worthless.
 */
const nextFrames = (count: number, timeoutMs = 1000): Promise<void> =>
  new Promise((resolve) => {
    let remaining = count;
    let settled = false;
    const done = (): void => {
      if (settled) {
        return;
      }
      settled = true;
      clearTimeout(timer);
      resolve();
    };
    const timer = setTimeout(done, timeoutMs);
    const step = (): void => {
      remaining -= 1;
      if (remaining <= 0) {
        done();
        return;
      }
      requestAnimationFrame(step);
    };
    requestAnimationFrame(step);
  });

/**
 * Where one GPU reading belongs: the sampler that was collecting when the query
 * was started, and the epoch it was then on. See `#endGpuQuery`.
 */
interface GpuTarget {
  sampler: FrameSampler;
  epoch: number;
}

/**
 * Whether this page can measure an absolute footprint at all.
 *
 * Two conditions, and the isolation one is the interesting half: the API is only
 * exposed to a cross-origin isolated context, so a page served without
 * `Cross-Origin-Opener-Policy: same-origin` and
 * `Cross-Origin-Embedder-Policy: credentialless` cannot see it however new the
 * browser. That is a deployment fact rather than a browser fact, which is why the
 * panel says which of the two is missing instead of just greying a control.
 */
export const canMeasureFootprint = (): boolean => window.crossOriginIsolated && typeof performance.measureUserAgentSpecificMemory === "function";

/** Best effort, and separate from Cesium's context so nothing internal is poked. */
function gpuName(): string {
  try {
    const gl = document.createElement("canvas").getContext("webgl2");
    const info = gl?.getExtension("WEBGL_debug_renderer_info");
    return info ? String(gl?.getParameter(info.UNMASKED_RENDERER_WEBGL)) : "unknown";
  } catch {
    return "unknown";
  }
}

export class CesiumBenchmarkTarget implements BenchmarkTarget {
  readonly #cc: CesiumController;

  readonly #live = new FrameSampler(LIVE_WINDOW_FRAMES);

  #sweep: FrameSampler | undefined;

  #preUpdateAt = 0;

  /** Duration of the clock tick that preceded the frame being rendered. See #instrumentClockTick. */
  #tickMs = 0;

  /** `EXT_disjoint_timer_query_webgl2`, or undefined where the browser has no such thing. */
  #timerExt: { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number } | undefined;

  #gl: WebGL2RenderingContext | undefined;

  /**
   * At most one query in flight. Beginning one per frame would queue hundreds of
   * query objects and force a flush on each; one at a time samples a subset of
   * frames, which is all a median needs.
   */
  #queryInFlight: { query: WebGLQuery; targets: GpuTarget[] } | undefined;

  options: TargetOptions = {};

  /**
   * What the app looked like before the first prepare(), so restore() can put it
   * back. Held until restore rather than per run, because a run that throws
   * still has to give the user their scene back.
   */
  #saved: { requestRenderMode: boolean; shouldAnimate: boolean; multiplier: number; scene: DesiredScene } | undefined;

  constructor(cc: CesiumController) {
    this.#cc = cc;
    const { scene } = cc.viewer;
    // Two marks per frame: the wall clock between presented frames comes from
    // postRender alone, and the work inside one frame needs both. Cesium runs
    // most position updates in clock onTick, before preUpdate, so measuring
    // from preUpdate deliberately excludes them — see README.
    this.#initGpuTimer(scene);
    this.#instrumentClockTick();
    scene.preUpdate.addEventListener(() => {
      this.#preUpdateAt = performance.now();
      this.#beginGpuQuery();
    });
    scene.postRender.addEventListener(() => {
      const now = performance.now();
      const cpuMs = now - this.#preUpdateAt;
      this.#live.push(now, cpuMs, this.#tickMs);
      this.#sweep?.push(now, cpuMs, this.#tickMs);
      // After the timing marks, so the read is never inside what it would
      // otherwise inflate. Per frame rather than once per step because a single
      // reading measures when the last GC happened, not what the scene costs.
      const bytes = performance.memory?.usedJSHeapSize;
      if (bytes !== undefined) {
        const mb = bytes / BYTES_PER_MB;
        this.#live.pushHeap(mb);
        this.#sweep?.pushHeap(mb);
      }
      this.#endGpuQuery();
    });
  }

  /**
   * The GPU-side clock. Optional in every sense: the extension is absent on some
   * browsers, blocked on others, and — as measured on ANGLE/Metal — can return
   * times several multiples of the frame interval, which is why nothing here
   * trusts the number on sight. The report gates it; this only collects it.
   */
  #initGpuTimer(scene: object): void {
    try {
      // `context` is Cesium-internal and untyped; reaching for it is the only way
      // to time the GPU on the context the app is actually drawing with.
      const gl = (scene as { context?: { _gl?: WebGL2RenderingContext } }).context?._gl;
      const ext = gl?.getExtension("EXT_disjoint_timer_query_webgl2") as { TIME_ELAPSED_EXT: number; GPU_DISJOINT_EXT: number } | null;
      if (gl && ext) {
        this.#gl = gl;
        this.#timerExt = ext;
      }
    } catch {
      // A missing or blocked extension is not worth a broken benchmark.
    }
  }

  #beginGpuQuery(): void {
    const gl = this.#gl;
    const ext = this.#timerExt;
    if (!gl || !ext || this.#queryInFlight) {
      return;
    }
    try {
      const query = gl.createQuery();
      if (!query) {
        return;
      }
      gl.beginQuery(ext.TIME_ELAPSED_EXT, query);
      const targets: GpuTarget[] = [{ sampler: this.#live, epoch: this.#live.epoch }];
      if (this.#sweep) {
        targets.push({ sampler: this.#sweep, epoch: this.#sweep.epoch });
      }
      this.#queryInFlight = { query, targets };
    } catch {
      this.#timerExt = undefined;
    }
  }

  #endGpuQuery(): void {
    const gl = this.#gl;
    const ext = this.#timerExt;
    const pending = this.#queryInFlight;
    if (!gl || !ext || !pending) {
      return;
    }
    const { query, targets } = pending;
    this.#queryInFlight = undefined;
    try {
      gl.endQuery(ext.TIME_ELAPSED_EXT);
    } catch {
      gl.deleteQuery(query);
      this.#timerExt = undefined;
      return;
    }
    // The result lands some frames later, so poll rather than block — and it is
    // delivered to the samplers this query was *started* for, at the epoch they
    // were then on. Delivering it to whatever was open on arrival is what made this
    // column untrustworthy: a query from the tail of a heavy step landed in the
    // next step's window, and a query from a warmup frame landed in the sample the
    // warmup exists to protect. A 5,000-satellite step reported 34.5 ms and the
    // 0-satellite step after it reported 24.
    let attempts = 0;
    const poll = (): void => {
      attempts += 1;
      try {
        if (gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) {
          // A disjoint means the GPU was interrupted and the timing is garbage.
          if (!gl.getParameter(ext.GPU_DISJOINT_EXT)) {
            const ns = gl.getQueryParameter(query, gl.QUERY_RESULT) as number;
            for (const target of targets) {
              if (target.sampler.epoch === target.epoch) {
                target.sampler.pushGpu(ns / 1e6);
              }
            }
          }
          gl.deleteQuery(query);
          return;
        }
        if (attempts > GPU_QUERY_MAX_POLLS) {
          gl.deleteQuery(query);
          return;
        }
        setTimeout(poll, GPU_QUERY_POLL_MS);
      } catch {
        this.#timerExt = undefined;
      }
    };
    setTimeout(poll, GPU_QUERY_POLL_MS);
  }

  /** True when this browser offered a GPU clock at all. */
  get gpuTimingAvailable(): boolean {
    return this.#timerExt !== undefined;
  }

  /** The in-browser readout, sampled continuously whether a sweep is running or not. */
  live(): LiveSnapshot {
    return {
      frames: this.#live.snapshot(),
      satellitesVisible: this.#cc.sats.visibleSatellites.length,
      componentsDrawn: this.#cc.sats.enabledComponents,
      clockMultiplier: this.#cc.viewer.clock.multiplier,
      entities: this.#cc.viewer.entities.values.length,
      primitives: this.#cc.viewer.scene.primitives.length,
    };
  }

  environment(): Record<string, string | number> {
    const { canvas } = this.#cc.viewer.scene;
    return {
      build: `${__BUILD_SHA__} ${__BUILD_DATE__}`,
      mode: import.meta.env.DEV ? "dev (unminified — numbers are pessimistic)" : "production build",
      userAgent: navigator.userAgent,
      gpu: gpuName(),
      canvas: `${canvas.width}x${canvas.height}`,
      devicePixelRatio: window.devicePixelRatio,
      hardwareConcurrency: navigator.hardwareConcurrency,
      // Recorded because it invalidates the whole run: a hidden tab presents no
      // frames at all, so every frame figure below would be noise.
      visibility: document.visibilityState,
      // Whether an absolute footprint was obtainable, which is a property of how
      // the page was served rather than of the machine.
      crossOriginIsolated: String(window.crossOriginIsolated),
    };
  }

  /**
   * Time the whole clock tick, by wrapping `clock.tick` rather than by adding a
   * listener to `clock.onTick`.
   *
   * Position updates happen in `onTick` listeners, and an `onTick` listener of
   * our own could only mark the point it is *itself* reached. Cesium raises
   * listeners in registration order, and two of the ones that matter — the
   * manager's derived-geometry refresh and the orbit batch's re-orientation —
   * are registered when the viewer is built, long before the panel that
   * constructs this target. A marker would sit behind them and quietly miss
   * exactly the work it was added to find.
   *
   * `clock.tick()` raises the event, so wrapping it captures every listener
   * whatever the order, which is the only version of this that cannot be wrong.
   * The cost is two `performance.now()` calls a frame, and it is only ever
   * installed in a session that has opened the benchmark panel.
   */
  #instrumentClockTick(): void {
    const { clock } = this.#cc.viewer;
    // eslint-disable-next-line @typescript-eslint/unbound-method
    const original = clock.tick;
    if ((original as { __benchmarkWrapped?: boolean }).__benchmarkWrapped) {
      return;
    }
    const wrapped = (): JulianDate => {
      const started = performance.now();
      try {
        return original.call(clock);
      } finally {
        this.#tickMs = performance.now() - started;
      }
    };
    (wrapped as { __benchmarkWrapped?: boolean }).__benchmarkWrapped = true;
    clock.tick = wrapped;
  }

  async prepare(): Promise<void> {
    const { clock } = this.#cc.viewer;
    const cesiumStore = useCesiumStore();
    this.#saved ??= { requestRenderMode: cesiumStore.requestRenderMode, shouldAnimate: clock.shouldAnimate, multiplier: clock.multiplier, scene: this.#storeScene() };
    // requestRenderMode skips frames when nothing moved, which would make the
    // frame deltas measure how idle the render loop is rather than how much a
    // scene costs. The clock has to run for the same reason: a stopped clock
    // means no position updates, and position updates are most of the cost.
    //
    // Through the store so the Render menu's switch follows: a sweep started from
    // the console with the panel closed still changes this, and a control showing
    // the opposite of what is in force is worse than no control.
    cesiumStore.requestRenderMode = false;
    clock.shouldAnimate = true;
    // Every count is sliced out of the loaded catalog, so the whole catalog has
    // to be there first — otherwise the sweep measures group downloads.
    await this.#cc.sats.catalog.ensureAll();
  }

  catalogSize(): number {
    return this.#names().length;
  }

  async apply(request: SceneRequest): Promise<SceneApplied> {
    const names = this.#names().slice(0, request.satelliteCount);
    const { clock } = this.#cc.viewer;

    // The clock is set back to real time for the build. A step at ×1000 would
    // otherwise sweep the sample window forward while the scene is being
    // constructed, so `buildMs` would carry a propagation cost belonging to the
    // measurement that follows it.
    clock.multiplier = 1;

    // Clear first, so buildMs is the cost of building this scene rather than
    // the cost of the diff from the previous one.
    const clearStart = performance.now();
    this.#cc.sats.reconcile(this.#scene([], []));
    const clearMs = performance.now() - clearStart;
    await nextFrames(2);

    // `buildMs` is the wall time to a complete scene, not the synchronous part
    // of the call. Satellites are instantiated to a per-frame budget now (see
    // SatelliteManager.#build), so reconcile returns with the queue still
    // draining — without the await, every row would report whatever fraction of
    // the population happened to exist when the first frame ended.
    const buildStart = performance.now();
    this.#cc.sats.reconcile(this.#scene(names, request.components));
    await this.#cc.sats.buildSettled();
    const buildMs = performance.now() - buildStart;
    await nextFrames(2);

    // Only now, so the warmup period absorbs the first refreshes at the new rate.
    clock.multiplier = request.clockMultiplier;

    const satellites = this.#cc.sats.visibleSatellites;
    const componentInstances: Record<string, number> = {};
    for (const satellite of satellites) {
      for (const component of satellite.componentNames) {
        componentInstances[component] = (componentInstances[component] ?? 0) + 1;
      }
    }
    return {
      satellitesRequested: request.satelliteCount,
      satellitesVisible: satellites.length,
      componentsRequested: [...request.components],
      componentsDrawn: this.#cc.sats.enabledComponents,
      componentInstances,
      clockMultiplier: clock.multiplier,
      entities: this.#cc.viewer.entities.values.length,
      primitives: this.#cc.viewer.scene.primitives.length,
      clearMs,
      buildMs,
    };
  }

  async measure(options: MeasureOptions): Promise<FrameSample> {
    const sampler = new FrameSampler();
    this.#sweep = sampler;
    try {
      await wait(options.warmupMs, options.signal);
      // The warmup frames are thrown away, not averaged in: the first frames
      // after a build carry shader compiles and buffer uploads that a steady
      // state does not.
      sampler.reset();
      await wait(options.sampleMs, options.signal);
      return sampler.snapshot();
    } finally {
      this.#sweep = undefined;
    }
  }

  /**
   * An absolute footprint for the scene currently up.
   *
   * The `JavaScript`/`Window` breakdown entry is singled out as `jsMb` because it
   * is the figure comparable with everything else here — `measureUAM`'s total also
   * counts DOM and shared memory across workers, which is a broader thing than the
   * heap the rest of the framework talks about. Both are kept: the total is the
   * honest answer to "what does this tab cost", and measured they are far apart
   * (427 MB against 297 MB at 5,000 satellites).
   */
  async measureFootprint(): Promise<FootprintSample | undefined> {
    const measure = performance.measureUserAgentSpecificMemory;
    if (!canMeasureFootprint() || !measure) {
      return undefined;
    }
    const startedAt = performance.now();
    // Stop the clock and let the app go quiet first, which is the difference
    // between measuring a scene and measuring the garbage it happens to be
    // producing. At 5,000 satellites with orbits the app propagates and
    // re-transforms sample windows continuously, and the reading lands wherever
    // that churn is at the time — measured on one scene, seconds apart:
    //
    //     clock running   1044 MB total (worker 557)   then 1295 MB (window 1106)
    //     clock stopped    550 MB total (worker  51)   then  544 MB (window  357)
    //
    // so the running figures were a factor of two and a half apart on a scene that
    // had not changed. Safe to do here and nowhere else: the footprint is captured
    // after the sample window has closed, so no frame timing can see it.
    const clock = this.#cc.viewer.clock;
    const wasAnimating = clock.shouldAnimate;
    clock.shouldAnimate = false;
    try {
      await new Promise((resolve) => setTimeout(resolve, FOOTPRINT_QUIESCE_MS));
      const result = await measure.call(performance);
      const js = result.breakdown.find((entry) => entry.types.includes("JavaScript") && entry.attribution.some((item) => item.scope === "Window"));
      // Separated out because `totalMb` counts them and nothing else does — see
      // FootprintSample.workerMb.
      const workerBytes = result.breakdown.filter((entry) => entry.attribution.some((item) => (item.scope ?? "").includes("Worker"))).reduce((sum, entry) => sum + entry.bytes, 0);
      return {
        totalMb: result.bytes / BYTES_PER_MB,
        jsMb: (js?.bytes ?? result.bytes) / BYTES_PER_MB,
        workerMb: workerBytes / BYTES_PER_MB,
        elapsedMs: performance.now() - startedAt,
      };
    } catch {
      // A rejected measurement is a missing row, not a failed sweep: the API can
      // refuse (a detached frame, a browser that changed its mind) and the run
      // still has every frame timing it came for.
      return undefined;
    } finally {
      clock.shouldAnimate = wasAnimating;
    }
  }

  async restore(): Promise<void> {
    const saved = this.#saved;
    if (!saved) {
      return;
    }
    const { clock } = this.#cc.viewer;
    useCesiumStore().requestRenderMode = saved.requestRenderMode;
    clock.shouldAnimate = saved.shouldAnimate;
    clock.multiplier = saved.multiplier;
    // The sweep drove the manager directly, so the store's scene has to be put
    // back by hand — sceneSync's watcher only fires when the store changes, and
    // the store never changed.
    this.#cc.sats.reconcile(this.#storeScene());
    this.#saved = undefined;
    await nextFrames(1);
  }

  /**
   * Names in a stable order, so "the first 500" is the same 500 whatever order
   * the groups happened to load in and whichever run this is. Deduplicated
   * because activation matches by name and two catalog entries may share one.
   */
  #names(): string[] {
    const entries = this.options.tag ? this.#cc.sats.catalog.entriesWithTag(this.options.tag) : this.#cc.sats.catalog.entries;
    // eslint-disable-next-line unicorn/no-array-sort -- already a fresh array
    return [...new Set(entries.map((entry) => entry.name))].sort();
  }

  #scene(enabledSatellites: string[], components: readonly string[]): DesiredScene {
    const station = this.options.groundStation;
    return {
      enabledTags: [],
      enabledSatellites,
      disabledSatellites: [],
      components: [...components],
      groundStations: station ? [{ lat: station.lat, lon: station.lon, name: "Benchmark" }] : [],
      overpassMode: "elevation",
      trackedSatellite: "",
    };
  }

  /** The scene the store currently wants, which is what restore() puts back. */
  #storeScene(): DesiredScene {
    const store = useSatStore();
    return {
      enabledTags: [...store.enabledTags],
      enabledSatellites: [...store.enabledSatellites],
      disabledSatellites: [...store.disabledSatellites],
      components: [...store.enabledComponents],
      groundStations: store.groundStations.map((station) => ({ ...station })),
      overpassMode: store.overpassMode,
      trackedSatellite: store.trackedSatellite,
    };
  }
}