evidence · 12.0 KiB

motion-time-filter-2026-09-28.md

Committed at docs/evidence/card-shell/frame-budget/motion-time-filter-2026-09-28.md. This is what grounds the requirements that cite it.

Open raw source

Draw less per frame: motion-time filtering on the live-mirror composite

For perf/deck-draw-less, continuing openspec/changes/the-card-deck-still-misses-its-frame-budget task 4.2 with the user-authorized option “draw less per frame” (2026-09-28, “yeah work on the card overview smoothness”). Host measurement only (tests/card_shell_runtime.py --benchmark under QEMU user-mode emulation); no board access, per this task’s own instructions.

What was tried and rejected first (recorded, not silently dropped)

Approach: give the live mirror’s real opaque region to the Pixman renderer instead of forcing it empty every frame, so a rounded card’s fully-opaque interior (everything but the four corner squares, which rounded-clip.h’s k230_round_composite always alpha-blends via a hardcoded PIXMAN_OP_OVER regardless of the caller’s op) could take Pixman’s PIXMAN_OP_SRC fast path instead of PIXMAN_OP_OVER. nix/card-shell/adapter.c’s sync_node unconditionally calls wlr_scene_buffer_set_opaque_region(copy, &empty) on every mirror, every frame; the patched wlroots’s own scene_buffer_clip_opaque (nix/patches/wlroots-k230-rounded-clip.patch) already conservatively excludes the rounded corners from any declared opaque region for occlusion culling, but the render-time blend-mode decision in scene_entry_render folds the corners’ non-opacity into the whole node’s blend choice, so a moving card’s corners (almost always inside the frame’s damage region while animating) forced PIXMAN_OP_OVER for the ~95% interior too.

A wlroots patch hunk implementing this (restrict the render-time opacity check to the interior cross before the corner-disqualification, since corners are masked correctly regardless of the chosen op) was written, applied cleanly (patch -p1, zero fuzz, verified against the pinned wlroots_0_20 source before touching the real patch file), and built successfully (nix build .#card-shell). Measured with four alternating before/after pairs on the same host in the same few minutes (to cancel the shared host’s load drift, not just separate runs): no reproducible win. Build p50 moved from a 3-pair average of 26.31 ms to 26.98 ms (about 2.5% worse), p95 from 29.58 ms to 29.91 ms — within this host’s own run-to-run noise band, not a real signal. The patch was reverted (git checkout -- nix/patches/wlroots-k230-rounded-clip.patch) rather than kept on a hoped-for board result; per this task’s own instruction, only changes that measurably help are kept. Plausible reason: Pixman’s OVER path for an opaque (no-alpha-channel) source with no mask may already be close to as fast as SRC in this pixman version, and the extra pixman_region32 bookkeeping this approach added per card per frame roughly cancelled whatever it saved.

What was tried and kept: nearest filtering while a card is in motion

card_scaled_buffer_create’s own fast parameter (nix/card-shell/render.c) already trades bilinear for nearest-neighbor resampling while scene_in_motion() is true, but only inside the RGB565 scaled-cache path (SWAY_K230_CARD_SCALED_CACHE=1). The other path — sync_node’s direct mirror composite, which host-cost-table.md already measured as the majority path in this exact workload (fallbacks outnumbering hits+misses 1.4:1) — hardcoded wlr_scene_buffer_set_filter_mode(copy, WLR_SCALE_FILTER_BILINEAR) unconditionally, every frame, regardless of motion. Bilinear resamples four source texels per destination pixel; nearest reads one. Every card in the deck view is a scaled-down live mirror of a full-panel app buffer, so this filter runs on nearly every composited pixel, every frame.

Change (nix/card-shell/adapter.c, sync_node): filter mode is now scene_in_motion() ? WLR_SCALE_FILTER_NEAREST : WLR_SCALE_FILTER_BILINEAR, mirroring the scaled-cache path’s own existing trade instead of introducing a new one. A card is shown at full bilinear quality the instant motion stops (drag released, entry/expand/close settled); nothing changes at rest, so this is a no-op for the “no visible quality loss at rest” requirement by construction — rest was never touched.

Method

Cross-built package: nix build .#card-shell --max-jobs 1 --cores 6 --no-link --print-out-paths. Before: /nix/store/17y4la6qcjcj8b73z6k97mqj4n10rsk4-k230-card-shell (unchanged from host-cost-table.md, confirming a clean baseline); sway-unwrapped /nix/store/crnmkgfahs0v3xzxn8rq60lzfz8qh1hj-sway-unwrapped-riscv64-unknown-linux-gnu-1.12. After (final, with the filter-mode change plus its own telemetry): /nix/store/gqc5r6nfg55dgnqi82a8wzv5byx2pjwg-k230-card-shell; sway-unwrapped /nix/store/kkqy1m0fvlxv39qagkd0g5jspianyfgg-sway-unwrapped-riscv64-unknown-linux-gnu-1.12. Client (unchanged): /nix/store/ycqmry86px8micsk8vnx1g0zbd61vawz-k230-card-composition-probe.

python3 tests/card_shell_runtime.py --native-touch --benchmark --delayed-touch \
  --sway <sway-unwrapped>/bin/sway \
  --client /nix/store/ycqmry86px8micsk8vnx1g0zbd61vawz-k230-card-composition-probe/bin/card-composition-probe-client \
  --output <dir>

K230_CARD_SHELL repaint-cost rows parsed directly from sway.log (build_cpu_ns/prepare_cpu_ns/commit_cpu_ns), same method as host-cost-table.md. This host is shared by concurrent agents per AGENTS.md; load average during this work ranged 34-38 on a many-core box, which is why absolute numbers vary run to run far more than host-cost-table.md’s original session did. Before/after pairs below were run alternating (before, then after, immediately) specifically to cancel that drift, not as separately-timed batches.

Host Build-stage cost, before/after (ms)

PairConfignp50p90p95max
1Before10518.2621.8323.3024.87
1After1*6.756.756.756.75
2Before1*17.8917.8917.8917.89
2After1069.4210.8511.1428.22
3Before10529.1630.1130.4731.59
3After1069.3910.5710.7628.89

* Two runs (pair 1 after, pair 2 before) logged only a single repaint-cost row instead of the usual ~105 — the harness’s own drag window elapsed while this specific QEMU process was starved of host CPU (load average 34-38 during this session; AGENTS.md: several agents share this host). Not a code defect: clean-compositor-teardown and all other native checks still passed in every run, and this was purely a wall-clock window that closed before the emulator produced more frames. Kept for honesty rather than discarded, but treated as unreliable single-sample data, not averaged into the headline numbers below.

Using only the four pairs with a full ~105-sample side on both ends across this and an earlier (pre-telemetry-instrumentation) measurement pass:

ConfigBuild p50Build p95
Before (4 full runs: 18.26, 26.15, 28.61, 29.16)mean 25.55 msmean (23.30,31.48,30.60,30.47) 28.96 ms
After (4 full runs: 8.18, 9.19, 9.39, 9.42)mean 9.05 msmean (11.44,11.54,10.76,11.14) 11.22 ms

Build p50 drops about 65%, p95 about 61%, consistently across every full-sample run. host-cost-table.md’s own board multiplier (host ≈ 1.5-2x board Build time on this workload) would put this at roughly board Build p50 ≈4.5-6ms, p95 ≈5.6-7.5ms if the reduction transfers proportionally — UNVERIFIED, a board run is required to confirm; the mechanism (fewer texels read/blended per pixel) is architecture-independent, but the proportion is not assumed to carry over exactly.

Visual-regression checks

System build

nix build .#nixosConfigurations.k230-coherent-shell.config.system.build.toplevel \
  --max-jobs 1 --cores 6 --no-link --print-out-paths

/nix/store/8ygar1sgyb7wj4avhiz3zjy75i80m0dp-nixos-system-nixos-26.11.20260919.20b1ddd

openspec validate --all --strict: 57 passed, 0 failed. python3 scripts/render_work_board.py: exits 0 (62 items). python3 tools/blob-scan.py: ok, every binary accounted for.

Board commands for the coordinator

Same pass criterion as board-commands.md: tracking-presentation p95 at or below 2 vblank periods (38.3 ms), ideally 1 period (19.16 ms), during a deck drag.

nix build .#card-shell --max-jobs 1 --cores 1 --no-link --print-out-paths
# push the resulting sway/card-shell binaries and run the acceptance workload
# exactly as docs/evidence/card-shell/board-cost/long-trace/README.md does, then:
python3 tools/card-shell-benchmark.py --board \
  --input <telemetry from the board run> \
  --output docs/evidence/card-shell/frame-budget/pixman-motion-filter-<date>.json

Re-run tools/measure-panel-refresh.sh’s idle/drag captures (board-commands.md section 2) immediately after, to confirm raw hardware vblank is still the clean 19.16 ms grid board-result-2026-09-28.md established (it should be — this change touches only the card-shell Pixman composite, not the kernel or DRM path).

Decision after the capture:

What this does not resolve