@solidrt/cli 0.0.46 → 0.0.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -28,16 +28,16 @@
28
28
  "zod": "^4.4.3"
29
29
  },
30
30
  "optionalDependencies": {
31
- "@solidrt/darwin-arm64": "0.0.46",
32
- "@solidrt/linux-arm64-gnu": "0.0.46",
33
- "@solidrt/linux-x64-gnu": "0.0.46",
34
- "@solidrt/win32-x64-msvc": "0.0.46"
31
+ "@solidrt/darwin-arm64": "0.0.48",
32
+ "@solidrt/linux-arm64-gnu": "0.0.48",
33
+ "@solidrt/linux-x64-gnu": "0.0.48",
34
+ "@solidrt/win32-x64-msvc": "0.0.48"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "typescript": "^7"
38
38
  },
39
39
  "devDependencies": {
40
- "@solidrt/flux-types": "0.0.46",
40
+ "@solidrt/flux-types": "0.0.48",
41
41
  "@types/babel__core": "^7.20.5",
42
42
  "@types/bun": "latest"
43
43
  }
@@ -37,6 +37,61 @@ Authoritative references ship inside the installed packages - read them:
37
37
  @./node_modules/@solidrt/core/AGENTS.md
38
38
  @./node_modules/@solidrt/cli/AGENTS.md
39
39
 
40
+ ## What you paint with (there is no CSS layer)
41
+
42
+ Layout and props are half the model. There is no stylesheet: no filters, no
43
+ box-shadow, no keyframes, no canvas. The visual range a web app gets from CSS
44
+ comes from the tiers below instead, and reaching past tier 1 is ordinary
45
+ app-building here, not optimization - a screen built only from view
46
+ backgrounds and text is using a fraction of the runtime. Pick the tier the
47
+ CONTENT calls for, not the one that looks safest.
48
+
49
+ 1. Laid-out elements - `<view>`/`<text>`, with `<rect>` (or a filling
50
+ `<d-rect>` child) for background, border, radius. The structure of a
51
+ screen, not its finish.
52
+ 2. Vector art, detached from layout - `d-path`/`d-rect`/`d-oval`/`d-line`,
53
+ whose `color` takes a gradient (createLinearGradient /
54
+ createRadialGradient) and which honour `blendMode`, plus `parseSvg` to
55
+ draw a whole SVG document as one subtree. Free-form shapes, decoration,
56
+ diagrams, charts, anything positioned rather than flowed. Examples:
57
+ parse-svg, detached-positioning, text-paint-styling.
58
+ 3. GPU textures - `createShaderTexture` puts a fragment shader in a
59
+ `<texture>` (moving gradients, noise, glow, dissolves, a background that
60
+ is alive), `createPipelineTexture` draws geometry you generate yourself
61
+ (particles, point clouds, splats), and the `shader` prop post-processes
62
+ content that already exists: on a `<view>` it grades, warps or dissolves
63
+ that subtree, on `<window>` the whole frame. Stack `<texture>` elements
64
+ with `blendMode` to combine passes. Examples: gpu-shader, gpu-particles,
65
+ gpu-pipeline, gpu-instancing, gpu-texture-blend, view-shader,
66
+ window-shader.
67
+ 4. 3D scenes - add `@solidrt/3d` (not a scaffold dependency): meshes,
68
+ materials and a camera declared as Solid components, rendered into a
69
+ texture that sits in the UI tree like any other element.
70
+
71
+ Tier 3 is cheaper than it looks. A shader costs one property write per frame
72
+ no matter how complex the effect, which is why the performance notes below
73
+ reach for it first rather than as a last resort.
74
+
75
+ Web reflexes and what replaces them:
76
+ - gradient background -> a gradient `color` on a `d-rect` (gradients are
77
+ paint values, usable anywhere a color is)
78
+ - `filter: blur/grayscale/hue-rotate`, and any "make this look processed" ->
79
+ a `shader` on the view (requires repaintBoundary="snapshot"), or on
80
+ `<window>` for the whole frame
81
+ - `box-shadow` / `text-shadow` / glow -> no shadow prop exists: draw an
82
+ offset `d-*` shape under the content, or a view shader with `outset` (the
83
+ transparent margin an effect bleeds into)
84
+ - `backdrop-filter` -> no equivalent. A view shader sees only its own
85
+ subtree's pixels, never what is behind it. Frost the whole frame with a
86
+ window shader, or fake the layer with your own content
87
+ - `@keyframes` / transitions -> `onFrame` writing a signal for discrete
88
+ motion; a `uTime` uniform when the animation is continuous and visual
89
+ - `<canvas>` 2D -> `d-*` primitives (rebuild one `d-path` string per frame
90
+ rather than animating N elements)
91
+ - `<canvas>` WebGL, three.js -> `createPipelineTexture`, or `@solidrt/3d`
92
+ - video background, animated hero, particle field -> a shader texture; this
93
+ is the case the runtime is built for
94
+
40
95
  ## The things assistants get wrong (this is not React/DOM)
41
96
 
42
97
  1. This is SolidJS 2.0 (see CHEATSHEET.md for the reactivity/control-flow
@@ -209,6 +264,22 @@ work stops being free" below is where it does not. Rules, in order of leverage:
209
264
  5. "snapshot" boundaries pay first-frame texture allocation + raster:
210
265
  creating many at once (dealing a board of 64 sprites) is a visible
211
266
  one-frame hiccup - pool or pre-warm if that moment matters.
267
+ 6. Shading pixels the app already drew is a different mechanism from rule 1's
268
+ generated textures, and both forms are a `shader` prop taking a linked
269
+ program from compileShader/linkProgram (@solidrt/core/gpu), not a
270
+ createShaderTexture source. On `<window>`, `shader={{ program, params }}`
271
+ runs the finished frame through the program as the last step before it
272
+ reaches the screen: the frame binds as `uniform sampler2D uSource`,
273
+ `iResolution` fills by name, and `previous: true` retains the last frame as
274
+ `uPrevious` for motion echo or frame differencing. On a `<view>` the same
275
+ prop shades that subtree in place and REQUIRES repaintBoundary="snapshot"
276
+ (without it the shader is ignored with a warning); the pass sees only the
277
+ subtree's own pixels - grading, warping or dissolving the panel works,
278
+ anything needing what is behind it does not - and is split from content
279
+ invalidation, so a params-only change re-runs the pass against the cached
280
+ snapshot instead of re-rasterizing. A window shader's output is invisible
281
+ to get_snapshot and every other MCP tool; `srt render` is the only way to
282
+ see it (Run / verify below).
212
283
 
213
284
  ### Where GPU work stops being free
214
285
 
@@ -290,7 +361,10 @@ up to the frame period, because work outside the frame call is not in them.
290
361
  iterating - `srt bundle` writes output files and reloads connected clients
291
362
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
292
363
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
293
- frames land)
364
+ frames land). It is also the ONLY way to see the output of a window shader
365
+ (the `shader` prop on `<window>`): that pass runs on the finished frame on
366
+ its way to the screen, past the point every other capture reads, so `render`
367
+ frames are the only programmatic view of what it produces
294
368
 
295
369
  ## MCP: inspect the running app
296
370
 
@@ -298,12 +372,22 @@ The project ships an MCP server (.mcp.json, `srt mcp`) that talks to the dev
298
372
  server `bunx srt run` starts. When it is loaded in your environment, prefer
299
373
  its tools over guessing at runtime state:
300
374
 
301
- - list_clients: connected app clients, their platform and runtime capabilities
375
+ - list_clients: connected app clients, their platform and runtime
376
+ capabilities, plus the server's `entry` (the app source it serves) and
377
+ `projectDir` - check entry matches the app you think you are driving; the
378
+ dev port is fixed, so another project's server answers on the same port.
379
+ Each client also lists `queries`, the dev-tool query kinds its runtime
380
+ answers - check it before planning verification against a mixed-version
381
+ fleet (no "input" = the client predates send_input)
302
382
  - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
303
383
  to catch output right after a reload; `level`/`contains` filters; repeated
304
384
  lines collapse into one entry with a `repeats` count)
305
385
  - get_render_tree: what the app actually rendered - node kinds, text, and
306
- window-relative boxes. Whole trees get large: `query` finds nodes by
386
+ window-relative boxes. Pass `props: true` for each node's current
387
+ property values (JSX names, off-default only - "is rotate/color/overflow/d
388
+ applied right now" is one call, not a probe entry) and, on transformed nodes, the
389
+ painted `quad` (four corners after transforms; the box is just its
390
+ axis-aligned bounds). Whole trees get large: `query` finds nodes by
307
391
  kind/text, then `root` + `depth` inspect just that region
308
392
  - client ids and log cursors die with the dev server: list_clients and
309
393
  get_logs responses carry `generation`, and a changed generation means
@@ -313,12 +397,46 @@ its tools over guessing at runtime state:
313
397
  paraShapes, dirtiedNodes, cacheGets/cacheHits) - when layoutMs looks
314
398
  wrong, these say whether the cost is text shaping, invalidation breadth,
315
399
  or a defeated layout cache (healthy incremental rebuilds show a near-100%
316
- cacheHits rate)
400
+ cacheHits rate). reusedPerSec/skippedPerSec are the demand gate's visible
401
+ signal: frames presented from the cached display list without a rebuild
402
+ (texture content changed, no property writes - expect reusedPerSec near
403
+ fps on texture-driven apps) and frames skipped entirely (nothing
404
+ requested one)
317
405
  - get_snapshot: PNG capture of any render-tree node's pixels (get node ids
318
- from get_render_tree; the window node captures everything). Pass `save_to`
319
- on get_snapshot or get_texture to also write the PNG to a file - the image
320
- in the tool result cannot be saved afterwards, so decide before capturing
321
- (e.g. keep a before/after pair to diff)
406
+ from get_render_tree; the window node captures everything). A subtree
407
+ capture renders with NO ancestor paint: pixels the subtree does not draw
408
+ come back transparent, not the background behind the node. Captures
409
+ re-rasterize the tree offscreen, so they are also PRE window shader: an app
410
+ with a `shader` on its `<window>` snapshots as its unshaded content, window
411
+ node included, and no MCP tool reads the shaded result (its layer is
412
+ runtime-owned, so get_texture has no id for it; get_gpu_resources reports
413
+ only that the pass exists). Use `srt render` for that one. Crop with
414
+ x/y/width/height (captured-image pixels) and magnify with `scale` (1-8,
415
+ nearest-neighbour) - a tight crop at 4-8x is how small geometry gets
416
+ verified. Pass `save_to` on get_snapshot or get_texture to also write the
417
+ PNG to a file - the image in the tool result cannot be saved afterwards,
418
+ so decide before capturing (e.g. keep a before/after pair to diff)
419
+ - set_time_scale / step_frames: the runtime clock. `set_time_scale 0`
420
+ freezes app time (onFrame, requestAnimationFrame, timers, and
421
+ performance.now all stop; Date.now stays wall time), so a snapshot can
422
+ catch an exact frame of any animation instead of racing it; `step_frames
423
+ n` then advances exactly n frames (one refresh period each). Pause,
424
+ snapshot, step, snapshot again to see precisely what changed. ALWAYS set
425
+ the scale back to 1 when done - a paused client looks wedged to the human
426
+ watching - though reload/load also reset it
427
+ - send_input: synthetic pointer/key/wheel/text events through the REAL
428
+ input pipeline (hit testing, focus, bubbling) - the way to verify an
429
+ interaction actually works, where call_debug would bypass it. A click is
430
+ one call ({type: "pointer", action: "tap", x, y} - logical points, the
431
+ same space get_render_tree reports); a key hold is {type: "key", action:
432
+ "tap", key: "w", holdMs: 500}; text needs the field focused first (tap
433
+ it), then {type: "text", text: "go"}; drags are down + moves (delayMs
434
+ ~16 apiece) + up. Sequences run in order with per-event delayMs and the
435
+ call returns after the last event is delivered, so a following snapshot
436
+ sees the result. A synthetic mouse keeps hovering at its last position
437
+ (like a real cursor at rest); use pointerType: "touch" for gestures that
438
+ should end hover-free. Composes with the clock: pause, send_input,
439
+ step_frames, snapshot = a deterministic interaction test
322
440
  - get_gpu_resources: inventory of GPU state - textures (size, render target
323
441
  or not), vertex buffers (byteLength), pipelines (draw count, attribute
324
442
  layout, bound textures, current uniform values - the most recent writes,
@@ -326,7 +444,7 @@ its tools over guessing at runtime state:
326
444
  - get_texture: any GPU texture read back as a PNG by id - atlases, data
327
445
  textures, and shader/pipeline render targets alike (a render target reads
328
446
  as its current output, pending writes included, with no frame or snapshot
329
- needed); crop with x/y/width/height
447
+ needed); crop with x/y/width/height, magnify with `scale`
330
448
  - get_buffer: a vertex-buffer range decoded to numbers (f32/u16/u8, 64 KiB
331
449
  per call) - verify geometry after a writeBuffer instead of inferring it
332
450
  from pixels
@@ -361,8 +479,8 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
361
479
  user's call to make, once, in their own tooling.
362
480
  - Multiple clients: several clients may be attached (desktop window,
363
481
  phone, tablet) with different sizes, display scales, and safe areas.
364
- reload pushes to all of them, but call_debug / get_snapshot / log
365
- cursors are per client, and interactive state does NOT sync - a flow
482
+ reload pushes to all of them, but call_debug / send_input / get_snapshot
483
+ / log cursors are per client, and interactive state does NOT sync - a flow
366
484
  driven on one client leaves the others sitting on the initial screen,
367
485
  which reads as a crash to a human holding that device. So: when driving
368
486
  state via call_debug, send the same call to every client (or say which
@@ -377,11 +495,18 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
377
495
  logs it and read it back via get_logs.
378
496
  - Better than debug keys when driving the app over MCP: register debug
379
497
  COMMANDS - `registerDebug(name, fn)` from `srt:dev`, invoked via the
380
- list_debug/call_debug tools. `seek`/`pause`/`play` commands turn verifying
381
- an animation into "jump to t, snapshot, look"; a `zoom` command that
382
- shrinks a viewBox to a region gives magnified captures without touching
383
- source. Registrations reset on hot reload, so register at module init;
384
- sync return values only.
498
+ list_debug/call_debug tools. Use them to SET UP state (jump to a level,
499
+ force a mode, seed a scenario); then the runtime-level tools take over -
500
+ set_time_scale 0 freezes the result for as many snapshots as you need,
501
+ and step_frames walks it forward deterministically. Set state, pause,
502
+ snapshot. Registrations reset on hot reload, so register at module init;
503
+ sync return values only - and note a signal you just wrote flushes on a
504
+ microtask, so returning a signal read straight after setting it returns
505
+ the OLD value.
506
+ - call_debug sets state directly, skipping focus, key routing, and
507
+ TextInput - fine for SETUP, but "the interaction works" is only shown by
508
+ the real pipeline: verify clicks, typing, and drags with send_input,
509
+ which enters events where SDL input does.
385
510
  - Key events start at the focused node and bubble to the window root; with
386
511
  nothing focused they go to the window root alone. So a debug key bound via
387
512
  `<window onKeyDown>` always fires (unless a focused component consumes the
@@ -402,10 +527,12 @@ flag: `"args": [..., "mcp", "--port", "N"]`.
402
527
  - Snapshots are downscaled by the time you see them, so a full-window capture
403
528
  cannot show you a defect a few pixels across. Whenever you hand-author
404
529
  geometry - a `d-path` from raw path math, a `radius` where two shapes meet,
405
- a stroke join - inspect it MAGNIFIED once, when you write it: a throwaway
406
- entry file drawing the construction at 4-8x (pushed with `load`), or a
407
- `zoom` debug command on the real app. Verifying that a shape is in the
408
- right place is not the same check as verifying it is drawn right.
530
+ a stroke join - inspect it MAGNIFIED once, when you write it: get_snapshot
531
+ with a tight crop at scale 4-8 shows the actual rendered pixels enlarged,
532
+ in one call, on the real app. Verifying that a shape is in the right place
533
+ is not the same check as verifying it is drawn right - and get_render_tree
534
+ props answers the third question, whether the value you set is the value
535
+ the renderer holds.
409
536
  - GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
410
537
  for draw counts/uniforms/sizes, get_texture for atlas or data-texture
411
538
  contents ("is this tile blank?" is a ten-second question), get_buffer for
@@ -10,12 +10,12 @@
10
10
  "android": "srt client --android"
11
11
  },
12
12
  "dependencies": {
13
- "@solidrt/core": "0.0.46",
14
- "@solidrt/components": "0.0.46"
13
+ "@solidrt/core": "0.0.48",
14
+ "@solidrt/components": "0.0.48"
15
15
  },
16
16
  "devDependencies": {
17
- "@solidrt/cli": "0.0.46",
18
- "@solidrt/flux-types": "0.0.46",
17
+ "@solidrt/cli": "0.0.48",
18
+ "@solidrt/flux-types": "0.0.48",
19
19
  "typescript": "^7"
20
20
  }
21
21
  }
package/server/control.ts CHANGED
@@ -61,6 +61,7 @@ export function clientList(withAddress = false) {
61
61
  version: info.version,
62
62
  profile: info.profile,
63
63
  capabilities: info.capabilities,
64
+ queries: info.queries,
64
65
  ...(withAddress ? { address: ws.remoteAddr ?? null } : {}),
65
66
  }))
66
67
  }
@@ -72,7 +73,7 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
72
73
  if (param === undefined) {
73
74
  if (entries.length === 1) return { ws: entries[0]![0] }
74
75
  if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
75
- return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
76
+ return { error: Response.json({ error: "Multiple clients connected; pass a client id (list_clients has the ids)" }, { status: 400 }) }
76
77
  }
77
78
  let id = parseInt(param, 10)
78
79
  let entry = entries.find(([, info]) => info.id === id)
@@ -92,7 +93,35 @@ function findClient(param: string | undefined): { ws: ServerWebSocket } | { erro
92
93
  return { ws: entry[0] }
93
94
  }
94
95
 
95
- async function handleQuery(query: Map<string, string>, kind: string, extra?: Record<string, unknown>): Promise<Response> {
96
+ // Optional crop rect shared by /snapshot and /texture: all four of
97
+ // x/y/width/height in captured/texture pixels, or none. Returns undefined
98
+ // when absent, a 400 Response when malformed.
99
+ function parseRect(query: Map<string, string>): { x: number; y: number; width: number; height: number } | Response | undefined {
100
+ let params = ["x", "y", "width", "height"].map((k) => query.get(k))
101
+ if (params.every((v) => v === undefined)) return undefined
102
+ let [x, y, width, height] = params.map((v) => parseInt(v ?? "", 10))
103
+ if (![x, y, width, height].every(Number.isFinite))
104
+ return Response.json({ error: "Crop rect requires all of x, y, width, height" }, { status: 400 })
105
+ return { x: x!, y: y!, width: width!, height: height! }
106
+ }
107
+
108
+ // Optional integer magnification shared by /snapshot and /texture. Returns
109
+ // undefined when absent or 1, a 400 Response when out of range.
110
+ function parseScale(query: Map<string, string>): number | Response | undefined {
111
+ let param = query.get("scale")
112
+ if (param === undefined) return undefined
113
+ let scale = parseInt(param, 10)
114
+ if (!Number.isFinite(scale) || scale < 1 || scale > 8)
115
+ return Response.json({ error: "Scale must be an integer between 1 and 8" }, { status: 400 })
116
+ return scale === 1 ? undefined : scale
117
+ }
118
+
119
+ async function handleQuery(
120
+ query: Map<string, string>,
121
+ kind: string,
122
+ extra?: Record<string, unknown>,
123
+ timeoutMs: number = QUERY_TIMEOUT_MS,
124
+ ): Promise<Response> {
96
125
  let target = findClient(query.get("client"))
97
126
  if ("error" in target) return target.error
98
127
  let id = nextQueryId++
@@ -100,7 +129,7 @@ async function handleQuery(query: Map<string, string>, kind: string, extra?: Rec
100
129
  pendingQueries.set(id, resolve)
101
130
  })
102
131
  target.ws.send(JSON.stringify({ type: "query", kind, id, ...extra }))
103
- let msg = await Promise.race([reply, sleep(QUERY_TIMEOUT_MS)])
132
+ let msg = await Promise.race([reply, sleep(timeoutMs)])
104
133
  pendingQueries.delete(id)
105
134
  if (!msg)
106
135
  return Response.json(
@@ -174,7 +203,15 @@ async function handleLogs(query: Map<string, string>): Promise<Response> {
174
203
  export async function handleControl(req: Request, path: string, query: Map<string, string>): Promise<Response> {
175
204
  switch (path) {
176
205
  case "/__control__/clients":
177
- return Response.json({ generation: state.generation, clients: clientList() })
206
+ // `entry`/`projectDir` identify which app this server is serving: the
207
+ // fixed dev port means an agent can reach a different project's server
208
+ // than it thinks, and a repl/MCP `load` moves the entry mid-session.
209
+ return Response.json({
210
+ generation: state.generation,
211
+ entry: state.config.entry ?? null,
212
+ projectDir: state.projectDir,
213
+ clients: clientList(),
214
+ })
178
215
  case "/__control__/logs":
179
216
  return handleLogs(query)
180
217
  case "/__control__/tree": {
@@ -185,6 +222,7 @@ export async function handleControl(req: Request, path: string, query: Map<strin
185
222
  if (Number.isFinite(depth)) extra.depth = depth
186
223
  let q = query.get("query")
187
224
  if (q) extra.query = q
225
+ if (query.get("props") === "true") extra.props = true
188
226
  return handleQuery(query, "tree", extra)
189
227
  }
190
228
  case "/__control__/stats":
@@ -192,7 +230,14 @@ export async function handleControl(req: Request, path: string, query: Map<strin
192
230
  case "/__control__/snapshot": {
193
231
  let nodeId = parseInt(query.get("node") ?? "", 10)
194
232
  if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=<id>" }, { status: 400 })
195
- return handleQuery(query, "snapshot", { nodeId })
233
+ let extra: Record<string, unknown> = { nodeId }
234
+ let rect = parseRect(query)
235
+ if (rect instanceof Response) return rect
236
+ if (rect) extra.rect = rect
237
+ let scale = parseScale(query)
238
+ if (scale instanceof Response) return scale
239
+ if (scale) extra.scale = scale
240
+ return handleQuery(query, "snapshot", extra)
196
241
  }
197
242
  case "/__control__/gpu":
198
243
  return handleQuery(query, "gpu")
@@ -211,17 +256,72 @@ export async function handleControl(req: Request, path: string, query: Map<strin
211
256
  case "/__control__/texture": {
212
257
  let textureId = parseInt(query.get("id") ?? "", 10)
213
258
  if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=<textureId>" }, { status: 400 })
214
- // Optional crop: all four of x/y/width/height, in texture pixels.
215
- let rectParams = ["x", "y", "width", "height"].map((k) => query.get(k))
216
259
  let extra: Record<string, unknown> = { textureId }
217
- if (rectParams.some((v) => v !== undefined)) {
218
- let [x, y, width, height] = rectParams.map((v) => parseInt(v ?? "", 10))
219
- if (![x, y, width, height].every(Number.isFinite))
220
- return Response.json({ error: "Texture rect requires all of x, y, width, height" }, { status: 400 })
221
- extra.rect = { x, y, width, height }
222
- }
260
+ let rect = parseRect(query)
261
+ if (rect instanceof Response) return rect
262
+ if (rect) extra.rect = rect
263
+ let scale = parseScale(query)
264
+ if (scale instanceof Response) return scale
265
+ if (scale) extra.scale = scale
223
266
  return handleQuery(query, "texture", extra)
224
267
  }
268
+ case "/__control__/clock": {
269
+ // Clock control: ?scale=<x> sets the client's time scale (0 pauses),
270
+ // ?step=<n> advances n frames while paused. Applied by the client
271
+ // runtime; the reply carries the resulting clock state.
272
+ if (req.method !== "POST") return Response.json({ error: "Clock requires POST" }, { status: 405 })
273
+ let extra: Record<string, unknown> = {}
274
+ let scaleParam = query.get("scale")
275
+ if (scaleParam !== undefined) {
276
+ let scale = parseFloat(scaleParam)
277
+ if (!Number.isFinite(scale) || scale < 0)
278
+ return Response.json({ error: "Clock scale must be a number >= 0" }, { status: 400 })
279
+ extra.scale = scale
280
+ }
281
+ let stepParam = query.get("step")
282
+ if (stepParam !== undefined) {
283
+ let step = parseInt(stepParam, 10)
284
+ if (!Number.isFinite(step) || step < 1 || step > 1000)
285
+ return Response.json({ error: "Clock step must be an integer between 1 and 1000" }, { status: 400 })
286
+ extra.step = step
287
+ }
288
+ if (!("scale" in extra) && !("step" in extra))
289
+ return Response.json({ error: "Clock requires ?scale=<x> or ?step=<n>" }, { status: 400 })
290
+ return handleQuery(query, "clock", extra)
291
+ }
292
+ case "/__control__/input": {
293
+ // Synthetic input injection: POST {events: [...]} forwards a timed
294
+ // event sequence to the client, which feeds it through the real input
295
+ // pipeline. Shape checks only here - the runtime validates each event
296
+ // and rejects the whole sequence on any bad one. The query timeout
297
+ // stretches by the sequence's own delays, since the client replies
298
+ // only after the last event has been sent.
299
+ if (req.method !== "POST") return Response.json({ error: "Input requires POST" }, { status: 405 })
300
+ let body: any = null
301
+ try {
302
+ body = await req.json()
303
+ } catch {}
304
+ let events = body?.events
305
+ if (!Array.isArray(events) || events.length === 0)
306
+ return Response.json({ error: "Input requires a body {events: [...]} with at least one event" }, { status: 400 })
307
+ if (events.length > 200) return Response.json({ error: "Input sequences are capped at 200 events" }, { status: 400 })
308
+ let totalMs = 0
309
+ for (let e of events) {
310
+ if (typeof e !== "object" || e === null)
311
+ return Response.json({ error: "Each event must be an object" }, { status: 400 })
312
+ for (let f of ["delayMs", "holdMs"]) {
313
+ let v = e[f]
314
+ if (v !== undefined) {
315
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 5000)
316
+ return Response.json({ error: `Event ${f} must be an integer between 0 and 5000` }, { status: 400 })
317
+ totalMs += v
318
+ }
319
+ }
320
+ }
321
+ if (totalMs > 30000)
322
+ return Response.json({ error: "Input sequence too long: delays and holds total over 30000 ms" }, { status: 400 })
323
+ return handleQuery(query, "input", { events }, QUERY_TIMEOUT_MS + totalMs)
324
+ }
225
325
  case "/__control__/buffer": {
226
326
  let bufferId = parseInt(query.get("id") ?? "", 10)
227
327
  if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=<bufferId>" }, { status: 400 })
@@ -258,6 +358,19 @@ export async function handleControl(req: Request, path: string, query: Map<strin
258
358
  if (!(await file(entry).exists())) {
259
359
  return Response.json({ error: `Entry not found: ${entry}` }, { status: 400 })
260
360
  }
361
+ // An entry outside the project root cannot resolve the project's
362
+ // dependencies, so the bundler would fail with misleading "bun install"
363
+ // advice; name the real constraint instead.
364
+ let norm = (p: string) => p.replace(/\\/g, "/")
365
+ let root = norm(state.projectDir).replace(/\/+$/, "") + "/"
366
+ if (!norm(entry).startsWith(root)) {
367
+ return Response.json(
368
+ {
369
+ error: `Entry is outside the project root: ${entry} is not under ${state.projectDir}. The dev server can only bundle sources inside the project it was started in - move the file into the project or start srt there.`,
370
+ },
371
+ { status: 400 },
372
+ )
373
+ }
261
374
  state.config.entry = entry
262
375
  let cut = Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\"))
263
376
  if (cut > 0) state.sourceDir = entry.slice(0, cut)
package/server/main.ts CHANGED
@@ -216,7 +216,14 @@ serve({
216
216
  websocket: {
217
217
  open(ws) {
218
218
  let id = state.nextClientId++
219
- state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [] })
219
+ state.clients.set(ws, {
220
+ platform: "unknown",
221
+ version: "unknown",
222
+ profile: "unknown",
223
+ id,
224
+ capabilities: [],
225
+ queries: [],
226
+ })
220
227
  console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`)
221
228
  // Advertise our real LAN address so clients dialed over a loopback hop
222
229
  // can show/remember the directly reachable address (see connection.rs).
@@ -243,6 +250,7 @@ serve({
243
250
  profile: data.profile ?? "unknown",
244
251
  id: existing?.id ?? state.nextClientId++,
245
252
  capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
253
+ queries: Array.isArray(data.queries) ? data.queries.map(String) : [],
246
254
  })
247
255
  console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`)
248
256
  } else if (data.type === "log") {
package/server/state.ts CHANGED
@@ -38,7 +38,16 @@ export type Config = {
38
38
  tunnel: boolean
39
39
  }
40
40
 
41
- export type ClientInfo = { platform: string; version: string; profile: string; id: number; capabilities: string[] }
41
+ export type ClientInfo = {
42
+ platform: string
43
+ version: string
44
+ profile: string
45
+ id: number
46
+ capabilities: string[]
47
+ /** Query kinds this client's runtime answers (empty on runtimes that predate
48
+ * the advertisement); dev tools plan their verification surface from it. */
49
+ queries: string[]
50
+ }
42
51
 
43
52
  export let state = {
44
53
  config: undefined as unknown as Config,
package/src/args.ts CHANGED
@@ -169,7 +169,7 @@ pack options:
169
169
  render options:
170
170
  --script <file> Script file to replay (default: no scripted input)
171
171
  --fps <N> Frames per second (default: 60)
172
- --duration <N> Duration in seconds (default: 1)
172
+ --duration <N> Duration in seconds, fractions allowed (default: 1)
173
173
  --size <WxH> Frame size in physical pixels (default: 1280x720)
174
174
  -o, --output <path> Where frames land: a directory (frame-NNNNNN.png inside it)
175
175
  or a path prefix (default: the current directory)
@@ -42,7 +42,7 @@ async function control(path: string, method: "GET" | "POST" = "GET", payload?: u
42
42
  let CLIENT_ARG = z
43
43
  .number()
44
44
  .int()
45
- .describe("Client id from list_clients (default: the only connected client)")
45
+ .describe("Client id from list_clients (default: the only connected client; required when several are connected)")
46
46
  .optional()
47
47
 
48
48
  let SAVE_TO_ARG = z
@@ -54,8 +54,9 @@ let SAVE_TO_ARG = z
54
54
 
55
55
  // readOnly marks tools that only inspect state; it is surfaced as the
56
56
  // MCP-standard readOnlyHint annotation so agent harnesses that honor it can
57
- // auto-approve the inspection majority. load, reload, and call_debug mutate
58
- // the running app and keep the default hints (destructive, not idempotent);
57
+ // auto-approve the inspection majority. load, reload, call_debug, and
58
+ // send_input mutate the running app and keep the default hints (destructive,
59
+ // not idempotent);
59
60
  // `annotations` overrides those defaults where a mutating tool is benign
60
61
  // (watch: a reversible, idempotent toggle). Every tool gets
61
62
  // openWorldHint: false - the bridge only ever talks to the local dev server.
@@ -70,7 +71,7 @@ let TOOLS: {
70
71
  name: "list_clients",
71
72
  readOnly: true,
72
73
  description:
73
- "List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors) and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
74
+ "List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors), `entry` (the app source file this server currently serves and rebuilds - check it matches the app you intend to drive before acting, since the dev port is fixed and a `load` moves the entry mid-session), `projectDir` (the project root the server was started in), and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime, and `queries` - the dev-tool query kinds that client's runtime answers (clock, input, snapshot, tree, ...). Check `queries` before planning a verification strategy: a client whose list lacks \"input\" predates send_input, one that lacks \"clock\" predates set_time_scale/step_frames (an empty list means the runtime predates the advertisement itself). Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
74
75
  inputSchema: {},
75
76
  },
76
77
  {
@@ -110,7 +111,7 @@ let TOOLS: {
110
111
  name: "get_render_tree",
111
112
  readOnly: true,
112
113
  description:
113
- "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
114
+ "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Pass props: true to also get each node's current property values (JSX names, only values that differ from the defaults - so an empty/absent props object means everything is at its default) and, for nodes moved off their box by a rotate/scale/3D transform anywhere on their ancestor chain, `quad`: the four painted corners in window coordinates [x0,y0, x1,y1, x2,y2, x3,y3] (pre-transform top-left, top-right, bottom-right, bottom-left). The box is always the quad's axis-aligned bounds, so under a transform the box alone overstates the footprint - read the quad for where edges actually landed. Use props to answer 'is rotate/color/d applied right now' in one call instead of loading probe entries. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` (+ props) to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
114
115
  inputSchema: {
115
116
  root: z
116
117
  .number()
@@ -130,6 +131,10 @@ let TOOLS: {
130
131
  "scope the search; `depth` is ignored.",
131
132
  )
132
133
  .optional(),
134
+ props: z
135
+ .boolean()
136
+ .describe("Include each node's current off-default property values and, for transformed nodes, the painted quad")
137
+ .optional(),
133
138
  client: CLIENT_ARG,
134
139
  },
135
140
  },
@@ -137,12 +142,30 @@ let TOOLS: {
137
142
  name: "get_snapshot",
138
143
  readOnly: true,
139
144
  description:
140
- "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the <texture> leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
145
+ "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the <texture> leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). A capture renders only that node's subtree, with no ancestor paint: pixels nothing in the subtree draws come back transparent, not the background an ancestor draws behind the node - capture the window root when the background matters. Pass x/y/width/height to crop and `scale` to magnify: captures may be downscaled before you see them, so verify small hand-authored geometry (sprites, path data, icons) with a tight crop at 4x-8x rather than squinting at a full capture. Crop coordinates are in captured-image pixels (the width x height a capture of that node reports - device pixels), not the logical units get_render_tree reports. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
141
146
  inputSchema: {
142
147
  nodeId: z
143
148
  .number()
144
149
  .int()
145
150
  .describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"),
151
+ x: z
152
+ .number()
153
+ .int()
154
+ .describe("Crop rect left edge in captured-image pixels (requires y, width, height)")
155
+ .optional(),
156
+ y: z.number().int().describe("Crop rect top edge in captured-image pixels").optional(),
157
+ width: z.number().int().describe("Crop rect width in captured-image pixels").optional(),
158
+ height: z.number().int().describe("Crop rect height in captured-image pixels").optional(),
159
+ scale: z
160
+ .number()
161
+ .int()
162
+ .min(1)
163
+ .max(8)
164
+ .describe(
165
+ "Integer magnification, 1-8: each captured pixel becomes an NxN block (nearest-neighbour), so you see " +
166
+ "the actual rendered pixels enlarged. Combine with a crop; the scaled output is capped at 8192 px per side",
167
+ )
168
+ .optional(),
146
169
  save_to: SAVE_TO_ARG,
147
170
  client: CLIENT_ARG,
148
171
  },
@@ -158,13 +181,23 @@ let TOOLS: {
158
181
  name: "get_texture",
159
182
  readOnly: true,
160
183
  description:
161
- "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShaderTexture/createPipelineTexture in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
184
+ "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShaderTexture/createPipelineTexture in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas, and `scale` to magnify small content like a single tile or glyph.",
162
185
  inputSchema: {
163
186
  id: z.number().int().describe("Texture id, from get_gpu_resources"),
164
187
  x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
165
188
  y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
166
189
  width: z.number().int().describe("Crop rect width in texture pixels").optional(),
167
190
  height: z.number().int().describe("Crop rect height in texture pixels").optional(),
191
+ scale: z
192
+ .number()
193
+ .int()
194
+ .min(1)
195
+ .max(8)
196
+ .describe(
197
+ "Integer magnification, 1-8: each texture pixel becomes an NxN block (nearest-neighbour). Combine with " +
198
+ "a crop; the scaled output is capped at 8192 px per side",
199
+ )
200
+ .optional(),
168
201
  save_to: SAVE_TO_ARG,
169
202
  client: CLIENT_ARG,
170
203
  },
@@ -213,6 +246,61 @@ let TOOLS: {
213
246
  entry: z.string().describe("App entry source file to load (relative paths resolve against the project root)"),
214
247
  },
215
248
  },
249
+ {
250
+ name: "set_time_scale",
251
+ annotations: { destructiveHint: false, idempotentHint: true },
252
+ description:
253
+ "Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, performance.now() holds still, and the picture stops - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. Date.now() stays wall time throughout. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.",
254
+ inputSchema: {
255
+ scale: z
256
+ .number()
257
+ .min(0)
258
+ .describe("Time scale: 0 = pause, 1 = normal, 0.5 = half speed, 2 = double speed"),
259
+ client: CLIENT_ARG,
260
+ },
261
+ },
262
+ {
263
+ name: "step_frames",
264
+ annotations: { destructiveHint: false },
265
+ description:
266
+ "While paused (set_time_scale 0), advance a running app client by exactly n frames: each frame moves app time forward one refresh period (~16.7 ms at 60 Hz), runs onFrame/requestAnimationFrame and any timers that come due, and presents the result. Deterministic single-stepping for animations and game logic: pause, snapshot, step, snapshot again to see exactly what changed in n frames. With the clock running this is a no-op (frames already flow). Steps are applied at the client's frame rate, so n frames take about n refresh periods of wall time before a following snapshot shows the result.",
267
+ inputSchema: {
268
+ n: z.number().int().min(1).max(1000).describe("Number of frames to advance (1-1000)"),
269
+ client: CLIENT_ARG,
270
+ },
271
+ },
272
+ {
273
+ name: "send_input",
274
+ description:
275
+ "Send synthetic input to a running app client through the real input pipeline (hit testing, focus, event bubbling) - the same path physical input takes, unlike call_debug which sets state directly, so use this to verify interactions actually work. Events run in order; each may wait delayMs (0-5000 ms) before firing, and the call returns after the last event has entered the pipeline, so a following get_snapshot sees the result. Event kinds: {type:'pointer', action:'down'|'up'|'move'|'tap', x, y} for clicks and drags - coordinates in logical points, the same space get_render_tree reports; 'tap' is down+up with an optional holdMs between; button 0 = left (default), 1 = middle, 2 = right; pointerType 'mouse' (default) keeps hovering at its last position afterwards like a real cursor, use 'touch' for gestures that should end hover-free. {type:'key', action:'down'|'up'|'tap', key} with W3C key names exactly as the runtime reports them ('w', 'ArrowLeft', 'Enter', ' '); a 'tap' with holdMs holds the key down that long, e.g. holdMs 500 = walk forward half a second in one call; modifier booleans shift/ctrl/alt/meta. {type:'text', text} enters text through the TextInput path - focus the target first with a pointer tap on it (the tap also activates the text session). {type:'wheel', x, y, deltaX, deltaY} scrolls; positive deltaY scrolls content down. Recipes: click a button = [{type:'pointer',action:'tap',x:400,y:300}]. Drag = down, then moves with delayMs 16 each, then up. Deterministic interaction test = set_time_scale 0, send_input, step_frames, get_snapshot. A down/up over empty space hits nothing, exactly like real input - check coordinates against get_render_tree when a click seems to do nothing.",
276
+ inputSchema: {
277
+ events: z
278
+ .array(
279
+ z.object({
280
+ type: z.enum(["key", "pointer", "wheel", "text"]),
281
+ action: z.enum(["down", "up", "move", "tap"]).optional(),
282
+ key: z.string().optional().describe("W3C key name, required for type key"),
283
+ text: z.string().optional().describe("Text to enter, required for type text"),
284
+ x: z.number().optional().describe("Logical points, required for pointer and wheel"),
285
+ y: z.number().optional().describe("Logical points, required for pointer and wheel"),
286
+ deltaX: z.number().optional(),
287
+ deltaY: z.number().optional(),
288
+ button: z.number().int().min(0).max(4).optional(),
289
+ pointerType: z.enum(["mouse", "touch"]).optional(),
290
+ delayMs: z.number().int().min(0).max(5000).optional().describe("Wait before this event"),
291
+ holdMs: z.number().int().min(0).max(5000).optional().describe("Tap only: time between down and up"),
292
+ shift: z.boolean().optional(),
293
+ ctrl: z.boolean().optional(),
294
+ alt: z.boolean().optional(),
295
+ meta: z.boolean().optional(),
296
+ }),
297
+ )
298
+ .min(1)
299
+ .max(200)
300
+ .describe("Event sequence, executed in order"),
301
+ client: CLIENT_ARG,
302
+ },
303
+ },
216
304
  {
217
305
  name: "watch",
218
306
  annotations: { destructiveHint: false, idempotentHint: true },
@@ -248,6 +336,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
248
336
  if (typeof args?.root === "number") params.set("root", String(args.root))
249
337
  if (typeof args?.depth === "number") params.set("depth", String(args.depth))
250
338
  if (typeof args?.query === "string") params.set("query", args.query)
339
+ if (args?.props === true) params.set("props", "true")
251
340
  if (typeof args?.client === "number") params.set("client", String(args.client))
252
341
  let qs = params.toString()
253
342
  return control(qs ? `/tree?${qs}` : "/tree")
@@ -267,9 +356,31 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
267
356
  case "get_snapshot": {
268
357
  if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
269
358
  let params = new URLSearchParams({ node: String(args.nodeId) })
359
+ for (let key of ["x", "y", "width", "height", "scale"]) {
360
+ if (typeof args?.[key] === "number") params.set(key, String(args[key]))
361
+ }
270
362
  if (typeof args?.client === "number") params.set("client", String(args.client))
271
363
  return control(`/snapshot?${params.toString()}`)
272
364
  }
365
+ case "set_time_scale": {
366
+ if (typeof args?.scale !== "number" || !(args.scale >= 0)) {
367
+ return { ok: false, message: "set_time_scale requires scale >= 0" }
368
+ }
369
+ let params = new URLSearchParams({ scale: String(args.scale) })
370
+ if (typeof args?.client === "number") params.set("client", String(args.client))
371
+ return control(`/clock?${params.toString()}`, "POST")
372
+ }
373
+ case "step_frames": {
374
+ if (typeof args?.n !== "number" || !(args.n >= 1)) return { ok: false, message: "step_frames requires n >= 1" }
375
+ let params = new URLSearchParams({ step: String(args.n) })
376
+ if (typeof args?.client === "number") params.set("client", String(args.client))
377
+ return control(`/clock?${params.toString()}`, "POST")
378
+ }
379
+ case "send_input": {
380
+ if (!Array.isArray(args?.events) || args.events.length === 0)
381
+ return { ok: false, message: "send_input requires a non-empty events array" }
382
+ return control(`/input${clientParam(args)}`, "POST", { events: args.events })
383
+ }
273
384
  case "get_gpu_resources":
274
385
  return control(`/gpu${clientParam(args)}`)
275
386
  case "list_debug":
@@ -283,7 +394,7 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
283
394
  case "get_texture": {
284
395
  if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
285
396
  let params = new URLSearchParams({ id: String(args.id) })
286
- for (let key of ["x", "y", "width", "height"]) {
397
+ for (let key of ["x", "y", "width", "height", "scale"]) {
287
398
  if (typeof args?.[key] === "number") params.set(key, String(args[key]))
288
399
  }
289
400
  if (typeof args?.client === "number") params.set("client", String(args.client))