@el4cteo/rbx-studio-mcp 0.1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,261 @@
1
+ import { z } from "zod";
2
+ import { propertiesOf, suggestClass, suggestProperty } from "../lib/apidump.js";
3
+ import { ToolError } from "../lib/errors.js";
4
+ import { table } from "../lib/format.js";
5
+ import { defineTool } from "../lib/tool.js";
6
+ const propertyBag = z
7
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
8
+ .optional()
9
+ .describe('Properties to set, as name → value. Values are written the way Studio\'s ' +
10
+ 'Properties panel shows them: "12, 0, 5" for a Vector3, "0.2, 0.6, 1" for ' +
11
+ 'a Color3, "Neon" or "Enum.Material.Neon" for an enum, true/false for a bool.');
12
+ const attributeBag = z
13
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
14
+ .optional()
15
+ .describe("Attributes to set, as name → value. An empty string removes one.");
16
+ const tagSpec = z
17
+ .object({
18
+ add: z.array(z.string()).optional().describe("CollectionService tags to add."),
19
+ remove: z.array(z.string()).optional().describe("Tags to remove."),
20
+ })
21
+ .optional()
22
+ .describe("CollectionService tags to add or remove.");
23
+ /**
24
+ * Resolves each property to its declared type, refusing unknown ones.
25
+ *
26
+ * This is the reason the server holds a live API dump. A misspelled property
27
+ * would otherwise reach Studio, fail there, and come back as an engine message
28
+ * with no idea what was meant; here it is caught before the round trip and
29
+ * answered with the closest real names.
30
+ */
31
+ async function resolveProperties(className, properties, where) {
32
+ if (!properties || Object.keys(properties).length === 0)
33
+ return undefined;
34
+ const known = await propertiesOf(className);
35
+ // No dump (offline, or a class too new for the cached copy) means passing the
36
+ // values through untyped is better than refusing work we cannot check.
37
+ if (known.length === 0)
38
+ return undefined;
39
+ const byName = new Map(known.map((property) => [property.name, property]));
40
+ const resolved = {};
41
+ for (const [name, value] of Object.entries(properties)) {
42
+ const info = byName.get(name);
43
+ if (!info) {
44
+ const suggestions = await suggestProperty(className, name);
45
+ throw new ToolError("UNKNOWN_PROPERTY", `${className} has no property "${name}" (${where}).`, suggestions.length > 0
46
+ ? `Did you mean: ${suggestions.join(", ")}?`
47
+ : `Call \`inspect\` with detail "full" on an existing ${className} to see ` +
48
+ "its properties.");
49
+ }
50
+ resolved[name] = { value, type: info.valueType };
51
+ }
52
+ return resolved;
53
+ }
54
+ async function assertCreatable(className) {
55
+ const known = await propertiesOf(className);
56
+ if (known.length > 0)
57
+ return;
58
+ const suggestions = await suggestClass(className);
59
+ if (suggestions.length > 0) {
60
+ throw new ToolError("UNKNOWN_CLASS", `"${className}" is not a Roblox class.`, `Did you mean: ${suggestions.join(", ")}?`);
61
+ }
62
+ }
63
+ const createSpec = z.lazy(() => z.object({
64
+ parent: z
65
+ .string()
66
+ .optional()
67
+ .describe('Where to put it, e.g. "Workspace". Required at the top level only.'),
68
+ className: z
69
+ .string()
70
+ .describe('Concrete class to create, e.g. "Part", "Folder", "Model", "SpawnLocation".'),
71
+ name: z.string().optional().describe("Name for the new instance."),
72
+ properties: propertyBag,
73
+ attributes: attributeBag,
74
+ tags: tagSpec,
75
+ children: z
76
+ .array(createSpec)
77
+ .optional()
78
+ .describe("Instances to create inside this one. Build a whole model in one call " +
79
+ "rather than creating a parent and then addressing it by a path you " +
80
+ "have to guess."),
81
+ }));
82
+ /** Walks the create tree, replacing each property bag with typed specs. */
83
+ async function typeCreateSpec(spec, where) {
84
+ await assertCreatable(spec.className);
85
+ return {
86
+ parent: spec.parent,
87
+ className: spec.className,
88
+ name: spec.name,
89
+ properties: await resolveProperties(spec.className, spec.properties, where),
90
+ attributes: spec.attributes,
91
+ tags: spec.tags,
92
+ children: spec.children
93
+ ? await Promise.all(spec.children.map((child, index) => typeCreateSpec(child, `${where} > ${spec.className}.children[${index}]`)))
94
+ : undefined,
95
+ };
96
+ }
97
+ function undoNote(response) {
98
+ return response.undoStep
99
+ ? `one undo step, "${response.undoStep}"`
100
+ : "Studio would not open an undo recording, so this is not undoable as one step";
101
+ }
102
+ export function registerInstanceTools(context) {
103
+ const { bridge } = context;
104
+ defineTool(context, {
105
+ name: "create",
106
+ title: "Create instances",
107
+ description: "Creates instances with their properties, attributes and tags set at " +
108
+ "creation, as one undoable step.\n\n" +
109
+ "Nest with `children` to build a whole model in a single call. That is " +
110
+ "both faster and safer than creating a parent and then addressing it: a " +
111
+ "new instance's path is not knowable until it exists, and same-named " +
112
+ "siblings make guessing it unreliable.\n\n" +
113
+ "Property names are checked against the live Roblox API dump before " +
114
+ "anything is sent to Studio, so a typo comes back with the closest real " +
115
+ "names rather than an engine error.\n\n" +
116
+ "Use `script_create` for Script, LocalScript and ModuleScript — it takes " +
117
+ "source directly.",
118
+ inputSchema: {
119
+ instances: z
120
+ .array(createSpec)
121
+ .min(1)
122
+ .max(100)
123
+ .describe("Instances to create together as one undoable step."),
124
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
125
+ },
126
+ }, async (args) => {
127
+ const specs = await Promise.all(args.instances.map((spec, index) => typeCreateSpec(spec, `instances[${index}]`)));
128
+ const response = await bridge.call("instances.create", { instances: specs }, { studioId: args.studioId, timeoutMs: 30_000 });
129
+ return table(["path", "className"], response.items, { more: undoNote(response) });
130
+ });
131
+ defineTool(context, {
132
+ name: "modify",
133
+ title: "Modify instances",
134
+ description: "Sets properties, attributes and tags on existing instances, as one " +
135
+ "undoable step.\n\n" +
136
+ "Each entry takes a list of `paths`, so one entry can apply the same " +
137
+ "change to many instances — anchoring 200 parts is one entry, not 200. " +
138
+ "Combine with `find` to build the path list.\n\n" +
139
+ "The batch is all-or-nothing: if any value is rejected the recording is " +
140
+ "cancelled and every instance reverts, rather than leaving the place " +
141
+ "half-changed.\n\n" +
142
+ "Values use the same notation the Properties panel shows — see the " +
143
+ "`properties` field. To change a script's code use `script_edit`.",
144
+ inputSchema: {
145
+ targets: z
146
+ .array(z.object({
147
+ paths: z
148
+ .array(z.string())
149
+ .min(1)
150
+ .describe('Instances to change, e.g. ["Workspace.Part[3]", "Workspace.Wall"].'),
151
+ properties: propertyBag,
152
+ attributes: attributeBag,
153
+ tags: tagSpec,
154
+ }))
155
+ .min(1)
156
+ .max(100)
157
+ .describe("Changes to apply together as one undoable step."),
158
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
159
+ },
160
+ destructive: true,
161
+ }, async (args) => {
162
+ // Property types depend on each instance's actual class, which is only
163
+ // known once Studio answers, so classes are probed first — the same
164
+ // two-pass shape `inspect` uses.
165
+ const needsTypes = args.targets.some((target) => target.properties && Object.keys(target.properties).length > 0);
166
+ let targets = args.targets;
167
+ if (needsTypes) {
168
+ const probe = await bridge.call("discover.inspect", { paths: args.targets.flatMap((target) => target.paths), includeChildren: false }, { studioId: args.studioId });
169
+ // Correlated on the path we asked about, not the one that came back. The
170
+ // canonical path carries an index where a name is shared, so matching on
171
+ // it silently found nothing for exactly the ambiguous paths that most
172
+ // need checking — and the properties were then dropped without a word.
173
+ const classOf = new Map(probe.items.map((item) => [item.requested ?? item.path, item.className]));
174
+ targets = await Promise.all(args.targets.map(async (target, index) => {
175
+ if (!target.properties)
176
+ return target;
177
+ const classes = [
178
+ ...new Set(target.paths.map((path) => classOf.get(path)).filter(Boolean)),
179
+ ];
180
+ if (classes.length === 0) {
181
+ throw new ToolError("UNRESOLVED_TARGET", `Could not read the class of any path in targets[${index}].`, "Check the paths with `find` or `tree`. Properties are typed from " +
182
+ "the class, so nothing was changed rather than guessing.");
183
+ }
184
+ // Paths in one entry can span classes and the property must exist on
185
+ // every one, so each is checked. The types agree across classes that
186
+ // share a property, so the last resolution stands for all of them.
187
+ let properties;
188
+ for (const className of classes) {
189
+ properties = await resolveProperties(className, target.properties, `targets[${index}]`);
190
+ }
191
+ return { ...target, properties };
192
+ }));
193
+ }
194
+ const response = await bridge.call("instances.modify", { targets }, { studioId: args.studioId, timeoutMs: 30_000 });
195
+ return table(["path", "className", "changed"], response.items, {
196
+ more: undoNote(response),
197
+ });
198
+ });
199
+ defineTool(context, {
200
+ name: "delete",
201
+ title: "Delete instances",
202
+ description: "Destroys instances and everything inside them, as one undoable step.\n\n" +
203
+ "Deleting a container deletes its whole subtree, so the response reports " +
204
+ "how many descendants went with each one — check it before telling the " +
205
+ "user what happened.\n\n" +
206
+ "Services cannot be deleted and are refused. Paths shift when same-named " +
207
+ "siblings are removed, so read fresh paths from `find` or `tree` before a " +
208
+ "second delete rather than reusing indexes from an earlier call.",
209
+ inputSchema: {
210
+ paths: z
211
+ .array(z.string())
212
+ .min(1)
213
+ .max(200)
214
+ .describe('Instances to destroy, e.g. ["Workspace.OldModel"].'),
215
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
216
+ },
217
+ destructive: true,
218
+ }, async (args) => {
219
+ const response = await bridge.call("instances.delete", { paths: args.paths }, { studioId: args.studioId, timeoutMs: 30_000 });
220
+ return table(["path", "className", "descendants"], response.items, {
221
+ more: undoNote(response),
222
+ });
223
+ });
224
+ defineTool(context, {
225
+ name: "move",
226
+ title: "Move or clone instances",
227
+ description: "Reparents instances, or clones them into a new parent, as one undoable " +
228
+ "step.\n\n" +
229
+ 'Set `mode: "clone"` to copy instead of move — that is how to duplicate ' +
230
+ "something, optionally renaming it in the same call.\n\n" +
231
+ "Moving an instance into itself or its own descendant is refused: it " +
232
+ "silently detaches the branch from the data model and undo does not " +
233
+ "bring it back.",
234
+ inputSchema: {
235
+ items: z
236
+ .array(z.object({
237
+ path: z.string().describe("Instance to move or clone."),
238
+ to: z.string().describe("New parent's path."),
239
+ mode: z
240
+ .enum(["move", "clone"])
241
+ .default("move")
242
+ .describe("'move' reparents the original; 'clone' leaves it and copies."),
243
+ name: z
244
+ .string()
245
+ .optional()
246
+ .describe("Rename it as part of the same step. Useful with clone."),
247
+ }))
248
+ .min(1)
249
+ .max(200)
250
+ .describe("Moves to apply together as one undoable step."),
251
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
252
+ },
253
+ destructive: true,
254
+ }, async (args) => {
255
+ const response = await bridge.call("instances.move", { items: args.items }, { studioId: args.studioId, timeoutMs: 30_000 });
256
+ return table(["path", "className", "cloned"], response.items, {
257
+ more: undoNote(response),
258
+ });
259
+ });
260
+ }
261
+ //# sourceMappingURL=instances.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instances.js","sourceRoot":"","sources":["../../src/tools/instances.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,367 @@
1
+ import { z } from "zod";
2
+ import { json, limitSchema, table, text, textOf } from "../lib/format.js";
3
+ import { defineTool } from "../lib/tool.js";
4
+ /**
5
+ * Whether a profiler frame belongs to a Studio plugin rather than to the place.
6
+ *
7
+ * `IsPlugin` looks like the answer and is not enough on its own: the engine sets
8
+ * it only on a plugin's nameless root frame, never on the frames underneath that
9
+ * carry the actual `Source` and the actual time. Filtering on it alone therefore
10
+ * strips one zero-cost row per plugin and lets every line of plugin work through
11
+ * -- measured, and the result was this server's own console animation sitting at
12
+ * the top of a profile of somebody else's game.
13
+ *
14
+ * The source prefix is the reliable signal. Plugins load from the Creator Store
15
+ * as `cloud_<assetId>.`, from a local file as `user_<file>.rbxmx.`, and ship with
16
+ * Studio as `builtin_`; a place's own scripts are named by their data model path.
17
+ */
18
+ function isPluginFrame(entry) {
19
+ if (entry.IsPlugin === true) {
20
+ return true;
21
+ }
22
+ return /^(cloud_|user_|builtin_)/.test(entry.Source ?? "");
23
+ }
24
+ export function registerPerfTools(context) {
25
+ const { bridge } = context;
26
+ defineTool(context, {
27
+ name: "console",
28
+ title: "Read Studio output",
29
+ description: "Reads the Studio Output window — prints, warnings and runtime errors, " +
30
+ "newest last.\n\n" +
31
+ "This is how to find out what actually happened after a playtest or an " +
32
+ "`execute_luau` call. An error here usually names the script and line, " +
33
+ "which `script_read` can then open directly.\n\n" +
34
+ "Filter with `level` to see only errors, or `pattern` to follow one " +
35
+ "subsystem's logging. Up to 2000 lines are held, so prefer a filter over " +
36
+ "a large `limit`.\n\n" +
37
+ "Each connected session keeps its own log, recorded from the moment its " +
38
+ "plugin loaded — the editor session and a running playtest server do not " +
39
+ "share one. To read what a playtest printed, target the playtest's " +
40
+ "studioId (see `list_studios`); the editor's log will not have it. " +
41
+ "Nothing printed before the plugin loaded is recoverable, and output from " +
42
+ "the playtest *client* is not reachable at all, because Studio forbids " +
43
+ "client sessions from making HTTP requests.",
44
+ inputSchema: {
45
+ level: z
46
+ .enum(["print", "info", "warning", "error"])
47
+ .optional()
48
+ .describe("Only this severity. Omit for everything."),
49
+ pattern: z
50
+ .string()
51
+ .optional()
52
+ .describe('Lua pattern the message must match, e.g. "Combat" or "^%[Server%]". ' +
53
+ "Lua patterns escape with %, not backslash."),
54
+ limit: limitSchema,
55
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
56
+ },
57
+ readOnly: true,
58
+ }, async (args) => {
59
+ const response = await bridge.call("perf.console", { level: args.level, pattern: args.pattern, limit: args.limit }, { studioId: args.studioId });
60
+ if (response.items.length === 0) {
61
+ // "Empty" on its own is ambiguous, and the ambiguity is the whole
62
+ // problem: in a playtest the plugin starts recording at the same moment
63
+ // the place's own scripts run, so anything logged during startup can be
64
+ // missed. Saying how long the window has been open lets the reader tell
65
+ // "nothing was logged" from "recording started after the event".
66
+ const window = response.recordingSeconds !== undefined
67
+ ? ` This session has been recording for ${response.recordingSeconds}s, ` +
68
+ "since its plugin loaded; anything logged before that is not recoverable."
69
+ : "";
70
+ return text((args.level || args.pattern
71
+ ? "No output matched. Try dropping the filter, or run a playtest first."
72
+ : "Nothing has been logged in this session.") + window);
73
+ }
74
+ // An error's stack trace is indented under it rather than given its own
75
+ // line, so the association survives being skimmed and the trace does not
76
+ // read as further unrelated output.
77
+ const lines = response.items.map((entry) => {
78
+ const head = `[${entry.level}] ${entry.message}`;
79
+ if (!entry.stack)
80
+ return head;
81
+ const trace = entry.stack
82
+ .split("\n")
83
+ .map((line) => line.trim())
84
+ .filter((line) => line.length > 0)
85
+ .map((line) => ` at ${line}`)
86
+ .join("\n");
87
+ const origin = entry.source ? `\n in ${entry.source}` : "";
88
+ return trace ? `${head}${origin}\n${trace}` : `${head}${origin}`;
89
+ });
90
+ const notes = [];
91
+ if (response.dropped > 0) {
92
+ notes.push(`showing the newest ${response.items.length} of ${response.total} matching lines`);
93
+ }
94
+ // A different loss from the one above, and the only one worth acting on:
95
+ // these lines are gone from the session entirely, not merely unshown.
96
+ if (response.evicted) {
97
+ notes.push(`${response.evicted} older lines have fallen out of the buffer and cannot be recovered`);
98
+ }
99
+ const trailer = notes.length > 0 ? `\n\n[${notes.join("; ")}]` : "";
100
+ return text(lines.join("\n") + trailer);
101
+ });
102
+ defineTool(context, {
103
+ name: "performance",
104
+ title: "Performance and memory",
105
+ description: "Reads the engine's own counters, and can run the script profiler.\n\n" +
106
+ "`snapshot` returns what the Developer Console shows: frame, physics and " +
107
+ "render times in milliseconds, instance and part counts, draw calls, " +
108
+ "network rates, and memory broken down by category. Use it to answer " +
109
+ "'why is this place heavy' with numbers instead of guesses.\n\n" +
110
+ "`profile` runs Studio's script profiler — the Script Performance window " +
111
+ "— for `seconds` and reports which scripts consumed CPU. It blocks for " +
112
+ "that long, so keep it short. It only sees code that actually runs, so " +
113
+ "start a playtest first; profiling an idle edit session returns nothing.\n\n" +
114
+ "`coverage` reports which lines of which scripts actually executed — dead " +
115
+ "code, untested branches, whether a fix was even reached. Pass `enable` " +
116
+ "first, then play, then read the coverage back FROM THE PLAYTEST session, " +
117
+ "not the editor: instrumenting is per data model, and the playtest is a " +
118
+ "different one. `enable` is remembered for the place and re-applied by " +
119
+ "each new session as it loads. Pass an empty `enable` array to stop.\n\n" +
120
+ "What it can and cannot see: instrumentation is fixed when a script is " +
121
+ "first compiled, so it measures modules required after that point — where " +
122
+ "most game logic lives — but never a script that starts with the place, " +
123
+ "which the data model compiles before any plugin exists. Those report 0 " +
124
+ "lines and are named as unmeasurable rather than counted as dead code.\n\n" +
125
+ "`scene` breaks the place down by what it is actually made of: instances " +
126
+ "by category, triangles and draw calls, and the assets holding script, " +
127
+ "animation and audio memory — each named, so \"2.4GB of memory\" becomes " +
128
+ "\"this animation is 138KB and these are the Animators using it\". It also " +
129
+ "reports UNPARENTED INSTANCES, which is the closest thing here to a leak " +
130
+ "detector: objects still alive with nothing holding them in the tree, " +
131
+ "invisible to `find` and to `tree` because they are in neither.\n\n" +
132
+ "Frame and network figures are only meaningful while something is " +
133
+ "running. Instance counts and memory are useful in edit mode too.",
134
+ inputSchema: {
135
+ op: z
136
+ .enum(["snapshot", "profile", "coverage", "scene"])
137
+ .default("snapshot")
138
+ .describe("'snapshot' reads counters now; 'profile' samples running scripts; " +
139
+ "'coverage' reports which lines have executed; 'scene' breaks the " +
140
+ "place down by what it is made of."),
141
+ section: z
142
+ .enum([
143
+ "composition",
144
+ "triangles",
145
+ "scriptMemory",
146
+ "animationMemory",
147
+ "audioMemory",
148
+ "unparented",
149
+ ])
150
+ .optional()
151
+ .describe("scene only: return just one section instead of all six."),
152
+ enable: z
153
+ .array(z.string())
154
+ .optional()
155
+ .describe("coverage only: scripts to start measuring. Remembered for this place " +
156
+ "and switched on by every session that loads afterwards, so a " +
157
+ "playtest instruments them before its scripts run. An empty array " +
158
+ "stops instrumenting."),
159
+ seconds: z
160
+ .number()
161
+ .int()
162
+ .min(1)
163
+ .max(30)
164
+ .default(5)
165
+ .describe("profile only: how long to sample. The call blocks for this long."),
166
+ frequency: z
167
+ .number()
168
+ .int()
169
+ .min(100)
170
+ .max(10000)
171
+ .default(1000)
172
+ .describe("profile only: samples per second. Higher is more precise and costlier."),
173
+ includePlugins: z
174
+ .boolean()
175
+ .default(false)
176
+ .describe("profile only: include Studio plugins in the results. Off by default " +
177
+ "— an idle Studio is mostly plugin activity, which buries the " +
178
+ "place's own scripts."),
179
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
180
+ },
181
+ readOnly: true,
182
+ }, async (args) => {
183
+ if (args.op === "coverage") {
184
+ const response = await bridge.call("perf.coverage", { enable: args.enable }, { studioId: args.studioId });
185
+ if (response.raw !== undefined) {
186
+ return json(response.raw, "Coverage came back in an unrecognised shape, so it is shown as Studio " +
187
+ "returned it rather than summarised into numbers that might be wrong.");
188
+ }
189
+ if (response.scripts.length === 0) {
190
+ return text((response.enabled.length > 0
191
+ ? `Coverage enabled for ${response.enabled.length} script(s). `
192
+ : "") +
193
+ "No coverage recorded yet.\n" +
194
+ "Coverage is recorded per session. If the code runs in a playtest, " +
195
+ "read it back from the playtest's studioId — the editor session " +
196
+ "instruments its own data model and never ran that code.\n" +
197
+ `This session carried over: ${JSON.stringify(response.carriedOver ?? [])}; ` +
198
+ `failed: ${JSON.stringify(response.carriedFailed ?? [])}; ` +
199
+ `remembered: ${JSON.stringify(response.remembered ?? [])}.`);
200
+ }
201
+ const summary = textOf(table(["path", "coveredLines", "instrumentedLines", "percent"], response.scripts, { more: "instrumented lines only; blanks, comments and `end` are excluded" }));
202
+ // The percentage says how much ran; these say what did not, which is the
203
+ // thing anyone measuring coverage is actually looking for.
204
+ const misses = response.scripts
205
+ .filter((entry) => entry.uncoveredLines && entry.uncoveredLines.length > 0)
206
+ .map((entry) => ` ${entry.path}: ${entry.uncoveredLines.join(", ")}`);
207
+ // Reported separately from a genuine zero, because they look identical
208
+ // in the table and mean opposite things.
209
+ const unmeasured = response.scripts.filter((entry) => entry.notMeasurable);
210
+ const sections = [summary];
211
+ // An empty `enable` clears what future sessions instrument; it cannot
212
+ // un-instrument this one, because Luau binds coverage at first compile.
213
+ // Without saying so, the call looks like it did nothing at all -- it
214
+ // returns the same table it returned before being asked to stop.
215
+ if (args.enable !== undefined && args.enable.length === 0) {
216
+ sections.push("Coverage will not be switched on for this place again. The figures " +
217
+ "above are from scripts this session already instrumented, which " +
218
+ "keep reporting until it ends — instrumentation is fixed at first " +
219
+ "compile and cannot be removed.");
220
+ }
221
+ if (misses.length > 0)
222
+ sections.push(`Lines never executed:\n${misses.join("\n")}`);
223
+ if (unmeasured.length > 0) {
224
+ sections.push(`Not measurable, reported as 0 lines: ${unmeasured.map((e) => e.path).join(", ")}.\n` +
225
+ "These were already compiled when coverage was switched on, and Luau " +
226
+ "keeps its first bytecode — re-running them does not help. This is " +
227
+ "normal for a script that starts with the place, since nothing can " +
228
+ "instrument it before the data model loads it. Coverage measures " +
229
+ "modules required after instrumentation, which is where most game " +
230
+ "logic lives.");
231
+ }
232
+ return text(sections.join("\n\n"));
233
+ }
234
+ if (args.op === "scene") {
235
+ const response = await bridge.call("perf.scene", { section: args.section }, { studioId: args.studioId, timeoutMs: 45_000 });
236
+ const blocks = [];
237
+ // Fixed order. Object key order comes back however the JSON happened to
238
+ // serialise, which put "unparented — 0" above the composition breakdown
239
+ // and made the report read differently between identical calls.
240
+ const ORDER = [
241
+ "composition",
242
+ "triangles",
243
+ "scriptMemory",
244
+ "animationMemory",
245
+ "audioMemory",
246
+ "unparented",
247
+ ];
248
+ const ordered = Object.entries(response).sort(([a], [b]) => ORDER.indexOf(a) - ORDER.indexOf(b));
249
+ for (const [name, section] of ordered) {
250
+ if (section.error !== undefined) {
251
+ blocks.push(`${name}: unavailable (${section.error})`);
252
+ continue;
253
+ }
254
+ const count = (value, unit) => `${value} ${value === 1 ? unit.replace(/s$/, "") : unit}`;
255
+ const heading = section.totals !== undefined
256
+ ? `${name} — ${Object.entries(section.totals)
257
+ .map(([key, value]) => count(value, key.toLowerCase()))
258
+ .join(", ")}`
259
+ : `${name} — ${count(section.total ?? 0, section.unit)}`;
260
+ if (section.entries.length === 0) {
261
+ // A total with no breakdown is not the same as nothing at all, and
262
+ // saying "(nothing)" under "90006 bytes" contradicts the line above
263
+ // it. Script memory does this: the engine reports the total without
264
+ // attributing it to named assets the way animation memory does.
265
+ blocks.push(`${heading}\n ${(section.total ?? 0) > 0
266
+ ? "(counted, but the engine did not break it down)"
267
+ : "(nothing)"}`);
268
+ continue;
269
+ }
270
+ // 120, not 40. These entries are a two-level tree of categories, not a
271
+ // ranked list, so cutting it drops whole categories rather than the
272
+ // least interesting tail — the first version hid twelve of them.
273
+ const shown = section.entries.slice(0, 120);
274
+ const rows = shown
275
+ .map((entry) => {
276
+ const indent = " ".repeat(entry.depth);
277
+ const size = entry.size !== undefined
278
+ ? ` — ${count(entry.size, section.unit)}`
279
+ : entry.triangles !== undefined
280
+ ? ` — ${count(entry.triangles, "triangles")}, ${count(entry.drawcalls ?? 0, "draw calls")}`
281
+ : "";
282
+ // Owners are what turn a number into something actionable: the
283
+ // asset's size says how much, the owners say who to go and look at.
284
+ const owners = entry.owners && entry.owners.length > 0
285
+ ? `\n${indent} used by ${entry.owners.slice(0, 3).join(", ")}`
286
+ : "";
287
+ return `${indent}${entry.name}${size}${owners}`;
288
+ })
289
+ .join("\n");
290
+ const dropped = section.entries.length - shown.length;
291
+ blocks.push(`${heading}\n${rows}${dropped > 0 ? `\n (${dropped} more)` : ""}`);
292
+ }
293
+ return text(blocks.join("\n\n"));
294
+ }
295
+ if (args.op === "profile") {
296
+ const response = await bridge.call("perf.profile", { seconds: args.seconds, frequency: args.frequency },
297
+ // The plugin blocks for the sample, so the deadline must outlast it.
298
+ { studioId: args.studioId, timeoutMs: (args.seconds + 20) * 1_000 });
299
+ const functions = response.data?.Functions ?? [];
300
+ if (functions.length === 0) {
301
+ return text(`Nothing ran during the ${response.seconds}s sample.\n` +
302
+ "The profiler only sees code that executes. Start a playtest, or " +
303
+ "profile while the behaviour you are investigating is happening.");
304
+ }
305
+ const ranked = [...functions]
306
+ .sort((a, b) => (b.TotalDuration ?? 0) - (a.TotalDuration ?? 0))
307
+ // A frame with no Source is a root entry -- one per script, carrying
308
+ // that script's total and duplicating the sourced frame directly under
309
+ // it. Dropping them removes the duplicate rows, and with them the only
310
+ // frames the engine bothers to mark IsPlugin.
311
+ .filter((entry) => entry.Source !== undefined)
312
+ .filter((entry) => (args.includePlugins ? true : !isPluginFrame(entry)))
313
+ .slice(0, 25)
314
+ .map((entry) => ({
315
+ source: entry.Source ?? "(engine)",
316
+ name: entry.Name ?? "",
317
+ line: entry.Line || "",
318
+ ms: Math.round((entry.TotalDuration ?? 0) * 1000 * 1000) / 1000,
319
+ plugin: isPluginFrame(entry) ? "yes" : "",
320
+ }));
321
+ if (ranked.length === 0) {
322
+ return text(`Every sample in ${response.seconds}s came from Studio plugins, not ` +
323
+ "from this place's scripts.\n" +
324
+ "No game code consumed measurable CPU. Pass `includePlugins` to see " +
325
+ "the plugin activity anyway.");
326
+ }
327
+ return table(["source", "name", "line", "ms", "plugin"], ranked, {
328
+ more: `sampled ${response.seconds}s at ${response.frequency}Hz, ` +
329
+ `slowest first, ms is total time in that function`,
330
+ });
331
+ }
332
+ const snapshot = await bridge.call("perf.snapshot", {}, { studioId: args.studioId });
333
+ // Memory is the one part that is a genuine list, and the part most often
334
+ // scanned for an outlier, so it gets a table instead of nested JSON.
335
+ const categories = snapshot.memory.categories.slice(0, 15);
336
+ const parts = [
337
+ textOf(json({
338
+ frame: snapshot.frame,
339
+ scene: snapshot.scene,
340
+ network: snapshot.network,
341
+ totalMemoryMb: snapshot.memory.totalMb,
342
+ })),
343
+ ];
344
+ if (categories.length > 0) {
345
+ parts.push("\nMemory by category (MB):\n" +
346
+ textOf(table(["category", "megabytes"], categories)));
347
+ }
348
+ else {
349
+ // An absent breakdown beside a healthy total reads as "this place uses
350
+ // no memory", so say which of the two reasons it is.
351
+ parts.push(snapshot.memory.trackingEnabled === false
352
+ ? "\n[No memory breakdown: Stats.MemoryTrackingEnabled is off in this session.]"
353
+ : `\n[No memory breakdown: ${snapshot.memory.problem ?? "Studio returned an empty one."}]`);
354
+ }
355
+ // Zeroes that mean "not measured here" look exactly like zeroes that mean
356
+ // "nothing to draw", and the flattering reading is the wrong one.
357
+ if (snapshot.renderless) {
358
+ parts.push("\n[This is a playtest server, which does not render: frame time, draw " +
359
+ "calls and triangle counts read zero because nothing measures them, " +
360
+ "not because the place is cheap. Physics, instance counts, network " +
361
+ "and memory above are real. For rendering figures, snapshot the " +
362
+ "editor session instead.]");
363
+ }
364
+ return text(parts.join("\n"));
365
+ });
366
+ }
367
+ //# sourceMappingURL=perf.js.map