Skip to content

feat: 3d acceleration, bunch of reworks - #52

Open
HuJK wants to merge 39 commits into
masterfrom
pr/3d-accel
Open

feat: 3d acceleration, bunch of reworks#52
HuJK wants to merge 39 commits into
masterfrom
pr/3d-accel

Conversation

@HuJK

@HuJK HuJK commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

為 DroidVM 補上三條 GPU 路線與其所需的記憶體模型,並把顯示、磁碟、網路、週邊
的管理面補齊到可日常使用的程度。

本專案 自有的檔案 改以 GPL-2.0/3.0-or-later 授權,並附帶允許上游重新授權的補充許可。

新增

  • 3D 圖形加速
    • DRM native context(drm2kgsl,turnip 直通,不需要主機 Vulkan capset)
    • GfxStream
    • Virgl venus
  • 顯示管線拆成螢幕與匯出端:一台 VM 可以有多個螢幕,每個螢幕各自綁定原生顯示
    或 VNC,輸入裝置隨綁定走;傳輸管線(CPU / GPU copy / 零拷貝 / 硬體編碼)依匯出端
    提供不同階梯
    • 新螢幕預設綁原生顯示:它的檢視器就是本 app,是唯一不必先架設任何東西就能看到
      第一次開機的匯出端
    • 開關只儲存開關本身,不覆寫其下的匯出端與埠:關掉再打開,選過的設定還在
  • VNC
    • 硬體 H.264,併回標準 RFB 埠
    • 監聽位址挑選:從手機自身的位址選(含 IPv6),預設 127.0.0.1
    • 兩個螢幕預設不同埠(virtio-gpu :0、SimpleFB :9),共用同一埠會在存檔前擋下
  • 磁碟
    • 一鍵建立 Linux VM
    • qcow2 增量分支管理面板:建立/合併/攤平/刪除/清空,操作後面板不關
    • 增量檔名為秒級(-ov-yyMMdd-HHmmss),同一分鐘建兩個不會撞名
    • vmpkg v2 匯入匯出,含 backing tree
  • 客體顯存池:guest 自行配置的 VRAM 池,gfxstream 的 host arena 在客體開機前
    就 share 完
    • 動態顯存:預配置量、步長、grant 上限;預設關閉,關閉時池子的大小就是它
      寫的大小,比較好預測
    • 主機池預設 64 MB:這個池子只放 per-context 的 msm shmem ring,需求是個位數 MB
  • 偽非保護模式:主機讀得到客體 RAM 的保護模式
  • 週邊
    • virtio-snd(端點具名、各有用途)
    • 序列埠(USB ACM gadget 池)
    • 相機與 VPU 條目
  • vCPU 放置計畫:affinity、capacity、cluster
  • 核心模組管理器與大頁記憶體面板,nproc_guard 以本 app 的 uid 載入、VM
  • 網路範本與設定精靈的網路步驟(預設 Wi-Fi:橋接需要主機真的有一個,而且弄錯時
    看起來像映像壞了而不是網路選錯)
  • Agent:無頭 VM 執行救援動作,共用一個終端面板

調整

  • swiotlb 預設 256 MB(原 64 MB)
  • 建立 VM 的三條路徑(Linux、自訂、匯入)共用同一套預設值,不再各自持有一份

優化

  • 大頁預檢與 daemon 共用同一條「客體池要不要傳」的規則,不再各持一份
  • GPU worker 的 cpuset 與即時排程:只有在 cpuset 存在時才給 RT
  • 提高 udmabuf 的單次 handle 上限,並附後備模組
  • 用來做 blit 的 Vulkan 由使用者挑選,並實際探測它做不做得到
  • 預建置 payload 指標改指向 CI 發佈的版本,而非只存在於本機的 commit

修正

  • Android 17 上 crosvm 無法連結:payload 裡的平台庫遮蔽了 /system/lib64
  • Android 14 缺少 path::__filename() 的相容性
  • 停止中的 VM 不再擋住刪除它所在的網路
  • 新網路在存檔時就向 daemon 註冊,而不是等到啟動
  • vmpkg 超過8G 時,大小解析錯誤

@HuJK
HuJK force-pushed the pr/3d-accel branch 3 times, most recently from 85c6ba2 to bc52566 Compare September 1, 2026 12:19
HuJK added 2 commits September 1, 2026 20:28
LICENSE.txt has always been the full GPLv3, but not one source file said so:
397 Java files with no header at all, so the licence of a file could only be
inferred from the repository around it. Every source file now carries an
SPDX-License-Identifier line.

ADDITIONAL-PERMISSIONS is the part that is new rather than merely written
down. It lets anyone relicense this material under the terms an upstream
project requires, for the purpose of getting it merged there and only for
that -- which is what these changes exist for, and what a plain GPLv3 file
cannot do when the project it is aimed at is Apache-2.0 or BSD. Contributions
carry the permission because they are made under the project licence;
CONTRIBUTING.md asks for a DCO sign-off rather than a CLA, so nobody signs
away anything to hold it.

LICENSE.txt itself is left alone. It is reflowed rather than hard-wrapped the
way the FSF renders it, which is a cosmetic difference in a file that should
not be churned.
…logs

check_arity.py walls off the constructors and methods that had grown past the
point where a caller can be read: a positional list long enough that swapping
two arguments of the same type still compiles. check_vector_icons.py holds the
drawables to one shape so an icon added in a hurry does not arrive at a
different optical size than the row it sits in. check_string_concat gets
stricter, and the two allow-lists carry the deliberate exceptions rather than
leaving the checks off.

isReturnDefaultValues lets a unit test cover a class that logs. Keeping every
testable class free of android.util.Log stopped being tenable once the classes
worth testing were the ones whose whole subject is a socket and a thread
outliving the object that owned them, and which say so out loud when they do.
Nothing asserts on a stub's return value; the stubs only let the class under
test be built.
The two display screens had grown the same features twice and disagreed about
half of them. A DisplaySource interface now names what a screen consumes (a
Surface for the native path, a bitmap for VNC), and the viewport and chrome
controllers hold the parts that were duplicated: the fit/zoom/pan arithmetic,
the toolbar and its auto-hide, the resolution-follow that resizes the guest to
the window it is being shown in.

Input is routed once. Pointer motion is coalesced before it reaches the guest,
gestures are translated in one place with an explicit mode (touch, mouse,
tablet) instead of each screen guessing, and the keyboard grew what a keyboard
needs: real key holds rather than tap-to-toggle, a laptop layout, a physical
keyboard view, and an extra-keys row that fits on a phone. Scan codes are
generated from one table -- the letters used to be wrong, so typing produced
different characters than the key that was pressed.

Two failure modes are fixed here because they belong to the framework rather
than to either screen. The binder wait for the display root service could leak
threads and retried without a cap, so a VM that had already stopped left the
app spinning; it now waits once and gives up with the VM. And a crosvm that
restarts under a live display used to leave the surfaces pointing at a dead
process -- the reconnect rebuilds them.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
@HuJK
HuJK force-pushed the pr/3d-accel branch 5 times, most recently from 6d5d235 to 7ceb2f3 Compare September 2, 2026 23:21
@HuJK HuJK changed the title pr/3d accel feat: 3d acceleration, bunch of reworks Sep 3, 2026
HuJK and others added 17 commits September 3, 2026 21:43
Importing an LXC rootfs and turning it into a bootable VM used to be a
sequence of steps a user had to know: import, create a disk, size it, write a
config, hope the compression was one crosvm can boot. It is one flow now, and
the pieces it needs are here -- the image picker, the create-Linux-VM screen,
the command generation, and the deletion path that takes the disks with it.

Overlays become a tree rather than a list. A qcow2 with a backing file is a
node with a parent, and the tree is what makes branch, switch and collapse
expressible at all; the backing chain linker and the dependency updater are
what keep the chain honest when a node moves. Disk maintenance -- resize,
recompression, auto-grow -- hangs off the same tree.

The rules that used to bite late now bite early. crosvm refuses to boot a
compressed qcow2 cluster it cannot decompress, so there is one definition of
what "compressed" means and imports stop re-compressing what is already right;
it refuses a disk with a snapshot, and the editor says so before the VM is
started rather than after; and a disk that has not finished importing cannot
be selected as a boot device.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
…, boot plans

Four small things that share nothing except the VM they configure.

The guest is told the real host CPU. crosvm forwards --smbios processor-version
through FDT /chosen and EDK2 publishes it as SMBIOS Type 4, so a UEFI guest
shows "Qualcomm Snapdragon 8 Elite" rather than the firmware default "Gunyah
vCPU". Game mode is declared so the platform raises CPU and GPU clocks for a
running VM instead of treating it as a background app. Per-VM environment
variables are read out of the config and applied to the crosvm process.

BootPlan stops being one constructor with nine positional arguments where four
of them are always empty. There are two kinds of plan -- UEFI firmware plus
vars, or a kernel plus initrd plus cmdline -- and each now has a constructor
that takes only its own fields, so a plan cannot be built half in one mode and
half in the other. The swiotlb size travels with the plan for the same reason:
it is a property of how the VM boots, not of the display or the GPU.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
…what it drives

The modules this app ships have to be matched to the kernel that is running
before they can be loaded, and the matching is not a substring test: KMI
directories are picked by exact version token, because 6.1 matching 6.12 loads
a module built for the wrong ABI and the failure arrives later, as a kernel
oops. match.json holds the rules and SocIdentity supplies the SoC half.
Autostart is only armed for a module that has actually been seen loaded, so a
module that fails once does not fail on every boot from then on. Each module
gets a description page, shipped with the module rather than written into the
app, so the answer to "what is this and why would I load it" is in one place.

The huge-page panel is a front end for gh-hugepage-reserve, an external Magisk
module, and that is worth saying plainly: it reads and writes that project's
settings.prop and its sysfs, branches on its v6/v7 differences, and links to
its releases page. When the module is absent the whole panel is off -- nothing
here fabricates a reserve. The advanced screen exposes the five knobs the GUI
cannot decide on the user's behalf, and an empty field means the module's own
default rather than zero.

PoolPreflight is the reason the panel matters to a VM that is starting.
Starting into the reserve's refill gap makes the hypervisor take movable memory
and migrate it out of CMA instead, which has been seen as a multi-minute
whole-host stall ending in the kernel OOM-killing crosvm, and as "qcom_scm:
Assign memory protection call failed -22", which resets the device. The
preflight sizes a VM against the free pool -- guest RAM, swiotlb, and the
route's GPU pools -- in the module's own unit of 2 MB pages, and refuses before
any of that can happen.
… VM exits

Symptom: after a few VM runs the app will not start any more and only a reboot
fixes it. Cause: setuid()'s set_user() -- which updates cred->user, the field
commit_creds() looks at to decide whether to move the NPROC charge -- sits
behind the CAP_SETUID branch. On the saved-uid path only cred->ucounts is
updated, so the process's NPROC charge is never added but is still subtracted
when it exits. Once the counter goes negative, is_rlimit_overlimit() fails
every fork for that uid and Zygote can no longer start anything.

The fix is setresuid() where the switching happens. This is the rescue for the
machines that have already drifted: nproc_guard takes the uid as an insmod
parameter, so the module is loaded with this app's uid, and the VM lifecycle
nudges a reset once a VM has exited. There is no hard dependency in either
direction -- with the module absent its sysfs node does not exist and this side
is a no-op.
A VM with no network is not much of a VM, and the network editor asks for a
bridge type, an uplink mode, a subnet and a VLAN before it will produce one.
NetworkPresets answers those questions for the two cases that cover almost
everybody: a Wi-Fi pseudo-bridge, where the VM sits on the phone's own segment
and takes its address from the upstream router (Wi-Fi in station mode cannot be
enslaved into a Linux bridge, hence pseudo), and a routed network with one
untagged VLAN, NAT to whatever uplink the host has, and DHCP.

The editor builds its blank form from the same helpers, so a network created in
one tap and one typed out by hand pick their subnets from the same pool and
avoid the same conflicts. The setup wizard gains a step that makes one, because
first run is exactly when the user does not yet have an opinion about bridge
types.
… panel

Some things can only be done from inside the guest -- changing a root password
whose value nobody remembers, growing a filesystem to match a disk that was
just resized. The agent runs the guest headless with an extra agent0 chardev on
the QEMU backend, drives it through a queue of actions with a declared spec
each, and shows what it is doing. The queue is ordinary enough to unit-test,
and is.

The password is typed rather than embedded. Building it into the script that
runs inside the guest puts it in a command line, a log and a config file; it is
sent over the agent channel instead, and the payload is chunked because a
console is not a pipe and a long line does not survive one.

The console, the agent screen and the disk operation screen were three copies
of the same terminal, and only one of them could be typed into. They share
TerminalPanelView now. The console and the disk screen are the refactor's
passengers; the agent screen is the change -- a rescue shell you can see is
worth much less than one you can answer.
…in the editor

Scheduling a VM's vCPUs onto a big.LITTLE phone by hand is the difference
between a desktop that responds and one that does not, and crosvm already
takes --cpu-affinity, --cpu-capacity and --cpu-cluster for it. What was missing
was a way to say what you wanted. CpuPlacementPlan holds the answer -- per-vCPU
core sets, capacities and clusters -- validates it, and turns it into those
three arguments; the editor picks it with a core picker rather than a text
field that accepts anything.

The GPU worker cpuset comes with it, because it is the same kind of setting
made of the same host cores, and because what depends on it comes later: the
cgroup path is created before crosvm starts, since crosvm opens <cgroup>/tasks
itself and a path that appears afterwards is a path it never saw.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
…hind them

A VM had one console and no way to ask for another. Serial ports are now a
peripheral category of their own: each port names its hardware (SBSA UART or
virtio-console) and its backend (stdio, a pty, a file, or a USB ACM function),
and the crosvm arguments are generated from that rather than from a special
case for the console.

The USB ACM backend is the one with a host side. The gadget's ACM functions are
a shared pool -- there is one USB device on the phone and every VM that wants a
port takes a slot out of it -- so UsbAcmPool reconciles the configfs gadget to
the number of ports configured, and keeps track of who holds which slot.
Unbinding the gadget needs a newline written to UDC and a moment for USB to
settle; without the wait the rebind lands on a controller that has not finished
tearing down and the whole gadget stops enumerating.

The VM info screen stops calling a stdio port a "text console" and names it
after the backend it actually is, because with a list of ports that is now an
answerable question.
…a purpose

The card is one device with a list of endpoints rather than one anonymous
stream. Each endpoint carries a direction, a purpose, a buffer size and an
underrun policy, and names a host device to open. Endpoints are grouped by
direction in the editor so a playback and a capture endpoint are not two rows
that look identical.

The host devices are enumerated and published rather than guessed. AAudio
reports device ids that are reassigned on every boot, so an endpoint stores a
type and an address and resolves that to an id at start time; a device that
cannot be opened is not offered at all, which is a better answer than an
endpoint that fails when the guest first touches it. Recording needs
RECORD_AUDIO, and the permission is asked for where the endpoint is configured
rather than at the point where the failure would be silent.
The configuration half of virtio-media: a camera peripheral and a VPU
peripheral in the editor, their schema, the host camera enumeration behind the
picker, and the CAMERA permission. A foreground service holds the peripheral
open, because a camera that stops delivering frames the moment the app leaves
the foreground is not a camera a guest can use.

The devices themselves are not written yet. This is deliberately the half that
can land first -- it is what the crosvm side will be configured by -- but it
means the rows here describe hardware that does not answer yet.
A protected VM has its memory lent to the guest, so every virtio buffer has to
travel through a bounce pool and the guest kernel needs
CONFIG_RESTRICTED_DMA_POOL to build one. No distribution ships that, which is
why every stock image failed to boot in the only mode that Gunyah's protected
path offered.

PSEUDO_UNPROTECTED is protected to the hypervisor, but the guest's window is
SHARE'd back before the payload runs, so the host can still reach it and there
is nothing to bounce through. A stock distribution kernel boots. Two
consequences are wired here rather than left to the user: the mode is not in
the list the boot tab warns about, because the warning would be false; and the
swiotlb size is ignored in this mode and hidden in the editor, because a
restricted-dma-pool node in the device tree of a guest that was never built to
honour one is the exact thing this mode exists to avoid.
… lending fault names the device

Dynamic memory sharing is not a checkbox a user should have to find. Features
that need it -- the GPU pools, the host-visible blob paths -- declare it, and
the editor turns it on for them and moves it to the basic tab where a
VM-shaped setting belongs. Under Gunyah the RingBlob backing is pinned
(gunyah-pvm) so the permanent SHARE that maps it stays valid.

The tab strip changes because the sections moved. It spreads across the full
width when the tabs fit and scrolls when they do not, and it leaves its drags
to the tab strip rather than fighting the pager underneath.

The rest is the diagnostic, and it exists because the failure it explains is
unreadable without it. When lending fails the kernel reports a hypervisor
error and a config symbol, and neither says which device asked for the memory.
The handler now names the devices behind the fault and links to something a
reader can act on, and the console log can be filtered so the line is findable
in a boot's worth of output.
Host-visible memory that gfxstream hands the guest has to be reachable from
both sides, and on a protected VM that means a Gunyah SHARE. Doing it per
allocation puts a hypervisor call on the allocation path, and on the fragile
sm8650 RM a stream of them is what precedes a SoC reset.

--pre-alloc gfx-host-mb= claims the arena once, before the guest is started,
and every host-visible allocation is sub-allocated out of it with no runtime
SHARE at all. The size is a field in the graphics tab rather than a constant,
because how much a guest needs is a property of what it runs.
… see the RAM

The other half of the same problem, from the other end. Instead of the host
owning a pool and lending it to the guest, the guest owns one and hands the
host dma-bufs out of it -- udmabuf=true is what gates
VIRTIO_GPU_F_CREATE_GUEST_HANDLE, and gpu-guest-mb declares the region. The
guest driver keeps one allocator, so the same pool serves whichever renderer
is configured.

It is only worth anything where the host cannot otherwise reach the guest's
memory. In an unprotected VM, or a pseudo-unprotected one whose window is
shared back before the payload runs, the pool is memory taken from the guest to
solve a problem that is not happening -- and virtio-gpu with no pool node to
find allocates from system RAM instead, which the host can read for the same
reason. The editor hides the field in those modes, and the backend zeroes the
stored size rather than trusting it, because a config that arrives through the
daemon API or that was saved before the mode was switched still carries one.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
Declaring the guest pool at the size of the worst case and SHARE'ing all of it
at boot costs that memory for the whole life of the VM, most of it never
touched. The pool is declared at full size but only the preallocation is
SHARE'd before boot; the guest grows and shrinks the rest at run time, one
memparcel per step, up to a ceiling on how many grants may be outstanding.

The three knobs are in the editor because none of them has a right answer: a
step too small makes a stall out of every allocation burst, a step too large
gives back the saving, and the grant ceiling is what keeps a runaway guest from
filling the hypervisor's table. Their defaults are the old behaviour --
preallocation equal to the pool, step zero, no grants -- so a config that says
nothing gets a pool that does not grow.

Co-authored-by: lateautumn233 <lateautumn233@foxmail.com>
udmabuf defaults to a 64 MB cap per handle, and a whole host-visible allocation
is routinely larger than that, so the import fails and the blob path falls back
to something slower or not at all. The limit is raised at VM start.

The glob matters: kernels without CONFIG_UDMABUF get the fallback module this
app ships, which lands under a versioned name
(/sys/module/udmabuf_gki_6.1/...), so writing only to /sys/module/udmabuf would
raise the limit on exactly the kernels that did not need the fallback.
Getting a frame from the VM to a Surface is either a memcpy or a dma-buf import
plus a GPU blit, and which one happens depends on a driver being loadable and
having the extensions for a raw dma-buf import. That is a separate axis from
the renderer: the same import path serves the virtio-gpu scanout and the
simplefb framebuffer, and it is used by the native bridge blitting into a
Surface and by the VNC sink blitting into a headless target of its own.

So the blit provider is its own setting -- the bundled turnip, the SoC's stock
Vulkan HAL, or off -- and vkprobe answers whether the choice will work before
it is offered, by opening the driver and looking for the extensions rather than
by hardcoding a vendor list. Qualcomm's stock driver lacks raw dma-buf import,
so SYSTEM degrades to the CPU copy instead of half-loading a driver that cannot
do the job. An unwired provider is listed and refused with a note rather than
silently treated as one of the others, because a silent downgrade is the
failure that looks like success.

simplefb gets the configured geometry rather than borrowing the GPU screen's,
which is what makes it a device the blit path can treat like any other source.
HuJK added 15 commits September 3, 2026 21:43
The graphics tab described one display with a GPU bolted to the side of it: one
width, one height, one refresh rate for whichever screen you happened to be
looking at, and a VM-wide input path underneath. Two display devices with one
geometry between them is one number answering two questions -- a 1400x1050
virtio-gpu mode and a 1280x720 framebuffer are an ordinary pair and the schema
could not say it.

A screen is now an object with its own size, its own rate, and the exporter
that shows it. The virtio-gpu screen takes a refresh rate because that is a
field of a mode the guest is told about; simplefb takes a poll rate instead,
because nothing in that device announces a frame -- the guest maps the region
write-combining and no write traps -- so the rate the host samples at is the
only thing that decides when a picture exists. Size and rate are picked from
menus of what the device can actually do rather than typed.

Input follows the scanout rather than the VM. A tablet and a keyboard belong to
the screen being exported, so two screens get two of each and the one you are
touching is the one that receives; the VM-wide input set retires with the
arbitration it belonged to. The transport between a screen and its exporter is
negotiated as a ceiling, not a demand: the source says what it can produce, the
sink what it can consume, the highest rung both reach wins, and a user's choice
only restricts downward -- so every value is honourable and nothing silently
downgrades. Rungs that are merely unbuilt are listed and refused with a note so
the ladder reads whole.
The console's VNC client advertises the Open H.264 encoding (50) and a private
DroidVM pseudo-encoding (DVH1), and reads the hardware stream off the one RFB
connection it already has for pixels and input. The separate side-channel port
is gone -- listener, config key, editor field, daemon argument and vnc_info
field. A third-party client that speaks encoding 50 gets the same stream; one
that does not still gets pixels.

Both rects are parsed in one place: encoding 50 as u32 length + u32 flags +
Annex-B honouring the reset flags, and the pseudo-encoding as a capabilities
and heartbeat payload. Those two are what RFB negotiation cannot express and
the reason the app speaks more than plain encoding 50 -- capabilities tell a
permanent "no encoder" apart from one that is still warming, and the heartbeat
tells a still screen apart from a dead stream.

Cold open is the case that decides the design. crosvm sends SPS/PPS once at
join and never again on a later bare IDR, but a TextureView's SurfaceTexture is
created a frame after the view is shown, so the first parameter-set-bearing
rect can arrive before any decoder exists to feed. The pipeline caches the
reset-flagged sync rect per coded geometry and replays it to a decoder that
attaches afterwards, which is what makes cold open, reconnect and a guest
resize all reach the decoder with parameter sets. The outgoing decoder is
released on resize; it used to leak its MediaCodec.
crosvm can promote the virtio-gpu worker to SCHED_FIFO. On gfxstream its
per-context render threads inherit that policy and spin-wait on the guest
command ring, which lets them starve the normal-priority vCPU that feeds them
-- a priority inversion that caps the present rate rather than raising it. So
real-time is opt-in and off by default.

It is also refused without the cpuset, rather than silently applied. RT
confined to the picked cores trades vCPU latency for render throughput on those
cores, which is the point of the switch; RT with no cpuset is a different thing
entirely -- FIFO 97 threads eligible for every core on the phone, above
everything else Android is running. The editor cannot save that combination and
the backend logs and skips it, because a config built through the daemon API,
or one whose cpuset setup soft-failed, can still arrive carrying it.
…kan capset

The guest runs its own turnip over vdrm and virglrenderer translates the msm
protocol into KGSL ioctls, so nothing is remoted at the GL or VK level and the
host advertises no Vulkan capset at all. Only the DRM capset goes out: not
advertising VIRGL2 is what keeps a stock guest on llvmpipe, which displays
correctly, instead of on host-GL virgl, whose buffers the CPU scanout cannot
read back -- black until the guest additions are installed.

The route needs two pools and they hold different things. drm-host-mb is the
host arena, now only the per-context msm shmem rings, so single-digit MB rather
than the gigabyte the buffer objects used to need; the guest's drm_buddy pool,
where every BO actually comes from, is the shared guest pool. That pool needs
udmabuf=true, which is what gates VIRTIO_GPU_F_CREATE_GUEST_HANDLE -- without
it guest mesa silently keeps a host-allocating path this host no longer
implements, the VM boots, the desktop comes up, and the failure waits for the
first large allocation.

The editor's GPU section splits into what is proxied and who serves it, because
"renderer" was answering both and the answers are not the same axis. A config
written before the split carries only gpu_api, so gpu_mode falls back to the
same migration the editor shows, and the ANGLE option is gone: crosvm has no
angle key and rejects unknown ones, so selecting it stopped the VM from
starting at all.
gfxstream serves both VK (turnip) and GL-on-VK (zink) clients from one host
process, so the guest gets the gfxstream-vulkan context type with both capsets
on. pci-bar-size is the host-visible BAR window and doubles as the GPU memory
ceiling, because the guest has no device-local-only memory type to bound it
some other way.

Dynamic VRAM is the host-alloc branch, and vram-limit is what turns it on: a
defined, non-zero limit supplies gfxstream's folio quota, the
VK_EXT_memory_budget capacity handed to the guest driver, and -- by being
defined at all -- enables fusion routing. Leaving it undefined keeps every
allocation inside the pre-allocated pool. It is only sent for host-alloc,
because under a guest-owned pool the pool is already the cap and crosvm ignores
it. The folio threshold and the fusion size gate are the two knobs that decide
which allocations are worth routing which way.

The host ICD is chosen here too. gfxstream dlopens ANDROID_EMU_VK_LOADER_PATH
ahead of the system loader, so the bundled turnip is pointed at unless the
provider row asks for the SoC's stock HAL, and it falls back to the system HAL
if the turnip file is missing.
…t pool

Venus is Vulkan proxied through virglrenderer: capset venus, guest-allocated
blobs, and the same host ICD and udmabuf import path gfxstream uses, which is
why it shares that env rather than getting one of its own. vulkan=true maps to
use_venus on the virgl path, and the capset forces use_venus and use_guest_vram
host-side, so the two together are belt and braces.

venus-host-mb is the pool the command-stream transport lives in: the
per-instance ring and the CS and reply chunks, every blob_id==0 shmem
sub-allocated out of one pre-shared region that the guest maps at pool_base
plus offset with no runtime SHARE. Sizing it too small is not a slowdown, it is
a hazard -- each blob that does not fit falls back to a per-blob memfd, which
means a runtime SHARE, and a stream of those SoC-resets the sm8650 RM. The
default is sized for the KDE-plus-vkmark transport peak, where the CS pool
alone is 8 MB or more per instance.

The provider row for this route names the host Vulkan ICD rather than "Venus",
because Venus is the route and the ICD is the choice.
A customer's a14 device cannot link crosvm: libgfxstream_backend.so
(built against the a16 soong libc++) imports
_ZNKSt3__14__fs10filesystem4path10__filenameEv and a14's platform
libc++ predates the out-of-line path parsers. A scan of every payload
binary shows it is the only __fs::filesystem import, so one jump alias
into the c++_static __ndk1 implementation covers it -- same pattern as
the filebuf shims next to it; path is {basic_string} and the return is
a string_view in both namespaces.

The paired report, CANNOT LINK against our own libnativewindow.so
(GraphicBuffer::getDataspace missing from a14's libui), needs no app
change: the meta repo stopped shipping the platform-shadow libraries
and cleanupPrebuilt() already deletes them on upgrade.
… by kind

A package carries one network per distinct one its VM's adapters were on, and
they need not be alike -- an L2 bridge and a routed gVisor network travel
together fine -- so the single whole-package choice could not say anything
useful about either. Each packaged network now gets its own card: join one this
phone already has, create it here, or leave it behind, in that order, with the
default landing on the first that is actually available.

Joining is what carries the settings that only exist for one kind of network:
an L3 VLAN's DHCP pool offsets, a gVisor network's IPv6 SNAT. So the target
list is exactly the networks of the same bridge type and uplink mode, closest
first -- by IPv4 prefix (IPv6 when the packaged network has no IPv4), or by
uplink name for L2 -- which puts the network the user means at the top. An
empty list greys "join" out and says so.

The other two greyings are the same question asked the other way. Creating is
refused when the packaged network would collide with one already here, and that
check is now scoped: two Linux bridges route in the host kernel and cannot hold
the same prefix, two gVisor networks collide the same way inside their own
user-space stacks, but a Linux bridge and a gVisor network never see each
other's routes at all -- gVisor's addressing lives in its process, the kernel
has no idea the prefix exists -- so the same subnet on both is fine, and
refusing it only cost address space. An L2 network has no prefix to collide
with; what it cannot share is the physical uplink it bridges, which one network
at a time owns (a rule the editor now enforces too). Names are deliberately not
scoped: the display name and the bridge name stay unique app-wide, which is why
they are also the one thing an import may quietly change, and the card shows
what it settled on before the import runs.

At most one of "join" and "create" is ever greyed, because a collision proves a
network of that kind exists, and that network is a join candidate.

Static DHCP leases come across too. An offset from the other phone is kept
where it can be -- it is the address the guest has been answering on -- and
otherwise moves to the next free one, wrapping rather than stopping at the top
of the VLAN, since the fallback is a dynamic address. Only when the VLAN has
nothing free, does not serve that family, or the NIC ended up on no network at
all does the lease go back to dynamic. A kept lease also gets a MAC: exporting
strips them (two phones must not hand out the same one) and a static lease is
keyed by MAC, so waiting for the user to open the NIC editor would leave the
VM unable to start.

The screen and the import task share NetworkImportPlan, so what the user is
shown is what gets built; the request carries the decisions as network_plan,
with network_mode kept as the fallback for a ref the plan does not mention.

Verified on device: cross-type same-subnet accepted, same-type refused, L2
uplink refused, join reuses the network and moves a colliding offset 70 -> 71,
create renames to lan50_1/brlan501 and gv50_1/brgv501 with the bridge type
intact.

Signed-off-by: HuJK <gh@hujk.org>
Deleting a network refused with "in use by running VMs" and named every VM
attached to it, running or not, so a network any VM had ever been attached to
could not be deleted at all.

The check reads vm_list, which reports the state as the enum name ("STOPPED"),
and compared it against lowercase "stopped" -- vm_status is the one that
lower-cases, and every other state check in the UI is already case-insensitive.

Found while testing vmpkg import on device: three stopped VMs held gv50 and the
delete silently did nothing.

Signed-off-by: HuJK <gh@hujk.org>
… when it starts

The daemon reads networks.json once, at startup, and otherwise learns about a
network only through network_create / network_modify. The editor sent neither
for a network that did not exist there yet -- it asked network_exists and, when
told no, sent nothing -- so a network created in the app and never started was
invisible to the daemon until the next restart.

Two things broke on that gap, both silently:

  * starting a VM whose NIC named such a network failed in setupTap with
    "Network <id> not found";
  * exporting that VM packed no network at all -- collectNetworks looks the id
    up in the daemon's store, found nothing, and skipped it, so the package's
    NICs referenced networks it did not carry and the import had nothing to
    offer.

So both writers of networks.json now sync: the editor for create as well as
modify, and the setup wizard, which normally runs before there is a daemon but
not always. Registering is not starting -- the instance lands STOPPED, exactly
as it would have on the next daemon start.

The export keeps its skip as a last resort, but says so in the log now: a
package quietly missing a network is not something anything else would report.

Signed-off-by: HuJK <gh@hujk.org>
The recorded commit was one a local build produced on a branch nobody
publishes, so a fresh clone resolved it to nothing. This one is what the CI
packaged from DroidVM-Prebuilt-Root 14c5886 -- the end-to-end clean build
that was accepted on device -- and its crosvm hash matches the binary that
run produced.

It carries the VNC listen address and the per-pool consume_system_mem
accounting in crosvm, the drm2kgsl and venus error paths in virglrenderer,
gfxstream's ColorBuffer import no longer aborting the VMM, a turnip rebuilt
from the Banners fork, and edk2 from a serial BaseTools. The eight platform
libraries are gone rather than updated: LD_LIBRARY_PATH searches the payload
before /system/lib64, so shipping them shadowed the platform's and cost
Android 17 the link entirely. nproc-guard arrives for all three KMIs.
padOctal produced 13 digits for entries of 64 GiB and more and field() cut the
string to 12, so the header carried a silently wrong size. Write base-256
(0x80 flag, big-endian) there instead, the form TarReader now reads and GNU
tar, bsdtar and Python's tarfile have always read. Sizes below 64 GiB are
still 11/12-digit octal, byte for byte as before.
A VM accumulates things worth writing down -- what it is for, how to log in,
what not to touch -- and a config field is the only place they stay attached to
the VM rather than to whoever set it up. So VMConfig grows "notes", which rides
along in a package like every other field: the notes are about the VM, not about
the phone it happens to be on.

They are edited on a page of their own. A row at the end of the editor's Basic
section carries the first line and an Edit button, because prose in a row-sized
text box is unusable the moment it is longer than a sentence. That page has
three modes -- source, rendered, both -- in the title bar rather than under it,
since a row of its own would cost the editor a line of text for a control that
is three buttons wide.

In "both", the split follows the screen's short axis: upright one pane above the
other, on its side one beside the other, so each pane keeps the longest lines it
can. It is taken from the container's own measurements rather than the display's,
which keeps it right in split-screen, in freeform, and when the keyboard takes
half the screen. The panes are sized in pixels, not weights, and that is not a
detail: a weighted LinearLayout measures its weighted children once with an
unbounded spec along the weight axis, and Compose throws outright when a
horizontally scrollable element -- which is what this renderer makes of a table
or a code block -- is measured against an infinite width. Upright that never
happens; turning the phone took the screen down every time until the weights
went.

The rendered notes appear as a card on the VM's own page and on the vmpkg import
screen, the latter being where someone handed a package finds out what they were
handed, before importing it.

Rendering is Compose, because the renderer is. It is confined to one file with
one entry point, so the editor's preview and the read-only cards cannot drift
apart -- they are the same call. Two things there are deliberate: the palette is
read off the hosting Android theme, so a card of notes sits on the same surface
colour as the card beside it instead of arriving in Compose purple; and the
heading ladder is retuned for a phone, since the library starts h1 at
displayLarge -- 57sp, right for a document on its own page and absurd in a card,
where an h1 would take a line and a half on its own.

The renderer itself is vendored rather than pulled as an artifact. Its published
builds use a newer Kotlin than AGP's built-in Kotlin plugin provides, and a
compiler refuses to read metadata from a newer one, which pinned us to 0.38.1 --
the last release built with Kotlin 2.2 -- and would have kept us there until AGP
moved. As source it compiles with whatever Kotlin the build has, so the version
becomes a choice: this takes 0.45.0, the current release, and it needs no change
to compile under 2.2.10. What is taken is the renderer and its Material 3
bindings, commonMain only -- not the Coil loaders, the Material 2 bindings, the
syntax-highlighting module, or the js/wasm/native source sets. 60 files,
Apache-2.0, under app/src/main/vendor in a root of their own so that "not ours,
and under its own licence" is structural rather than a convention, with no SPDX
header of ours added to them. One file is changed and marked in place:
MarkdownLogger declares `internal expect fun platformLog` upstream with an
`actual` per platform, which needs the Kotlin Multiplatform plugin, so the
androidMain implementation is inlined instead. The root is registered with
kotlin.srcDir, not java.srcDir -- the latter compiles nothing here and leaves
every reference unresolved, which is a confusing way to find out.

Kotlin needs no plugin: AGP 9 compiles it out of the box and already owns the
"kotlin" extension, so asking for the Kotlin plugin fails outright. Only the
Compose compiler plugin is applied, pinned to the Kotlin AGP carries. What stays
an artifact is what the renderer depends on -- the org.jetbrains markdown parser,
kotlinx collections-immutable, coroutines and Compose -- all still subject to
the metadata rule, and all currently built with an older Kotlin than ours.

Signed-off-by: HuJK <gh@hujk.org>
HuJK and others added 4 commits September 4, 2026 02:42
…itor

Two VMs on one image only corrupt it while both hold the file, so sharing
alone is no longer a reason to take the write away from a slot the user
configured:

- the disk editor stops forcing read-only for a path another VM attaches.
  What it still forces is what its own rows can prove: a base with
  registered overlays, and two rows here pointing at the same file.
- a pre-start guard asks the daemon instead. With every other VM on the
  disk stopped the start proceeds writable and untouched; with any of them
  not stopped it offers to attach those disks read-only, with the same 5s
  countdown the compressed-disk guard uses so an unattended start is not
  blocked. That flip lasts one boot: the daemon is handed a copy of the
  config and the store keeps its writable slots, so the next start decides
  again from what was saved.
- deleting a VM now offers exactly the disks that deletion would leave
  dangling -- writable, and referenced by no other VM -- rather than
  counting every writable attachment and quietly keeping the shared ones
  afterwards.

VmDiskSharing holds the one "who else attaches this path" rule the three
sites now share, with the CDROM bus counting as unwritable everywhere: both
backends open it read-only whatever the flag says, so it is neither at risk
from a co-tenant nor a file the VM owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTc7yZ1DPCX7xpbYcUo2V
Each host module under Settings supplies something a particular
configuration reaches for at run time, and without it the VM fails as
anything but a missing module: a guest that cannot see its own RAM, GPU
allocations failing at random, a big VM refusing to boot with "out of
memory" on a phone with memory to spare. A pre-start guard asks up front,
from what the config actually turns on -- pseudo-unprotected RAM wants
gunyah_host_share, GPU acceleration wants gh_unmovable and udmabuf, and a
VM over 2 GB wants the kvcalloc fix.

Which of those apply to this phone is not decided here. The Kernel Module
list already answers it -- the KMI directory picks the build for the
running kernel and match.json drops what was written for another SoC -- so
a module the list does not offer is skipped without a word rather than
warned about. That is also why the kvcalloc rule needs no "and only on the
8 Gen 3": the fix is built for the 6.1 GKI whose Gunyah driver has the bug,
and for Qualcomm, so it can only surface there. It keeps working as the
module set grows to other vendors.

The dialog offers to start anyway (5s countdown, the answer an unattended
start gets), to open the module page, or to cancel. Nothing here loads a
module on the user's behalf: that is a decision of its own, made where the
modules are managed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTc7yZ1DPCX7xpbYcUo2V
…stead of crashing

Stored values outlive the sets that produced them: an option gets retired
between releases, and several graphics rows build their item set out of
another row's value, so a config written under one combination is routinely
read back under another. EnumPicker answered that with an exception, which
made every restore path a crash waiting for the first user whose VM
predates the current build -- and it arrived: an old VM naming a GL
provider, under a backend whose set no longer lists one, took the editor
down as it opened.

setSelectedItem now lands an item the picker does not list, or lists only
to refuse, on the row's default and returns whether the value itself was
taken. setDefaultItem names that default wherever the head of the list is
not the sensible answer -- a transport ladder whose head is the CPU copy
should not be where "that rung is not built yet" ends up. Refused now means
refused from every direction, a stored config included, so the picker no
longer sits on a value it would not let the user pick; rotate-mode steps
over refused rungs for the same reason.

The graphics tab stops re-deriving which values belong to the current
backend -- the copy of that rule at the call site is what did not cover the
retired provider -- and hands each stored value to the picker that owns the
set. If any row moved, the tab says so once: what is on screen is not what
was saved, and the user should not have to notice that on their own. PanVK
joins the same mechanism as a listed-and-refused item, replacing its
toast-and-revert listener.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTc7yZ1DPCX7xpbYcUo2V
All five came out of a PR review of this branch. What they have in common
is that each fails somewhere the message does not point at: a black screen
with the stream still arriving, a boot that stops at an ioctl, a daemon
that answers nothing for ten seconds, a prompt about memory a VM never
touches. The review's other findings were checked and left alone -- either
correct as they are, or a pre-existing limitation this branch did not
introduce.

vnc: the sync frame outlives the pipeline that was not there to take it.
The server sends the parameter sets exactly once per client, on the
reset-flagged rect that starts its stream, and the presentation console has
nowhere to put that rect: it connects when it opens and builds its decoder
only once a display has been chosen. The rect was dropped, every bare IDR
after it decoded to nothing, and no reconnect followed because rects were
still arriving. H264SyncFrameCache holds it for the connection instead of
for the pipeline, which also covers the window being dismissed and rebuilt,
and the race where DesktopSize reaches the main thread a post later than
the sync rect reaches the message loop.

hugepage: the reserve is asked about only the VMs that draw on it. The
pre-start check sized every VM against pool_avail, including the ones on
hypervisors that hand no memory away and cannot hit the failure it exists
to avoid. They got the dialog, or ten seconds of waiting, for a pool their
boot will not spend a page of.

display: a refused socket path gives back the sockets bound before it.
startListening() published its slots only after the last one bound, so the
release() the caller already makes on that path freed an empty set --
leaking listening fds, inodes under run/, and an accept thread each, parked
in accept(2) for the life of the daemon. Unreachable today; the two screen
ids are spelt g0 and sfb precisely so their names stay short, and a third
screen is what would arrive without a tag. The test pins the budget such a
name would be chosen against.

daemon: auto-start runs behind the socket, not in front of it. The sweep
waits on the reserve from inside the ServerContext constructor, which is
before the socket binds, before the signal handlers exist and before the VM
event callback is wired. It runs from Server.run() on its own thread now,
which is also why the sweep snapshots its list rather than iterating across
the wait, why start() takes a lock from the state test to the worker, and
why stopAll() can call the wait off: a daemon answering clients while the
sweep runs is a sweep with company.

vm: refuse to start a VM whose LEND parcels this kernel will not take. 6.6
demand-pages a parcel; 6.1 commits every one as it arrives and refuses a
later one, so a 3 GB VM configured for 256 MB parcels does not boot there
and says only "failed to initialize virtual machine". A better default
would not have helped, because the value travels: an imported VM carries
the mode of the phone its package came from. The dialog offers to correct
it and keep the correction.

Verified on a 6.1 Qualcomm device. The daemon bound its socket, logged the
reserve wait 32 ms later, and authenticated a client 122 ms after that with
nine seconds of waiting still to go; a SIGINT mid-wait was answered rather
than queued behind it. The LEND dialog appeared on start, its fix wrote
single to vms.json, the next crosvm command line carried
--prepare-lend-mthp-mode single, and the VM reached RUNNING where it had
been dying at GH_VM_START. 293 unit tests and the five CI source checks
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QzTPwWuSaDfFJg62dtqZW
Signed-off-by: HuJK <gh@hujk.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant