sloptimize 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/sloptimize.mjs +346 -0
- package/docs/DESIGN-mecharoyale-v0.md +32 -0
- package/docs/INTEGRATION.md +230 -0
- package/docs/JITTER-AND-FOOTPRINTS.md +236 -0
- package/docs/SPEC-attach.md +176 -0
- package/docs/SPEC.md +845 -0
- package/docs/USAGE.md +227 -0
- package/hooks/hooks.json +16 -0
- package/mcp/server.mjs +127 -0
- package/package.json +64 -0
- package/skills/install/SKILL.md +143 -0
- package/skills/sloptimize/SKILL.md +74 -0
- package/src/attach.mjs +197 -0
- package/src/census.js +193 -0
- package/src/classify.js +70 -0
- package/src/footprint.js +170 -0
- package/src/history.js +273 -0
- package/src/index.js +8 -0
- package/src/inject-body.js +152 -0
- package/src/motion.js +345 -0
- package/src/panel.js +530 -0
- package/src/proposals.mjs +268 -0
- package/src/recorder.js +235 -0
- package/src/watch.mjs +242 -0
package/src/attach.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// attach.mjs — tier 0: attach to a browser, inject, record (SPEC-attach)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Raw CDP over Node's built-in WebSocket — no dependencies, the package
|
|
5
|
+
// posture. Owns: injection (classify.js + inject-body.js concatenated into
|
|
6
|
+
// one IIFE), the emit binding, the rolling sampling profiler, incident
|
|
7
|
+
// CLUSTERING (M-A1: one cause = one cluster, however often it fires), and
|
|
8
|
+
// the .sloptimize/ files.
|
|
9
|
+
import { readFileSync, mkdirSync, writeFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
export function buildInjectScript() {
|
|
17
|
+
const classify = readFileSync(join(SRC, 'classify.js'), 'utf8').replace(/^export /gm, '');
|
|
18
|
+
const body = readFileSync(join(SRC, 'inject-body.js'), 'utf8');
|
|
19
|
+
return `(() => {\n${classify}\n${body}\n})();`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** M-A1 — incident identity. One CAUSE investigates once: cluster key is the
|
|
23
|
+
* classification plus the top attributed frame (or creation-stack head);
|
|
24
|
+
* repeats increment a count instead of re-waking anyone. */
|
|
25
|
+
export function clusterKey(rec, topFrame) {
|
|
26
|
+
const guess = rec.classification && rec.classification[0] ? rec.classification[0].guess : rec.type;
|
|
27
|
+
return `${guess}|${topFrame ?? ''}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Top self-time frames from a CDP Profiler.stop payload, idle/program
|
|
31
|
+
* filtered, heaviest first. Pure — unit-tested against a fixture. */
|
|
32
|
+
export function topFramesFromProfile(profile, limit = 5) {
|
|
33
|
+
if (!profile || !profile.nodes) return [];
|
|
34
|
+
const self = new Map();
|
|
35
|
+
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
|
|
36
|
+
const samples = profile.samples ?? [];
|
|
37
|
+
const deltas = profile.timeDeltas ?? [];
|
|
38
|
+
for (let i = 0; i < samples.length; i++) {
|
|
39
|
+
const us = deltas[i] ?? 0;
|
|
40
|
+
self.set(samples[i], (self.get(samples[i]) ?? 0) + us);
|
|
41
|
+
}
|
|
42
|
+
const rows = [];
|
|
43
|
+
for (const [id, us] of self) {
|
|
44
|
+
const n = byId.get(id);
|
|
45
|
+
if (!n) continue;
|
|
46
|
+
const f = n.callFrame ?? {};
|
|
47
|
+
if (f.functionName === '(idle)' || f.functionName === '(program)' || f.functionName === '(garbage collector)') continue;
|
|
48
|
+
rows.push({
|
|
49
|
+
fn: f.functionName || '(anonymous)',
|
|
50
|
+
url: f.url ? `${f.url.split('/').slice(-1)[0]}:${(f.lineNumber ?? 0) + 1}` : '',
|
|
51
|
+
selfMs: +(us / 1000).toFixed(1),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
rows.sort((a, b) => b.selfMs - a.selfMs);
|
|
55
|
+
return rows.slice(0, limit);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function discoverTarget(port) {
|
|
59
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
60
|
+
const targets = await res.json();
|
|
61
|
+
const page = targets.find((t) => t.type === 'page' && !t.url.startsWith('devtools'));
|
|
62
|
+
if (!page) throw new Error('no page target — is a tab open?');
|
|
63
|
+
return page.webSocketDebuggerUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function attach(opts = {}) {
|
|
67
|
+
const port = opts.port ?? 9222;
|
|
68
|
+
const dir = opts.dir ?? '.sloptimize';
|
|
69
|
+
const log = opts.log ?? ((...a) => console.log('[attach]', ...a));
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
let child = null;
|
|
73
|
+
if (opts.launch) {
|
|
74
|
+
const bin = process.env.SLOPTIMIZE_BROWSER
|
|
75
|
+
?? ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome'].find(existsSync);
|
|
76
|
+
if (!bin) throw new Error('no browser found — set SLOPTIMIZE_BROWSER');
|
|
77
|
+
child = spawn(bin, [`--remote-debugging-port=${port}`, '--no-first-run',
|
|
78
|
+
...(opts.headless ? ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--enable-unsafe-swiftshader', '--use-angle=swiftshader'] : []),
|
|
79
|
+
opts.launch], { stdio: 'ignore' });
|
|
80
|
+
log(`launched ${bin} → ${opts.launch}`);
|
|
81
|
+
for (let i = 0; i < 50; i++) {
|
|
82
|
+
try { await discoverTarget(port); break; } catch { await new Promise((r) => setTimeout(r, 300)); }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const wsUrl = await discoverTarget(port);
|
|
87
|
+
const ws = new WebSocket(wsUrl);
|
|
88
|
+
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
89
|
+
let seq = 0;
|
|
90
|
+
const pending = new Map();
|
|
91
|
+
const send = (method, params = {}) => new Promise((res, rej) => {
|
|
92
|
+
const id = ++seq;
|
|
93
|
+
pending.set(id, { res, rej });
|
|
94
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── State: clusters + last profile chunk ──
|
|
98
|
+
const clusters = new Map(); // key → {count, firstAt, lastAt, sample}
|
|
99
|
+
let lastCreateStackHead = null;
|
|
100
|
+
let profiling = false;
|
|
101
|
+
|
|
102
|
+
async function rotateProfile() {
|
|
103
|
+
if (!profiling) return null;
|
|
104
|
+
try {
|
|
105
|
+
const { profile } = await send('Profiler.stop');
|
|
106
|
+
await send('Profiler.start');
|
|
107
|
+
return profile;
|
|
108
|
+
} catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function onRecord(rec) {
|
|
112
|
+
if (rec.type === 'gpu-create') {
|
|
113
|
+
lastCreateStackHead = (rec.stack || '').split('\n')[0]?.trim() ?? null;
|
|
114
|
+
appendFileSync(join(dir, 'perf.jsonl'), JSON.stringify(rec) + '\n');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (rec.type === 'profile') {
|
|
118
|
+
writeFileSync(join(dir, 'profile.json'), JSON.stringify({ ...rec, regime: opts.headless ? 'software' : 'unknown', at: new Date().toISOString() }, null, 2));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (rec.type === 'hitch') {
|
|
122
|
+
// Attribute: grab the current profiler chunk and take the heaviest
|
|
123
|
+
// frames. The chunk spans up to the rotation window, so a freeze that
|
|
124
|
+
// dominated its window names itself; the caveat rides the record.
|
|
125
|
+
const profile = await rotateProfile();
|
|
126
|
+
rec.topFrames = topFramesFromProfile(profile);
|
|
127
|
+
rec.profileWindow = 'rolling-chunk';
|
|
128
|
+
const guess = rec.classification?.[0]?.guess;
|
|
129
|
+
const top = guess === 'shader-compile' ? lastCreateStackHead
|
|
130
|
+
: rec.topFrames[0] ? `${rec.topFrames[0].fn}@${rec.topFrames[0].url}` : null;
|
|
131
|
+
let key = clusterKey(rec, top);
|
|
132
|
+
// MERGE before minting (M-A1): if any existing cluster's identifying
|
|
133
|
+
// frame appears anywhere in this hitch's top frames, this is the same
|
|
134
|
+
// cause seen from a different leaf — V8 inlining moves the hot function
|
|
135
|
+
// into its caller between occurrences (measured on the exit fixture:
|
|
136
|
+
// freeze #1 named seededFreezeWork, freeze #2 arrived as its caller).
|
|
137
|
+
// Inlining that erases the frame ENTIRELY still splits a cause in two;
|
|
138
|
+
// stated in the spec as a standing limit, not papered over.
|
|
139
|
+
if (!clusters.has(key)) {
|
|
140
|
+
const names = new Set((rec.topFrames ?? []).slice(0, 3).map((f) => `${f.fn}@${f.url}`));
|
|
141
|
+
for (const existing of clusters.keys()) {
|
|
142
|
+
const frame = existing.split('|')[1];
|
|
143
|
+
if (frame && names.has(frame)) { key = existing; break; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const c = clusters.get(key);
|
|
147
|
+
if (c) {
|
|
148
|
+
c.count++; c.lastAt = rec.at;
|
|
149
|
+
rec.cluster = { key, count: c.count, new: false };
|
|
150
|
+
} else {
|
|
151
|
+
clusters.set(key, { count: 1, firstAt: rec.at, lastAt: rec.at, sample: rec });
|
|
152
|
+
rec.cluster = { key, count: 1, new: true };
|
|
153
|
+
// The PUSH edge: only a NEW cause reaches stdout (the agent's wake
|
|
154
|
+
// line) — M-A1's exit criterion made mechanical.
|
|
155
|
+
log(`INCIDENT ${key} — ${rec.frameMs}ms, top: ${top ?? 'unattributed'}`);
|
|
156
|
+
}
|
|
157
|
+
appendFileSync(join(dir, 'perf.jsonl'), JSON.stringify(rec) + '\n');
|
|
158
|
+
writeFileSync(join(dir, 'clusters.json'), JSON.stringify([...clusters.entries()].map(([k, v]) => ({ key: k, count: v.count, firstAt: v.firstAt, lastAt: v.lastAt })), null, 2));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
appendFileSync(join(dir, 'perf.jsonl'), JSON.stringify(rec) + '\n');
|
|
162
|
+
if (rec.type === 'armed') log(`recorder armed in page: ${rec.url}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ws.onmessage = (ev) => {
|
|
166
|
+
const msg = JSON.parse(ev.data);
|
|
167
|
+
if (msg.id && pending.has(msg.id)) {
|
|
168
|
+
const { res, rej } = pending.get(msg.id);
|
|
169
|
+
pending.delete(msg.id);
|
|
170
|
+
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (msg.method === 'Runtime.bindingCalled' && msg.params.name === '__sloptimizeEmit') {
|
|
174
|
+
try { void onRecord(JSON.parse(msg.params.payload)); } catch { /* one bad record */ }
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await send('Runtime.enable');
|
|
179
|
+
await send('Page.enable');
|
|
180
|
+
await send('Runtime.addBinding', { name: '__sloptimizeEmit' });
|
|
181
|
+
await send('Page.addScriptToEvaluateOnNewDocument', { source: buildInjectScript() });
|
|
182
|
+
await send('Profiler.enable');
|
|
183
|
+
await send('Profiler.setSamplingInterval', { interval: 500 });
|
|
184
|
+
await send('Profiler.start');
|
|
185
|
+
profiling = true;
|
|
186
|
+
// The injection applies to NAVIGATIONS — a page that was already loading
|
|
187
|
+
// when we attached (the --launch race) never runs it. One reload closes
|
|
188
|
+
// that hole deterministically; dev pages reload for a living.
|
|
189
|
+
if (opts.navigate) await send('Page.navigate', { url: opts.navigate });
|
|
190
|
+
else await send('Page.reload', { ignoreCache: false });
|
|
191
|
+
log(`attached on :${port} — recorder injected; profiler rolling`);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
close: async () => { try { ws.close(); } catch { /* done */ } if (child) child.kill(); },
|
|
195
|
+
clusters,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/census.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// census.js — the static per-entity cost census (SPEC §4.1)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// A scene walk, on demand, never per-frame. The host hands it entity roots
|
|
5
|
+
// (id + Object3D); everything else is read off the graph. Draw calls are a
|
|
6
|
+
// runtime fact and are reported as `null` here — profile.json carries the
|
|
7
|
+
// measured number (principle 4).
|
|
8
|
+
|
|
9
|
+
const INSTANCING_MIN_COUNT = 20; // N same-geometry+material meshes worth a hint
|
|
10
|
+
const MATERIAL_DEDUP_MIN = 8;
|
|
11
|
+
const OVERSIZED_TEXTURE_DIM = 4096;
|
|
12
|
+
|
|
13
|
+
function triCount(geometry) {
|
|
14
|
+
if (!geometry) return 0;
|
|
15
|
+
// drawRange first: a pooled/merged mesh trims what it actually submits via
|
|
16
|
+
// setDrawRange, and counting the full buffer bills the GPU for triangles it
|
|
17
|
+
// never sees — the first field census read a 2-mesh corpse pool as 524k
|
|
18
|
+
// tris this way. Infinity (the default) falls through to the buffer size.
|
|
19
|
+
const range = geometry.drawRange;
|
|
20
|
+
const full = geometry.index ? geometry.index.count
|
|
21
|
+
: (geometry.attributes && geometry.attributes.position ? geometry.attributes.position.count : 0);
|
|
22
|
+
const used = range && range.count !== Infinity ? Math.min(range.count, full) : full;
|
|
23
|
+
return Math.floor(used / 3);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function materialKey(m) {
|
|
27
|
+
// Identical-parameter detection without serializing textures: type + the
|
|
28
|
+
// scalar/color params that force separate programs or batches.
|
|
29
|
+
try {
|
|
30
|
+
return [m.type, m.color?.getHex?.() ?? '', m.roughness ?? '', m.metalness ?? '',
|
|
31
|
+
m.map?.uuid ?? '', m.transparent ?? false, m.side ?? 0].join('|');
|
|
32
|
+
} catch { return m.uuid; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walkEntity(root) {
|
|
36
|
+
const out = {
|
|
37
|
+
meshes: 0, visibleMeshes: 0, instancedMeshes: 0, triangles: 0,
|
|
38
|
+
visibleTriangles: 0, castShadow: 0,
|
|
39
|
+
materials: new Map(), geometries: new Map(), pairs: new Map(),
|
|
40
|
+
matParams: new Map(), textures: new Map(),
|
|
41
|
+
};
|
|
42
|
+
// Visibility is INHERITED: an invisible group's children never draw
|
|
43
|
+
// whatever their own flag says, and a pooled mesh parked with
|
|
44
|
+
// visible=false costs no draw call. The census reports both counts —
|
|
45
|
+
// total (memory/census weight) and visible (draw weight) — and the
|
|
46
|
+
// instancing hint keys on the VISIBLE count, because sending an agent to
|
|
47
|
+
// instance a parked pool is a guess dressed as a measurement.
|
|
48
|
+
const stack = [[root, root.visible !== false]];
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const [node, parentVisible] = stack.pop();
|
|
51
|
+
if (!node) continue;
|
|
52
|
+
const vis = parentVisible && node.visible !== false;
|
|
53
|
+
if (node.isMesh) {
|
|
54
|
+
const inst = !!node.isInstancedMesh;
|
|
55
|
+
out.meshes++;
|
|
56
|
+
if (vis) out.visibleMeshes++;
|
|
57
|
+
if (inst) out.instancedMeshes++;
|
|
58
|
+
// For InstancedMesh, .count IS the drawn instance count (the host's
|
|
59
|
+
// pools shrink it via setInstancedDrawCount), so an empty pool
|
|
60
|
+
// multiplies by ~0 rather than by its capacity.
|
|
61
|
+
const tris = triCount(node.geometry) * (inst ? (node.count ?? 1) : 1);
|
|
62
|
+
out.triangles += tris;
|
|
63
|
+
if (vis) out.visibleTriangles += tris;
|
|
64
|
+
if (node.castShadow) out.castShadow++;
|
|
65
|
+
const mats = Array.isArray(node.material) ? node.material : [node.material];
|
|
66
|
+
for (const m of mats) {
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
out.materials.set(m.uuid, m);
|
|
69
|
+
const pk = materialKey(m);
|
|
70
|
+
out.matParams.set(pk, (out.matParams.get(pk) ?? 0) + 1);
|
|
71
|
+
for (const slot of ['map', 'normalMap', 'roughnessMap', 'emissiveMap', 'aoMap']) {
|
|
72
|
+
const tex = m[slot];
|
|
73
|
+
if (tex && tex.image) out.textures.set(tex.uuid, tex);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (node.geometry) {
|
|
77
|
+
out.geometries.set(node.geometry.uuid, node.geometry);
|
|
78
|
+
const m0 = mats[0];
|
|
79
|
+
const pairKey = `${node.geometry.uuid}|${m0 ? m0.uuid : ''}`;
|
|
80
|
+
const p = out.pairs.get(pairKey) ?? { geometry: node.geometry.uuid, material: m0?.uuid ?? '', count: 0, visibleCount: 0, instanced: false };
|
|
81
|
+
p.count += 1;
|
|
82
|
+
if (vis) p.visibleCount += 1;
|
|
83
|
+
p.instanced = p.instanced || inst;
|
|
84
|
+
out.pairs.set(pairKey, p);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const kids = node.children;
|
|
88
|
+
if (kids) for (let i = 0; i < kids.length; i++) stack.push([kids[i], vis]);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function textureBytesEstimate(textures) {
|
|
94
|
+
let bytes = 0;
|
|
95
|
+
for (const t of textures.values()) {
|
|
96
|
+
const img = t.image;
|
|
97
|
+
const w = img?.width ?? 0, h = img?.height ?? 0;
|
|
98
|
+
bytes += w * h * 4 * (t.generateMipmaps === false ? 1 : 1.33);
|
|
99
|
+
}
|
|
100
|
+
return Math.round(bytes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {object} opts
|
|
105
|
+
* @param {{id:string, root:object, persistent?:boolean}[]} opts.entities
|
|
106
|
+
* @param {{geometries:number,textures:number}} [opts.previousTotals]
|
|
107
|
+
*/
|
|
108
|
+
export function buildCensus(opts) {
|
|
109
|
+
const entities = [];
|
|
110
|
+
const hints = [];
|
|
111
|
+
const totals = { calls: null, meshes: 0, triangles: 0, uniqueMaterials: 0, uniqueGeometries: 0, textureBytesEstimate: 0, geometries: 0, textures: 0 };
|
|
112
|
+
const allMats = new Set(); const allGeos = new Set(); const allTex = new Set();
|
|
113
|
+
|
|
114
|
+
for (const ent of opts.entities ?? []) {
|
|
115
|
+
const w = walkEntity(ent.root);
|
|
116
|
+
const sharedGroups = [...w.pairs.values()].filter((p) => p.count >= 2)
|
|
117
|
+
.sort((a, b) => b.count - a.count).slice(0, 10);
|
|
118
|
+
const row = {
|
|
119
|
+
id: ent.id,
|
|
120
|
+
meshes: w.meshes,
|
|
121
|
+
visibleMeshes: w.visibleMeshes,
|
|
122
|
+
instancedMeshes: w.instancedMeshes,
|
|
123
|
+
triangles: w.triangles,
|
|
124
|
+
visibleTriangles: w.visibleTriangles,
|
|
125
|
+
uniqueMaterials: w.materials.size,
|
|
126
|
+
uniqueGeometries: w.geometries.size,
|
|
127
|
+
sharedGeometryGroups: sharedGroups,
|
|
128
|
+
textureBytesEstimate: textureBytesEstimate(w.textures),
|
|
129
|
+
castShadow: w.castShadow,
|
|
130
|
+
visible: ent.root.visible !== false,
|
|
131
|
+
persistent: ent.persistent ?? true,
|
|
132
|
+
};
|
|
133
|
+
entities.push(row);
|
|
134
|
+
totals.meshes += w.meshes;
|
|
135
|
+
totals.triangles += w.triangles;
|
|
136
|
+
for (const u of w.materials.keys()) allMats.add(u);
|
|
137
|
+
for (const u of w.geometries.keys()) allGeos.add(u);
|
|
138
|
+
for (const u of w.textures.keys()) allTex.add(u);
|
|
139
|
+
totals.textureBytesEstimate += row.textureBytesEstimate;
|
|
140
|
+
|
|
141
|
+
// ── hints (closed vocabulary, SPEC §4.1) ──
|
|
142
|
+
for (const grp of sharedGroups) {
|
|
143
|
+
// Keyed on VISIBLE members: a parked pool (visible=false) draws
|
|
144
|
+
// nothing, and hinting it would send the loop after a non-cost. The
|
|
145
|
+
// pool still shows in the counts above; it is just not a draw-call fix.
|
|
146
|
+
if (!grp.instanced && (grp.visibleCount ?? grp.count) >= INSTANCING_MIN_COUNT) {
|
|
147
|
+
hints.push({
|
|
148
|
+
kind: 'instancing-candidate',
|
|
149
|
+
entity: ent.id,
|
|
150
|
+
detail: `${grp.visibleCount} visible meshes share one geometry+material and are not instanced (${grp.count} incl. pooled)`,
|
|
151
|
+
estimate: { callsSavedAtLeast: (grp.visibleCount ?? grp.count) - 1 },
|
|
152
|
+
fix: `merge into one InstancedMesh at the construction site of ${ent.id}`,
|
|
153
|
+
});
|
|
154
|
+
break; // one per entity — the top group carries the point
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [, n] of w.matParams) {
|
|
158
|
+
if (n >= MATERIAL_DEDUP_MIN && w.materials.size > 1) {
|
|
159
|
+
hints.push({
|
|
160
|
+
kind: 'material-dedup-candidate',
|
|
161
|
+
entity: ent.id,
|
|
162
|
+
detail: `${n} materials share identical parameters — one shared material batches them`,
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const t of w.textures.values()) {
|
|
168
|
+
const img = t.image;
|
|
169
|
+
if (img && (img.width > OVERSIZED_TEXTURE_DIM || img.height > OVERSIZED_TEXTURE_DIM)) {
|
|
170
|
+
hints.push({ kind: 'oversized-texture', entity: ent.id,
|
|
171
|
+
detail: `texture ${img.width}x${img.height} exceeds ${OVERSIZED_TEXTURE_DIM}` });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
totals.uniqueMaterials = allMats.size;
|
|
177
|
+
totals.uniqueGeometries = allGeos.size;
|
|
178
|
+
totals.geometries = allGeos.size;
|
|
179
|
+
totals.textures = allTex.size;
|
|
180
|
+
|
|
181
|
+
if (opts.previousTotals) {
|
|
182
|
+
const dg = totals.geometries - (opts.previousTotals.geometries ?? 0);
|
|
183
|
+
const dt = totals.textures - (opts.previousTotals.textures ?? 0);
|
|
184
|
+
if (dg > 50 || dt > 20) {
|
|
185
|
+
hints.push({
|
|
186
|
+
kind: 'undisposed-suspect',
|
|
187
|
+
detail: `geometries ${opts.previousTotals.geometries}→${totals.geometries}, textures ${opts.previousTotals.textures}→${totals.textures} across censuses — monotonic growth suggests missing dispose()`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { at: new Date().toISOString(), totals, entities, hints };
|
|
193
|
+
}
|
package/src/classify.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// classify.js — the closed hitch-classification vocabulary (SPEC §3.3)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// A guess without its reason is banned (principle 4): every entry returned
|
|
5
|
+
// carries `evidence`, and the set is closed — extending it is a spec change,
|
|
6
|
+
// not a code change.
|
|
7
|
+
|
|
8
|
+
/** @typedef {{guess:string, confidence:'low'|'medium'|'high', evidence:string}} Guess */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Classify one hitch from its counter deltas and timing split.
|
|
12
|
+
* Returns guesses ranked most-likely-first; always at least one.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} h
|
|
15
|
+
* @param {number} h.frameMs whole frame delta
|
|
16
|
+
* @param {number} h.medianMs rolling median at the time of the hitch
|
|
17
|
+
* @param {number} h.insideRenderMs wall time inside the render call
|
|
18
|
+
* @param {object} h.delta counter deltas vs previous frame
|
|
19
|
+
* @param {number} [h.spawned] entities spawned this frame (if known)
|
|
20
|
+
* @param {boolean} [h.memorySampled] performance.memory was available
|
|
21
|
+
* @returns {Guess[]}
|
|
22
|
+
*/
|
|
23
|
+
export function classifyHitch(h) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const d = h.delta ?? {};
|
|
26
|
+
if ((d.programs ?? 0) > 0) {
|
|
27
|
+
out.push({
|
|
28
|
+
guess: 'shader-compile',
|
|
29
|
+
confidence: (d.programs ?? 0) >= 2 ? 'high' : 'medium',
|
|
30
|
+
evidence: `programs +${d.programs} in the hitch frame`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if ((d.textures ?? 0) > 0 && (d.programs ?? 0) === 0) {
|
|
34
|
+
out.push({
|
|
35
|
+
guess: 'texture-upload',
|
|
36
|
+
confidence: 'medium',
|
|
37
|
+
evidence: `textures +${d.textures}, programs unchanged`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if ((h.spawned ?? 0) >= 3) {
|
|
41
|
+
out.push({
|
|
42
|
+
guess: 'spawn-burst',
|
|
43
|
+
confidence: 'medium',
|
|
44
|
+
evidence: `${h.spawned} entities spawned in the hitch frame`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const inside = h.insideRenderMs ?? 0;
|
|
48
|
+
if (inside > 0 && inside >= h.frameMs * 0.6) {
|
|
49
|
+
out.push({
|
|
50
|
+
guess: 'long-render',
|
|
51
|
+
confidence: 'high',
|
|
52
|
+
evidence: `inside-render ${inside.toFixed(1)}ms of a ${h.frameMs.toFixed(1)}ms frame`,
|
|
53
|
+
});
|
|
54
|
+
} else if (h.frameMs > 0 && inside < h.frameMs * 0.25) {
|
|
55
|
+
out.push({
|
|
56
|
+
guess: 'long-script',
|
|
57
|
+
confidence: inside > 0 ? 'medium' : 'low',
|
|
58
|
+
evidence: `frame ${h.frameMs.toFixed(1)}ms with only ${inside.toFixed(1)}ms inside render`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (out.length === 0) {
|
|
62
|
+
out.push({
|
|
63
|
+
guess: 'gc-or-upload-by-elimination',
|
|
64
|
+
confidence: h.memorySampled ? 'medium' : 'low',
|
|
65
|
+
evidence: 'no counter moved and the render share is inconclusive'
|
|
66
|
+
+ (h.memorySampled ? '' : ' (performance.memory unavailable, downgrading)'),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
package/src/footprint.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// footprint.js — the identity of a bottleneck, apart from its occurrence (SPEC §3.7)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// A ledger line says WHEN something happened and how bad it was. Two lines a
|
|
5
|
+
// day apart, on two builds, on two players' machines, are very often the SAME
|
|
6
|
+
// thing happening again — and a catalogue that cannot say so is a log, not a
|
|
7
|
+
// catalogue. The footprint is the part of a record that names the CAUSE:
|
|
8
|
+
// the kind of incident, the phase it lives in, the closed-vocabulary verdict,
|
|
9
|
+
// and whatever the record carries that identifies the site (the materials a
|
|
10
|
+
// hitch minted, the track and axis a jitter moved on, the tag a warm ran).
|
|
11
|
+
// Never the parts that name the OCCURRENCE: the timestamp, the frame number,
|
|
12
|
+
// the exact milliseconds or metres, the build, the machine.
|
|
13
|
+
//
|
|
14
|
+
// Same cause on a new build → same footprint: that is what lets "how often has
|
|
15
|
+
// this happened" and "which fixes were applied to it" be answered by a fold
|
|
16
|
+
// over the ledger, and what a service aggregating many clients dedupes on.
|
|
17
|
+
//
|
|
18
|
+
// THE GAME'S OWN STATE IS PART OF THE CAUSE. A hitch while flying a heavy hull
|
|
19
|
+
// with a copilot aboard in a firefight is not the same issue as the same hitch
|
|
20
|
+
// on foot in an empty lobby, and no profiler can know which facets matter for
|
|
21
|
+
// a given game. So the host declares them: `context()` returns a few
|
|
22
|
+
// LOW-CARDINALITY facets (`{ stance: 'helm', hull: 'walker', crew: 'copilot',
|
|
23
|
+
// combat: 'yes' }` — categories, never positions or counters), the runtime
|
|
24
|
+
// canonicalises them (`canonicalContext`) and stamps the string on every
|
|
25
|
+
// record as `ctx`, and the key hashes it. Any game feeding sloptimize gets the
|
|
26
|
+
// same catalogue shape from its own facets.
|
|
27
|
+
//
|
|
28
|
+
// Time is deliberately NOT a facet: when an issue happened is the occurrence
|
|
29
|
+
// (`at`, and the catalogue's first/last); the footprint is what was going on.
|
|
30
|
+
// The key is kept readable beside the id so a human — or a service re-deriving
|
|
31
|
+
// ids after a vocabulary change — can see what was hashed. `v` versions the
|
|
32
|
+
// derivation: a change to what goes into a key is a new version, never a
|
|
33
|
+
// silent re-shuffle of old ids.
|
|
34
|
+
//
|
|
35
|
+
// Pure, dependency-free, browser and node: the same bytes hash to the same id
|
|
36
|
+
// wherever the record is read.
|
|
37
|
+
|
|
38
|
+
export const FOOTPRINT_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
/** FNV-1a, 32-bit, as 8 hex characters. Not cryptographic and not meant to be
|
|
41
|
+
* — a dedupe key over a few thousand distinct causes, stable across runtimes
|
|
42
|
+
* without a crypto dependency. */
|
|
43
|
+
export function fnv1a32(str) {
|
|
44
|
+
let h = 0x811c9dc5;
|
|
45
|
+
for (let i = 0; i < str.length; i++) {
|
|
46
|
+
h ^= str.charCodeAt(i);
|
|
47
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
48
|
+
}
|
|
49
|
+
return h.toString(16).padStart(8, '0');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The host's situation facets as ONE canonical string: keys sorted, `k=v`
|
|
54
|
+
* pairs joined by ',', separators scrubbed from values. Cheap enough to
|
|
55
|
+
* refresh once a second and hand to the recorder per frame as a string —
|
|
56
|
+
* nothing allocates on the frame path.
|
|
57
|
+
*/
|
|
58
|
+
export function canonicalContext(ctx) {
|
|
59
|
+
if (!ctx || typeof ctx !== 'object') return '';
|
|
60
|
+
const scrub = (v) => String(v).replace(/[|,=\s]+/g, '_').slice(0, 40);
|
|
61
|
+
return Object.keys(ctx).filter((k) => ctx[k] !== undefined && ctx[k] !== null && ctx[k] !== '').sort()
|
|
62
|
+
.map((k) => `${scrub(k)}=${scrub(ctx[k])}`).join(',');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The trailing context segment of a key, as facets — `{}` when none. */
|
|
66
|
+
export function contextOfKey(key) {
|
|
67
|
+
const seg = String(key ?? '').split('|').find((p) => p.startsWith('ctx:'));
|
|
68
|
+
if (!seg) return {};
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const pair of seg.slice(4).split(',')) { const i = pair.indexOf('='); if (i > 0) out[pair.slice(0, i)] = pair.slice(i + 1); }
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The verdict a record leads with, or 'unclassified'. */
|
|
75
|
+
function topGuess(rec) {
|
|
76
|
+
return rec.classification?.[0]?.guess ?? 'unclassified';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The materials a hitch minted, as a sorted, deduped site list — `a@b` pairs
|
|
80
|
+
* (material@object), the same identity the wake line prints. */
|
|
81
|
+
function mintSite(rec) {
|
|
82
|
+
if (!Array.isArray(rec.mints) || rec.mints.length === 0) return '';
|
|
83
|
+
const ids = [...new Set(rec.mints.map((m) => `${m.material ?? '?'}@${m.object ?? '?'}`))].sort();
|
|
84
|
+
return ids.join(',');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Which way a jitter moved: vertical (a step, a ground clamp, a fall) or
|
|
88
|
+
* horizontal (a correction, a teleport across the ground). x vs z would only
|
|
89
|
+
* say which way the pilot happened to be facing. */
|
|
90
|
+
function jumpAxis(rec) {
|
|
91
|
+
const j = Array.isArray(rec.jump) ? rec.jump : null;
|
|
92
|
+
if (!j || j.length < 3) return 'unknown';
|
|
93
|
+
const [x, y, z] = j.map((n) => Math.abs(Number(n) || 0));
|
|
94
|
+
const mag = Math.sqrt(x * x + y * y + z * z);
|
|
95
|
+
if (mag === 0) return 'unknown';
|
|
96
|
+
return y >= 0.7 * mag ? 'vertical' : 'horizontal';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The readable key of a record's footprint, or null for records that are not
|
|
101
|
+
* incidents (heartbeats, arm probes, a settle that settled).
|
|
102
|
+
*/
|
|
103
|
+
export function footprintKey(rec) {
|
|
104
|
+
const base = baseKey(rec);
|
|
105
|
+
if (base === null) return null;
|
|
106
|
+
// The host's situation, when the record carries one (see the header).
|
|
107
|
+
const ctx = typeof rec.ctx === 'string' && rec.ctx ? rec.ctx : (rec.ctx && typeof rec.ctx === 'object' ? canonicalContext(rec.ctx) : '');
|
|
108
|
+
return ctx ? `${base}|ctx:${ctx}` : base;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function baseKey(rec) {
|
|
112
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
113
|
+
const phase = rec.phase ?? '?';
|
|
114
|
+
switch (rec.type) {
|
|
115
|
+
case 'hitch': {
|
|
116
|
+
const site = mintSite(rec);
|
|
117
|
+
return `hitch|${phase}|${topGuess(rec)}${site ? `|${site}` : ''}`;
|
|
118
|
+
}
|
|
119
|
+
case 'usermark': {
|
|
120
|
+
// The human's press is a timestamp; the cause is what the worst frame
|
|
121
|
+
// under it was doing. A "nominal" window (nothing under the bar) is its
|
|
122
|
+
// own bucket: the operator felt something the counters did not see.
|
|
123
|
+
const w = rec.worstFrames?.[0];
|
|
124
|
+
return `usermark|${phase}|${w?.classification?.[0]?.guess ?? 'unclassified'}`;
|
|
125
|
+
}
|
|
126
|
+
case 'jitter':
|
|
127
|
+
return `jitter|${rec.track ?? '?'}|${rec.kind ?? '?'}|${phase}|${topGuess(rec)}|${jumpAxis(rec)}`;
|
|
128
|
+
case 'warm':
|
|
129
|
+
return `warm|${rec.tag ?? '?'}|${rec.kind ?? '?'}|${phase}`;
|
|
130
|
+
case 'gpu-stall':
|
|
131
|
+
return `gpu-stall|${phase}`;
|
|
132
|
+
case 'gpu-settle':
|
|
133
|
+
// Only a cap hit is an incident; a settled wait is verification evidence.
|
|
134
|
+
return rec.settled === false ? `gpu-settle|${rec.tag ?? '?'}` : null;
|
|
135
|
+
default:
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `{ v, id, key }` for an incident record, or null when it has none. A record
|
|
141
|
+
* that already carries a footprint of the current version keeps it — the
|
|
142
|
+
* writer's word stands, and the fold never re-hashes what was stamped. */
|
|
143
|
+
export function footprintOf(rec) {
|
|
144
|
+
if (rec && rec.footprint && rec.footprint.v === FOOTPRINT_VERSION && typeof rec.footprint.id === 'string') return rec.footprint;
|
|
145
|
+
const key = footprintKey(rec);
|
|
146
|
+
if (key === null) return null;
|
|
147
|
+
return { v: FOOTPRINT_VERSION, id: fnv1a32(`v${FOOTPRINT_VERSION}:${key}`), key };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The glyph and a short human label for a footprint key — what a row leads
|
|
151
|
+
* with in every reader (watch, report, the debugger's Issues tab). */
|
|
152
|
+
export function describeFootprint(key) {
|
|
153
|
+
const parts = String(key ?? '').split('|').filter((p) => !p.startsWith('ctx:'));
|
|
154
|
+
const ctx = contextOfKey(key);
|
|
155
|
+
const d = describeBase(parts);
|
|
156
|
+
return { ...d, ctx };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function describeBase(parts) {
|
|
160
|
+
const [type] = parts;
|
|
161
|
+
switch (type) {
|
|
162
|
+
case 'hitch': return { glyph: '⚡', label: `hitch · ${parts[2] ?? '?'}${parts[3] ? ` · ${parts[3].split(',').length} mint site(s)` : ''}`, phase: parts[1] ?? '?' };
|
|
163
|
+
case 'usermark': return { glyph: '★', label: `keyframe · ${parts[2] ?? '?'}`, phase: parts[1] ?? '?' };
|
|
164
|
+
case 'jitter': return { glyph: '↯', label: `jitter · ${parts[1] ?? '?'} ${parts[2] ?? '?'} · ${parts[4] ?? '?'} · ${parts[5] ?? '?'}`, phase: parts[3] ?? '?' };
|
|
165
|
+
case 'warm': return { glyph: '🔥', label: `warm · ${parts[1] ?? '?'} (${parts[2] ?? '?'})`, phase: parts[3] ?? '?' };
|
|
166
|
+
case 'gpu-stall': return { glyph: '⏳', label: 'gpu-process stall', phase: parts[1] ?? '?' };
|
|
167
|
+
case 'gpu-settle': return { glyph: '⏳', label: `gpu-settle cap hit · ${parts[1] ?? '?'}`, phase: '' };
|
|
168
|
+
default: return { glyph: '·', label: String(key ?? ''), phase: '' };
|
|
169
|
+
}
|
|
170
|
+
}
|