mobile-dev-debug-tool
Use when inspecting or reporting the state of the running Decentraland Godot Explorer client — on desktop or on a mobile device (iOS/Android) — over the unified scene-inspector debug-hub (`cargo run -- debug-hub`; device port 9231, consumer port 9230) that the client dials out to. Covers the SCENE_I
Install
mkdir -p .claude/skills/mobile-dev-debug-tool && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/14430" && unzip -o skill.zip -d .claude/skills/mobile-dev-debug-tool && rm skill.zipInstalls to .claude/skills/mobile-dev-debug-tool
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Use when inspecting or reporting the state of the running Decentraland Godot Explorer client — on desktop or on a mobile device (iOS/Android) — over the unified scene-inspector debug-hub (`cargo run -- debug-hub`; device port 9231, consumer port 9230) that the client dials out to. Covers the SCENE_INSPECTOR_CMD JSON protocol, the five trees (`scene`/`entity`, `ui_scene`/`ui_entity`, `avatars`/`avatar`, `app_ui`, `ping`/`scenes`), the `focus` keyboard-focus tracker, the `log`/`network` streams, the shared `filters` dict, the `websocat` helper scripts (`scripts/unified.sh`, `unified-tail.sh`), and the wiring across `godot/src/tool/debug_server/`, `scene_inspector_bridge.gd` and the Rust `SceneManager::debug_*` / `AvatarScene::debug_*` hooks. Also covers the `eval` command for running arbitrary GDScript against the live client (non-production only). Trigger when the user asks what state the running app/client is in (what scenes/realm are loaded, where the avatar is, what the UI is showing — desktop or on-device/mobile/iOS/Android), asks to connect to or host the debug-hub, or mentions the scene-inspector channel, debug-hub, port 9230/9231, `DebugWs`, `debug_collector`, websocat against the client, or running/evaluating GDScript against the running client.About this skill
Mobile dev debug tool — scene-inspector debug-hub
A single WebSocket channel exposes live client state (scenes / entities / UI /
avatars / focus), a log/network/lifecycle stream, and an eval command that
runs arbitrary GDScript — reachable on any platform, including iOS/Android
devices that can't be dialed into. The client dials out to a desktop
debug-hub; local tools (AI / websocat) connect to the hub's consumer port.
There is one transport: the scene-inspector CMD protocol (the source-of-truth
contract an external inspector app already parses, so additions stay
backward-compatible). eval is hard-disabled in production builds.
Bring up the hub
cargo run -- debug-hub # device port 9231, consumer port 9230
# launch the client pointed at the hub's device port (LAN IP shown in the banner):
cargo run -- run -- --scene-inspector=ws://127.0.0.1:9231 # desktop
cargo run -- run --target ios -- --scene-inspector=ws://<this-mac>:9231 # device
On iOS the dcl-ios-devtools export plugin auto-bakes the hub address (debug
builds), so even a Godot-editor deploy phones home — just accept the
local-network prompt on first launch. The bridge activates at boot
(global.gd::_activate_scene_inspector_from_config, from _ready + on every
deeplink), so the channel is live from the lobby, before login — no need to
enter a world first.
Answering "what state is the app in?" — ONE step
Run the pre-armed connector in the background, read its output, then query:
scripts/hub-connect.sh # Bash tool: run_in_background: true — read its output
hub-connect.sh does the whole cold-start dance in one shot: wires Android
adb reverse, ensures a hub (reuse or start), waits ≤35 s for the device to dial
in, and prints either === CONNECTED === + a ping snapshot or a NO DEVICE
relaunch hint. If it started the hub it keeps the task alive so the hub persists.
Then query with the id-filtered helpers:
scripts/unified.sh scenes
scripts/unified.sh scene '{"scene_id":0,"filters":{"limit":5}}'
scripts/unified.sh eval 'return {"scene": str(get_tree().current_scene.name), "scenes_loaded": Global.scene_runner.debug_get_loaded_scene_ids().size()}'
Why it just works: the client dials out and retries forever (backoff
1s→…→30 s cap, scene_inspector_websocket.gd), and debug builds default the
target to ws://127.0.0.1:9231 with no arg (global.gd), so a hub started
after the app is picked up within ≤30 s — no app restart needed. Per platform:
- iOS — the
dcl-ios-devtoolsexport plugin bakesws://<LAN-IP>:9231(even a Godot-editor deploy phones home); accept the Local Network prompt once. - Android — NO plugin bakes the arg (the baking plugin is iOS-only, and Android
editor-deploy CLI args don't reach the app). The debug loopback default +
adb reverse tcp:9231(set byhub-connect.sh) carry it instead. A build made before that default won't connect — rebuild+redeploy, or usecargo run -- run --target android. - desktop —
cargo run -- run -- --scene-inspector=ws://127.0.0.1:9231, or an editor F5 also auto-dials loopback.
If hub-connect.sh reports NO DEVICE, the app simply isn't dialing — follow the
hint it prints (usually: relaunch/redeploy the app).
Wiring
- Command backend:
DebugWsautoload →godot/src/tool/debug_server/debug_ws_server.gd(run_command) +debug_collector.gd(data assembly). No longer a server — purely the shared inspection/eval backend + keyboard-focus tracker. - Transport:
godot/src/tool/scene_inspector_bridge.gd(drives CMD ↔ ACK and the streams) +godot/src/logic/scene_inspector_websocket.gd; Rust side inlib/src/tools/scene_inspector/. The hub is thedebug-hubxtask (src/log_server.rs). - Rust
#[func]hooks for state only Rust can reach:SceneManager::debug_*(lib/src/scene_runner/scene_manager.rs) — CRDT enumeration, deserialization, UI control lookup.AvatarScene::debug_*(lib/src/avatars/avatar_scene.rs) — avatar listing, address/alias/entity/local lookup.
Protocol (scene-inspector CMD)
- request:
{"type":"SCENE_INSPECTOR_CMD","cmd":"<verb>","args":{...},"id":"<id>"} - reply:
{"type":"SCENE_INSPECTOR_CMD_ACK","id":"<id>","ok":<bool>,"data":...}(or{"ok":false,"error":"..."}) - streams (push):
{"type":"SCENE_INSPECTOR","payload":{"sessionId":...,"entries":[{type:...}]}}whereentries[].type∈ crdt | op_call_start | op_call_end | scene_lifecycle | perf | log | network | session_start | session_end.
The id is echoed in the ACK — always match replies by it (see the perf-vs-ACK
note below). The helpers (scripts/unified.sh) do this for you.
Five trees, one command surface
| cmd | Tree | Identified by |
|---|---|---|
scene / entity | 3D entity tree (DclSceneNode → DclNodeEntity3d) | (scene_id, entity_id) |
ui_scene / ui_entity | per-scene SDK UI (UiNode.base_control) | (scene_id, entity_id) |
avatars / avatar | global AvatarScene | by ∈ {address,alias,entity,local} |
app_ui | Explorer's own UI | auto-detected (/root/explorer/UI or /root/Menu) |
ping / scenes / focus | — | — |
All four data cmds (scene, ui_scene, avatar, app_ui) share a filters dict:
component: [...]— OR-match SDK component names (cheap, no proto decode)property_is: {component, field, contains}— generic substring filter on any (SDK component, field) paircollect_nodes: {<child_name>: [<property>, ...]}— per-child-node property dump viaObject.get(); values pass through_variant_to_jsoninclude_parents,include_children,limit,offset,depth,class_filter,name_contains— traversal/pagination knobs
Querying — unified.sh
scripts/unified.sh <cmd> [args-json] sends one CMD frame to the hub consumer
port (ws://127.0.0.1:9230) and returns its matching ACK. It bakes in
-B 16777216 so websocat doesn't split large replies, and keeps the socket open
until the ACK's id matches (needed for on-device round-trips). Requires
websocat on $PATH (cargo install websocat or your package manager).
# Confirm the round-trip to the connected client
scripts/unified.sh ping
# All loaded scenes
scripts/unified.sh scenes
# All TextShape entities in scene 0 with their Label3D properties
scripts/unified.sh scene '{"scene_id":0,"filters":{
"component":["TextShape"],
"collect_nodes":{"TextShape":["text","font_size","pixel_size","outline_size","modulate"]}
}}'
# Your own avatar — what it's wearing + what's playing
scripts/unified.sh avatar '{"by":"local","filters":{
"collect_nodes":{"AnimationPlayer":["current_animation","autoplay"],"AnimationTree":["active"]}
}}'
# Explorer's own UI hierarchy (lobby in this state, scene UI when loaded)
scripts/unified.sh app_ui '{"filters":{"depth":2}}'
# Keyboard-focus tracker — current owner + ui_root + change history
scripts/unified.sh focus
Eval — running GDScript
scripts/unified.sh eval '<gdscript>' compiles and runs the snippet against the
live client and returns the serialized result — the agent-facing equivalent of a
devtools console. Non-production only: in a production build the cmd replies
{"ok":false,"error":"eval disabled in production builds"}.
code is a GDScript function body. Use return X to send a value back. Three
locals are in scope:
| local | what |
|---|---|
tree | the SceneTree (tree.root, tree.get_node(...)) |
global | the Global autoload |
server | the DebugWsServer command-backend node |
Autoloads (Global, DebugWs, …) and engine singletons (OS, Engine,
Time, …) are reachable directly too. A bare single-line expression is
auto-wrapped in return, so eval '1 + 1' works without the keyword.
The result passes through the same _variant_to_json used elsewhere
(primitives, Vector*, Color, AABB, Array, Dictionary; everything else
— Object/Node/Callable — falls back to str()).
# Bare expression (auto-wrapped)
scripts/unified.sh eval 'Engine.get_frames_per_second()'
# Reach into the tree
scripts/unified.sh eval 'return tree.get_root().get_child_count()'
# Multi-line statement body
scripts/unified.sh eval 'var names = []
for c in tree.get_root().get_children():
names.append(c.name)
return names'
Limitations: synchronous only — await is not supported (it would return a
coroutine signal, not the awaited value). GDScript runtime errors (e.g. a
null access) are logged to the client console and the eval returns null with
ok:true; only compile errors come back as ok:false.
focus — keyboard-focus tracker
focus takes no args and returns the viewport's current keyboard-focus owner
plus a timestamped change history (no filters). Reply data:
{"current": "<path> [<class>]", "ui_root_path": "/root/explorer/UI", "history": [{"t_ms", "frame", "from", "to"}, ...]} (last FOCUS_HISTORY_MAX
= 64 changes; "<none>" means focus was released to null).
DebugWs polls get_viewport().gui_get_focus_owner() every _process frame (in
debug builds) so the history captures transient changes — including
release-to-null, which the engine's gui_focus_changed signal misses.
Use it for "input stops working" bugs: mobile walk/jump are gated by
player.gd → explorer_has_focus() (== ui_root.has_focus()), so movement
silently dies whenever current ≠ ui_root_path. The history shows which
control stole focus and on which frame. (This is how the navbar-toggle
focus-steal bug was found: the gate read true→false when a press landed focus
on the navbar's full-rect Button.)
Streams — unified-tail.sh
scripts/unified-tail.sh # logs only (default)
scripts/unified-tail.sh log,network # logs + HTTP
scripts/unified-tail.sh log,lifecycle # logs + per-tick scene lifecycle
Content truncated.