# Where the per-frame time goes: a host measurement, not an inference
For `openspec/changes/the-card-deck-still-misses-its-frame-budget`, authorized
2026-09-28 ("frame budget go"). This is a host measurement (the real
cross-built RISC-V compositor under QEMU user-mode emulation, the existing
`tests/card_shell_runtime.py --benchmark` harness) plus one new,
board-reusable diagnostic: a per-frame damage-extent row. It changes no
visual behavior; the one production-affecting finding it surfaces (the
scaled-cache path) is reported, not silently patched, because removing it
would drop a requirement (the capped live-preview rate) this change must
preserve.
## New instrumentation
`nix/card-shell/telemetry.c`'s `card_bench_render_damage()` (declared in
`telemetry.h`) logs the exact region `wlr_scene_output_build_state` computed
for the frame, via `sway/desktop/output.c` reading `pending.damage` right
after a successful build (`nix/patches/sway-k230-card-shell.patch`). It emits
a `K230_CARD_SHELL frame-damage run=... frame_id=... rects=N damage_px=N
bbox_x1=.. bbox_y1=.. bbox_x2=.. bbox_y2=.. output_w=.. output_h=..` row --
deliberately **not** a `K230_CARD_BENCH v=1 event=...` row, since
`tools/card-shell-benchmark.py`'s `parse_rows` raises on any event name
outside its fixed `FIELDS` whitelist and this diagnostic must never break
that existing strict acceptance parse. It costs nothing when `card_bench_arm`
was never called (the `bench.armed` guard already every other diagnostic
here uses).
## Method
Cross-built package: `nix build .#card-shell --max-jobs 1 --cores 6
--no-link --print-out-paths` -> `/nix/store/17y4la6qcjcj8b73z6k97mqj4n10rsk4-k230-card-shell`
(sway-unwrapped `/nix/store/crnmkgfahs0v3xzxn8rq60lzfz8qh1hj-sway-unwrapped-riscv64-unknown-linux-gnu-1.12`).
Client: `nix build .#card-composition-probe` ->
`/nix/store/ycqmry86px8micsk8vnx1g0zbd61vawz-k230-card-composition-probe`.
```sh
python3 tests/card_shell_runtime.py --native-touch --benchmark --delayed-touch \
--sway /nix/store/crnmkgfahs0v3xzxn8rq60lzfz8qh1hj-sway-unwrapped-riscv64-unknown-linux-gnu-1.12/bin/sway \
--client /nix/store/ycqmry86px8micsk8vnx1g0zbd61vawz-k230-card-composition-probe/bin/card-composition-probe-client \
--output
```
All 17 native-input headless checks passed in every run cited here
(`horizontal-live-deck`, `expand-focus-keyboard`, `three-dynamic-views`,
`clean-compositor-teardown`, ... -- the full list `repaint-stages/README.md`
already documents). One run of several hit the harness's own
`k230.card.one`/`k230.card.two` frame-advance assertion
(`tests/card_shell_runtime.py:560`) under this machine's current load
(`uptime` showed a load average of 63 on 32 cores from unrelated concurrent
work -- multiple agents share this host per `AGENTS.md`); two immediate
retries of the identical binary/flags passed cleanly, and the same assertion
never failed against the **other** build in this investigation, so this is
recorded as host-machine contention, not a regression, per the "does every
frame damage" scope of this change -- it does not bear on a hardware timing
claim, and this is a host-only diagnostic, not board acceptance evidence.
`K230_CARD_SHELL frame-cost`/`repaint-cost`/`frame-damage` rows were parsed
directly from `sway.log`; the full raw logs are not committed (each is
several MB and QEMU-user timing on a shared host is not a page of evidence
in the sense `docs/evidence/` requires -- the board capture already
committed in this directory is the timing evidence of record). The numbers
below reproduce to within noise across three independent runs of the final
build.
## Ranked host cost table (105 submitted frames, single-output benchmark)
| Stage | Share of render CPU | p50 | p90 | p95 | max |
| --- | ---: | ---: | ---: | ---: | ---: |
| **Build** (`wlr_scene_output_build_state`: scene config, card prepare already counted separately below, Pixman composite) | 94.9% | 28.12 ms | 29.63 ms | 29.98 ms | 31.27 ms |
| **Prepare** (`card_shell_prepare`: `sync_scene`/`sync_card`, label/plate/icon cache checks, appearance-canvas refresh, home/backdrop self-heal) | 4.6% | 1.36 ms | -- | -- | 2.96 ms |
| **Commit** (`wlr_output_commit_state` CPU only -- headless backend, so this excludes any DRM register write/vblank wait; **not** comparable to the board's `canaan_crtc_atomic_flush` cost) | 0.5% | 0.08 ms | -- | -- | 7.10 ms (one outlier attempt) |
Total render CPU: p50 29.56 ms, p95 31.68 ms, max 32.73 ms -- for reference,
the board's own `repaint-stages/README.md` capture (real hardware, normal
kernel) measured Build CPU p95 14.00 ms (one card) / 19.79 ms (two cards),
i.e. **this host run is 1.5-2.1x slower than the board** for the same
diagnostic stage. QEMU user-mode emulation of the RISC-V binary on this x86
host is not free, and does not obviously accelerate the RVV paths the same
way real silicon does; treat any host number here as ordering information
(which stage dominates, which change helps or hurts) rather than a
board-cost predictor. State the multiplier this way -- **host ≈ 1.5-2x board
build time on this workload** -- rather than assuming host is a cheap stand-in
for a faster board.
## Damage extent: does every frame damage the full output?
Effectively yes, during any card-shell motion -- not because damage tracking
is broken, but because the webOS-fan deck layout moves most on-screen cards
at once. Across 486 `frame-damage` rows in one run:
| Phase | Damage as % of 568x1232 output | rects |
| --- | ---: | ---: |
| Outside the overview (single live app, its own commits) | 76.3% (constant, same bbox every frame -- this is the app's own repaint, not card-shell's) | 3 |
| `CS_ENTERING` transition start | up to 99.6% | 3 |
| Settled `CS_DECK` browsing | 76-82% (p50 81.5%, max 97.0% across the 105 frames paired with a repaint-cost row) | 5-13 |
The one clean counter-example (`frame_id=217`, 6.6% damage, 4.18 ms build)
confirms damage tracking itself is working -- when a change really is small,
the composited region really is small. During deck browsing it almost never
is: `cs_card_rect`'s webOS-fan layout keeps several cards visible and moving
together, so the union of old+new rects for one frame typically spans nearly
the whole usable deck viewport (`clip_box()`, i.e. the panel minus the title
and footer bars). **This is inherent to the current visual design, not a
damage-tracking defect**, and the task's own constraint ("without changing
visuals or gesture feel") forecloses shrinking it further without redesigning
the deck layout itself (out of scope; not proposed here).
## Candidate wins already implemented (verified by reading, not assumed)
Every code-level candidate this change's task list names was already present
before this investigation, from prior rounds cited in `proposal.md`'s "Why":
- **Damage-limited rendering**: the wlroots scene graph's own damage tracking
(unchanged, no defect found -- see above).
- **Avoid re-clip/re-scale of unchanged cards**: `label_update` (exact
text/size match), `card_background` (brush/size/radius `memcmp`), and
`appearance_canvas_refresh` (generation/size/brush `memcmp`, plus a
prepare-time warm path, `nix/card-shell/adapter.c:429-518`) all already
skip rebuilding when nothing changed.
- **Cache each card's rounded, scaled result as a buffer**: this exists
(`scaled_mirror`/`card_scaled_buffer_create`, `nix/card-shell/adapter.c:
1014-1084` and `render.c:47-75`) gated by `SWAY_K230_CARD_SCALED_CACHE`.
See the negative result below -- it exists, and measurably does not help
this workload.
- **Skip scene sync when geometry is unchanged**: `card_shell_prepare` runs
`sync_scene()` unconditionally on every output repaint by design --
`adapter.c:2445-2454`'s own comment explains why (an unrelated Sway focus
change was observed to silently re-enable the Home layer underneath the
overview if this self-healing sync ever stopped running every frame; see
`docs/evidence/card-shell/bottom-band-flicker/hypotheses.md`). Skipping it
would reopen that fixed bug. Its cost is 4.6% of render CPU regardless
(measured above), so skipping it would not have moved the dominant Build
stage either.
- **Reduce allocations per frame**: `scaled_mirror`'s own cache buffer is
swapped via `wlr_buffer_drop`/`wlr_buffer_init` per miss; the old buffer's
scene-graph reference (`copy->buffer`, dropped only inside the later
`wlr_scene_buffer_set_buffer` call in the same function) outlives the
mirror's own reference, so reusing its backing allocation in place would
require restructuring buffer lifetime tracking this investigation judged
too risky to land unverified on hardware within this task's scope.
## Negative result: the scaled-cache path measurably does not help here
`SWAY_K230_CARD_SCALED_CACHE=1` is set unconditionally in `nix/shell.nix`
(the real production image). Toggling it in this same benchmark:
| Config | Build p50 | Build p90 | Build p95 | Build max |
| --- | ---: | ---: | ---: | ---: |
| Off (default RGB, no scaled-cache) | 28.12 ms | 29.63 ms | 29.98 ms | 31.27 ms |
| On (`--scaled-cache --rgb565`, matching the board's real format) | 30.57 ms | 33.26 ms | 33.62 ms | 34.71 ms |
Cache stats for the "on" run: `hits=1863 misses=337 fallbacks=3072` --
fallbacks (the exact same code path as "off") outnumber hits+misses more
than 1.4:1, so most of the time this workload pays the cache's own
eligibility checks and still runs the plain path underneath, and the
eligible fraction does not recoup that cost. This reproduces the parent
proposal's own historical note ("unchanged by ... a scaled-cache mirror path
(on or off)") with a clean before/after on host.
**This is not proposed as a fix here.** `scaled_mirror`'s 66 ms/~15fps
live-preview throttle (`adapter.c:1044-1056`) exists *only* inside this same
code path -- disabling `SWAY_K230_CARD_SCALED_CACHE` would also remove the
capped live-preview rate this change's own task explicitly requires
preserving. The two are currently coupled in one function; decoupling them
(keep the rate cap, drop the pixel-cache path that this measurement shows is
net-negative for Build cost) is a real, bounded follow-up, but it touches the
same function `card_shown_large`/`scene_in_motion` rely on and was judged too
large to land inside this task without a board round to confirm the
live-preview cap still holds -- left for the coordinator as an explicit,
evidence-backed option rather than silently folded in here.
## What this does and does not resolve
This confirms (from the host side) what `analysis.md`'s board capture already
established from the hardware side: the CPU-side, damage-side and cache-side
levers this change's task list names are already implemented, and the
remaining Build cost is dominated by a large, largely fixed composited area
inherent to the current webOS-fan visual design -- not by an avoidable
inefficiency this investigation could find and fix without either changing
visuals (forbidden by this task) or touching the kernel commit path (see
`commit-pipelining-assessment.md`).