@pix3/vite-plugin 2.0.0-alpha.0 → 2.0.0-alpha.1

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.
@@ -17,6 +17,7 @@ export const PROJECT_SCRIPTS_ID = 'virtual:pix3/project-scripts';
17
17
  export const SPINE_ID = 'virtual:pix3/spine';
18
18
  export const POSTPROCESSING_ID = 'virtual:pix3/postprocessing';
19
19
  export const NETWORK_ID = 'virtual:pix3/network';
20
+ export const DEV_ID = 'virtual:pix3/dev';
20
21
  /** What `postprocessing` resolves to in a single-file build whose scenes place no `PostProcess`. */
21
22
  export const POSTPROCESSING_STUB_ID = 'virtual:pix3/postprocessing-stub';
22
23
  /** What `yaml` resolves to for the runtime when the scenes ship as JSON. */
@@ -30,6 +31,7 @@ export const PLAYER_VIRTUAL_IDS = [
30
31
  SPINE_ID,
31
32
  POSTPROCESSING_ID,
32
33
  NETWORK_ID,
34
+ DEV_ID,
33
35
  POSTPROCESSING_STUB_ID,
34
36
  YAML_STUB_ID,
35
37
  GLTF_LOADER_STUB_ID,
@@ -202,3 +204,51 @@ export const networkSource = (usesNetwork) => usesNetwork
202
204
  'export function installNetworkService() {}',
203
205
  '',
204
206
  ].join('\n');
207
+ /**
208
+ * `virtual:pix3/dev`: the player's dev-only hooks. In dev:
209
+ *
210
+ * - `devSceneOverride()` is the URL's `?scene=` (`res://` stripped), the scene the editor's
211
+ * "Play in Browser Tab / with Profiler" asks for; the player checks it against its scene list;
212
+ * - `installDevTools(context)` imports the debug overlay when the URL has `debug`, otherwise the
213
+ * "Pix3 editor" link (top-level page, editor on, no `?clean`), so a plain `/` costs one small
214
+ * module and `/?clean` none.
215
+ *
216
+ * In a build both are no-ops and nothing of the overlay or the link is bundled (a single-file
217
+ * HTML cannot fetch a chunk anyway). The modules are imported by file path, not by a package
218
+ * subpath: Vite's resolver answers bare specifiers from `exports` before any plugin's
219
+ * `resolveId` runs.
220
+ */
221
+ export const devSource = (mode, options) => {
222
+ if (mode === 'none' || !options) {
223
+ return [
224
+ '// A build has no dev tools: the debug overlay, the editor link and ?scene= are dev-only.',
225
+ 'export function devSceneOverride() { return null; }',
226
+ 'export function installDevTools() {}',
227
+ '',
228
+ ].join('\n');
229
+ }
230
+ return [
231
+ `const editorUrl = ${JSON.stringify(options.editorUrl)};`,
232
+ `const params = new URLSearchParams(location.search);`,
233
+ `const failed = what => error => console.error('[pix3] The ' + what + ' failed to load:', error);`,
234
+ `export function devSceneOverride() {`,
235
+ ` const scene = params.get('scene');`,
236
+ ` return scene ? scene.replace(/^res:\\/\\//i, '').replace(/^\\/+/, '') : null;`,
237
+ `}`,
238
+ `export function installDevTools(context) {`,
239
+ ` if (params.has('debug')) {`,
240
+ ` import(${JSON.stringify(options.overlayPath)}).then(`,
241
+ ` overlay => overlay.mountDebugOverlay(context, { editorUrl }),`,
242
+ ` failed('debug overlay')`,
243
+ ` );`,
244
+ ` return;`,
245
+ ` }`,
246
+ ` if (!editorUrl || window.top !== window || params.has('clean')) return;`,
247
+ ` import(${JSON.stringify(options.editorLinkPath)}).then(`,
248
+ ` link => link.mountEditorLink(editorUrl),`,
249
+ ` failed('editor link')`,
250
+ ` );`,
251
+ `}`,
252
+ '',
253
+ ].join('\n');
254
+ };
@@ -21,33 +21,33 @@ const VITE_CLIENT_PROBE = 'window.__PIX3_VITE_CLIENT__=false;try{performance.set
21
21
  const escapeHtml = (text) => text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`);
22
22
  /** The `<meta>` the page client reads (and removes) the editor session token from. */
23
23
  export const SESSION_META = 'pix3-session';
24
- export const editorPageHtml = (base, options = {}) => `<!doctype html>
25
- <html lang="en">
26
- <head>
27
- <meta charset="utf-8" />
24
+ export const editorPageHtml = (base, options = {}) => `<!doctype html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="utf-8" />
28
28
  <meta name="viewport" content="width=device-width, initial-scale=1" />${options.session
29
29
  ? `\n <meta name="${SESSION_META}" content="${escapeHtml(options.session)}" />`
30
- : ''}
31
- <title>Pix3</title>
32
- <style>html,body,#pix3-editor{margin:0;height:100%}</style>
33
- <script>${VITE_CLIENT_PROBE}</script>${options.css ? `\n <link rel="stylesheet" href="${base}__pix3/editor.css" />` : ''}
34
- </head>
35
- <body>
36
- <div id="pix3-editor"></div>
37
- <script type="module" src="${base}@id/__x00__${EDITOR_HOST_ID}"></script>
38
- </body>
39
- </html>
30
+ : ''}
31
+ <title>Pix3</title>
32
+ <style>html,body,#pix3-editor{margin:0;height:100%}</style>
33
+ <script>${VITE_CLIENT_PROBE}</script>${options.css ? `\n <link rel="stylesheet" href="${base}__pix3/editor.css" />` : ''}
34
+ </head>
35
+ <body>
36
+ <div id="pix3-editor"></div>
37
+ <script type="module" src="${base}@id/__x00__${EDITOR_HOST_ID}"></script>
38
+ </body>
39
+ </html>
40
40
  `;
41
41
  /** What `/__pix3/` shows instead of the editor when the version gate fails (plan §A.3). */
42
- export const versionGatePageHtml = (message) => `<!doctype html>
43
- <html lang="en">
44
- <head><meta charset="utf-8" /><title>Pix3 — version mismatch</title></head>
45
- <body style="font:15px/1.5 system-ui,sans-serif;margin:3rem;max-width:44rem">
46
- <h1 style="font-size:1.4rem">Pix3 cannot open this project yet</h1>
47
- <p>${escapeHtml(message)}</p>
48
- <p>Then restart the dev server.</p>
49
- </body>
50
- </html>
42
+ export const versionGatePageHtml = (message) => `<!doctype html>
43
+ <html lang="en">
44
+ <head><meta charset="utf-8" /><title>Pix3 — version mismatch</title></head>
45
+ <body style="font:15px/1.5 system-ui,sans-serif;margin:3rem;max-width:44rem">
46
+ <h1 style="font-size:1.4rem">Pix3 cannot open this project yet</h1>
47
+ <p>${escapeHtml(message)}</p>
48
+ <p>Then restart the dev server.</p>
49
+ </body>
50
+ </html>
51
51
  `;
52
52
  /**
53
53
  * Source of `virtual:pix3/editor-host`. The script roots are imported statically, so they are in
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { devSceneManifestSource } from "./build/dev-manifest.js";
7
7
  import { finishBuild } from "./build/finish.js";
8
8
  import { libraryStubsPlugin, singleFilePlugins } from "./build/output-plugins.js";
9
9
  import { flushEditor, planBuild, runtimeModulePath, shippedBytes, } from "./build/plan.js";
10
- import { EMBEDDED_ASSETS_ID, embeddedAssetsSource, GLTF_LOADER_STUB_ID, gltfLoaderStubSource, NETWORK_ID, networkSource, noEmbeddedAssetsSource, PLAYER_VIRTUAL_IDS, POSTPROCESSING_ID, POSTPROCESSING_STUB_ID, postprocessingSource, postprocessingStubSource, PROJECT_SCRIPTS_ID, projectScriptsSource, SCENE_MANIFEST_ID, sceneManifestSource, SPINE_ID, spineSource, YAML_STUB_ID, yamlStubSource, } from "./build/player-modules.js";
10
+ import { DEV_ID, devSource, EMBEDDED_ASSETS_ID, embeddedAssetsSource, GLTF_LOADER_STUB_ID, gltfLoaderStubSource, NETWORK_ID, networkSource, noEmbeddedAssetsSource, PLAYER_VIRTUAL_IDS, POSTPROCESSING_ID, POSTPROCESSING_STUB_ID, postprocessingSource, postprocessingStubSource, PROJECT_SCRIPTS_ID, projectScriptsSource, SCENE_MANIFEST_ID, sceneManifestSource, SPINE_ID, spineSource, YAML_STUB_ID, yamlStubSource, } from "./build/player-modules.js";
11
11
  import { normalizeResRoot } from "./build/project-manifest.js";
12
12
  import { buildStrippedModuleSource } from "./build/strippable-runtime-modules.js";
13
13
  import { installedVersion, pluginVersion } from "./dev-info.js";
@@ -33,6 +33,10 @@ const FROM_SOURCE = import.meta.url.endsWith('.ts');
33
33
  const CLIENT_ENTRY = fileURLToPath(new URL(`./client/index.${FROM_SOURCE ? 'ts' : 'js'}`, import.meta.url));
34
34
  /** The player (`player/` beside `src/`; `dist/player/` once built). */
35
35
  const PLAYER_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/index.ts' : './player/index.js', import.meta.url));
36
+ /** The player's dev tools, imported by path from `virtual:pix3/dev`: the debug overlay (`/?debug`)… */
37
+ const DEBUG_OVERLAY_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/debug/index.ts' : './player/debug/index.js', import.meta.url));
38
+ /** …and the "Pix3 editor" link on the game. */
39
+ const EDITOR_LINK_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/dev/editor-link.ts' : './player/dev/editor-link.js', import.meta.url));
36
40
  export function pix3(options = {}) {
37
41
  const settings = {
38
42
  resRoot: normalizeResRoot(options.resRoot ?? '.'),
@@ -222,6 +226,14 @@ export function pix3(options = {}) {
222
226
  return postprocessingStubSource();
223
227
  case NETWORK_ID:
224
228
  return networkSource(build ? build.scan.usesNetwork : true);
229
+ case DEV_ID:
230
+ return config?.command === 'serve'
231
+ ? devSource('dev', {
232
+ overlayPath: normalizePath(DEBUG_OVERLAY_ENTRY),
233
+ editorLinkPath: normalizePath(EDITOR_LINK_ENTRY),
234
+ editorUrl: settings.editor ? `${baseUrl()}__pix3/` : null,
235
+ })
236
+ : devSource('none');
225
237
  default:
226
238
  return null;
227
239
  }
@@ -21,7 +21,7 @@ export const realFileOf = (id) => {
21
21
  let real = realPaths.get(file);
22
22
  if (real === undefined) {
23
23
  try {
24
- real = realpathSync(file);
24
+ real = realpathSync.native(file);
25
25
  }
26
26
  catch {
27
27
  real = file;
@@ -0,0 +1,9 @@
1
+ import type { AudioService, RuntimeRenderer, SceneRunner } from '@pix3/runtime';
2
+ import type { Pix3PlayerState } from '../index';
3
+ /** What the player hands the debug overlay (`virtual:pix3/dev`'s `installDevTools`). */
4
+ export interface Pix3DebugContext {
5
+ readonly runner: SceneRunner;
6
+ readonly renderer: RuntimeRenderer;
7
+ readonly audio: AudioService;
8
+ readonly state: Pix3PlayerState;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ /** What the debug overlay shows about the device (the 1.x remote preview's `device-info`). */
2
+ export interface DeviceInfo {
3
+ readonly userAgent: string;
4
+ /** `UNMASKED_RENDERER_WEBGL` when the browser exposes it, else `RENDERER`. */
5
+ readonly gpu: string | null;
6
+ readonly devicePixelRatio: number;
7
+ readonly screen: string;
8
+ readonly viewport: string;
9
+ /** `navigator.deviceMemory` (Chromium, coarse and capped at 8). */
10
+ readonly memoryGb: number | null;
11
+ readonly cores: number | null;
12
+ }
13
+ export declare const readDeviceInfo: () => DeviceInfo;
14
+ /** Used JS heap in MiB (`performance.memory`, Chromium only). */
15
+ export declare const readHeapMb: () => number | null;
@@ -0,0 +1,36 @@
1
+ /** A throwaway GL context: the game's own context is not touched. */
2
+ const readGpu = () => {
3
+ try {
4
+ const canvas = document.createElement('canvas');
5
+ const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
6
+ if (!gl)
7
+ return null;
8
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
9
+ const renderer = gl.getParameter(debugInfo ? debugInfo.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
10
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
11
+ return typeof renderer === 'string' && renderer.length > 0 ? renderer : null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ };
17
+ let gpu;
18
+ export const readDeviceInfo = () => {
19
+ if (gpu === undefined)
20
+ gpu = readGpu();
21
+ const nav = navigator;
22
+ return {
23
+ userAgent: navigator.userAgent,
24
+ gpu,
25
+ devicePixelRatio: window.devicePixelRatio || 1,
26
+ screen: `${window.screen?.width ?? 0}×${window.screen?.height ?? 0}`,
27
+ viewport: `${window.innerWidth}×${window.innerHeight}`,
28
+ memoryGb: typeof nav.deviceMemory === 'number' ? nav.deviceMemory : null,
29
+ cores: typeof navigator.hardwareConcurrency === 'number' ? navigator.hardwareConcurrency : null,
30
+ };
31
+ };
32
+ /** Used JS heap in MiB (`performance.memory`, Chromium only). */
33
+ export const readHeapMb = () => {
34
+ const used = performance.memory?.usedJSHeapSize;
35
+ return typeof used === 'number' && Number.isFinite(used) ? used / (1024 * 1024) : null;
36
+ };
@@ -0,0 +1,79 @@
1
+ import type { SceneRunnerFrameSample } from '@pix3/runtime';
2
+ /**
3
+ * The debug overlay's frame bookkeeping, DOM-free: a fixed-bucket interval histogram for the
4
+ * session's percentiles, a ring of recent frames for the chart, and a rolling window of averages
5
+ * (frame split, FPS, `FrameProfiler` activities) that the overlay swaps at its text cadence.
6
+ * Everything here is O(1) per frame and allocates nothing per frame.
7
+ */
8
+ /** Frames past this count as hitches: ~2 missed vsyncs at 60 Hz. */
9
+ export declare const LONG_FRAME_MS = 33.4;
10
+ export interface FrameStability {
11
+ readonly sampleCount: number;
12
+ readonly p50Ms: number | null;
13
+ readonly p95Ms: number | null;
14
+ readonly p99Ms: number | null;
15
+ readonly worstMs: number | null;
16
+ /** Frames longer than {@link LONG_FRAME_MS}. */
17
+ readonly longCount: number;
18
+ readonly longPercent: number | null;
19
+ }
20
+ /**
21
+ * 1 ms buckets up to 100 ms plus an overflow bucket (the 1.x Profiler's `FrameIntervalHistogram`):
22
+ * a session can run for hours, so nothing keeps every frame. Percentiles are nearest-rank and
23
+ * report the bucket's upper edge clamped to the worst frame, so they are never optimistic.
24
+ */
25
+ export declare class FrameIntervalHistogram {
26
+ private readonly buckets;
27
+ private count;
28
+ private long;
29
+ private worst;
30
+ reset(): void;
31
+ record(intervalMs: number): void;
32
+ snapshot(): FrameStability;
33
+ private percentile;
34
+ }
35
+ /** The last {@link capacity} frames, split the way the chart stacks them. */
36
+ export declare class FrameRing {
37
+ readonly capacity: number;
38
+ readonly logicMs: Float32Array;
39
+ readonly renderMs: Float32Array;
40
+ /** Frame duration (`dt`), the bar's full height; the rest above logic + render is "not us". */
41
+ readonly frameMs: Float32Array;
42
+ private next;
43
+ private filled;
44
+ constructor(capacity?: number);
45
+ push(logicMs: number, renderMs: number, frameMs: number): void;
46
+ get length(): number;
47
+ /** Ring index of the `i`-th oldest retained frame (`0` = oldest). */
48
+ indexOf(i: number): number;
49
+ clear(): void;
50
+ }
51
+ export interface FrameWindow {
52
+ readonly frames: number;
53
+ /** Frames per second of wall clock in the window (`frames / Σdt`), null when empty. */
54
+ readonly fps: number | null;
55
+ readonly frameMs: number | null;
56
+ readonly logicMs: number | null;
57
+ readonly renderMs: number | null;
58
+ readonly unaccountedMs: number | null;
59
+ readonly rafLatenessMs: number | null;
60
+ /** `FrameProfiler` activities, mean self time per frame, largest first. */
61
+ readonly activities: readonly {
62
+ readonly label: string;
63
+ readonly ms: number;
64
+ }[];
65
+ }
66
+ export declare const EMPTY_WINDOW: FrameWindow;
67
+ /** Sums since the last {@link close}; `close` turns them into a {@link FrameWindow}. */
68
+ export declare class FrameWindowAccumulator {
69
+ private frames;
70
+ private dtSec;
71
+ private logic;
72
+ private render;
73
+ private unaccounted;
74
+ private lateness;
75
+ private readonly activitySums;
76
+ add(sample: SceneRunnerFrameSample): void;
77
+ close(): FrameWindow;
78
+ private addActivities;
79
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The debug overlay's frame bookkeeping, DOM-free: a fixed-bucket interval histogram for the
3
+ * session's percentiles, a ring of recent frames for the chart, and a rolling window of averages
4
+ * (frame split, FPS, `FrameProfiler` activities) that the overlay swaps at its text cadence.
5
+ * Everything here is O(1) per frame and allocates nothing per frame.
6
+ */
7
+ /** Frames past this count as hitches: ~2 missed vsyncs at 60 Hz. */
8
+ export const LONG_FRAME_MS = 33.4;
9
+ const HISTOGRAM_MAX_MS = 100;
10
+ /**
11
+ * 1 ms buckets up to 100 ms plus an overflow bucket (the 1.x Profiler's `FrameIntervalHistogram`):
12
+ * a session can run for hours, so nothing keeps every frame. Percentiles are nearest-rank and
13
+ * report the bucket's upper edge clamped to the worst frame, so they are never optimistic.
14
+ */
15
+ export class FrameIntervalHistogram {
16
+ buckets = new Uint32Array(HISTOGRAM_MAX_MS + 1);
17
+ count = 0;
18
+ long = 0;
19
+ worst = 0;
20
+ reset() {
21
+ this.buckets.fill(0);
22
+ this.count = 0;
23
+ this.long = 0;
24
+ this.worst = 0;
25
+ }
26
+ record(intervalMs) {
27
+ if (!Number.isFinite(intervalMs) || intervalMs < 0)
28
+ return;
29
+ this.buckets[Math.min(Math.floor(intervalMs), HISTOGRAM_MAX_MS)] += 1;
30
+ this.count += 1;
31
+ if (intervalMs > this.worst)
32
+ this.worst = intervalMs;
33
+ if (intervalMs > LONG_FRAME_MS)
34
+ this.long += 1;
35
+ }
36
+ snapshot() {
37
+ const empty = this.count === 0;
38
+ return {
39
+ sampleCount: this.count,
40
+ p50Ms: this.percentile(50),
41
+ p95Ms: this.percentile(95),
42
+ p99Ms: this.percentile(99),
43
+ worstMs: empty ? null : this.worst,
44
+ longCount: this.long,
45
+ longPercent: empty ? null : (this.long / this.count) * 100,
46
+ };
47
+ }
48
+ percentile(percent) {
49
+ if (this.count === 0)
50
+ return null;
51
+ const rank = Math.max(1, Math.ceil((percent / 100) * this.count));
52
+ let cumulative = 0;
53
+ for (let bucket = 0; bucket < this.buckets.length; bucket += 1) {
54
+ cumulative += this.buckets[bucket];
55
+ if (cumulative >= rank) {
56
+ // The overflow bucket has no upper edge: the worst frame is the only honest answer.
57
+ return bucket === HISTOGRAM_MAX_MS ? this.worst : Math.min(bucket + 1, this.worst);
58
+ }
59
+ }
60
+ return this.worst;
61
+ }
62
+ }
63
+ /** The last {@link capacity} frames, split the way the chart stacks them. */
64
+ export class FrameRing {
65
+ capacity;
66
+ logicMs;
67
+ renderMs;
68
+ /** Frame duration (`dt`), the bar's full height; the rest above logic + render is "not us". */
69
+ frameMs;
70
+ next = 0;
71
+ filled = 0;
72
+ constructor(capacity = 240) {
73
+ this.capacity = capacity;
74
+ this.logicMs = new Float32Array(capacity);
75
+ this.renderMs = new Float32Array(capacity);
76
+ this.frameMs = new Float32Array(capacity);
77
+ }
78
+ push(logicMs, renderMs, frameMs) {
79
+ this.logicMs[this.next] = logicMs;
80
+ this.renderMs[this.next] = renderMs;
81
+ this.frameMs[this.next] = frameMs;
82
+ this.next = (this.next + 1) % this.capacity;
83
+ this.filled = Math.min(this.filled + 1, this.capacity);
84
+ }
85
+ get length() {
86
+ return this.filled;
87
+ }
88
+ /** Ring index of the `i`-th oldest retained frame (`0` = oldest). */
89
+ indexOf(i) {
90
+ return (this.next - this.filled + i + this.capacity) % this.capacity;
91
+ }
92
+ clear() {
93
+ this.next = 0;
94
+ this.filled = 0;
95
+ }
96
+ }
97
+ export const EMPTY_WINDOW = Object.freeze({
98
+ frames: 0,
99
+ fps: null,
100
+ frameMs: null,
101
+ logicMs: null,
102
+ renderMs: null,
103
+ unaccountedMs: null,
104
+ rafLatenessMs: null,
105
+ activities: Object.freeze([]),
106
+ });
107
+ /** Sums since the last {@link close}; `close` turns them into a {@link FrameWindow}. */
108
+ export class FrameWindowAccumulator {
109
+ frames = 0;
110
+ dtSec = 0;
111
+ logic = 0;
112
+ render = 0;
113
+ unaccounted = 0;
114
+ lateness = 0;
115
+ activitySums = new Map();
116
+ add(sample) {
117
+ this.frames += 1;
118
+ this.dtSec += sample.dt;
119
+ this.logic += sample.logicMs;
120
+ this.render += sample.renderMs;
121
+ this.unaccounted += sample.unaccountedMs;
122
+ this.lateness += sample.rafLatenessMs;
123
+ if (sample.profilerActivities)
124
+ this.addActivities(sample.profilerActivities);
125
+ }
126
+ close() {
127
+ const n = this.frames;
128
+ if (n === 0)
129
+ return EMPTY_WINDOW;
130
+ const activities = [...this.activitySums]
131
+ .map(([label, sum]) => ({ label, ms: sum / n }))
132
+ .sort((a, b) => b.ms - a.ms);
133
+ const window = {
134
+ frames: n,
135
+ fps: this.dtSec > 0 ? n / this.dtSec : null,
136
+ frameMs: (this.dtSec * 1000) / n,
137
+ logicMs: this.logic / n,
138
+ renderMs: this.render / n,
139
+ unaccountedMs: this.unaccounted / n,
140
+ rafLatenessMs: this.lateness / n,
141
+ activities,
142
+ };
143
+ this.frames = 0;
144
+ this.dtSec = 0;
145
+ this.logic = 0;
146
+ this.render = 0;
147
+ this.unaccounted = 0;
148
+ this.lateness = 0;
149
+ this.activitySums.clear();
150
+ return window;
151
+ }
152
+ addActivities(activities) {
153
+ for (const activity of activities) {
154
+ if (!Number.isFinite(activity.selfTimeMs))
155
+ continue;
156
+ this.activitySums.set(activity.label, (this.activitySums.get(activity.label) ?? 0) + activity.selfTimeMs);
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,10 @@
1
+ import type { Pix3DebugContext } from './context';
2
+ /**
3
+ * Debug mode (`/?debug` on the dev server; `virtual:pix3/dev` imports this only then, and a
4
+ * build never). Mounts the overlay over the running game and exposes
5
+ * `__PIX3_PLAYER__.debug.snapshot()` for an agent. `editorUrl` (null with `editor: false`) adds
6
+ * the header's Editor button. Returns the teardown.
7
+ */
8
+ export declare function mountDebugOverlay(context: Pix3DebugContext, options?: {
9
+ readonly editorUrl?: string | null;
10
+ }): () => void;
@@ -0,0 +1,20 @@
1
+ import { DebugOverlay } from './overlay';
2
+ import { DebugSession } from './session';
3
+ /**
4
+ * Debug mode (`/?debug` on the dev server; `virtual:pix3/dev` imports this only then, and a
5
+ * build never). Mounts the overlay over the running game and exposes
6
+ * `__PIX3_PLAYER__.debug.snapshot()` for an agent. `editorUrl` (null with `editor: false`) adds
7
+ * the header's Editor button. Returns the teardown.
8
+ */
9
+ export function mountDebugOverlay(context, options = {}) {
10
+ const session = new DebugSession(context);
11
+ session.start();
12
+ const overlay = new DebugOverlay(session, options.editorUrl ?? null);
13
+ overlay.mount();
14
+ context.state.debug = { snapshot: () => session.snapshot() };
15
+ return () => {
16
+ overlay.dispose();
17
+ session.dispose();
18
+ delete context.state.debug;
19
+ };
20
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The browser's **Long Animation Frame** entries (LoAF, Chromium), a port of the 1.x Profiler's
3
+ * `LongAnimationFrameObserver`. The runner's own stopwatch only sees its logic and paint; LoAF is
4
+ * the browser's view of the same frame and the only source that says what else ran. LoAF
5
+ * attributes only scripts that ran longer than ~5 ms, so a long frame with no script is a finding
6
+ * (late delivery, compositing, GC), not a gap: it is kept as `script: null`.
7
+ */
8
+ /** One `scripts[]` entry of a LoAF timing (TS's DOM lib has no LoAF types yet). */
9
+ interface LoafScriptLike {
10
+ readonly sourceURL?: string;
11
+ readonly sourceFunctionName?: string;
12
+ readonly duration?: number;
13
+ readonly invoker?: string;
14
+ }
15
+ interface LoafEntryLike {
16
+ readonly duration?: number;
17
+ readonly blockingDuration?: number;
18
+ readonly scripts?: readonly LoafScriptLike[];
19
+ }
20
+ export interface LongFrameRecord {
21
+ readonly durationMs: number;
22
+ readonly blockingMs: number;
23
+ /** `basename.js · function` of the longest attributed script, or null: none was attributed. */
24
+ readonly script: string | null;
25
+ }
26
+ export interface LongFrameStats {
27
+ /** False where the browser has no `long-animation-frame` entries (Firefox, Safari). */
28
+ readonly supported: boolean;
29
+ readonly count: number;
30
+ /** The worst few of the session, longest first. */
31
+ readonly worst: readonly LongFrameRecord[];
32
+ }
33
+ export declare class LongFrameObserver {
34
+ readonly supported: boolean;
35
+ private observer;
36
+ private count;
37
+ private worst;
38
+ constructor();
39
+ start(): void;
40
+ stop(): void;
41
+ reset(): void;
42
+ stats(): LongFrameStats;
43
+ /** One entry, as the observer's callback hands it over (public for the spec). */
44
+ record(entry: LoafEntryLike): void;
45
+ }
46
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The browser's **Long Animation Frame** entries (LoAF, Chromium), a port of the 1.x Profiler's
3
+ * `LongAnimationFrameObserver`. The runner's own stopwatch only sees its logic and paint; LoAF is
4
+ * the browser's view of the same frame and the only source that says what else ran. LoAF
5
+ * attributes only scripts that ran longer than ~5 ms, so a long frame with no script is a finding
6
+ * (late delivery, compositing, GC), not a gap: it is kept as `script: null`.
7
+ */
8
+ const KEEP_WORST = 4;
9
+ export class LongFrameObserver {
10
+ supported;
11
+ observer = null;
12
+ count = 0;
13
+ worst = [];
14
+ constructor() {
15
+ const types = typeof PerformanceObserver === 'undefined'
16
+ ? undefined
17
+ : PerformanceObserver.supportedEntryTypes;
18
+ this.supported = Array.isArray(types) && types.includes('long-animation-frame');
19
+ }
20
+ start() {
21
+ if (!this.supported || this.observer)
22
+ return;
23
+ this.observer = new PerformanceObserver(list => {
24
+ for (const entry of list.getEntries())
25
+ this.record(entry);
26
+ });
27
+ this.observer.observe({ type: 'long-animation-frame' });
28
+ }
29
+ stop() {
30
+ this.observer?.disconnect();
31
+ this.observer = null;
32
+ }
33
+ reset() {
34
+ this.count = 0;
35
+ this.worst = [];
36
+ }
37
+ stats() {
38
+ return { supported: this.supported, count: this.count, worst: [...this.worst] };
39
+ }
40
+ /** One entry, as the observer's callback hands it over (public for the spec). */
41
+ record(entry) {
42
+ const durationMs = typeof entry.duration === 'number' ? entry.duration : 0;
43
+ if (!Number.isFinite(durationMs) || durationMs <= 0)
44
+ return;
45
+ this.count += 1;
46
+ const record = {
47
+ durationMs,
48
+ blockingMs: Number.isFinite(entry.blockingDuration) ? entry.blockingDuration : 0,
49
+ script: describeScript(longestScript(entry.scripts)),
50
+ };
51
+ const at = this.worst.findIndex(existing => durationMs > existing.durationMs);
52
+ if (at === -1) {
53
+ if (this.worst.length < KEEP_WORST)
54
+ this.worst.push(record);
55
+ return;
56
+ }
57
+ this.worst.splice(at, 0, record);
58
+ if (this.worst.length > KEEP_WORST)
59
+ this.worst.length = KEEP_WORST;
60
+ }
61
+ }
62
+ const longestScript = (scripts) => {
63
+ let longest = null;
64
+ for (const script of scripts ?? []) {
65
+ if (!longest || (script.duration ?? 0) > (longest.duration ?? 0))
66
+ longest = script;
67
+ }
68
+ return longest;
69
+ };
70
+ const describeScript = (script) => {
71
+ if (!script)
72
+ return null;
73
+ const url = script.sourceURL?.trim().split(/[?#]/)[0];
74
+ const basename = url ? url.slice(url.lastIndexOf('/') + 1) || url : null;
75
+ const fn = script.sourceFunctionName?.trim();
76
+ if (basename && fn)
77
+ return `${basename} · ${fn}`;
78
+ return basename || fn || script.invoker?.trim() || null;
79
+ };
@@ -0,0 +1,25 @@
1
+ import type { DebugSession } from './session';
2
+ export declare class DebugOverlay {
3
+ private readonly session;
4
+ private readonly editorUrl;
5
+ private readonly host;
6
+ private readonly root;
7
+ private readonly panel;
8
+ private readonly canvas;
9
+ private readonly fps;
10
+ private readonly sections;
11
+ private readonly pauseButton;
12
+ private readonly stepButton;
13
+ private readonly collapseButton;
14
+ private timer;
15
+ private ticks;
16
+ constructor(session: DebugSession, editorUrl?: string | null);
17
+ mount(): void;
18
+ dispose(): void;
19
+ /** The timer: the chart every tick; every {@link TEXT_EVERY}th, a new window and the text. */
20
+ private tick;
21
+ private render;
22
+ private renderHeader;
23
+ private readonly onClick;
24
+ private setCollapsed;
25
+ }