irisandClaude Opus 5 1d71730b56 Render on the GPU with no display, through Xwayland
This VM sees the host's 7900 XT through virtio-gpu and renderD128 is
readable without the video group, so the software rasteriser was never
necessary. What was in the way is that the emulator's `-gpu host` renderer
speaks GLX: with no DISPLAY it cannot open a context at all
("GlxEnginegetDefaultDisplay: Failed to open display 0") and silently falls
back.

So `emu up` now starts a headless sway whose only job is to provide an
Xwayland, and Xwayland's GLX runs on the render node. The guest's GLES
reports `virgl (AMD Radeon RX 7900 XT)` afterwards. One compositor per
machine, on a named socket so a second `emu up` reuses it; no seat, no DRM
master, no card node.

Scrolling a list in the stock Settings app: 25.5% janky frames and 8 slow
UI-thread frames on software, 3.3% and 0 on the GPU. An emulator also costs
1.1 GB less resident (2.9 GB against 3.9 GB), which is real headroom on a
machine that only takes two.

The reason this is worth the machinery is measurement rather than speed.
Before it, frame timings from in here said nothing about an app at all --
ai-app scrolled *better* than Settings, because both were bounded by the
rasteriser rather than by anything either was doing.

Host Vulkan is off in `GPU_HOST_FEATURES`, and that one is a bug rather than
a preference: gfxstream asks Venus for a memory type for its ColorBuffers
that Venus does not offer, and the emulator dies before adb sees it. GLES is
unaffected. Retry it whenever mesa moves; the failure is loud.

A missing sway, or one that will not start, says so and falls back to
software exactly as before -- verified by moving the config aside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:29:02 -04:00
2026-08-30 13:12:44 -04:00

emulator-tools

Everything this machine uses to drive an Android emulator, in one place: which emulator a command means, whether there is room to start it, reading a screen as text, and taking a screenshot that does not cost a thousand tokens.

./install.sh

symlinks bin/* into ~/.local/bin and share/ui-trace into ~/.local/share, so editing this repo is editing what runs. It keeps anything it replaces as <name>.bak.

What is here

emu this checkout's emulator: name, serial, list, up, down
adb adb, aimed at this checkout's emulator, with screenshots scaled
ui-trace records a screen as text at 60Hz, and drives it
lib/project-avd.sh the rules all three share
share/ui-trace/ the device recorder (UiTrace.java) and its build

One emulator per checkout, named after it

emu and adb both work out which AVD you mean from the enclosing git checkout: in ~/repos/ai-app-2 that is ai-app-2. Nothing has to be configured, and AVD_NAME overrides it where the guess is wrong.

This is the rule that lets several agent sessions work here at once. The emulator used to be the one resource that could not be parallelised: taking it meant messaging the other sessions, waiting, and handing it back — and installing an app onto somebody else's running emulator steals the foreground from whatever they were looking at.

It is also why adb fills in -s. With two emulators attached, a bare adb shell pm list packages comes back empty rather than failing, which reads as the app having been uninstalled rather than as the question being ambiguous. An explicit -s, ANDROID_SERIAL, or a subcommand that is not about one device (adb devices) all turn the defaulting off, and nothing is guessed when this checkout's emulator is not running: adb's own error is better than a wrapper picking a stranger's device.

Rendering on the GPU with no display

This machine has no screen, and for a long time that meant the emulator rasterised in software. It does not have to: the VM sees the host's GPU through virtio-gpu, and /dev/dri/renderD128 is readable without being in the video group.

What was in the way is that the emulator's -gpu host renderer speaks GLX, so with no DISPLAY it cannot open a context at all -- it says GlxEnginegetDefaultDisplay: Failed to open display 0 and falls back. So emu up starts a headless sway whose only job is to provide an Xwayland, and Xwayland's GLX runs on the render node. The guest's GLES then reports virgl (AMD Radeon RX 7900 XT) instead of a software rasteriser. One compositor is started for the machine and reused, on a named socket under XDG_RUNTIME_DIR; it needs no seat, no DRM master and no card node.

Measured 2026-08-30, scrolling a list in the stock Settings app:

janky frames 90th percentile slow UI-thread frames
software 25.5% 93ms 8
on the GPU 3.3% 28ms 0

An emulator also costs about 1.1 GB less resident this way (2.9 GB against 3.9 GB), which is a whole emulator's worth of headroom on a machine that only takes two.

This matters for more than speed. Before it, frame timings measured in here said nothing at all about an app: our own app scrolled better than Settings did, because both were bounded by the rasteriser rather than by anything either of them was doing. Now the platform floor is low enough that an app's own jank is visible above it.

GPU_HOST_FEATURES carries the one workaround: host Vulkan is switched off, because gfxstream asks Venus for a memory type for its ColorBuffers that Venus does not offer, and the emulator dies before adb sees it. GLES is unaffected. It is worth retrying whenever mesa moves -- the failure is loud and costs one boot to find.

If sway is missing, or the compositor will not start, emu up says so and starts on software rendering as it always did. None of this is essential.

Refusing to start one

emu up checks MemAvailable first and refuses if starting an emulator would leave the machine short, printing what is attached, what is large, and what usually frees enough. EMU_FORCE=1 overrides it.

The reason is a real incident rather than tidiness. On 2026-08-30 an emulator was started on this box with 2.8 GB available; the OOM killer ran, and what it took was not the emulator that had just started — it walked the user slice and killed pipewire, dbus-broker and another session's emulator first. The cost of one too many lands on somebody else's work, minutes later, looking like an unrelated crash.

Measured numbers behind the defaults: a headless x86_64 AVD is about 3.8 GB resident, this VM has 23 GB with a swap that is routinely full, two emulators are comfortable, and three were not.

Reading a screen without screenshots

ui-trace drives a small resident recorder on the device and samples the accessibility tree at 60Hz, so an animation or a settling layout is visible rather than happening between two samples.

ui-trace record -d 3000 --do 'tap 540 800' -o /tmp/t.txt
ui-trace elements /tmp/t.txt              # what is on screen, to pick from
ui-trace show /tmp/t.txt                  # what moved
ui-trace show /tmp/t.txt -m 'Send|Stop' --field box

Bounds come out in device pixels, so they feed straight back into a --do tap. Prefer this to a screenshot for anything positional: two buttons that measured identically at 171x105 looked different in a screenshot, because the filled one read as larger than the disabled grey one. Screenshots are for appearance — colour, weight, whether it looks right.

adb exec-out screencap -p still works and comes back scaled to 800px on its long edge, which is ~380 tokens to read instead of ~1460 and still legible for layout, contrast and small labels. ADB_SCREENCAP_MAX_EDGE raises it for one call.

Using it from a project

A project's own run-android.sh should build and install; the emulator half belongs here:

SERIAL=$(emu up)
./gradlew :androidApp:assembleDebug
adb install -r <apk>          # already aimed at this checkout's emulator
S
Description
No description provided
Readme
185 KiB
0 Stars 1 Watchers 0 Forks
Languages
Shell 45.4%
Java 28.8%
Python 25.8%