@mavonengine/cli 0.0.1 → 0.0.3
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/README.md +289 -10
- package/dist/chunk-NIBSLCCZ.js +91 -0
- package/dist/chunk-XPKCYSMN.js +44 -0
- package/dist/chunk-ZEMFSSJP.js +87 -0
- package/dist/docs-O2V2WSUG.js +13 -0
- package/dist/index.js +2440 -359
- package/dist/server-NNL4IMYV.js +925 -0
- package/package.json +7 -5
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
fetchPluginReadme,
|
|
4
|
+
fetchPluginRegistry
|
|
5
|
+
} from "./chunk-ZEMFSSJP.js";
|
|
6
|
+
import {
|
|
7
|
+
LOG_LEVELS,
|
|
8
|
+
READ_ONLY,
|
|
9
|
+
registerDocsTools,
|
|
10
|
+
run,
|
|
11
|
+
vec3
|
|
12
|
+
} from "./chunk-NIBSLCCZ.js";
|
|
13
|
+
import {
|
|
14
|
+
version
|
|
15
|
+
} from "./chunk-XPKCYSMN.js";
|
|
16
|
+
|
|
17
|
+
// src/mcp/server.ts
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { z as z4 } from "zod";
|
|
20
|
+
|
|
21
|
+
// src/mcp/clientTools.ts
|
|
22
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
23
|
+
import { tmpdir } from "os";
|
|
24
|
+
import { dirname, join, resolve } from "path";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
|
|
27
|
+
// src/mcp/trace.ts
|
|
28
|
+
var REFRESH_RATES = [30, 50, 60, 75, 90, 100, 120, 144, 165, 180, 240, 360];
|
|
29
|
+
var GC_DROP_BYTES = 1024 * 1024;
|
|
30
|
+
var MB = 1024 * 1024;
|
|
31
|
+
var VITE_DEPS_RE = /^\/node_modules\/\.vite\/deps\//;
|
|
32
|
+
var LEADING_SLASH_RE = /^\//;
|
|
33
|
+
var round = (n, digits = 2) => Math.round(n * 10 ** digits) / 10 ** digits;
|
|
34
|
+
function percentile(values, p) {
|
|
35
|
+
if (!values.length)
|
|
36
|
+
return 0;
|
|
37
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
38
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))];
|
|
39
|
+
}
|
|
40
|
+
function stats(values) {
|
|
41
|
+
if (!values.length)
|
|
42
|
+
return null;
|
|
43
|
+
const sum2 = values.reduce((a, b) => a + b, 0);
|
|
44
|
+
return { avg: round(sum2 / values.length), p50: round(percentile(values, 50)), p95: round(percentile(values, 95)), p99: round(percentile(values, 99)), max: round(Math.max(...values)) };
|
|
45
|
+
}
|
|
46
|
+
function refreshRate(intervals) {
|
|
47
|
+
if (intervals.length < 5)
|
|
48
|
+
return null;
|
|
49
|
+
const hz = 1e3 / percentile(intervals, 20);
|
|
50
|
+
return REFRESH_RATES.reduce((best, rate) => Math.abs(rate - hz) < Math.abs(best - hz) ? rate : best);
|
|
51
|
+
}
|
|
52
|
+
function frameLabel(profile, frameId) {
|
|
53
|
+
const frame = profile.frames[frameId];
|
|
54
|
+
if (!frame)
|
|
55
|
+
return { name: "(unknown)", location: null };
|
|
56
|
+
const resource = frame.resourceId !== void 0 ? profile.resources[frame.resourceId] : void 0;
|
|
57
|
+
let file = null;
|
|
58
|
+
if (resource) {
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(resource);
|
|
61
|
+
file = url.pathname.replace(VITE_DEPS_RE, "deps/").replace(LEADING_SLASH_RE, "");
|
|
62
|
+
} catch {
|
|
63
|
+
file = resource;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
name: frame.name || "(anonymous)",
|
|
68
|
+
location: file ? `${file}${frame.line !== void 0 ? `:${frame.line}` : ""}${frame.column !== void 0 ? `:${frame.column}` : ""}` : null
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function stackFrames(profile, stackId) {
|
|
72
|
+
const frames = [];
|
|
73
|
+
const seen = /* @__PURE__ */ new Set();
|
|
74
|
+
let id = stackId;
|
|
75
|
+
while (id !== void 0 && !seen.has(id)) {
|
|
76
|
+
seen.add(id);
|
|
77
|
+
const stack = profile.stacks[id];
|
|
78
|
+
if (!stack)
|
|
79
|
+
break;
|
|
80
|
+
frames.push(stack.frameId);
|
|
81
|
+
id = stack.parentId;
|
|
82
|
+
}
|
|
83
|
+
return frames.reverse();
|
|
84
|
+
}
|
|
85
|
+
function weightedSamples(profile) {
|
|
86
|
+
return profile.samples.map((sample, i) => {
|
|
87
|
+
const next = profile.samples[i + 1];
|
|
88
|
+
const weight = next ? Math.min(next.timestamp - sample.timestamp, profile.sampleIntervalMs * 4) : profile.sampleIntervalMs;
|
|
89
|
+
return { ...sample, weight: Math.max(0, weight) };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function hotFunctions(profile, limit = 15, from = -Infinity, to = Infinity) {
|
|
93
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
94
|
+
let busyMs = 0;
|
|
95
|
+
let idleMs = 0;
|
|
96
|
+
for (const sample of weightedSamples(profile)) {
|
|
97
|
+
if (sample.timestamp < from || sample.timestamp > to)
|
|
98
|
+
continue;
|
|
99
|
+
const frames = stackFrames(profile, sample.stackId);
|
|
100
|
+
if (!frames.length) {
|
|
101
|
+
idleMs += sample.weight;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
busyMs += sample.weight;
|
|
105
|
+
const keys = frames.map((frameId) => {
|
|
106
|
+
const label = frameLabel(profile, frameId);
|
|
107
|
+
const key = `${label.name}@${label.location}`;
|
|
108
|
+
if (!byKey.has(key))
|
|
109
|
+
byKey.set(key, { ...label, selfMs: 0, totalMs: 0, selfPercent: 0 });
|
|
110
|
+
return key;
|
|
111
|
+
});
|
|
112
|
+
for (const key of new Set(keys))
|
|
113
|
+
byKey.get(key).totalMs += sample.weight;
|
|
114
|
+
byKey.get(keys.at(-1)).selfMs += sample.weight;
|
|
115
|
+
}
|
|
116
|
+
const functions = [...byKey.values()].sort((a, b) => b.selfMs - a.selfMs).slice(0, limit).map((fn) => ({ ...fn, selfMs: round(fn.selfMs, 1), totalMs: round(fn.totalMs, 1), selfPercent: busyMs ? round(fn.selfMs / busyMs * 100, 1) : 0 }));
|
|
117
|
+
return { busyMs: round(busyMs, 1), idleMs: round(idleMs, 1), functions };
|
|
118
|
+
}
|
|
119
|
+
var sum = (values) => values.reduce((a, b) => a + b, 0);
|
|
120
|
+
var mean = (values) => values.length ? sum(values) / values.length : 0;
|
|
121
|
+
function overlaps(span, from, to) {
|
|
122
|
+
return span.start < to && span.start + span.duration > from;
|
|
123
|
+
}
|
|
124
|
+
function hitchCauses(capture, index, budgetMs) {
|
|
125
|
+
const frame = capture.frames[index];
|
|
126
|
+
const previous = capture.frames[index - 1];
|
|
127
|
+
const from = previous ? previous.start : frame.start - (frame.interval ?? 0);
|
|
128
|
+
const to = frame.start + frame.cpu;
|
|
129
|
+
const causes = [];
|
|
130
|
+
const gl = [previous?.gl, frame.gl].filter(Boolean);
|
|
131
|
+
const compileMs = sum(gl.map((g) => g.shaderCompileMs));
|
|
132
|
+
const compiles = sum(gl.map((g) => g.shaderCompiles));
|
|
133
|
+
if (compileMs >= 1)
|
|
134
|
+
causes.push(`Shader compilation: ${round(compileMs, 1)}ms${compiles ? ` linking ${compiles} program${compiles === 1 ? "" : "s"}` : ""} (a material or light setup rendered for the first time)`);
|
|
135
|
+
const uploadMs = sum(gl.map((g) => g.textureUploadMs));
|
|
136
|
+
if (uploadMs >= 1)
|
|
137
|
+
causes.push(`Texture uploads: ${round(uploadMs, 1)}ms for ${sum(gl.map((g) => g.textureUploads))} upload(s)`);
|
|
138
|
+
const bufferBytes = sum(gl.map((g) => g.bufferUploadBytes));
|
|
139
|
+
if (bufferBytes >= MB)
|
|
140
|
+
causes.push(`Buffer uploads: ${round(bufferBytes / MB, 1)}MB of geometry/attributes`);
|
|
141
|
+
const syncMs = sum(gl.map((g) => g.syncMs));
|
|
142
|
+
if (syncMs >= 1)
|
|
143
|
+
causes.push(`GPU sync stalls: ${round(syncMs, 1)}ms in readPixels/getError/finish/clientWaitSync`);
|
|
144
|
+
if (frame.cpu > budgetMs)
|
|
145
|
+
causes.push(`Render CPU time ${round(frame.cpu, 1)}ms (${frame.calls} draw calls)`);
|
|
146
|
+
if (frame.gpu !== null && frame.gpu > budgetMs)
|
|
147
|
+
causes.push(`GPU time ${round(frame.gpu, 1)}ms`);
|
|
148
|
+
for (const tick of capture.ticks) {
|
|
149
|
+
if (overlaps(tick, from, to) && tick.duration >= Math.max(4, budgetMs / 3)) {
|
|
150
|
+
const parts = [tick.physics !== null && `physics ${round(tick.physics, 1)}ms`, tick.world !== null && `world.update ${round(tick.world, 1)}ms`].filter(Boolean);
|
|
151
|
+
causes.push(`Game tick took ${round(tick.duration, 1)}ms${parts.length ? ` (${parts.join(", ")})` : ""}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (previous?.heap != null && frame.heap != null && previous.heap - frame.heap >= GC_DROP_BYTES)
|
|
155
|
+
causes.push(`Garbage collection likely: JS heap dropped ${round((previous.heap - frame.heap) / MB, 1)}MB`);
|
|
156
|
+
const scripts = capture.spans.filter((s) => s.category === "script" && overlaps(s, from, to) && s.duration >= 2).sort((a, b) => b.duration - a.duration).slice(0, 3);
|
|
157
|
+
for (const script of scripts) {
|
|
158
|
+
const detail = script.detail ?? {};
|
|
159
|
+
causes.push(`Script ${script.name} ran ${round(script.duration, 1)}ms${detail.invoker ? ` (${String(detail.invoker)})` : ""}${detail.source ? ` at ${String(detail.source)}` : ""}${detail.forcedStyleAndLayout ? `, forced layout ${String(detail.forcedStyleAndLayout)}ms` : ""}`);
|
|
160
|
+
}
|
|
161
|
+
const measures = capture.spans.filter((s) => s.category === "measure" && overlaps(s, from, to) && s.duration >= 1);
|
|
162
|
+
for (const measure of measures.sort((a, b) => b.duration - a.duration).slice(0, 3))
|
|
163
|
+
causes.push(`performance.measure "${measure.name}" ${round(measure.duration, 1)}ms`);
|
|
164
|
+
if (capture.profile) {
|
|
165
|
+
const { functions } = hotFunctions(capture.profile, 3, from, to);
|
|
166
|
+
const hot = functions.filter((fn) => fn.selfMs >= capture.profile.sampleIntervalMs);
|
|
167
|
+
if (hot.length)
|
|
168
|
+
causes.push(`Hot JS in this frame: ${hot.map((fn) => `${fn.name} ${fn.selfMs}ms${fn.location ? ` (${fn.location})` : ""}`).join(", ")}`);
|
|
169
|
+
}
|
|
170
|
+
if (!causes.length)
|
|
171
|
+
causes.push("No cause recorded in the tab: the browser or OS held the frame back (another tab, compositor, GPU process, power saving), or work outside the game loop");
|
|
172
|
+
return causes;
|
|
173
|
+
}
|
|
174
|
+
function analyzeTrace(capture, { maxHitches = 10 } = {}) {
|
|
175
|
+
const frames = capture.frames;
|
|
176
|
+
const intervals = frames.map((f) => f.interval).filter((n) => n !== null);
|
|
177
|
+
const display = refreshRate(intervals);
|
|
178
|
+
const maxFps = capture.maxFps ?? 0;
|
|
179
|
+
const capped = maxFps > 0 && (display === null || maxFps < display * 1.05);
|
|
180
|
+
const hz = capped ? maxFps : display;
|
|
181
|
+
const budgetMs = hz ? 1e3 / hz : null;
|
|
182
|
+
const medianInterval = percentile(intervals, 50);
|
|
183
|
+
const gpuTimes = frames.map((f) => f.gpu).filter((n) => n !== null);
|
|
184
|
+
const cpuTimes = frames.map((f) => f.cpu);
|
|
185
|
+
const notes = [];
|
|
186
|
+
const findings = [];
|
|
187
|
+
if (!capture.visible)
|
|
188
|
+
notes.push("The tab was in the background: browsers throttle or stop rendering hidden tabs, so bring it to the front and trace again");
|
|
189
|
+
if (frames.length < 2)
|
|
190
|
+
notes.push("Fewer than two frames rendered while tracing: the tab is hidden, frozen or stuck before its first frame");
|
|
191
|
+
if (capped)
|
|
192
|
+
notes.push(`Rendering is capped at renderer.maxFps = ${maxFps}: frame times are measured against ${round(1e3 / maxFps, 1)}ms. Set it to 0 to see how fast the game can render`);
|
|
193
|
+
if (!capture.gpuTiming.enabled || capture.gpuTiming.note)
|
|
194
|
+
notes.push(`GPU timing: ${capture.gpuTiming.enabled ? "on" : "off"}${capture.gpuTiming.note ? `, ${capture.gpuTiming.note}` : ""}`);
|
|
195
|
+
if (!capture.jsProfile.enabled)
|
|
196
|
+
notes.push(`JS sampling: off, ${capture.jsProfile.note ?? "not available"}`);
|
|
197
|
+
const ticks = capture.ticks;
|
|
198
|
+
const tickStats = ticks.length ? {
|
|
199
|
+
...stats(ticks.map((t) => t.duration)),
|
|
200
|
+
count: ticks.length,
|
|
201
|
+
physics: stats(ticks.map((t) => t.physics).filter((n) => n !== null)),
|
|
202
|
+
world: stats(ticks.map((t) => t.world).filter((n) => n !== null))
|
|
203
|
+
} : null;
|
|
204
|
+
const passes = capture.passes.map((name, i) => {
|
|
205
|
+
const times = frames.map((f) => f.passes[i]?.[1]).filter((n) => n !== void 0);
|
|
206
|
+
return { name, avgMs: round(mean(times)), maxMs: round(times.length ? Math.max(...times) : 0) };
|
|
207
|
+
});
|
|
208
|
+
const steady = frames.length > 1 ? frames.slice(1) : frames;
|
|
209
|
+
const avg = (pick) => round(mean(steady.map(pick)), 1);
|
|
210
|
+
const perFrame = {
|
|
211
|
+
drawCalls: avg((f) => f.calls),
|
|
212
|
+
triangles: Math.round(mean(steady.map((f) => f.triangles))),
|
|
213
|
+
glDraws: avg((f) => f.gl.draws),
|
|
214
|
+
instancedDraws: avg((f) => f.gl.instancedDraws),
|
|
215
|
+
programSwitches: avg((f) => f.gl.programSwitches),
|
|
216
|
+
textureBinds: avg((f) => f.gl.textureBinds),
|
|
217
|
+
framebufferBinds: avg((f) => f.gl.framebufferBinds),
|
|
218
|
+
vertexArrayBinds: avg((f) => f.gl.vertexArrayBinds),
|
|
219
|
+
uniformCalls: avg((f) => f.gl.uniformCalls),
|
|
220
|
+
bufferUploads: avg((f) => f.gl.bufferUploads),
|
|
221
|
+
bufferUploadKB: round(mean(steady.map((f) => f.gl.bufferUploadBytes)) / 1024, 1),
|
|
222
|
+
textureUploads: avg((f) => f.gl.textureUploads)
|
|
223
|
+
};
|
|
224
|
+
const totalOf = (pick) => sum(frames.map((f) => pick(f.gl)));
|
|
225
|
+
const totals = {
|
|
226
|
+
shaderCompiles: totalOf((g) => g.shaderCompiles),
|
|
227
|
+
shaderCompileMs: round(totalOf((g) => g.shaderCompileMs), 1),
|
|
228
|
+
textureUploads: totalOf((g) => g.textureUploads),
|
|
229
|
+
textureUploadMs: round(totalOf((g) => g.textureUploadMs), 1),
|
|
230
|
+
bufferUploadMB: round(totalOf((g) => g.bufferUploadBytes) / MB),
|
|
231
|
+
syncCalls: totalOf((g) => g.syncCalls),
|
|
232
|
+
syncMs: round(totalOf((g) => g.syncMs), 1)
|
|
233
|
+
};
|
|
234
|
+
const js = capture.profile ? hotFunctions(capture.profile, 15) : null;
|
|
235
|
+
const busyPercent = js && js.busyMs + js.idleMs > 0 ? round(js.busyMs / (js.busyMs + js.idleMs) * 100, 1) : null;
|
|
236
|
+
const cpuP50 = percentile(cpuTimes, 50);
|
|
237
|
+
const gpuP50 = gpuTimes.length ? percentile(gpuTimes, 50) : null;
|
|
238
|
+
const tickPerFrame = ticks.length && frames.length ? sum(ticks.map((t) => t.duration)) / frames.length : 0;
|
|
239
|
+
let bottleneck;
|
|
240
|
+
if (!budgetMs || frames.length < 5) {
|
|
241
|
+
bottleneck = { verdict: "unknown", reason: "Too few frames to tell" };
|
|
242
|
+
} else if (medianInterval <= budgetMs * 1.15 && (capped || hz >= 60 || Math.max(cpuP50 + tickPerFrame, gpuP50 ?? 0) < medianInterval * 0.6)) {
|
|
243
|
+
const used = Math.max(cpuP50 + tickPerFrame, gpuP50 ?? 0);
|
|
244
|
+
bottleneck = { verdict: "smooth", reason: `Frames keep up with ${capped ? `renderer.maxFps = ${maxFps}` : `the ${hz}Hz display`}: a typical frame uses ${round(used, 1)}ms of the ${round(budgetMs, 1)}ms budget (render CPU ${round(cpuP50, 1)}ms${gpuP50 !== null ? `, GPU ${round(gpuP50, 1)}ms` : ""}, game ticks ~${round(tickPerFrame, 1)}ms per frame)` };
|
|
245
|
+
} else if (gpuP50 !== null && gpuP50 >= medianInterval * 0.75 && gpuP50 > cpuP50 + tickPerFrame) {
|
|
246
|
+
bottleneck = { verdict: "gpu-bound", reason: `The GPU needs ${round(gpuP50, 1)}ms per frame against frames every ${round(medianInterval, 1)}ms, while render CPU time is ${round(cpuP50, 1)}ms` };
|
|
247
|
+
} else if (cpuP50 >= medianInterval * 0.6) {
|
|
248
|
+
bottleneck = { verdict: "cpu-render-bound", reason: `Renderer.update takes ${round(cpuP50, 1)}ms of each ${round(medianInterval, 1)}ms frame on the CPU, submitting ${perFrame.drawCalls} draw calls` };
|
|
249
|
+
} else if (tickPerFrame >= medianInterval * 0.4) {
|
|
250
|
+
bottleneck = { verdict: "cpu-game-logic-bound", reason: `Game ticks take ~${round(tickPerFrame, 1)}ms per rendered frame (tick p50 ${tickStats?.p50}ms at ${ticks.length} ticks)` };
|
|
251
|
+
} else if (busyPercent !== null && busyPercent >= 70) {
|
|
252
|
+
bottleneck = { verdict: "main-thread-busy", reason: `The main thread is busy ${busyPercent}% of the time outside what the render and tick account for: see js.hotFunctions` };
|
|
253
|
+
} else {
|
|
254
|
+
bottleneck = { verdict: "unknown", reason: `Frames take ${round(medianInterval, 1)}ms against a ${round(budgetMs, 1)}ms budget, but neither render CPU (${round(cpuP50, 1)}ms), GPU (${gpuP50 !== null ? `${round(gpuP50, 1)}ms` : "not measured"}) nor ticks explain it` };
|
|
255
|
+
}
|
|
256
|
+
const thresholdMs = round(Math.max(medianInterval * 1.5, medianInterval + 4));
|
|
257
|
+
const late = frames.map((frame, index) => ({ frame, index })).filter(({ frame }) => frame.interval !== null && frame.interval > thresholdMs);
|
|
258
|
+
const worst = [...late].sort((a, b) => b.frame.interval - a.frame.interval).slice(0, maxHitches).map(({ frame, index }) => ({
|
|
259
|
+
frame: index,
|
|
260
|
+
at: round(frame.start, 1),
|
|
261
|
+
intervalMs: round(frame.interval, 1),
|
|
262
|
+
cpuMs: round(frame.cpu, 1),
|
|
263
|
+
gpuMs: frame.gpu === null ? null : round(frame.gpu, 1),
|
|
264
|
+
causes: hitchCauses(capture, index, budgetMs ?? medianInterval)
|
|
265
|
+
}));
|
|
266
|
+
if (totals.shaderCompiles > 0)
|
|
267
|
+
findings.push(`${totals.shaderCompiles} shader program(s) were linked while playing (${totals.shaderCompileMs}ms on the main thread). Compile materials up front with renderer.compileAsync(scene, camera) after loading, and avoid changing defines / light counts at runtime`);
|
|
268
|
+
if (perFrame.drawCalls > 1e3)
|
|
269
|
+
findings.push(`${perFrame.drawCalls} draw calls per frame is a lot for WebGL: merge static meshes, use InstancedMesh / BatchedMesh for repeated objects, and share materials`);
|
|
270
|
+
else if (perFrame.drawCalls > 300 && bottleneck.verdict === "cpu-render-bound")
|
|
271
|
+
findings.push(`${perFrame.drawCalls} draw calls per frame drive the render CPU time: instancing or merging would cut it`);
|
|
272
|
+
if (perFrame.programSwitches > 100)
|
|
273
|
+
findings.push(`${perFrame.programSwitches} shader program switches per frame: fewer distinct materials, or the same material on more meshes, lets three.js sort and batch`);
|
|
274
|
+
if (perFrame.textureUploads >= 1)
|
|
275
|
+
findings.push(`${perFrame.textureUploads} texture upload(s) every frame: a texture (often a canvas, video or DataTexture) has needsUpdate set each frame`);
|
|
276
|
+
if (perFrame.bufferUploadKB >= 256)
|
|
277
|
+
findings.push(`${perFrame.bufferUploadKB}KB of buffer data uploaded per frame: geometry attributes are updated every frame (needsUpdate / updateRanges), consider updating only the ranges that change`);
|
|
278
|
+
if (totals.syncCalls > frames.length && totals.syncMs >= 1)
|
|
279
|
+
findings.push(`${totals.syncCalls} GPU sync calls (${totals.syncMs}ms): readPixels / getError stall the pipeline. renderer.debug.checkShaderErrors = true calls getError after compiles, and picking with readPixels each frame stalls too`);
|
|
280
|
+
if (bottleneck.verdict === "gpu-bound") {
|
|
281
|
+
findings.push(`GPU-bound at ${capture.canvas.width}x${capture.canvas.height} (pixel ratio ${capture.canvas.pixelRatio}): lower the pixel ratio (cap it at 1.5\u20132), shadow map size or shadow-casting lights, and look at the most expensive passes`);
|
|
282
|
+
const heavyPasses = passes.filter((p) => p.name !== "RenderPass" && p.name !== "OutputPass");
|
|
283
|
+
if (heavyPasses.length)
|
|
284
|
+
findings.push(`Post-processing passes cost GPU fill rate on every pixel: ${heavyPasses.map((p) => p.name).join(", ")}`);
|
|
285
|
+
}
|
|
286
|
+
if (tickStats && budgetMs && tickStats.p95 > budgetMs / 2)
|
|
287
|
+
findings.push(`Game ticks are slow (p95 ${tickStats.p95}ms${tickStats.physics ? `, physics p95 ${tickStats.physics.p95}ms` : ""}${tickStats.world ? `, world.update p95 ${tickStats.world.p95}ms` : ""}) and block rendering when they run`);
|
|
288
|
+
const gcHitches = worst.filter((h) => h.causes.some((c) => c.startsWith("Garbage collection"))).length;
|
|
289
|
+
if (gcHitches >= 2)
|
|
290
|
+
findings.push(`${gcHitches} hitches line up with garbage collection: reuse Vector3 / Matrix4 / arrays in update loops instead of allocating each frame`);
|
|
291
|
+
if (late.length && budgetMs)
|
|
292
|
+
findings.push(`${late.length} of ${frames.length} frames came late (over ${thresholdMs}ms), ${round(late.length / frames.length * 100, 1)}%`);
|
|
293
|
+
const measureMap = /* @__PURE__ */ new Map();
|
|
294
|
+
for (const span of capture.spans.filter((s) => s.category === "measure"))
|
|
295
|
+
measureMap.set(span.name, [...measureMap.get(span.name) ?? [], span.duration]);
|
|
296
|
+
const measures = Array.from(measureMap, ([name, durations]) => ({ name, count: durations.length, avgMs: round(mean(durations)), maxMs: round(Math.max(...durations)) })).sort((a, b) => b.avgMs * b.count - a.avgMs * a.count).slice(0, 20);
|
|
297
|
+
return {
|
|
298
|
+
durationMs: capture.durationMs,
|
|
299
|
+
frames: frames.length,
|
|
300
|
+
fps: round(frames.length / (capture.durationMs / 1e3), 1),
|
|
301
|
+
refreshRate: hz,
|
|
302
|
+
maxFps,
|
|
303
|
+
budgetMs: budgetMs && round(budgetMs),
|
|
304
|
+
frameInterval: stats(intervals),
|
|
305
|
+
renderCpu: stats(cpuTimes),
|
|
306
|
+
gpu: stats(gpuTimes),
|
|
307
|
+
tick: tickStats,
|
|
308
|
+
passes,
|
|
309
|
+
perFrame,
|
|
310
|
+
totals,
|
|
311
|
+
bottleneck,
|
|
312
|
+
hitches: { count: late.length, thresholdMs, worst },
|
|
313
|
+
js: js && { mainThreadBusyPercent: busyPercent ?? 0, hotFunctions: js.functions },
|
|
314
|
+
longAnimationFrames: capture.spans.filter((s) => s.category === "long-animation-frame" || s.category === "long-task").length,
|
|
315
|
+
measures,
|
|
316
|
+
findings,
|
|
317
|
+
notes
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
var PID = 1;
|
|
321
|
+
var TRACKS = { frames: 1, passes: 2, gpu: 3, ticks: 4, js: 5, gl: 6, browser: 7 };
|
|
322
|
+
var TRACK_NAMES = {
|
|
323
|
+
[TRACKS.frames]: "Frames (render CPU)",
|
|
324
|
+
[TRACKS.passes]: "Composer passes",
|
|
325
|
+
[TRACKS.gpu]: "GPU time per frame (duration, not GPU clock)",
|
|
326
|
+
[TRACKS.ticks]: "Game ticks",
|
|
327
|
+
[TRACKS.js]: "JS samples",
|
|
328
|
+
[TRACKS.gl]: "Slow WebGL calls",
|
|
329
|
+
[TRACKS.browser]: "Long animation frames, scripts, measures"
|
|
330
|
+
};
|
|
331
|
+
var us = (ms) => Math.round(ms * 1e3);
|
|
332
|
+
function profileEvents(profile) {
|
|
333
|
+
const events = [];
|
|
334
|
+
const open = [];
|
|
335
|
+
const close = (depth, at) => {
|
|
336
|
+
while (open.length > depth) {
|
|
337
|
+
const { frameId, start } = open.pop();
|
|
338
|
+
const label = frameLabel(profile, frameId);
|
|
339
|
+
events.push({ name: label.name, cat: "js", ph: "X", ts: us(start), dur: Math.max(1, us(at - start)), pid: PID, tid: TRACKS.js, args: label.location ? { location: label.location } : void 0 });
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
const samples = weightedSamples(profile);
|
|
343
|
+
for (const sample of samples) {
|
|
344
|
+
const frames = stackFrames(profile, sample.stackId);
|
|
345
|
+
let common = 0;
|
|
346
|
+
while (common < open.length && common < frames.length && open[common].frameId === frames[common])
|
|
347
|
+
common++;
|
|
348
|
+
close(common, sample.timestamp);
|
|
349
|
+
for (let i = common; i < frames.length; i++)
|
|
350
|
+
open.push({ frameId: frames[i], start: sample.timestamp });
|
|
351
|
+
}
|
|
352
|
+
const last = samples.at(-1);
|
|
353
|
+
close(0, last ? last.timestamp + last.weight : 0);
|
|
354
|
+
return events;
|
|
355
|
+
}
|
|
356
|
+
function spanEvent(span) {
|
|
357
|
+
return {
|
|
358
|
+
name: span.name,
|
|
359
|
+
cat: span.category,
|
|
360
|
+
ph: "X",
|
|
361
|
+
ts: us(span.start),
|
|
362
|
+
dur: Math.max(1, us(span.duration)),
|
|
363
|
+
pid: PID,
|
|
364
|
+
tid: span.category === "gl" ? TRACKS.gl : TRACKS.browser,
|
|
365
|
+
args: span.detail
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function toTraceEvents(capture, label = "MavonEngine game tab") {
|
|
369
|
+
const events = [
|
|
370
|
+
{ name: "process_name", ph: "M", ts: 0, pid: PID, tid: 0, args: { name: label } },
|
|
371
|
+
...Object.entries(TRACK_NAMES).flatMap(([tid, name]) => [
|
|
372
|
+
{ name: "thread_name", ph: "M", ts: 0, pid: PID, tid: Number(tid), args: { name } },
|
|
373
|
+
{ name: "thread_sort_index", ph: "M", ts: 0, pid: PID, tid: Number(tid), args: { sort_index: Number(tid) } }
|
|
374
|
+
])
|
|
375
|
+
];
|
|
376
|
+
capture.frames.forEach((frame, index) => {
|
|
377
|
+
events.push({
|
|
378
|
+
name: `Frame ${index}`,
|
|
379
|
+
cat: "frame",
|
|
380
|
+
ph: "X",
|
|
381
|
+
ts: us(frame.start),
|
|
382
|
+
dur: Math.max(1, us(frame.cpu)),
|
|
383
|
+
pid: PID,
|
|
384
|
+
tid: TRACKS.frames,
|
|
385
|
+
args: { intervalMs: frame.interval, cpuMs: frame.cpu, gpuMs: frame.gpu, drawCalls: frame.calls, triangles: frame.triangles, gl: frame.gl }
|
|
386
|
+
});
|
|
387
|
+
frame.passes.forEach((pass, i) => {
|
|
388
|
+
const [start, duration] = pass ?? [];
|
|
389
|
+
if (start !== void 0 && duration !== void 0)
|
|
390
|
+
events.push({ name: capture.passes[i] ?? `Pass ${i}`, cat: "pass", ph: "X", ts: us(start), dur: Math.max(1, us(duration)), pid: PID, tid: TRACKS.passes });
|
|
391
|
+
});
|
|
392
|
+
if (frame.gpu !== null)
|
|
393
|
+
events.push({ name: `GPU frame ${index}`, cat: "gpu", ph: "X", ts: us(frame.start), dur: Math.max(1, us(frame.gpu)), pid: PID, tid: TRACKS.gpu });
|
|
394
|
+
const counters = { drawCalls: frame.calls, glDraws: frame.gl.draws };
|
|
395
|
+
if (frame.interval)
|
|
396
|
+
counters.fps = round(1e3 / frame.interval, 1);
|
|
397
|
+
events.push({ name: "Frame", ph: "C", ts: us(frame.start), pid: PID, tid: 0, args: counters });
|
|
398
|
+
if (frame.heap !== null)
|
|
399
|
+
events.push({ name: "JS heap (MB)", ph: "C", ts: us(frame.start), pid: PID, tid: 0, args: { used: round(frame.heap / MB) } });
|
|
400
|
+
});
|
|
401
|
+
for (const tick of capture.ticks) {
|
|
402
|
+
events.push({ name: "tick", cat: "tick", ph: "X", ts: us(tick.start), dur: Math.max(1, us(tick.duration)), pid: PID, tid: TRACKS.ticks, args: { physicsMs: tick.physics, worldMs: tick.world } });
|
|
403
|
+
}
|
|
404
|
+
events.push(...capture.spans.map(spanEvent));
|
|
405
|
+
if (capture.profile)
|
|
406
|
+
events.push(...profileEvents(capture.profile));
|
|
407
|
+
return {
|
|
408
|
+
traceEvents: events,
|
|
409
|
+
displayTimeUnit: "ms",
|
|
410
|
+
metadata: {
|
|
411
|
+
source: "mavonengine mcp client_trace",
|
|
412
|
+
startedAt: new Date(capture.startedAt).toISOString(),
|
|
413
|
+
userAgent: capture.userAgent,
|
|
414
|
+
canvas: capture.canvas,
|
|
415
|
+
gpuTiming: capture.gpuTiming,
|
|
416
|
+
jsProfile: capture.jsProfile
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/mcp/clientTools.ts
|
|
422
|
+
var CLIENT_LOG_LEVELS = ["error", "warn", "info", "log", "debug"];
|
|
423
|
+
var SEVERITY = { error: 0, warn: 1, info: 2, log: 2, debug: 3 };
|
|
424
|
+
var clientId = z.string().optional().describe("Tab id from list_clients. Default: the only open tab, or the only one not running the editor");
|
|
425
|
+
function delta(a, b) {
|
|
426
|
+
const d = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z };
|
|
427
|
+
return { ...d, length: Math.hypot(d.x, d.y, d.z) };
|
|
428
|
+
}
|
|
429
|
+
function compareEntities(id, server, client) {
|
|
430
|
+
if (!server || !client) {
|
|
431
|
+
return {
|
|
432
|
+
id,
|
|
433
|
+
server,
|
|
434
|
+
client,
|
|
435
|
+
note: !server && !client ? "Neither side has this entity" : !server ? "Only the client has it: it may be client-only, or the server removed it and the client did not get SV_REMOVE_ENTITY" : "Only the server has it: it may be out of this client's sync distance, not sent yet, or the client failed to create it"
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const serverFields = server.serialized ?? {};
|
|
439
|
+
const clientFields = client.serialized ?? {};
|
|
440
|
+
const differentFields = [.../* @__PURE__ */ new Set([...Object.keys(serverFields), ...Object.keys(clientFields)])].filter((field) => !["id", "position", "rotation", "scale"].includes(field)).filter((field) => JSON.stringify(serverFields[field]) !== JSON.stringify(clientFields[field])).map((field) => ({ field, server: serverFields[field], client: clientFields[field] }));
|
|
441
|
+
const { serialized: _s, ...serverSummary } = server;
|
|
442
|
+
const { serialized: _c, ...clientSummary } = client;
|
|
443
|
+
return {
|
|
444
|
+
id,
|
|
445
|
+
clientId: client.id === id ? void 0 : client.id,
|
|
446
|
+
positionDelta: delta(server.position, client.position),
|
|
447
|
+
rotationDelta: delta(server.rotation, client.rotation),
|
|
448
|
+
scaleDelta: delta(server.scale, client.scale),
|
|
449
|
+
differentFields,
|
|
450
|
+
server: serverSummary,
|
|
451
|
+
client: clientSummary,
|
|
452
|
+
note: "Client state of remote entities is interpolated and the local player is predicted, so small deltas are expected while moving"
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function defaultTracePath(clientId3, at = /* @__PURE__ */ new Date()) {
|
|
456
|
+
const stamp = at.toISOString().replace(/[:.]/g, "-");
|
|
457
|
+
return join(tmpdir(), "mavonengine-traces", `trace-${clientId3}-${stamp}.json`);
|
|
458
|
+
}
|
|
459
|
+
function registerClientTools(server, client) {
|
|
460
|
+
server.registerTool("list_clients", {
|
|
461
|
+
title: "Open game tabs",
|
|
462
|
+
description: "Browser tabs running the game client in dev, connected through the dev bridge: id, page, whether it runs the editor, its player id on the server (playerId), visibility, focus and FPS. The other client_* tools take one of these ids.",
|
|
463
|
+
annotations: READ_ONLY
|
|
464
|
+
}, () => run(async () => {
|
|
465
|
+
const { clients } = await client.clients();
|
|
466
|
+
return { devServer: client.clientUrl, clients, ...clients.length ? {} : { note: "No game tab is open. Open the game in a browser" } };
|
|
467
|
+
}));
|
|
468
|
+
server.registerTool("client_status", {
|
|
469
|
+
title: "Client status",
|
|
470
|
+
description: "One tab's rendering and state: FPS, draw calls / triangles / points / lines of one frame, GPU geometries / textures / shader programs, JS heap, canvas size, camera, entity count, active scene, resource loading.",
|
|
471
|
+
inputSchema: { clientId },
|
|
472
|
+
annotations: READ_ONLY
|
|
473
|
+
}, ({ clientId: clientId3 }) => run(() => client.client("status", {}, clientId3)));
|
|
474
|
+
server.registerTool("client_entities", {
|
|
475
|
+
title: "Entities on a client",
|
|
476
|
+
description: "Entities in a tab's world, in the same shape as list_entities on the server, plus `localPlayer` for the entity the tab controls. Filter by type and/or distance to a point (nearest first).",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
clientId,
|
|
479
|
+
type: z.string().optional().describe("Entity class / networked type name, or scene object type, case-insensitive"),
|
|
480
|
+
near: vec3.optional(),
|
|
481
|
+
radius: z.number().nonnegative().optional(),
|
|
482
|
+
limit: z.number().int().min(1).max(500).default(50),
|
|
483
|
+
offset: z.number().int().min(0).default(0)
|
|
484
|
+
},
|
|
485
|
+
annotations: READ_ONLY
|
|
486
|
+
}, ({ clientId: clientId3, near, ...query }) => run(() => client.client("entities", { ...query, near: near && `${near.x},${near.y},${near.z}` }, clientId3)));
|
|
487
|
+
server.registerTool("client_entity", {
|
|
488
|
+
title: "One entity on a client",
|
|
489
|
+
description: "One entity in a tab's world with its serialized state. The server id of the tab's own player finds its local player.",
|
|
490
|
+
inputSchema: { id: z.string().min(1), clientId },
|
|
491
|
+
annotations: READ_ONLY
|
|
492
|
+
}, ({ id, clientId: clientId3 }) => run(async () => {
|
|
493
|
+
const { entity } = await client.client("entity", { id }, clientId3);
|
|
494
|
+
if (!entity)
|
|
495
|
+
throw new Error(`The client has no entity with id "${id}". client_entities shows its ids`);
|
|
496
|
+
return entity;
|
|
497
|
+
}));
|
|
498
|
+
server.registerTool("client_scene", {
|
|
499
|
+
title: "Scene on a client",
|
|
500
|
+
description: "The scene loaded in a tab, in the same shape as get_scene: objects with `built` telling whether the client built them.",
|
|
501
|
+
inputSchema: { clientId, includeObjects: z.boolean().default(true) },
|
|
502
|
+
annotations: READ_ONLY
|
|
503
|
+
}, ({ clientId: clientId3, includeObjects }) => run(async () => {
|
|
504
|
+
const { scene } = await client.client("scene", {}, clientId3);
|
|
505
|
+
if (!scene)
|
|
506
|
+
return { scene: null, note: "No scene is loaded in this tab. The client opens one with game.openScene(name)" };
|
|
507
|
+
if (includeObjects)
|
|
508
|
+
return scene;
|
|
509
|
+
const { objects, ...summary } = scene;
|
|
510
|
+
return { ...summary, objectCount: objects?.length ?? 0 };
|
|
511
|
+
}));
|
|
512
|
+
server.registerTool("client_network", {
|
|
513
|
+
title: "Client network state",
|
|
514
|
+
description: "A tab's connection to the game server: connected, player id, ping, editor mode, inputs sent but not yet acknowledged (pendingCommands, replayed for prediction) and the next input sequence id.",
|
|
515
|
+
inputSchema: { clientId },
|
|
516
|
+
annotations: READ_ONLY
|
|
517
|
+
}, ({ clientId: clientId3 }) => run(() => client.client("network", {}, clientId3)));
|
|
518
|
+
server.registerTool("client_logs", {
|
|
519
|
+
title: "Client console",
|
|
520
|
+
description: "A tab's console output, uncaught errors and unhandled promise rejections since it loaded (the last 500), oldest first. Pass the returned latestId as afterId later to get only newer lines.",
|
|
521
|
+
inputSchema: {
|
|
522
|
+
clientId,
|
|
523
|
+
level: z.enum(CLIENT_LOG_LEVELS).optional().describe("Only this level and more severe ones, e.g. warn includes error"),
|
|
524
|
+
contains: z.string().optional().describe("Only lines containing this text, case-insensitive"),
|
|
525
|
+
afterId: z.number().int().min(0).default(0),
|
|
526
|
+
limit: z.number().int().min(1).max(500).default(100).describe("Newest lines kept when more match")
|
|
527
|
+
},
|
|
528
|
+
annotations: READ_ONLY
|
|
529
|
+
}, ({ clientId: clientId3, level, contains, afterId, limit }) => run(async () => {
|
|
530
|
+
const { entries = [], latestId = 0 } = await client.client("logs", { after: afterId }, clientId3);
|
|
531
|
+
const maxSeverity = level ? SEVERITY[level] : Infinity;
|
|
532
|
+
const needle = contains?.toLowerCase();
|
|
533
|
+
const matches = entries.filter((entry) => (SEVERITY[entry.level] ?? 2) <= maxSeverity && (!needle || entry.message.toLowerCase().includes(needle)));
|
|
534
|
+
return {
|
|
535
|
+
latestId,
|
|
536
|
+
matched: matches.length,
|
|
537
|
+
entries: matches.slice(-limit).map(({ timestamp, ...entry }) => ({ ...entry, time: new Date(timestamp).toISOString() }))
|
|
538
|
+
};
|
|
539
|
+
}));
|
|
540
|
+
server.registerTool("client_screenshot", {
|
|
541
|
+
title: "Client screenshot",
|
|
542
|
+
description: "What a tab renders right now, as an image. Only the WebGL canvas: HTML UI drawn over it is not included.",
|
|
543
|
+
inputSchema: {
|
|
544
|
+
clientId,
|
|
545
|
+
maxWidth: z.number().int().min(64).max(4096).default(1024).describe("Scaled down to this width"),
|
|
546
|
+
format: z.enum(["jpeg", "png"]).default("jpeg")
|
|
547
|
+
},
|
|
548
|
+
annotations: READ_ONLY
|
|
549
|
+
}, async ({ clientId: clientId3, maxWidth, format }) => {
|
|
550
|
+
try {
|
|
551
|
+
const shot = await client.client("screenshot", { maxWidth, format }, clientId3);
|
|
552
|
+
return {
|
|
553
|
+
content: [
|
|
554
|
+
{ type: "image", data: shot.data, mimeType: shot.mimeType },
|
|
555
|
+
{ type: "text", text: `${shot.width}x${shot.height} ${shot.mimeType}` }
|
|
556
|
+
]
|
|
557
|
+
};
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
server.registerTool("client_trace", {
|
|
563
|
+
title: "Trace client rendering",
|
|
564
|
+
description: "Record a tab for a few seconds and analyse its performance: frame time distribution, render CPU vs GPU time per frame, time per composer pass, game tick time (physics, world.update), WebGL work per frame (draw calls, program / texture / framebuffer switches, uniform calls, uploads), the bottleneck, and the worst hitches with their likely causes (shader compiles, texture uploads, GPU syncs, GC, slow ticks, long scripts, hot JS functions). JS sampling uses the browser's JS Self-Profiling API (Chromium) and GPU time EXT_disjoint_timer_query_webgl2. Also saves a Chrome Trace Event file to open as a flame chart in https://ui.perfetto.dev or chrome://tracing. Keep the tab in the foreground while it records.",
|
|
565
|
+
inputSchema: {
|
|
566
|
+
clientId,
|
|
567
|
+
durationMs: z.number().int().min(500).max(15e3).default(3e3).describe("How long to record"),
|
|
568
|
+
jsProfile: z.boolean().default(true).describe("Sample JS call stacks"),
|
|
569
|
+
sampleIntervalMs: z.number().int().min(1).max(100).default(10).describe("JS sampling interval, the browser may round it up"),
|
|
570
|
+
gpu: z.boolean().default(true).describe("Time each frame on the GPU"),
|
|
571
|
+
maxHitches: z.number().int().min(0).max(50).default(10).describe("Worst late frames to explain"),
|
|
572
|
+
savePath: z.string().optional().describe("Where to write the trace file. Default: a file in the system temp directory"),
|
|
573
|
+
save: z.boolean().default(true).describe("Write the trace file")
|
|
574
|
+
},
|
|
575
|
+
annotations: { ...READ_ONLY, idempotentHint: false }
|
|
576
|
+
}, ({ clientId: clientId3, durationMs, jsProfile, sampleIntervalMs, gpu, maxHitches, savePath, save }) => run(async () => {
|
|
577
|
+
const capture = await client.client("trace", { durationMs, jsProfile: String(jsProfile), sampleIntervalMs, gpu: String(gpu) }, clientId3);
|
|
578
|
+
const analysis = analyzeTrace(capture, { maxHitches });
|
|
579
|
+
let traceFile;
|
|
580
|
+
if (save) {
|
|
581
|
+
traceFile = savePath ? resolve(savePath) : defaultTracePath(clientId3 ?? "auto");
|
|
582
|
+
await mkdir(dirname(traceFile), { recursive: true });
|
|
583
|
+
await writeFile(traceFile, JSON.stringify(toTraceEvents(capture)));
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
...analysis,
|
|
587
|
+
traceFile,
|
|
588
|
+
traceFileNote: traceFile ? "Chrome Trace Event JSON: drag it into https://ui.perfetto.dev (or chrome://tracing, or speedscope.app) for the flame chart" : void 0
|
|
589
|
+
};
|
|
590
|
+
}));
|
|
591
|
+
server.registerTool("compare_entity", {
|
|
592
|
+
title: "Compare an entity on server and client",
|
|
593
|
+
description: "One entity as the server has it next to a tab's copy: position / rotation / scale deltas and the serialized fields that differ. For the tab's own player pass its server id (playerId from list_clients). Needs the server in development too.",
|
|
594
|
+
inputSchema: { id: z.string().min(1).describe("Entity id on the server"), clientId },
|
|
595
|
+
annotations: READ_ONLY
|
|
596
|
+
}, ({ id, clientId: clientId3 }) => run(async () => {
|
|
597
|
+
const [onServer, onClient] = await Promise.all([client.entity(id), client.client("entity", { id }, clientId3)]);
|
|
598
|
+
return compareEntities(id, onServer.entity, onClient.entity);
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/mcp/networkTools.ts
|
|
603
|
+
import { z as z2 } from "zod";
|
|
604
|
+
var clientId2 = z2.string().optional().describe("Tab id from list_clients. Default: the only open tab, or the only one not running the editor");
|
|
605
|
+
var packetFilters = {
|
|
606
|
+
kind: z2.enum(["packet", "event"]).optional().describe("packet: messages on the wire. event: connect, disconnect, sync_enter, sync_leave, dropped, state_gap\u2026"),
|
|
607
|
+
direction: z2.enum(["in", "out"]).optional().describe("Seen from the side that recorded it: out was sent, in was received"),
|
|
608
|
+
type: z2.string().optional().describe("Message or event name, e.g. sv_state or command. Several separated by commas"),
|
|
609
|
+
afterId: z2.number().int().min(0).default(0),
|
|
610
|
+
limit: z2.number().int().min(1).max(2e3).default(50).describe("Newest records kept when more match")
|
|
611
|
+
};
|
|
612
|
+
function withTime({ timestamp, ...record }) {
|
|
613
|
+
return { ...record, time: new Date(timestamp).toISOString() };
|
|
614
|
+
}
|
|
615
|
+
function listing(list) {
|
|
616
|
+
return {
|
|
617
|
+
latestId: list.latestId,
|
|
618
|
+
matched: list.matched,
|
|
619
|
+
records: list.records.map(withTime),
|
|
620
|
+
...list.enabled === false ? { note: "Nothing is being recorded on this side" } : {}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function mergeTimeline(server, client) {
|
|
624
|
+
return [
|
|
625
|
+
...server.map((record) => ({ side: "server", ...record })),
|
|
626
|
+
...client.map((record) => ({ side: "client", ...record }))
|
|
627
|
+
].sort((a, b) => a.timestamp - b.timestamp || (a.side === b.side ? a.id - b.id : a.side === "server" ? -1 : 1));
|
|
628
|
+
}
|
|
629
|
+
function registerNetworkTools(server, client) {
|
|
630
|
+
server.registerTool("network_packets", {
|
|
631
|
+
title: "Packets on the server",
|
|
632
|
+
description: "Packets the game server sent and received and its network events, oldest first: time, direction, message name, client (peer), size in bytes, server tick, input sequence id and a few facts about the payload (entity count, ack, command). The server keeps the last 2000. Pass the returned latestId as afterId to get only newer ones; network_packet shows a payload. Development only.",
|
|
633
|
+
inputSchema: { ...packetFilters, peer: z2.string().optional().describe("Only one client: its player id (list_players, or playerId from list_clients)") },
|
|
634
|
+
annotations: READ_ONLY
|
|
635
|
+
}, ({ afterId, ...query }) => run(async () => listing(await client.packets({ ...query, after: afterId }))));
|
|
636
|
+
server.registerTool("network_packet", {
|
|
637
|
+
title: "One packet on the server",
|
|
638
|
+
description: "One record from network_packets with its payload, as it was serialized for the wire. Payloads of old records are dropped to bound memory (hasPayload false). Development only.",
|
|
639
|
+
inputSchema: { id: z2.number().int().min(1) },
|
|
640
|
+
annotations: READ_ONLY
|
|
641
|
+
}, ({ id }) => run(async () => {
|
|
642
|
+
const { record } = await client.packet(id);
|
|
643
|
+
if (!record)
|
|
644
|
+
throw new Error(`No record ${id}: it is older than the 2000 the server keeps, or was never recorded`);
|
|
645
|
+
return withTime(record);
|
|
646
|
+
}));
|
|
647
|
+
server.registerTool("network_sync", {
|
|
648
|
+
title: "State sync",
|
|
649
|
+
description: "Who is sent what: the current server tick, the sync distance, each player with the entities in its last state update (tracked), its last acknowledged input and the size of that update; and each synced entity with how many players track it (trackedBy, 0 means no player is in range), whether it changed since it was last synced and the tick it was last sent in. For entities that do not show up or stop updating on a client. Development only.",
|
|
650
|
+
inputSchema: {
|
|
651
|
+
player: z2.string().optional().describe("Only this player, and the entities with `tracked` telling whether it is sent them"),
|
|
652
|
+
includeEntities: z2.boolean().default(true)
|
|
653
|
+
},
|
|
654
|
+
annotations: READ_ONLY
|
|
655
|
+
}, ({ player, includeEntities }) => run(async () => {
|
|
656
|
+
const sync = await client.sync();
|
|
657
|
+
const { entities = [], players = [], ...rest } = sync;
|
|
658
|
+
if (!player)
|
|
659
|
+
return includeEntities ? sync : { ...rest, players, entityCount: entities.length };
|
|
660
|
+
const one = players.find((p) => p.id === player);
|
|
661
|
+
if (!one)
|
|
662
|
+
throw new Error(`No player "${player}". Players: ${players.map((p) => p.id).join(", ") || "none"}`);
|
|
663
|
+
const tracked = new Set(one.tracked);
|
|
664
|
+
return {
|
|
665
|
+
...rest,
|
|
666
|
+
player: one,
|
|
667
|
+
...includeEntities ? { entities: entities.map((entity) => ({ ...entity, tracked: tracked.has(entity.id) })) } : { entityCount: entities.length }
|
|
668
|
+
};
|
|
669
|
+
}));
|
|
670
|
+
server.registerTool("client_packets", {
|
|
671
|
+
title: "Packets on a client",
|
|
672
|
+
description: "Packets a tab sent and received and its network events (connect, disconnect, state_gap for lost state updates, state_out_of_order), in the same shape as network_packets. Recorded since the tab loaded, the last 2000.",
|
|
673
|
+
inputSchema: { clientId: clientId2, ...packetFilters },
|
|
674
|
+
annotations: READ_ONLY
|
|
675
|
+
}, ({ clientId: clientId3, afterId, ...query }) => run(async () => listing(await client.client("packets", { ...query, after: afterId }, clientId3))));
|
|
676
|
+
server.registerTool("client_packet", {
|
|
677
|
+
title: "One packet on a client",
|
|
678
|
+
description: "One record from client_packets with its payload.",
|
|
679
|
+
inputSchema: { id: z2.number().int().min(1), clientId: clientId2 },
|
|
680
|
+
annotations: READ_ONLY
|
|
681
|
+
}, ({ id, clientId: clientId3 }) => run(async () => {
|
|
682
|
+
const { record } = await client.client("packet", { id }, clientId3);
|
|
683
|
+
if (!record)
|
|
684
|
+
throw new Error(`No record ${id}: it is older than the 2000 the tab keeps, or was never recorded`);
|
|
685
|
+
return withTime(record);
|
|
686
|
+
}));
|
|
687
|
+
server.registerTool("network_timeline", {
|
|
688
|
+
title: "Network timeline of one client",
|
|
689
|
+
description: "What happened between the server and one tab, in order of time: the server's records for that client and the tab's own, merged, each marked with its side. Shows a packet leaving one side and arriving on the other (or not), inputs against the state updates that acknowledge them, and events like sync_enter / sync_leave, dropped and state_gap. Needs the server in development too.",
|
|
690
|
+
inputSchema: {
|
|
691
|
+
clientId: clientId2,
|
|
692
|
+
sinceMs: z2.number().int().min(100).max(6e5).default(5e3).describe("How far back to look"),
|
|
693
|
+
includePings: z2.boolean().default(false).describe("Keep ping / pong, one pair per second"),
|
|
694
|
+
eventsOnly: z2.boolean().default(false).describe("Leave the packets out"),
|
|
695
|
+
limit: z2.number().int().min(1).max(2e3).default(200).describe("Newest entries kept when more match")
|
|
696
|
+
},
|
|
697
|
+
annotations: READ_ONLY
|
|
698
|
+
}, ({ clientId: clientId3, sinceMs, includePings, eventsOnly, limit }) => run(async () => {
|
|
699
|
+
const { network } = await client.client("network", {}, clientId3);
|
|
700
|
+
if (!network?.playerId)
|
|
701
|
+
throw new Error("This tab is not connected to the game server, so the server has no records for it");
|
|
702
|
+
const kind = eventsOnly ? "event" : void 0;
|
|
703
|
+
const [onServer, onClient] = await Promise.all([
|
|
704
|
+
client.packets({ peer: network.playerId, kind, limit: 2e3 }),
|
|
705
|
+
client.client("packets", { kind, limit: 2e3 }, clientId3)
|
|
706
|
+
]);
|
|
707
|
+
const from = Date.now() - sinceMs;
|
|
708
|
+
const entries = mergeTimeline(onServer.records, onClient.records).filter((record) => record.timestamp >= from && (includePings || record.type !== "ping" && record.type !== "pong"));
|
|
709
|
+
return {
|
|
710
|
+
playerId: network.playerId,
|
|
711
|
+
pingMs: network.pingMs,
|
|
712
|
+
matched: entries.length,
|
|
713
|
+
entries: entries.slice(-limit).map(withTime),
|
|
714
|
+
note: "Times are each side's own clock. On one machine they line up; otherwise shift one side by the clock difference"
|
|
715
|
+
};
|
|
716
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/mcp/pluginTools.ts
|
|
720
|
+
import { z as z3 } from "zod";
|
|
721
|
+
var installCommand = (pkg) => `npx @mavonengine/cli install ${pkg}`;
|
|
722
|
+
async function installedPlugins(client) {
|
|
723
|
+
const [{ plugins = [] }, routes] = await Promise.all([
|
|
724
|
+
client.plugins(),
|
|
725
|
+
// An older server lists its plugins but not its routes: the plugins are still worth showing
|
|
726
|
+
client.routes().then((result) => result.routes ?? [], () => null)
|
|
727
|
+
]);
|
|
728
|
+
return plugins.map((plugin) => ({
|
|
729
|
+
...plugin,
|
|
730
|
+
endpoints: routes ? routes.filter((route) => route.source === "plugin" && route.plugin === plugin.package).map(({ method, path, devOnly, summary }) => ({ method, path, devOnly, summary })) : null
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
function registerPluginTools(server, client, list) {
|
|
734
|
+
server.registerTool("list_plugins", {
|
|
735
|
+
title: "Plugins of the project",
|
|
736
|
+
description: "The engine plugins in the project's mavonengine.json: version, whether each is enabled and runs on this server, where it runs (client, server or both), its prefabs and the HTTP endpoints it registered. A problem is reported for a plugin that is missing or invalid. See available_plugins for plugins the project could add.",
|
|
737
|
+
annotations: READ_ONLY
|
|
738
|
+
}, () => run(async () => ({ plugins: await installedPlugins(client) })));
|
|
739
|
+
if (!list)
|
|
740
|
+
return;
|
|
741
|
+
const registry = () => fetchPluginRegistry({ url: list.url, fetch: list.fetch });
|
|
742
|
+
server.registerTool("available_plugins", {
|
|
743
|
+
title: "Plugins that can be installed",
|
|
744
|
+
description: "The official MavonEngine plugins: what each does, where it runs, the engine versions it supports and the command that installs it. Says which ones the project already has. Check this before building something a plugin already does, such as chat or a monitoring dashboard, and read plugin_readme before suggesting one. Installing is for the user to do: this server is read-only.",
|
|
745
|
+
annotations: { ...READ_ONLY, openWorldHint: true }
|
|
746
|
+
}, () => run(async () => {
|
|
747
|
+
const [{ url, plugins }, installed] = await Promise.all([
|
|
748
|
+
registry(),
|
|
749
|
+
// Whether the project has them is extra: the list is worth showing without a running server
|
|
750
|
+
installedPlugins(client).then((found) => new Map(found.map((plugin) => [plugin.package, plugin])), () => null)
|
|
751
|
+
]);
|
|
752
|
+
return {
|
|
753
|
+
source: url,
|
|
754
|
+
projectPlugins: installed ? "known" : "unknown: the game server could not be asked",
|
|
755
|
+
plugins: plugins.map((plugin) => ({
|
|
756
|
+
package: plugin.package,
|
|
757
|
+
name: plugin.name,
|
|
758
|
+
description: plugin.description,
|
|
759
|
+
runsOn: plugin.target,
|
|
760
|
+
engine: plugin.engine,
|
|
761
|
+
homepage: plugin.homepage,
|
|
762
|
+
hasReadme: !!plugin.readme,
|
|
763
|
+
inProject: installed ? installed.has(plugin.package) : null,
|
|
764
|
+
install: installCommand(plugin.package)
|
|
765
|
+
}))
|
|
766
|
+
};
|
|
767
|
+
}));
|
|
768
|
+
server.registerTool("plugin_readme", {
|
|
769
|
+
title: "Read a plugin's readme",
|
|
770
|
+
description: "The README of an official MavonEngine plugin as Markdown, by its package name from available_plugins: what it does, its options for mavonengine.json, how a game hooks into it and its limits.",
|
|
771
|
+
inputSchema: { package: z3.string().describe("npm package of the plugin, e.g. @mavonengine/chat") },
|
|
772
|
+
annotations: { ...READ_ONLY, openWorldHint: true }
|
|
773
|
+
}, async ({ package: pkg }) => {
|
|
774
|
+
try {
|
|
775
|
+
const { plugins } = await registry();
|
|
776
|
+
const plugin = plugins.find((candidate) => candidate.package === pkg);
|
|
777
|
+
if (!plugin)
|
|
778
|
+
throw new Error(`${pkg} is not on the list of plugins. It has: ${plugins.map((candidate) => candidate.package).join(", ") || "nothing"}`);
|
|
779
|
+
return { content: [{ type: "text", text: await fetchPluginReadme(plugin, { fetch: list.fetch }) }] };
|
|
780
|
+
} catch (err) {
|
|
781
|
+
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/mcp/server.ts
|
|
787
|
+
var INSTRUCTIONS = `Reads the live state of a running MavonEngine game: the game server (health, performance, players, scene,
|
|
788
|
+
entities, logs, HTTP requests) and, in development, the game tabs open in a browser (renderer stats, the client's world,
|
|
789
|
+
network state, console logs, screenshots). Every tool is read-only.
|
|
790
|
+
Server scene / entity tools need the server in development (NODE_ENV is not "production"); client tools need the client
|
|
791
|
+
served by Vite in dev. Start with server_status and list_clients. Use type / near filters rather than paging through a world.
|
|
792
|
+
compare_entity shows one entity on both sides, for prediction, interpolation and sync bugs.
|
|
793
|
+
network_packets / client_packets list the packets each side sent and received, network_timeline merges both sides for one
|
|
794
|
+
client, and network_sync tells which entities each player is sent: use them when something does not arrive or arrives late.
|
|
795
|
+
client_trace records a few seconds of a tab for frame drops, stutter and slow rendering: the bottleneck, hitches with
|
|
796
|
+
their causes, hot JS functions, WebGL load, and a trace file for flame charts.
|
|
797
|
+
list_plugins shows the plugins the project has, available_plugins the official ones it could add and plugin_readme how
|
|
798
|
+
one is set up and used: look there before building something a plugin already does, like chat.
|
|
799
|
+
docs_list_pages / docs_get_page read the MavonEngine documentation: check it before guessing how an engine API works.`;
|
|
800
|
+
function summarize(metrics) {
|
|
801
|
+
const { cpu, bandwidth, tick, players, ...rest } = metrics ?? {};
|
|
802
|
+
return {
|
|
803
|
+
...rest,
|
|
804
|
+
players: Array.isArray(players) ? players.length : 0,
|
|
805
|
+
cpu: cpu && { current: cpu.current, avg1s: cpu.avg1s, avg10s: cpu.avg10s, avg60s: cpu.avg60s },
|
|
806
|
+
bandwidth: bandwidth && { sentPerSecond: bandwidth.sentPerSecond, receivedPerSecond: bandwidth.receivedPerSecond },
|
|
807
|
+
tick: tick && { budgetMs: tick.budgetMs, latest: tick.latest }
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
function levelOf(level) {
|
|
811
|
+
return level.toLowerCase().replace(/[^a-z]/g, "");
|
|
812
|
+
}
|
|
813
|
+
function createMcpServer(client, docs, pluginList) {
|
|
814
|
+
const server = new McpServer({ name: "mavonengine", version }, { instructions: INSTRUCTIONS });
|
|
815
|
+
server.registerTool("server_status", {
|
|
816
|
+
title: "Game server status",
|
|
817
|
+
description: "Health and current performance of the game server: uptime, tick rate and tick time against its budget, CPU, bandwidth, memory, player and entity counts, physics bodies, latency simulation. History is left out, see server_metrics.",
|
|
818
|
+
annotations: READ_ONLY
|
|
819
|
+
}, () => run(async () => ({ server: client.url, ...summarize(await client.metrics()) })));
|
|
820
|
+
server.registerTool("server_metrics", {
|
|
821
|
+
title: "Full metrics snapshot",
|
|
822
|
+
description: "The full metrics snapshot. With includeHistory, also the last ~60 samples of CPU, bandwidth and tick timings, to spot spikes.",
|
|
823
|
+
inputSchema: { includeHistory: z4.boolean().default(false).describe("Include the per-sample history arrays") },
|
|
824
|
+
annotations: READ_ONLY
|
|
825
|
+
}, ({ includeHistory }) => run(async () => {
|
|
826
|
+
const metrics = await client.metrics();
|
|
827
|
+
if (includeHistory)
|
|
828
|
+
return metrics;
|
|
829
|
+
const { cpu, bandwidth, tick } = metrics ?? {};
|
|
830
|
+
const { history: _cpu, ...cpuNow } = cpu ?? {};
|
|
831
|
+
const { history: _bandwidth, ...bandwidthNow } = bandwidth ?? {};
|
|
832
|
+
const { history: _tick, ...tickNow } = tick ?? {};
|
|
833
|
+
return { ...metrics, cpu: cpuNow, bandwidth: bandwidthNow, tick: tickNow };
|
|
834
|
+
}));
|
|
835
|
+
server.registerTool("list_players", {
|
|
836
|
+
title: "Connected players",
|
|
837
|
+
description: "Connected players: id, display name, position, last processed input sequence, tracked entities, time connected, and connection details (address, round trip time, bytes sent and received).",
|
|
838
|
+
annotations: READ_ONLY
|
|
839
|
+
}, () => run(async () => (await client.metrics())?.players ?? []));
|
|
840
|
+
server.registerTool("get_scene", {
|
|
841
|
+
title: "Active scene",
|
|
842
|
+
description: "The scene loaded on the server: name, file, whether it hot-reloads, unsaved changes, settings, and its objects with type, transform, props, the plugin that registered the type and whether the server built it (false for client-only types). Development only.",
|
|
843
|
+
inputSchema: { includeObjects: z4.boolean().default(true).describe("Include the scene objects, not just the summary") },
|
|
844
|
+
annotations: READ_ONLY
|
|
845
|
+
}, ({ includeObjects }) => run(async () => {
|
|
846
|
+
const { scene } = await client.scene();
|
|
847
|
+
if (!scene)
|
|
848
|
+
return { scene: null, note: "No scene is loaded. The server opens one with server.openScene(name)" };
|
|
849
|
+
if (includeObjects)
|
|
850
|
+
return scene;
|
|
851
|
+
const { objects, ...summary } = scene;
|
|
852
|
+
return { ...summary, objectCount: objects?.length ?? 0 };
|
|
853
|
+
}));
|
|
854
|
+
server.registerTool("list_entities", {
|
|
855
|
+
title: "Entities in the world",
|
|
856
|
+
description: "Entities in the server world: id, type, position, rotation, scale, whether networked or a player, and the scene object it was built from. Filter by type and/or distance to a point (results are then nearest first). Returns the total match count. Development only.",
|
|
857
|
+
inputSchema: {
|
|
858
|
+
type: z4.string().optional().describe("Entity class / networked type name, or scene object type, case-insensitive"),
|
|
859
|
+
near: vec3.optional().describe("Only entities around this point, nearest first"),
|
|
860
|
+
radius: z4.number().nonnegative().optional().describe("Max distance from `near`"),
|
|
861
|
+
limit: z4.number().int().min(1).max(500).default(50),
|
|
862
|
+
offset: z4.number().int().min(0).default(0)
|
|
863
|
+
},
|
|
864
|
+
annotations: READ_ONLY
|
|
865
|
+
}, (query) => run(() => client.entities(query)));
|
|
866
|
+
server.registerTool("get_entity", {
|
|
867
|
+
title: "One entity",
|
|
868
|
+
description: "One entity by id, with the state it sends to clients (its serialize() output). Development only.",
|
|
869
|
+
inputSchema: { id: z4.string().min(1) },
|
|
870
|
+
annotations: READ_ONLY
|
|
871
|
+
}, ({ id }) => run(async () => {
|
|
872
|
+
const { entity } = await client.entity(id);
|
|
873
|
+
if (!entity)
|
|
874
|
+
throw new Error(`No entity with id "${id}" in the world. list_entities shows the ids`);
|
|
875
|
+
return entity;
|
|
876
|
+
}));
|
|
877
|
+
server.registerTool("recent_logs", {
|
|
878
|
+
title: "Server logs",
|
|
879
|
+
description: "Recent game server log lines, oldest first. The server keeps the last 500 by default. Pass the returned latestId as afterId later to get only newer lines.",
|
|
880
|
+
inputSchema: {
|
|
881
|
+
level: z4.enum(LOG_LEVELS).optional().describe("Only this level and more severe ones, e.g. warn includes error"),
|
|
882
|
+
contains: z4.string().optional().describe("Only lines containing this text, case-insensitive"),
|
|
883
|
+
afterId: z4.number().int().min(0).default(0),
|
|
884
|
+
limit: z4.number().int().min(1).max(500).default(100).describe("Newest lines kept when more match")
|
|
885
|
+
},
|
|
886
|
+
annotations: READ_ONLY
|
|
887
|
+
}, ({ level, contains, afterId, limit }) => run(async () => {
|
|
888
|
+
const { entries = [], latestId = 0 } = await client.logs(afterId);
|
|
889
|
+
const maxSeverity = level ? LOG_LEVELS.indexOf(level) : Infinity;
|
|
890
|
+
const needle = contains?.toLowerCase();
|
|
891
|
+
const matches = entries.filter((entry) => {
|
|
892
|
+
const severity = LOG_LEVELS.indexOf(levelOf(entry.level));
|
|
893
|
+
return (severity === -1 || severity <= maxSeverity) && (!needle || entry.message.toLowerCase().includes(needle));
|
|
894
|
+
});
|
|
895
|
+
return {
|
|
896
|
+
latestId,
|
|
897
|
+
matched: matches.length,
|
|
898
|
+
entries: matches.slice(-limit).map(({ timestamp, ...entry }) => ({ ...entry, time: new Date(timestamp).toISOString() }))
|
|
899
|
+
};
|
|
900
|
+
}));
|
|
901
|
+
server.registerTool("recent_requests", {
|
|
902
|
+
title: "HTTP requests",
|
|
903
|
+
description: "Recent HTTP requests the game server answered: method, path, status, duration, bytes, address. Pass the returned latestId as afterId later to get only newer ones.",
|
|
904
|
+
inputSchema: {
|
|
905
|
+
pathContains: z4.string().optional(),
|
|
906
|
+
minStatus: z4.number().int().optional().describe("e.g. 400 for errors only"),
|
|
907
|
+
afterId: z4.number().int().min(0).default(0),
|
|
908
|
+
limit: z4.number().int().min(1).max(500).default(50).describe("Newest requests kept when more match")
|
|
909
|
+
},
|
|
910
|
+
annotations: READ_ONLY
|
|
911
|
+
}, ({ pathContains, minStatus, afterId, limit }) => run(async () => {
|
|
912
|
+
const { entries = [], latestId = 0 } = await client.requests(afterId);
|
|
913
|
+
const matches = entries.filter((entry) => (!pathContains || entry.path.includes(pathContains)) && (minStatus === void 0 || (entry.status ?? 0) >= minStatus));
|
|
914
|
+
return { latestId, matched: matches.length, entries: matches.slice(-limit) };
|
|
915
|
+
}));
|
|
916
|
+
registerClientTools(server, client);
|
|
917
|
+
registerNetworkTools(server, client);
|
|
918
|
+
registerPluginTools(server, client, pluginList);
|
|
919
|
+
if (docs)
|
|
920
|
+
registerDocsTools(server, docs);
|
|
921
|
+
return server;
|
|
922
|
+
}
|
|
923
|
+
export {
|
|
924
|
+
createMcpServer
|
|
925
|
+
};
|