Decision
Cordial gains an opt-in development control surface: a Unix socket, created only whenCORDIAL_DEV_CONTROL is set, which can
- capture the frame the engine just presented, read out of the Vulkan swapchain, and
- deliver pointer motion, clicks, keys, text and scroll through Cordial’s own
input::pass_*entry points, and - fill a focused Roblox TextBox in one edit rather than one synthetic
keystroke per character (
paste/settext), for a harness payload too large to type.
tools/cordial-mcp.py speaks MCP over stdio against that socket and adds
debugger tools on top of it.
paste/settext are the one pair of verbs that do not go through
input::pass_*. Since fd0f0c6 a real gtk::Text owns a focused box’s text
on Wayland (see the text-entry sections of docs/NEXT.md), so the two verbs
write that widget directly through ordinary GtkEditable calls
(insert_text/set_text) and let its own changed signal do what a keystroke
already does — mirror the write, push it to the engine, redraw. Writing
Cordial’s own mirror buffer and the engine instead, bypassing the widget, was
tried first and is not what shipped: the widget would not agree with the
engine until the next pump tick’s overlay refresh caught up, and a real
keystroke landing in that window would revert the edit. X11 has no such
widget (ADR-024) and keeps the buffer-and-engine path this whole surface used
before the widget existed.
It works in coordinates and pixels only. There is no semantic access to
Roblox’s interface: no element tree, no “click the button named Play”.
Why
Debugging this client has been bottlenecked on instruments rather than on reasoning, and one session made the cost impossible to ignore. On 2026-08-21 a rendering bug consumed most of a day, and every wrong turn in it was a broken measurement rather than bad thinking:mainWorkCallbackwas read as a per-frame heartbeat. It fires exactly twice in healthy runs too, so “it stopped after two” was never a symptom.onFlagsLoaded’s byte count looked like a delivery readout for the settings document. It is a constant: 1,308,253 for anFIntsix characters longer, for anFString1,001 longer, and for a document with 903 keys and 87 KB removed.- Present counts collapse to exactly 1.0/s from the engine’s idle throttle whenever input is not being driven for the whole measurement, which was very nearly recorded as a bisect signal.
xprop/import see nothing for a native Wayland client, GNOME’s
org.gnome.Shell.Screenshot answers AccessDenied, ffmpeg -f kmsgrab wants
membership of the video group, the portal wants a human to click a dialog, and
no nested compositor is installable on an immutable host. So every visual check
ended with a person being asked to look at the screen and describe it, and a
bisect that should have taken three runs did not happen at all.
Why the swapchain, rather than the compositor
Reading the presented image gets a strictly better answer than any screenshot API could. It is what the engine drew, so it is unaffected by occlusion, by another window covering Cordial, by the window being off-screen or on another workspace, or by the compositor’s own colour management. It also needs no permission, no portal and no group membership. The copy runs insidevkQueuePresentKHR before the frame is forwarded, because
that is the one moment the image is both complete and in a layout the copy can
name: the engine has finished rendering and has just left it in
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR.
Why no semantic access, and why that is not squeamishness
An element tree was the obvious thing to want, and it was investigated properly before being ruled out. Roblox exposes no accessibility tree on Android at all, measured four ways on 2026-08-21:libroblox.so has 517 Java_* exports
across twenty engine interfaces and none mention accessibility; no com/roblox/
class in the dex is named *Accessib* or implements a provider; the dex has no
com/roblox/** View or Surface subclass for a virtual-descendant provider
to hang off; and a run with the AT-SPI bridge genuinely attached, so the
isEnabled gate answered true honestly, produced zero calls into
native/accessibility.cpp.
So there is nothing to read, and obtaining it would mean introspecting the
engine — which ADR-001 and
ADR-003 place permanently out of scope, absent
rather than disabled. Coordinates and pixels are what a human tester has, and
they turn out to be enough.
Why input goes through Cordial and never the compositor
AGENTS.md already forbids synthesising input withXTestFake*, ydotool,
wlr-virtual-keyboard or the RemoteDesktop portal, because they land on
whatever has focus — which is the developer’s own session, and one of them has
already hijacked a cursor mid-session. Cordial is the client, so there is
nothing to send through: the socket queues commands and the pump applies them by
calling input::pass_* directly, on the same thread CORDIAL_SCRIPT has always
used.
Why it is off by default and unreachable from plugins
The socket exists only whenCORDIAL_DEV_CONTROL is set, and it lives inside
the profile directory so ADR-012’s
one-instance flock already decides who owns it.
No plugin can reach it. ADR-007 gives
plugins effects rather than channels, and a socket that drives input is exactly
the channel ADR-003 exists to prevent. This is a
development aid for whoever is running the client, not a capability.
Consequences
A freeze is now investigated by asking the client rather than asking a human:cordial_info twice a few seconds apart separates a wedged engine from a slow
one, because a wedged engine leaves the present count fixed while everything
else keeps running. That exact reading — 42 presents against 74 million polls —
is what finally characterised the 2026-08-21 freeze, and it took seconds once
the counters were reachable.
The cost is a per-capture command pool, buffer and device-wide wait. That is
deliberate: a screenshot is taken a handful of times a run, and keeping a
command pool and a mapped buffer alive for the whole session would be a
permanent cost paid for a rare benefit.
What this does not settle. The surface is only as good as the questions
asked through it, and nothing here prevents a badly chosen instrument — the
three retracted above were all arithmetic, not tooling. cordial_backtrace
therefore quotes the process’s CPU beside the stack, because a spinning pump and
a blocked one produce byte-identical backtraces and that distinction has been
read backwards here before.