# Debugging a running app

Read this before investigating a bug in a running app, or before driving the
app to verify a change: over MCP, or through the same control API from a
shell or a CI step ("The control API without MCP" below).

## Driving the app over MCP

The project ships an MCP server (`sol mcp`) that talks to the dev server
`bun run sol run` starts. If your client lists no `solidrt` tools, it has not
been pointed at the server yet - see "Wiring up an agent client" below. Each
tool documents its arguments in full - read the tool description rather than
guessing at them; the records the tools return are the control API's, and
their fields are documented once, under "The control API without MCP" below.
What the individual descriptions cannot tell you:

- A flow that opens a file dialog (`flux:dialog`) needs its answer queued
  with `answer_dialog` BEFORE the input that triggers it: an unanswered
  request shows the native dialog on the client, which nothing remote can
  dismiss. `get_dialog` shows what the app asked for (kind, filters,
  suggested name) and how the last request was answered.
- If `list_clients` is empty, no app is running: ask the user to start
  `bun run sol run` rather than starting a second one yourself.
  The bridge needs no port: it resolves the server currently serving this
  project, whatever `--port` it was started with, and re-resolves when
  that server goes away or a different project's server takes its port.
  Passing the flag to `sol mcp` pins the bridge to that one server instead.
- Several clients may be attached at once (desktop window, phone, tablet)
  with different sizes, display scales and safe areas. `reload` pushes to all
  of them, but `call_debug` / `send_input` / `get_snapshot` are per client
  (`get_logs` takes a `client` filter), and interactive state does NOT sync
  - a flow driven on one client leaves the others sitting on the initial
  screen, which reads as a crash to a human holding that device. So: when
  driving state via `call_debug`, send the same call to every client (or say
  which client you are using); and before calling a visual change done,
  snapshot each distinct form factor at least once - a layout that fits one
  window can clip or overflow another.
- Measuring or testing? `mute_user_input` first: it mutes the user's own
  input on every client (a stray click or keypress mid-measurement corrupts
  the result); `send_input` still works. `unmute_user_input` the moment you
  are done, or whenever you need the human to press something: they see an
  unresponsive client meanwhile. The bridge unmutes when it exits, the
  server when it stops, but neither is a reason to leave a mute on.
- Editing? `pause_watch` first: the server reloads on save, and a
  half-finished save would land on the user's screens as a build error or
  a broken app. Edit, `reload`, then `resume_watch`. The two holds are
  separate on purpose: the mute keeps the human out while you measure, the
  pause keeps your own saves out while you edit, and a human editing next
  to you keeps auto-reload unless you hold it. The bridge resumes when it
  exits, but do not rely on that.
- A `shader` on `<window>` runs on the finished frame past the point every
  capture reads: `get_snapshot` returns the UNSHADED content (window node
  included), `get_texture` has no id for the shaded layer, and
  `get_gpu_resources` reports only that the pass exists. `bun run sol render` is
  the only programmatic view of what a window shader produces.
- Permission prompts: agents typically ask approval per MCP tool. All of
  these tools only talk to the local dev server the user started with
  `bun run sol run` - nothing leaves the machine - so approving the server as a
  whole is a reasonable default. If repeated prompts get in the way, do not
  work around them; tell the user they can pre-approve the server in their
  agent's settings (most agents have a per-server trust or allowlist setting
  - in Claude Code, add "mcp__solidrt" to `permissions.allow` in
  ~/.claude/settings.json to cover every solidrt project). This is the user's
  call to make, once, in their own tooling.

## Wiring up an agent client

`.mcp.json` in the project root is Claude Code's convention, and a scaffolded
app ships one. MCP standardizes the protocol, not how a client discovers
servers, so every other client reads its own file. The entry is the same
everywhere, in the client's syntax:

```json
{ "command": "bun", "args": ["node_modules/@solidrt/cli/bin/sol", "mcp"] }
```

Where it goes (these locations move between client releases; check the
client's own docs if it does not match):

- Claude Code: `.mcp.json` in the project root, under `mcpServers`. Claude
  Code asks once, per project, before it will launch a server from this
  file; until that is approved the `solidrt` tools are absent.
- Cursor: `.cursor/mcp.json`, under `mcpServers`
- VS Code / Copilot: `.vscode/mcp.json`, under `servers`
- Gemini CLI: `.gemini/settings.json`, under `mcpServers`
- Codex CLI: `~/.codex/config.toml`, under `[mcp_servers.solidrt]`

Several of these also ship a command that writes the entry for you
(`claude mcp add`, `codex mcp add`); prefer it over hand-editing.

The one constraint: the bridge must run with the project as its working
directory. It finds the dev server through the nearest package.json above
its cwd, and the `args` path is relative to the project root. A per-project
file gives that for free. A user-level entry works too, but only while the
client itself was launched from the project root (or the client supports a
`cwd` field and it is set); launched from elsewhere, every tool call answers
"No dev server for ..." while one is running.

## The dev server

What the dev server (`sol run` / `sol server`) is and what it serves.

- A dev server serves a project (started in its root: the cwd must hold the
  package.json) or a single file (`sol run <file>` outside a project). Both
  in one place is ambiguous, so `sol run <file>` in a project root needs
  `--project` (the project, with this entry) or `--file` (the file alone).
  Nothing searches upward for a package.json.
- One server per project or file. The server binds the port it had last
  time, else the first free one from 34884 up, and prints it; `--port <N>`
  pins it. Loopback only unless `--lan`.
- Dev state lives in `~/.solidrt/`: `servers/<key hash>/` holds each server's
  `live.json` (the registry record, written by the server and removed at
  exit), its remembered `port` and tunnel key; `clients/client<N>/` the
  client trees. `sol client` and `sol mcp` resolve the server from the
  registry by the project (or served file) at their cwd; `--port` pins one.
  A record whose process died is pruned when the next server starts, and
  resolution confirms a record against the server before using it.
- The server is one flux process, complete on its own:
  `flux dist/server.js [file] [--project|--file] [--port N] [--lan]
  [--proxy-http] [--capture f] [--tunnel] [--stats] [--client N ...] [-- args]`
  (src/server/args.ts). It finds the platform binaries, bun and sol through
  `SOLIDRT_PLATFORM_DIR`, `SOLIDRT_BUN` and `SOLIDRT_CLI`, which `sol` sets; started by
  hand in a checkout it needs only `SOLIDRT_HOME`.
- Reload-on-save watches the bundle's inputs (every file the running
  bundle was built from, dependencies included) and the `assets/` tree, not
  a directory: a file the app does not import never triggers a rebuild,
  and every one it does - package sources included - pushes a fresh app
  (debug state and resource ids reset, so re-seed).
  While the last build failed, the source tree is watched as a whole until
  a build succeeds. `POST /__control__/watch?active=false` pauses it (the
  MCP `pause_watch` tool) while an agent edits; `POST /__control__/reload`
  (the `reload` tool) is the agent's push.

## The control API without MCP

Every MCP tool is a thin wrapper over the dev server's HTTP control API, so
a shell script, a CI step, or an agent with no MCP bridge can drive the app
the same way. Base: `http://127.0.0.1:<port>/__control__/`, where `<port>`
is the one `sol run` printed at startup (also in the server's
`~/.solidrt/servers/*/live.json` record). GET unless noted;
every endpoint answers JSON and an error is `{ "error": "..." }` with a
4xx/5xx status, and every response carries `x-solidrt-project` (the key
served) and `x-solidrt-generation` (the server run) headers, so a caller can
confirm it reached the server it meant and notice a restart. Endpoints that
talk to a client take `?client=<id>` (from `/clients`); it may be omitted
when exactly one client is connected.

- `/clients` - `{ generation, key, mode, entry, projectDir, userInputMuted,
  watchPaused, clients: [{ id, loading, ... }] }`. Check `key` (the project
  root or single file served) is the app you mean. A client's `loading` is
  true while it still builds the first frame of the last push: a query to
  it waits for that frame within its timeout and answers 503 if it never
  comes (null on a runtime that predates the signal, queried right away).
- `/logs?since=<seq>&level=<lvl>&contains=<text>&wait=<ms>&client=<id>` -
  `{ entries: [{ seq, at, client, level, text, repeats? }], latest,
  generation }`. Pass the previous `latest` as `since` to read only new
  output; `client` keeps one client's entries (all clients without); a
  changed `generation` means the server restarted and cursors and client
  ids are stale. Native engine diagnostics from the local client the
  server spawned - flutter/Impeller `[ERROR:...]`/`[WARNING:...]` stderr
  lines, validation breaks among them - arrive as entries with
  `client: -1` at level error/warn; they never pass the engine logger, so
  this is their only route into `/logs`, and a remote client's stderr
  stays out of reach.
- `/tree?query=<text>&root=<id>&depth=<n>&props=true` - `{ limit, matches:
  [{ id, kind, path, x, y, width, height }] }` for a query (a node's kind
  equals it, or its text or `label` contains it, case-insensitive), the
  nested tree otherwise (the `get_render_tree` tool returns it verbatim).
  `/tree?at=<x>,<y>` is the hit test as a read: `{ hit: [...] }`, the
  records of the nodes a pointer at that window point reaches, root first,
  the node it lands on last, empty when nothing takes a pointer there -
  what covers a node that does not respond to a tap. Node ids are
  per client and change on reload; re-query after `/reload`. The per-node
  record, documented here and nowhere else (an app test reads a node as
  this same record, through `@solidrt/test`):
  - `id`, `kind`, `x`, `y`, `width`, `height`: the window-relative box of
    the node as painted, the axis-aligned bounds of its painted corners;
    zero before the first layout. `detached: true` marks a d-* node.
  - `text`: a text's content; `label`: the node's `label` prop, a stable
    name that survives a reload where ids do not; `children`, cut off past
    `depth`, with `childCount` saying how many the node has (descend with
    `root=<id>`).
  - `props` (with `props=true`): the node's current property values under
    their JSX names, off-default values only - an absent or empty `props`
    means everything is at its default. Answers "is rotate/color/d applied
    right now" in one call.
  - `quad`: when a rotate, scale or 3D transform anywhere on the ancestor
    chain moved the node off its box, 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 the quad's
    axis-aligned bounds, so under a transform it overstates the footprint:
    read the quad for where edges landed.
  - `exiting: true` on the root of a removal cascade, and
    `exit: { <prop>: "200ms ease-in delay 70ms" }` on every node of it, the
    motion in force per exiting property; freeze the clock
    (`/clock?scale=0`) and step to read an exit's curve off `props`.
  - `slide: { x, y, w, h }` on a node mid-slide (a `layout` transition):
    the box above is the one it is painted at, `slide` the offset (`x`,
    `y`) and growth (`w`, `h`) it has still to cover to its solved box, so
    a slide and a jump read apart frame by frame.
- `/snapshot?node=<id>` - `{ width, height, pngBase64 }`, display-scaled;
  add `&format=raw` for `rgbaBase64` (RGBA8 bytes, no decoder needed for
  pixel assertions), `&x=&y=&width=&height=` (all four) to crop,
  `&scale=<1-8>`. Snapshot the smallest node that shows the change, not the
  window root. A snapshot paints the current state afresh, after every
  pending microtask, so a glitch confined to one frame never shows in it:
  `&step=1`, with the clock paused, advances one frame and captures inside
  it instead - the picture exactly as that frame's own code drew it, before
  anything the frame left to a microtask or a promise lands. With the
  clock running it is refused: pause first.
- `/texture?id=<textureId>` - same shape and options as `/snapshot`, at the
  texture's native size (a scene or shader target behind a `<texture>` leaf).
- `/gpu?lose=1` - force a GPU context loss (ANGLE's test hook, so Windows,
  macOS and the Android emulator; `{ forced: false }` elsewhere): the
  client logs the loss and the recovery, `/stats` counts it in
  `contextLosses`, the app's `onContextRestored` fires, and the next frame
  draws on the rebuilt context. The way to see an app survive a driver
  reset without one.
- `/gpu?label=<text>&draw=<id>` - the GPU resource inventory; `label` keeps
  only the resources created with exactly that label (ids change on reload,
  labels do not). Each texture carries its `format` and declared `sampler`
  (`filter`/`wrap`/`mipmap`/`anisotropy`), the creation-time state a soft
  or aliased map is usually missing, its `shape` (`2d`, `cube`, `3d`,
  `2d-array`; a volume's `depth` is its slices or layers), and its
  `byteLength`: the texel storage at that format, mip chain, cube faces
  and volume slices included, so summing
  it over the textures is a scene's texture memory (a compressed format
  is a quarter of rgba8). A draw target's entries report uniforms wider than a vec4
  (matrices) as their length, `"[16]"`; `draw` names the one entry (ids are
  per target, so pair it with `label`) reported in full. An entry carries
  its own `label` when the add gave one (a 3d mesh reports its geometry's
  label, so a model part's entry is found by the part's name), beside its
  `indexCount`/`instanceCount`. An entry the submission budget cut reports
  `slices` (the submissions its last render was issued as) and
  `submissionMs` (the longest of them, or a one-draw render's upper
  bound): the engine keeps every GPU submission under about half a
  second, a quarter of the hang timeout at which a driver faults the
  context, so a heavy draw on a weak GPU shows here as several
  submissions instead of one black screen (`gpuDrawSlices` in `/stats`
  counts them; okf/done/splat-adreno-gpu-fault.md). `groups` sums the textures and buffers by
  exact label (`textures`, `textureBytes`, `buffers`, `bufferBytes`,
  `bytes`): a consumer that labels every resource it creates (a tile
  layer's chunks under the layer's label) reads its footprint as one
  number before and after a residency change; it is what is ALLOCATED
  under the label, pooled idle targets included (`/stats` `app.poolHeld`
  says how many ids wait in pools).
- `/buffer?id=<bufferId>&offset=<n>&length=<n>&as=<f32|u16|u8>` - vertex
  buffer contents (default f32; reads cap at 64 KiB).
- `/stats?window=<ms>` (or `?frames=<n>`, the last n frames that changed
  the picture: the window for a paused clock, where a time window expires
  while you step) - the performance statistics. POST
  `/stats?active=true|false` switches the on-screen stats overlay instead
  (the `set_stats_overlay` tool): one client with `&client=<id>`, every
  client (and the setting new clients join with) without; `/clients`
  reports each client's `stats`. `frames` counts the frames that changed
  the picture: tree rebuilds, plus GPU content changes presented without
  one (a layer write, a shader param, an upload - a sprite or shader app
  rebuilds nothing, every frame of it is one of these).
  `fps` is the refresh rate presented at: when motion looks wrong and `fps`
  looks fine, `frames` is the number to read - a picture that only changes
  26 times a second shows 26 there, and the stutter is the app's update
  cadence, not the engine's. `frames: 0` means the picture did not change
  at all in the window. Probes quote the window's `missedPresents` as the
  primary jank figure (presents the display missed while a next frame was
  demanded - the repeated frame every average hides); `cadenceHold` is
  the whole number of refreshes each frame interval is currently held to
  below the refresh rate (1 = no hold; a held interval is not a miss),
  which the client chooses from the app's frame work time where the
  policy is on (touch and remote-driven clients) and `SOLIDRT_CADENCE_HOLD`
  (`off`, `auto`, or a number of refreshes) pins for a measurement;
  `frameWorkMeanMs` / `frameWorkMaxMs` are that work time over the last
  second (frame signal to swap call plus GPU time), what the hold's
  step-down prediction reads; `videoLatched`, `videoSkipped` and
  `videoLateLatches` are the texture video path's counters (frames the
  raster thread latched into a video texture, due frames it skipped
  because a newer one was due for the same present or the producer ran
  past the latch's queue, and frames latched for a present whose draw
  gate saw nothing due, so a cached boundary over the texture lagged one
  frame): a fluency verdict on a `<texture>` showing video is these three
  over a run (skipped and late both 0) plus the app's `currentTime()`
  stepping at the content interval, never a present census;
  `cameraUploaded` and `cameraSkipped` are the camera path's: frames the
  raster thread uploaded into a camera texture, and frames the camera
  produced that no upload showed (gaps in the frame sequence), so a camera
  verdict is uploads per second at the camera's rate with skipped at 0;
  the residency gate's figures are `regionsWanted` (resident plus
  pending), `regionsResident`, `regionsPending` (enters not delivered
  yet) of which `regionsStalled` (held back by a limit, waiting on a view
  move, not a frame), `regionEnters`/`regionLeaves` (cumulative; the
  window's own `regionEnters`/`regionLeaves` are the pair over its span)
  and `residentWeight` (every grid's resident cells times its weight: a
  tile layer's resident texels), so a streaming world's verdict is a flat
  `regionsResident` and `residentWeight` while the camera roams with the
  window's enters and leaves saying how much it churned; `app` is the
  counters the app registered with `sol:dev` `registerStat` (name to
  number, null where a reader failed), the core's resource pools among
  them (`poolHits`, `poolMisses`, cumulative takes that found an id or
  created one, so hits over the two is the reuse rate; `poolHeld`, ids
  waiting in pools now); `submittedFrames` and `retiredFrames` are the
  frames handed to the GPU and the frames whose GPU work has completed
  (the difference is the frames in flight, zero at rest; the pools hand
  out only what was released at a submitted count the retired count has
  reached);
  `slowFrames`,
  `gpuFrameExecMsPerFrame`, `fenceTimeoutsPerSec` and `rasterCmdMsPerSec`
  say why. A snapshot capture stalls the frame it lands in (a blocking
  readback inside the paint): those frames are `captureFrames`, left out
  of the percentiles, `slowFrames` and `worst`, and their slow-frame log
  line says "snapshot capture(s) in the paint" - not a hitch to chase; the
  first rebuild after a load says "first frame after load" for the same
  reason (that rebuild shapes, decodes and records everything at once),
  and is still worth a look when it is hundreds of ms.
  `gpuDrawSlices` counts the extra GPU submissions the submission budget
  issued by slicing heavy draws and `gpuDrawSliceWaitMs` the raster-thread
  time spent waiting on them (part of `gpuPassIssueMs`): 0 on a desktop,
  climbing on a GPU that needs the slicing, where a draw's own cost is
  the `submissionMs` beside its inventory entry. The line measures
  the JS thread's phases only, so a load whose cost is uploads and
  compiles on the raster thread gets no line at all: a first frame under
  budget there is normal, not a missing warning. The `gpu*ExecMs` figures are absent (not 0) when the client's
  context has no timer queries or when the startup attribution self-test
  caught the driver booking deferred pass execution to the wrong query,
  as some tiled GPUs do; where they are absent, measure the GPU by
  subtraction (change one variable, take the frame-time delta). On
  Android `gpuFrameExecMs` comes from the compositor's frame timestamps,
  not a timer query (the pass figures stay timer queries): the span from
  the instant the swap queued the frame's buffer to its rendering-complete
  fence, the same "frameReady minus queue" a SurfaceFlinger census reports,
  so the two agree by construction. When
  comparing two configurations, divide a `frames` delta by a `timeMs`
  delta rather than reading `frameMs`: it is a smoothed EMA and disagrees
  with the counters under bimodal frame times. Two breakdowns name a
  cost without a reload per hypothesis: `paintOps` (top level for the
  latest rebuild, and in `window.worst`) counts the display-list ops the
  paint walk recorded - `draws`, `textLayers` (the text layers the glyph
  pass rasterized for the rebuild; a text whose pixels are current
  composites its retained layer, a draw and no layer; `window.textLayers`
  sums them over the window's frames, the count that says how often a
  scale animation re-rasterized its labels), `clips` (of which
  `roundedClips`),
  `saveLayers`, and
  the paints that leave a tiled GPU's cheap path, `blends` (not
  source-over) and `gradients`; ops inside a reused repaint-boundary
  recording are not re-recorded and not counted. `window.targets` splits
  the pass figures per draw target that rendered in the window (`label` as
  created, `id` 0 = node shaders): `passesPerFrame`,
  `gpuPassIssueMsPerFrame`, `gpuPassExecMsPerFrame` (absent without timer
  queries) and `verticesPerFrame`, the vertices (indices on an indexed
  draw) submitted, which says a pass is vertex-bound before any timer is
  read; fill still needs subtraction. On Android the compositor's own
  record of the presents is `sol android --census` (`--clear --seconds
  <N>` around an interaction): present intervals in refreshes and the
  per-frame GPU span, the figures every pacing and paint-cost verdict is
  checked against.
- `/debug` - the app's registered debug commands; POST
  `/debug?name=<cmd>` with a JSON body as its args to call one.
- `/dialog` - the file dialog state (`flux:dialog`): `pending`, the request
  waiting for an answer (`kind` openFiles/saveFile/openDirectory and its
  options) or null; `last`, the last request made with its `answer`
  (`picked` entries, `cancelled`, or `failed`); `queued`, answers waiting
  for the next request; `available`, whether the client's platform can show
  a native dialog. POST `/dialog` with a JSON body `{ files: [path, ...] }`,
  `{ directory: path }` or `{ cancel: true }` answers the waiting request
  (`answered: true`) or queues the answer for the next one (`queued:
  true`). Queue BEFORE triggering the flow: on a visible client an
  unanswered request shows the native dialog, which nothing can dismiss
  remotely. Paths are in the client's own file system; the app gets
  `flux:fs` handles on them as from a real pick.
- `/link` - GET reads the location the app reports (`{ location }`, a
  router's current path, null when the app reports none; a reload of the
  same app starts at the last reported location, so a rebuild comes back
  to the screen it left); POST
  `/link?link=<link>` delivers a link to the app the way an OS-routed one
  arrives (raw, on `onLink`) and answers `{ delivered }`, false when
  nothing listened. The way to open a screen directly, and to test link
  handling with no scheme registered anywhere.
- POST `/input` with `{ "events": [...] }` - synthetic input through the
  real pipeline, same event shape as the `send_input` tool (tap real
  coordinates read from `/tree` just before: the window's logical size
  follows the display it sits on, so a size read earlier can be stale).
  A gesture is spelled out in one place, for this API and for app tests
  alike: a `tap` is a down and an up, the up one frame and its `holdMs`
  later, never in the same frame (a real tap is not 0 ms, and the pressed
  state would never render); a pointer `drag` (`to: { x, y }`,
  `durationMs`, default 300) is a down, one move per frame along the line
  and an up at the end point; a mouse tap or drag first moves the pointer
  to the point, a frame ahead of the down, so hover is what a real mouse
  leaves. A frame here is one 60 Hz interval. A pointer `cancel` ends a
  pressed pointer the way the system does when it takes a touch away (a
  back swipe, palm rejection): the app sees `onPointerCancel` and never
  an up, so a press fires nothing and a drag ends without a fling.
  A wheel event's `deltaX`/`deltaY` reach the app unscaled, in the units
  a physical wheel reports: one mouse notch is 100, and a positive
  `deltaY` scrolls content down.
  A gamepad is driven like a key: `{ "type": "gamepad", "action":
  "connect" }` seats a synthetic pad in the lowest free slot (or `slot`),
  `{ "action": "set", "slot": 0, "buttons": ["south"], "axes": { "leftY":
  -1 } }` holds that state until the next set (level, as a physical pad's
  state is; `holdMs` returns it to rest in the same call), `{ "action":
  "disconnect", "slot": 0 }` frees the slot. Synthetic pads take slots
  next to physical ones and reach `gamepads()` and everything on it; the
  mute leaves them alone.
  `{ "type": "back" }` is the user's back intent: the one event the
  Android back button, a pad's back button and the desktop dev chord
  become, into the app's `onBack` stack (a focused TextInput cancels, an
  open Modal, Select or ContextMenu closes, the router pops) and never a
  key.
- `/settle` (`?max=<ms>`, default 5000, at most 30000) - waits until the
  app is at rest and says whether it got there: `{ settled, waitedMs,
  demand, inFlight, timerDue, idleDue }`. At rest means nothing the app
  started is in flight (a fetch, a file or body read, a query, an isolate
  call or a step of its stream), no timer is due, no idle callback waits for its idle period and no
  frame is demanded. Call it after `/input`, `/debug`,
  `/link` or `/reload` and before `/tree` or `/snapshot`, in place of a
  sleep. When `settled` is false the rest says why: `demand` lists what
  still wants frames ("onFrame", "a transition on view labelled \"drawer\"
  under window > column", the node placed under its ancestors so it can be
  found in an outline,
  "a playing video", "requestAnimationFrame", "a write to the tree or to a
  texture"), `inFlight` counts the work per kind (`{ "fetch": 1 }`). An
  app that animates by itself never settles; freeze it with `/clock`
  instead, or, when something loads behind the animation (a spinner over
  a model load), `?frames=0` waits for the app's work alone: nothing in
  flight, and a frame after the last of it in which nothing more
  started; `settled` then says whether the work finished. `?max=0` reads
  the state without waiting. What stands (a server, an open socket, a
  timer not yet due) is not waited for.
- POST `/clock?scale=<x>` (0 pauses) / `?step=<n>` frames while paused;
  `{ scale, pendingSteps }` back. Steps apply one per frame at the client's
  frame rate, and the reply waits for them: `pendingSteps` is 0 once they
  all ran, so the next read or snapshot sees the stepped state. A nonzero
  count means the client stopped running frames (a wedged app) and the
  reply gave up waiting. GET `/clock` reads the same record without
  touching it. `/clients` reports each client's `timeScale`, reset to 1 by
  every push.
- A forwarded query that gets no answer in its budget (10 s, more for a
  raw-format image or a stepped clock) is a 504 naming the budget. On a
  slow device, or for a heavy reply (a raw snapshot of a large window), a
  retry usually answers; only a repeat means the client's JS thread is
  busy or the app is wedged.
- POST `/reload` - rebuild and push to every client; `{ ok, clients }` or
  the build error.
- POST `/load` with `{ "entry": "<path>" }` - switch the entry and push it;
  `{ ok, entry, clients }` or the build error. Relative paths resolve
  against the project root (file mode: the served file's directory). A
  project server only loads files inside its project.
- POST `/mute?active=true|false` - mute/unmute the user's own input on
  every client, gamepads included (synthetic `/input` still goes through;
  resize and close cannot be muted). `{ ok, active, clients }`. The mute
  lifts when the server stops; unmute yourself when done.
- POST `/watch?active=true|false` - resume/pause reload-on-save (`active`
  is whether it watches). `{ ok, active }`. Paused, saves push nothing
  until `/reload`; changes made meanwhile are not replayed on resume. The
  pause lifts when the server stops; resume yourself when done.

The loop is the same as over MCP: `/reload`, then `/logs?since=`, then
`/tree` for coordinates and `/snapshot` of the smallest relevant node.

## Lessons that cost real time

- console.log + get_logs is your primary probe into runtime state. For state
  you will want repeatedly (a pose, a mode, a counter), bind a debug key that
  logs it and read it back via get_logs.
- Better than debug keys when driving the app over MCP: register debug
  COMMANDS - `registerDebug(name, fn)` from `sol:dev` (it returns the
  unregister: a command over one screen's state is registered at the
  screen's mount and unregistered in its onCleanup), invoked via the
  list_debug/call_debug tools. Use them to SET UP state (jump to a level,
  force a mode, seed a scenario); then the runtime-level tools take over -
  set_time_scale 0 freezes the result for as many snapshots as you need,
  and step_frames walks it forward deterministically. Set state, pause,
  snapshot. To see one frame exactly as its own code drew it (a flash, a
  one-frame stale draw), pause and call get_snapshot with `step: true`: it
  advances that one frame and captures inside it, where a plain snapshot
  after step_frames paints afresh and shows the settled state.
  Registrations reset on hot reload, so register at module init;
  sync return values only - and note a signal you just wrote flushes on a
  microtask, so returning a signal read straight after setting it returns
  the OLD value.
- call_debug sets state directly, skipping focus, key routing, and
  TextInput - fine for SETUP, but "the interaction works" is only shown by
  the real pipeline: verify clicks, typing, and drags with send_input,
  which enters events where SDL input does.
- Key events start at the focused node and bubble to the window root; with
  nothing focused they go to the window root alone. So a debug key bound via
  `<window onKeyDown>` always fires (unless a focused component consumes the
  key with stopPropagation, as TextInput does for editing keys). `key` and
  `code` are W3C KeyboardEvent values, so arrow keys arrive as "ArrowLeft"/
  "ArrowRight"/"ArrowUp"/"ArrowDown" (not "Left"), alongside "Enter",
  "Escape", "a".
- Idle frames skip work: shaders/pipelines only re-render when an input
  changes - their own params/geometry, or a sampled texture (a data upload,
  or a sampled target re-rendering; chains propagate automatically). Measure
  performance while inputs are actually changing. get_snapshot works on an
  idle client (it requests its own frame); a timeout means the JS thread is
  busy or wedged. get_texture on a pipeline's render target reads the
  current output, pending writes included, without needing a new frame.
- When a human reports a visual bug: capture a snapshot and SAY WHAT YOU SEE
  in it before investigating, so you agree on the symptom. If you cannot see
  the problem in the capture, say that instead of guessing.
- Snapshots are downscaled by the time you see them, so a full-window capture
  cannot show you a defect a few pixels across. Whenever you hand-author
  geometry - a `d-path` from raw path math, a `radius` where two shapes meet,
  a stroke join - inspect it MAGNIFIED once, when you write it: get_snapshot
  with a tight crop at scale 4-8 shows the actual rendered pixels enlarged,
  in one call, on the real app. Verifying that a shape is in the right place
  is not the same check as verifying it is drawn right - and get_render_tree
  props answers the third question, whether the value you set is the value
  the renderer holds.
- GPU/geometry bugs: inspect the actual GPU data FIRST - get_gpu_resources
  for draw counts/uniforms/sizes, get_texture for atlas or data-texture
  contents ("is this tile blank?" is a ten-second question), get_buffer for
  vertex data. The pixels only tell you THAT something is wrong; the
  resources tell you WHERE the data stops being right. In a one-big-pipeline
  app the render tree is a single <texture> leaf and tells you nothing -
  these tools are the visibility layer behind it. Only when the GPU data is
  all correct (so the bug is in producing it, or in the shader), reproduce
  the math CPU-side in a scratch bun script against the app's real data and
  print values.
- "The draw is slow and JS is idle" is answered by get_gpu_resources, in one
  line: each entry reports `indexCount` and `instanceCount` beside `execMs`,
  so a big instanceCount next to a tiny indexCount is per-instance setup
  cost, not shading (a point cloud drawn as 1.2M instances of a 3-vertex
  primitive ran 4x slower than the same points as one indexed geometry).
  Vertex cost versus fill is not reported and still needs subtraction:
  change the splat size or the vertex count, take the execMs delta
  (okf/done/gpu-vertex-fill-attribution.md).
- Generating files counts as editing: pause_watch before a data-prep script
  writes into `assets/`, not just before source edits. The dev server watches
  the assets tree, so a script writing a few hundred MB rebuilds and pushes
  the app on every write, and the human watches their window thrash while the
  app reloads half a gigabyte over and over. Nothing in the tool output says
  this happened - `generation` in get_logs/list_clients is the only tell.
- Validate assets at load time and log anomalies (missing lumps/files,
  fully-transparent composites, zero-sized images). Silent fallbacks hide
  bugs for days; a one-line warning surfaces them the first run.
- After every reload the app restarts from its initial state. If reaching
  the bug site takes navigation, add a dev shortcut (teleport key, noclip,
  initial-state override) before iterating - the round trips add up fast.
- Clamp onFrame time deltas to [0, cap], not just capped: across a hot
  reload the runtime's tick counter resets AFTER the new instance's first
  frame, so the second frame computes a hugely NEGATIVE delta.
  Math.min(dt, cap) lets it through, and one bad frame can corrupt anything
  integrated from dt (positions fly off, accumulators go so negative they
  never recover). Math.max(0, Math.min(dt, cap)) costs nothing.
- A fixed-timestep simulation on that clamped dt still drifts: no panel
  presents at exactly its nominal rate (a "60 Hz" panel measured 60.3) and
  the paced tick tracks the real cadence, so a 16.667 ms step against a
  16.59 ms average dt comes up one step short every few seconds - one frame
  runs no step (freeze), the next runs two (jump), and frame jitter
  scatters which frame it lands on, so it reads as random stutter. The
  runtime hands every callback the refresh rate,
  `onFrame((tick, frame, rate) => ...)` (SDL's nominal Hz): when the step is
  within a few percent of `1000 / rate`, run whole steps per frame
  (`Math.round(dt / STEP_MS)`, clamped to [0, cap]) so the world rides the
  refresh; only accumulate (`acc += dt; while (acc >= STEP_MS) ...`) when
  the display is genuinely off-rate (50, 120, 144 Hz), or interpolate the
  render by `acc / STEP_MS` if the game does not snap to whole pixels. It
  is invisible in a five-second look and survives every renderer
  optimisation; measure it with a steps-per-frame histogram, not by eye.
- A registered onFrame is a standing request, not demand-gated: it re-requests
  the next frame every time it runs, so the runtime keeps calling it - and
  presents - every frame at the refresh rate until you deregister it (fps
  stays at the refresh rate on an idle screen; that is the 60fps burn called
  out in @solidrt/core agents/performance.md, rule 4). The upside is that a
  self-running loop - a game clock, a shader driver, a stepped VM doing silent
  CPU work with no console output - keeps advancing on its own: it does NOT
  stall when the body changes nothing and needs no startup "prime" write.
  Deregister onFrame (return its cleanup, or let onCleanup fire) whenever
  there is nothing left to advance.
- Layout is incremental: a change re-solves only the dirty path, and clean
  subtrees answer from a per-node cache, so long lists no longer cap layout
  (a thousand-node tree relays out in well under a millisecond). If layoutMs
  still grows with tree size, read the get_stats counters - a low
  cacheHits/cacheGets ratio means the layout cache is being defeated, high
  wordShapes means text is actually reshaping. Paint is viewport-culled:
  under an `overflow="hidden"` scroller only the subtrees that can reach the
  visible box are painted, so paintMs tracks what is on screen, not what is
  mounted (nodesPainted in get_stats shows the count). Very long lists still
  pay for the initial mount and for memory, so windowing stays sensible at
  the thousands-of-rows scale.
- Remote images: createImage (and Image) dedupes repeated URLs, caches the
  bytes on disk, and the runtime rate-limits concurrent asset fetches per
  host - do not build your own promise cache around it. Images are fetched
  with no freshness check (an already-cached URL is never re-checked), so
  use versioned URLs for content that changes. Use Image's `fallback` prop
  (an image source) for the broken-image case instead of catching errors
  yourself.
- fetch() never caches by default and ignores server cache headers. Caching
  is explicit and per call: `fetch(url, { cache: "force-cache" })` for
  assets (serve from disk or fetch-and-store, no freshness),
  `{ cache: "reload" }` to refresh an entry. Image/createImage already do
  this for you.
