# Anthias Hardware

> Hard-won per-board hardware knowledge for Anthias — display stack (linuxfb/eglfs/wayland-cage), video HW-vs-SW decode per chip (Pi 2/3/4/5, x86, Rock Pi 4), the Qt6 presentation bottleneck, rotation convention, WebGL, boot splash, audio, and the Pi 2 armhf toolchain SIGBUS. Read before touching viewer rendering, codec, rotation, display-power, or Qt build code.

- Skill: `screenly/anthias-hardware` (Agent Skill)
- Install (CLI): `npx skillmds@latest add screenly/anthias-hardware`
- Raw SKILL.md: https://api.skillmd.com/api/skills/screenly/anthias-hardware/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: screenly (https://skillmd.com/u/screenly)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/screenly/anthias-hardware

---


# Anthias board / display / codec knowledge

Durable engineering findings from field debugging across the board fleet. Each
bullet carries the mechanism plus the fixing PR / CalVer release where known;
OPEN items are called out. Boards map to a rendering stack by userspace arch, so
read the display-stack section first.

## Display stack fundamentals (QPA: linuxfb / eglfs / wayland-cage)

- **Board → QPA plugin mapping.** Pi 1/2/3-32 (armhf/Qt5) run `QT_QPA_PLATFORM=linuxfb` (needs `/dev/fb0`); Pi 3-64/Pi 4 (arm64/Qt6) run `eglfs` (DRM/GBM, holds DRM master, no fb0); Pi 5/x86/Rock Pi 4 (arm64/Qt6) run cage/wlroots wayland. Pi 4 switched linuxfb→eglfs in #2904 for HW video. Stale `start_viewer.sh` comments claiming "Pi 4 runs linuxfb" were corrected in #2962.
- **DRM master ownership determines what can blank/rotate/recover.** On eglfs/linuxfb the Qt app (or drm_fb_helper) owns DRM master; on wayland the cage compositor owns the connector. This single fact drives blanking, hotplug recovery, and rotation mechanism per stack.
- **Board selection is by userspace arch (`dpkg --print-architecture`), NOT `uname -m` or model string.** 32-bit Raspberry Pi OS ships a 64-bit kernel by default on Pi 3 (`arm_64bit=1`), so `uname -m`=`aarch64` while docker/apt are armhf. Gating on kernel arch mispicked `pi3-64` and pulled `linux/arm/v8` (no matching manifest). Fixed in PR #3076 (2026-06-16) to gate on userspace arch. `arm64` catch-all branch still uses `uname -m` (no armhf image for generic SBCs). No in-place arch switch — only reflash changes streams.

## Display blanking (per-platform power-off)

- **Wayland boards can true-power-off; eglfs/linuxfb cannot.** `wlr-randr --output <NAME> --off` DPMS-powers-off the monitor on wayland (run as `viewer` user with `XDG_RUNTIME_DIR=/run/user/1000` + `WAYLAND_DISPLAY=wayland-0`). On eglfs/linuxfb the Qt app holds DRM master so external blank is rejected (`echo 3 > /sys/class/drm/cardN-HDMI-A-1/dpms` → EBUSY under atomic KMS; `vcgencmd display_power 0` no-op under vc4-KMS) — the only blank without stopping the viewer is to paint the screen black. PR #3065 added first-class `blank`/`unblank` viewer commands (Redis `anthias.viewer` channel) that do wlr-randr off on wayland, paint `BLACK_SCREEN` (`img/black.png`) on eglfs/linuxfb.

## Headless-boot / HDMI-hotplug recovery (differs sharply by stack)

- **linuxfb (32-bit Pi 1/2/3): headless boot = no `/dev/fb0` = crash-loop.** Full KMS (`dtoverlay=vc4-kms-v3d`) only creates `/dev/fb0` when a display is connected at boot; headless → connector `disconnected` → no fb0 → linuxfb hard-requires it → `Unable to figure out framebuffer device` → `no screens available` → Qt heap-corrupts on the no-screen path (`malloc(): unaligned tcache`). Firmware `hdmi_force_hotplug`/`hdmi_*` knobs are a DEAD END (full KMS ignores them); only kernel cmdline `video=<connector>:...` forces a fb, unsafe for a fleet. Fix = graceful wait-and-retry in viewer (#2959 fb0-wait), self-heals when display hotplugs. `/dev/dri/card0`+renderD128 exist even headless; only fbdev emulation is absent.
- **eglfs (Pi 3-64/Pi 4): does NOT need the linuxfb stale-`/dev` exit-1 guard.** Verified by clean power-cycle: `/dev/dri/cardN` is created at vc4 driver bind (kernel boot), present headless; `open()` routes by (major,minor)=226,1 to the live device even with a stale-timestamp node; connector status is read from sysfs (truthful). eglfs opens container card1 and modesets HDMI-A-1 after headless-boot+connect. But eglfs boards **refuse to start the viewer while headless** (`wait_for_eglfs_display` in start_viewer.sh from #2962 polls `/sys/class/drm/card*-*/status` for non-`disconnected`) — correct signage behavior but means a headless testbed renders nothing.
- **eglfs headless gap was also a crash-loop before #2962.** Pi 4 eglfs headless → `no screens available` → binary exits before D-Bus handshake → container crash-loop. #2962 (`wait_for_eglfs_display`) mirrors the linuxfb fb0 wait. Separately #2969 added capped-backoff retry of the AnthiasViewer spawn (`BROWSER_SPAWN_MAX_ATTEMPTS=30`, `BROWSER_SPAWN_BACKOFF_CAP_SECONDS=15`).
- **wayland-cage (Pi 5/x86): headless boot binds ZERO wl_outputs and never recovers on hotplug** (#3155, refactor #3161, shipped 2026-07-08). cage enumerates DRM connectors at startup; if none connected+enabled it binds 0 wl_outputs and runs forever (no exit → no restart → permanent black). It can't recover on hotplug because the viewer container has **no udevd / no `/run/udev`**, so wlroots' libudev hotplug monitor gets no uevent. Contrast: eglfs Qt exits non-zero on no-fb and `restart: always` self-heals. Symptom: viewer log spams `wlr-randr could not apply rotation N`; `grim` → `no wl_output`; scheduler runs normally. Fix = `_wayland_output_watchdog()` in `__init__.py` per asset_loop tick: `sys.exit(1)` if no bindable output past `WAYLAND_OUTPUT_GRACE_S=60` → container restart → cage re-enumerates.
  - **Gate on BINDABLE, not just `connected`.** A marginally-seated HDMI asserts HPD (`status`=`connected`) but EDID never read → `/sys/class/drm/<c>/modes` EMPTY → cage has no mode to bind → restart-loops forever. `_kernel_has_bindable_display()` requires `connected` AND non-empty `modes`.
  - **Second bug: restart recovery is racy.** The restart-policy relaunch is nearly instantaneous; if the old cage's DRM-master release + vc4 connector reset haven't drained, the fresh cage races a half-reset connector → EDID not re-read → empty modes → headless again (~50% wedge). `docker restart` recovers reliably (its stop→start gap lets vc4 reset). Fix in `start_viewer.sh` cage branch: on a `connected` connector, `sleep 4` then force `echo detect > status` + bounded-wait for `modes` to repopulate before launching cage.
- **HDMI hotplug resolution drop to 1024×768 (issue #3052, fixed PR #3075).** `1024×768` is the kernel `drm_fb_helper` hard-coded fallback mode, triggered when a `vc4-kms-v3d` re-probe races the sink's EDID return (slow TVs). **linuxfb boards vulnerable** (no process holds DRM master; Qt reads `/dev/fb0` once, can't follow a later mode change). **eglfs boards immune** (Qt holds DRM master, keeps its modeset across hotplug). Recover in-container without fbset: `echo "U:1920x1080p-0" > /sys/class/graphics/fb0/mode` (the `-0` refresh field is CORRECT — vc4-kms-v3d fbdev emulation registers modes as `U:WxHp-0`). True active geometry = `fbset -i`; `virtual_size` is the static allocated buffer, `/modes` head is a sticky unreliable list.
- **Container `/dev` is a boot-time snapshot.** If the privileged viewer container started headless and a display is plugged in later, the host gets `/dev/fb0` but the container does NOT (only `/sys/class/graphics/fb0` shows through) → `wait_for_framebuffer` parks forever. `docker restart` re-snapshots `/dev`. Only bites hotplug-after-boot. The celery container can NEVER do display recovery (not privileged, no `/dev/fb0` or `/dev/dri`, `/sys` read-only) — only the privileged viewer container.

## Video decode (HW vs SW) by chip

- **Pi 4 (bcm2835, stateful v4l2m2m): ≤1080p H.264 HW-decodes; >1080p falls to SW.** `h264_v4l2m2m` (the `+rpt1` ffmpeg build) IS engaged by QtMultimedia for ≤1080p H.264 — `/dev/video10` fd held open whole clip, ~120-140% CPU, ~0 drops. 4K30 / High10 / 4:2:2 / level>4.x → not opened, cores pegged, ~36% drops. `QT_FFMPEG_DECODING_HW_DEVICE_TYPES` only accepts AVHWDeviceType names (cuda/drm/vaapi), NOT v4l2m2m (a standalone AVCodec) — Qt reaches it empirically anyway on `+rpt1`. Probe = `/dev/video10` open + CPU-delta.
- **Pi 5 (Hantro G2, v4l2-request): HEVC HW-decodes through QtMultimedia libavcodec.** Verified via `/proc/<pid>/fd`: `/dev/video19` (rpi-hevc-dec), `/dev/dma_heap/linux,cma`, `/dev/media2`, `anon_inode:request`, `/dmabuf:` frames; 4K30/4K60 HEVC both play at 1.0×. The chain QtMultimedia→libavcodec61→v4l2-request→`/dev/video19` is load-bearing; don't strip device passthrough or the `+rpt1` pin. Pi 5 has **no H.264 HW decode block** (upload gate rejects H.264, supported set = `{hevc}`).
- **Pi 3-64 (VideoCore IV, `+rpt1` gate): was SW-only until #3110 added `pi3-64` to the board tuple.** `docker/_rpt1-ffmpeg-pin.j2` gated the RPi `+rpt1` ffmpeg on `board in ('pi4-64','pi5','arm64')` — pi3-64 was MISSING, so it shipped stock Debian ffmpeg and SW-decoded (~0.7× realtime). PR #3110 (shipped, HW-validated) → `/dev/video10` held, 30fps realtime, `dropped=0`. H.264-only HW decode on VC IV (`_HW_DECODE_VIDEO_CODECS['pi3-64'] = {'h264'}`).
- **Rock Pi 4 (RK3399, stateless v4l2_request): both H.264 and HEVC SW-decode in practice (OPEN).** No-hwaccel picks native SW — what QtMultimedia's libavcodec does. `-hwaccel drm` engages rkvdec for HEVC but produces `Decode fail` at 0.77× (broken on Armbian 6.18 kernel); H.264 has NO v4l2_request hwaccel binding in the `+rpt1` 7.1.3 build. `h264_v4l2m2m`/`hevc_v4l2m2m` = "Could not find a valid device" (stateful M2M wrappers don't bind the stateless RK3399 nodes). Real arm64 HW decode needs QtMultimedia to request `-hwaccel drm` against v4l2_request, and even then only HEVC works (with errors) on this kernel.
- **x86 (Intel UHD 610, iHD/VAAPI): HEVC HW decode fails inside cage's GL context (OPEN, worked around).** `vainfo` lists HEVC VLD, container ffmpeg + an offscreen AnthiasViewer both VAAPI-decode HEVC cleanly, but the real display-up viewer emits `FormatError code=2` / 0 frames on HEVC RTSP (H.264 in the same context is fine). VAAPI-HW × GL-context × HEVC interaction; `QT_DISABLE_HW_TEXTURES_CONVERSION=1` did NOT fix it. Workaround: `QT_FFMPEG_DECODING_HW_DEVICE_TYPES=,` (empty → disable HW decode) → SW decode plays. Tradeoff: disables HW decode for ALL codecs on x86; a board-conditional list is better than blanket-off.

## Video presentation bottleneck (Qt6 boards — decode ≠ what reaches screen)

- **Qt6 boards present video at ~8-12fps despite perfect HW decode** (issue #2967). Root cause: `QGraphicsVideoItem::paint → QVideoFrame::toImage → qImageFromVideoFrame` — Qt 6.8 converts YUV→RGB on the GPU then reads it back to a QImage, CPU-blits into raster backing store, re-uploads to GPU. Two GPU/CPU crossings per frame. Measured: Pi 4 1080p30 H.264 8.3fps presented; Pi 5 11.7fps. **PR #2975 fix** = QML `VideoOutput` in a QQuickWidget (`player->setVideoSink(item.videoSink)`) → Pi 4 30.0fps/66% CPU. A QOpenGLWidget viewport is NOT sufficient (toImage stays); QVideoWidget = second native window, fails the eglfs single-window constraint.
- **#2975 fix does NOT hold at 1080p (OPEN, resolution-scaling).** RC QA (forced 1080p60 headless): 720p H.264 ~28fps, but **1080p H.264 ~5.8fps** (~40% pacer-dropped). Not a regression (Qt6 video byte-identical across releases). #3006 added a pacer that DROPS frames to match scene-render capacity. CONFOUND: measured on a no-EDID headless display; confirm on a real Pi 4 + monitor. Tracked in issue #2987.
- **Measure PRESENTED fps via the kernel DRM vblank tracepoint, never `playback-stats.log`.** Count `drm_vblank_event_delivered` events over 10s (`/sys/kernel/debug/tracing`, `count/10 = presented fps`, `crtc=N` disambiguates outputs) — zero observer effect. `playback-stats.log` counts sink deliveries (full decode rate) and is blind to paints coalescing. `frames-rendered` (=`QQuickWindow::afterRendering`) is scene-render into an offscreen FBO, NOT scanout.
- **Architecture note (misnomer):** Qt6 boards (pi4/pi5/x86/rockpi4) use `MPVMediaPlayer` — a misnomer, it drives IN-PROCESS QtMultimedia in the C++ webview over D-Bus, NO mpv binary anywhere post-#2904. pi1/2/3-32 use `GstFbdevMediaPlayer` (GStreamer HW → fbdevsink). pi3-64/pi4-64 routed via `force_mpv` env override in `media_player.py`.

## issue #2987 video regressions (2026.6.1, four distinct defects)

- **1. pi3-32 portrait stretch** — #2972's `_video_sink()` forced fb-sized caps; fix = CAPS-event pad probe → aspect-fit caps with `pixel-aspect-ratio=1/1` (PR #3004, shipped 2026.06.3).
- **2. pi3-32 freeze/cut clips** — bash `while true; gst-launch` loop rebuilt the pipeline per EOS. Fix: playbin `about-to-finish` gapless loop in `gst_fbdev_player.py` (PR #3004). Helper MUST be spawned by path, not `-m` (package `__init__` imports Django).
- **3. Pi 4 (Qt6) 1080p60 collapse** — 22.6fps presented, clips killed by slot duration. Fix (PR #3006): intermediate QVideoSink gating on `afterRendering` + single-slot latest-frame mailbox. CEILING ~23fps for 60fps sources; full 60fps needs the bigger single-window rework. `dropped=` counter reads 0 in fast-forward mode — don't use it to detect this.
- **4. Backup timeout** — sync tar gzip-9 = 98s for 355MB on Pi 4, browsers abort byte-less requests ~5min. Fix: StreamingHttpResponse over a pipe + compresslevel 1 (PR #3005). Pre-existing.

## Pi 3-64 video BLACK on VideoCore IV (issue #3084 / forum 6720/6730) — RESOLVED via DRM overlay plane

- **Root cause: vc4 (VideoCore IV) GLES2 can composite RGB but CANNOT render QtMultimedia's `VideoOutput` scene-graph video node — for HW OR SW frames.** Video plays fine on Pi 4 (V3D 4.2) and Pi 5 (V3D 7.x) but is pure black on Pi 3-64, while still images + webview composite fine. Decode is healthy. Pinned by a red-backdrop diagnostic (`Rectangle{color:RED; VideoOutput{}}` → JUST RED). The #2967 substrate swap (`toImage` CPU-raster → `VideoOutput` RHI-texture) traded slow-but-visible for fast-but-black on VC IV.
- **CPU-raster fallback works (unblacks) but caps ~1.5fps** — `QVideoFrame::toImage()` on a 1080p HW SAND/DMABUF frame = ~600-800ms/frame on VC IV. SW decode makes toImage cheap but SW-decoding 1080p30 REBOOTS the 1GB Pi 3.
- **RESOLVED PR #3164 (merged 2026-07-09): in-process GStreamer HW decode → kmssink on a vc4 DRM OVERLAY plane, HW-composited alongside eglfs's primary plane, 30fps zero ongoing drops.** Qt eglfs exposes its DRM master fd (`libQt6EglFsKmsSupport.so` native-resource keys `dri_fd`, `dri_crtcid`, `dri_connectorid`, `dri_atomic_request`). Winning pipeline: `filesrc ! qtdemux ! h264parse ! v4l2h264dec capture-io-mode=mmap ! queue max-size-buffers=4 ! kmssink fd=<dri_fd> connector-id=<> plane-id=<overlay> force-modesetting=false can-scale=true skip-vsync=true qos=true`.
  - **Two keys:** (1) **decoder-direct, NO v4l2convert** — the bcm2835 ISP I420→NV12 convert caps ~15-18fps at 1080p; decoder alone does 30fps+. (2) **`capture-io-mode=mmap`** — default (exported dmabuf) lets kmssink zero-copy and PIN the decoder DPB buffers → pool starves → DEADLOCK (~frame 15). mmap forces kmssink to COPY each frame (~3MB, cheap) and release the decoder buffer immediately.
  - **Audio runs a fully independent GStreamer pipeline** → `alsasink device=sysdefault:CARD=vc4hdmi` DIRECT to HDMI (not pulsesink; pulse defaults to 3.5mm). Every shared-pipeline attempt froze video at frame 1. Added `gstreamer1.0-libav` for AC3/AAC/MP3.
  - In-process kmssink on eglfs's SHARED master fd is fragile: a crashed/SIGKILLed kmssink leaves the overlay plane bound → cascading DRM wedge until reboot — a supervised DRM-lease child would be more robust.
- **OPEN follow-up: presented fps << decode fps under memory pressure.** On the ~787MB Pi 3-64 the merged build decodes 30fps but kmssink QoS-drops ~11fps as "late" → screen presents ~19-21fps under swap thrash. NOT a code regression. Next lever untried: `kmssink qos=false`.

## Rotation (`screen_rotation` convention + per-stack mechanism)

- **`screen_rotation` is CLOCKWISE uniformly on every stack as of 2026.07.3 (PR #3202, 2026-07-16).** Before 2026.07.3 linuxfb turned CW but eglfs/wayland turned CCW for the same setting. Canonical = clockwise (matches linuxfb paint path + GStreamer videoflip).
  - **linuxfb (Pi 1/2/3-32):** QPA ignores `rotation=N`, so content is turned by hand — images/GIFs in `View::paintEvent` (`rotation.cpp` `linuxfbRotationOverride`), web pages via injected CSS (`webpageRotationScript`), video via `videoflip` (90→CW).
  - **eglfs (Pi 3-64/Pi 4, Rock Pi 4):** `QT_QPA_EGLFS_ROTATION` NEGATED — `{90:-90, 180:180, 270:90}`. A literal `270` rotates content but skips Qt's width/height swap (viewport stays 1920×1080 → stretched); emit `-90` instead (PR #2973, #2970).
  - **wayland (Pi 5/x86):** `_wlr_transform_value` — `{90:'270', 180:'180', 270:'90'}` (wlr transforms turn CCW).
  - **pi3-64 video:** vc4 overlay plane rotate-180 for 0/180 (HW, #3189); 90/270 drop the overlay → eglfs raster fallback inherits the compositor turn.
- **`_is_wayland_board()` rotation dispatch bug (RESOLVED #3047, issue #3044).** Returned `DEVICE_TYPE == 'x86'` only, so pi5/arm64 never reached the wlr-randr transform path. Fixed to `os.environ.get('QT_QPA_PLATFORM','').startswith('wayland')`.
- **Pi 4 rotation was a no-op on eglfs (RESOLVED).** #2882 appended `rotation=N` to `QT_QPA_PLATFORM`, but that's a linuxfb-plugin option eglfs ignores. Fix: set `QT_QPA_EGLFS_ROTATION=<deg>` in `_build_webview_env` for eglfs AND remove the app-level `video-rotate` branch in `media_player.py` (else video double-rotates). Firmware `display_rotate` is ignored under vc4-kms-v3d + eglfs_kms.
- **pi2/pi3-32 linuxfb: only video rotated pre-fix, images + web pages upright (RESOLVED PR #3197, 2026.07.3).** Fix: images/GIFs rotated by hand in `View::paintEvent` (`linuxfbRotationOverride()`); web pages via injected CSS through imperative `runJavaScript`. Gated linuxfb-only (eglfs/wayland rotate the whole compositor output).
- **qt5pi declarative `QWebEngineScript` silently never fires** — even with `ApplicationWorld`+`DocumentReady`. Use imperative `page->runJavaScript(js, QWebEngineScript::ApplicationWorld)` from a PERSISTENT `loadFinished` handler (re-applies on every auto-refresh reload; keep a MutationObserver for SPA rewrites). Debug without rebuild: `QTWEBENGINE_REMOTE_DEBUGGING=9222` + a stdlib CDP WebSocket client.
- **Rotated video is ~1-2fps on Pi 1/2/3 linuxfb (issue #3198).** Software `videoflip` rotates full-res frames single-threaded on A7/A53 → 100% of one core, ~1fps (vs 26fps unrotated). Pre-existing from #3004. **Split verdict (PR #3207):** 180° fixable for FREE via the bcm2835 ISP — `v4l2convert extra-controls=c,horizontal_flip=1,vertical_flip=1` runs the flip in HW → full baseline fps, pixel-exact. **90/270 INFEASIBLE on VideoCore IV** — no `rotate` control on any bcm2835 v4l2 device (only hflip/vflip). Scale-before-flip is a dead end. Measure fps with `fpsdisplaysink`, NOT fb0 mmap sampling.

## GPU / WebGL

- **WebGL 1+2 is hardware-accelerated out of the box on every Qt6 board** (verified 2026-06-07): Pi 5 = V3D 7.1.7.0 (wayland), Pi 4 = V3D 4.2.14.0 (eglfs), Rock Pi 4 = Mali-T860/Panfrost (wayland). No `QTWEBENGINE_CHROMIUM_FLAGS` set; `WebGLEnabled` default true. **Qt5 boards (Pi 1-3 32-bit, linuxfb) have NO GL** — Qt built without `-opengl es2` (enabling it breaks QtWebEngine); WebGL cannot work there. If a WebGL asset misbehaves on a Qt6 board, suspect page JS or memory pressure, not the GPU context.

## Webpage transition stale-buffer flash (issue #2954) — RESOLVED

- **Webpage→webpage transition flashed the (n-2 mod N) page for ~1 frame.** The 2-buffer ping-pong revealed a buffer whose last-composited GPU surface still held the page from two rotations ago. `loadFinished` ≠ first-paint, and an occluded QWebEngineView does NOT composite (Chromium frame-callback-throttles occluded WebContents). **Fix (2026-07-08, live-verified Pi 5):** collapse to the single-QWebEngineView path for ALL boards — an in-place `load()` on the visible view keeps the old page composited until the new one paints. Removed the `ANTHIAS_LOW_RAM` viewer flag. Residual: x86 wayland logs periodic `Atomic commit failed: Device or resource busy` (benign).

## Boot splash (Plymouth)

- **x86: Plymouth boot splash holds DRM master → cage wedges (RESOLVED #2955, shipped 2026.05.2).** balenaOS plymouthd on x86 opens `/dev/dri/card0` as DRM MASTER (efifb→i915 early KMS). cage then runs as non-master and every atomic commit is rejected — screen frozen on splash while `Swapchain for output 'DP-1' failed test` spams. Confirm via `/sys/kernel/debug/dri/0/clients`. Fix = `start_viewer.sh` asks host systemd (over the host system bus) to run `plymouth-quit.service` before launching cage. **x86-specific:** Pi plymouthd opens `/dev/fb0`, never becomes DRM master.
- **"Splash broken" on a fresh Pi is usually a pending-reboot artifact, NOT a bug.** The running kernel still holds the stock rpi-imager cmdline (no `splash`); Ansible writes the Anthias cmdline.txt but it only takes effect next reboot. **First diagnostic:** diff `cat /proc/cmdline` vs `cat /boot/firmware/cmdline.txt`. Use `dpkg-query -W` (not `dpkg -l | grep`) to check the package.
- **Pi 2 splash rendered the TEXT theme, not Anthias (RESOLVED PR #3199, 2026.07.3).** plymouthd waits `DeviceTimeout` for a graphics device (distro default **8s**); on a Pi 2 (A7 @900MHz) vc4's `/dev/dri/card0` only appears at ~14.3s → plymouthd commits to text at 12.65s. Fix: `DeviceTimeout=30` in the plymouthd default + a Regenerate-initramfs handler. **plymouthd runs from the initramfs and reads its config from the copy baked there** — a config-only on-disk change is inert; verify with `unmkinitramfs`. A `disconnected` HDMI fakes this exact bug.

## Audio

- **Qt6 Multimedia (Debian trixie) has ONLY a PulseAudio backend** — links libpulse, zero ALSA code, no `QT_AUDIO_BACKEND` escape. No pulse server → `QMediaDevices::audioOutputs()` empty → silent video (issue #3000, all Qt6 boards after the #2905 mpv→QtMultimedia migration). Fix (PR #3001): `start_pulseaudio()` in `start_viewer.sh` runs a per-container pulse daemon as the viewer user. Sink-naming trick keeps routing: `load-module module-alsa-card ... name=<alsa card id>` → sink names embed the card name (`alsa_output.vc4hdmi0.hdmi-stereo`). Verify via `/proc/asound/cardN/pcm0p/sub0/status`=`RUNNING`.
- **HDMI/3.5mm `audio_output` selector is a no-op on x86 + arm64 (RESOLVED issue 3208 / PR 3209).** `get_alsa_audio_device` collapsed to `'default'` on non-Pi boards. Fix: non-Pi boards discover sinks at runtime via `pactl list short sinks` and pick by the **profile token in the sink name** (`hdmi-*` vs `analog-*`), not the card name; return the bare sink name, matched against `QAudioDevice::id()`; fall back to `default` if pulse unreachable. Pi paths unchanged. A headless x86 has no HDMI pulse sink, so it correctly falls back to default.

## Pi 2 blank / Qt5 armhf toolchain (release blocker) — RESOLVED

- **Pi 2 (and 32-bit Pi 3/armhf) blanks: deterministic ARMv7 alignment SIGBUS inside libQt5WebEngineCore (forum thread 6732).** AnthiasViewer handshakes on D-Bus then SIGBUSes post-handshake: `Alignment trap ... f9400adf`. A 64-bit NEON doubleword store (`vst1.8 {q8},[rN:64]`) stamped `:64` alignment lands on a 4-byte-aligned Chromium address; Cortex-A7 faults, kernel can't fix up NEON → SIGBUS. 100% deterministic even with empty playlist.
- **Root cause is the Debian gcc-14 armhf TOOLCHAIN, not the Qt version.** Bisection on real Pi 2: 5.15.19 crashes; prebuilt 5.15.14 (built with Linaro gcc-7) is clean; 5.15.18 AND 5.15.14 rebuilt with gcc-14 BOTH crash. The culprit is the toolchain switch (~PR #3070 "replace retired Linaro armhf toolchain with Debian's"). The faulting store comes from gcc's ARM backend RTL block-move expander (`arm_block_set_aligned_vect`), NOT the SLP vectorizer (so `-fno-tree-vectorize` does nothing). GCC WONTFIX by policy; only Linaro gcc-7 doesn't emit it.
- **Fix VALIDATED on real Pi 2 (2026-07-10), 0 alignment traps — requires BOTH:** (1) `arm_use_neon=false` in qtwebengine `src/core/config/common.pri`; (2) global `-mno-unaligned-access` via a compiler wrapper shim on `/usr/bin/arm-linux-gnueabihf-{gcc,g++}`. HW SIMD video decode preserved (libvpx/Skia re-add `-mfpu=neon` per-file). **Metric truth = runtime `dmesg | grep -c "alignment exception"`, NOT the `vst1:64` count in the .so** and NOT the launch harness (post-handshake, reports OK while the screen still blanks).
- **Qt5 toolchain rebuild is broken on trixie** (Python 3.13 removed stdlib modules Chromium 87's build needs). Fixes: `imp`/`pipes`/`cgi` shims via `PYTHONPATH`, overwrite Chromium's vendored six with distro six 1.17; the re2 API break — drop `libre2-dev` so `use_system_re2=false` (also clear `config.cache`'s `webengine-re2.result=true`). **QtWebEngine ninja OOMs the build box regardless of `MAKE_CORES`** (bundled ninja self-detects nproc); fix: `export NINJAFLAGS="-j${MAKE_CORES}"`.
- **Qt5 5.15.14→5.15.19 toolchain bump: MERGED PR #3121, WebView-v2026.07.0.** Qt6 boards untouched (track trixie apt 6.8.2; `qt_version='6.4.2'` in `utils.py` is dead, only names the Qt5 tarball). The viewer image downloads `qt5-<ver>-trixie-<board>.tar.gz` from the WebView release, so tarballs must be built + uploaded BEFORE the utils.py bump merges or pi2/pi3 image builds 404 on the sha256.

## Pi 2/3-32 armhf webview crash-loop (headless AND display-present)

- **Two distinct malloc-unaligned crashes on arm32 — don't conflate.** (a) `malloc(): unaligned tcache` on the NO-SCREEN/headless path (see headless-hotplug). (b) On a device WITH a valid `/dev/fb0`, the Qt5 viewer heap-corrupts while building the font database (deterministically at the synthetic-oblique `Monospace` style) → crash-loop. arm32-only. The real corruptor is Qt5 QtWebEngine/Chromium init on armv7 (only surfaces with `--no-sandbox`, which the fleet uses); the font DB is just the victim. Stop-gap = #2969 capped-backoff spawn retry. Real fix = the arm64/Qt6 `pi3-64` migration (Qt6 WebEngine inits clean on Pi 3 silicon).

## Board / fleet mapping facts

- **pi3-64** (arm64/Qt6/eglfs_kms) added alongside legacy 32-bit armhf/Qt5 `pi3`; mirrors the pi4-64 path (in-process QtMultimedia, no cage, no mpv); H.264-only HW decode on VC IV. `get_device_type()` still returns `'pi3'`; routing relies on `force_mpv`. `docker/eglfs-kms.json` shared pi4-64/pi3-64, rewritten at runtime by start_viewer.sh DRM-card detection.
- **Rock Pi 4** (`rockpi-4b-rk3399`) runs the GENERIC arm64 images (no rockpi4 board build; deploy scripts rewrite BOARD→arm64 and strip `/dev/vchiq`). `board.get_board_subtype` falls back to `/proc/device-tree/model` in-container when redis is empty. Rock Pi 4 is 1GB and OOM-wedges even idle on the arm64 viewer.
- **Pi 5 runs a 16KB-page kernel** (`getconf PAGESIZE`=16384). 32-bit armhf userspace (built for 4KB pages) fails to load (`ELF load command ... not page-aligned`) even under `--platform linux/arm/v7`; no qemu binfmt on these boards. For native armv7/armhf validation use the Pi 4 (arm64 kernel, 4KB pages). Pi 5 outputs 4K@30 → presented-fps ceiling is 30, not 60.

## Diagnostic / harness techniques

- **Webview viewport probe via D-Bus** (rotation/geometry without a screen capture): read `DBUS_SESSION_BUS_ADDRESS` from `/proc/<AnthiasViewer pid>/environ`, `docker exec -u viewer` pydbus `bus.get('anthias.viewer','/Anthias').loadPage(url)` (root gets "connection is closed"), load a `data:` page that `fetch()`es `innerWidth/innerHeight/screen.width` to the server, read dims from the uvicorn access log.
- **Offscreen D-Bus repro harness** (validate AnthiasWebview on the dev host, no testbed): x86 viewer image + build `src/anthias_webview` with `qmake6 && make` inside, run under `dbus-run-session` with `QT_QPA_PLATFORM=offscreen` + `QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu"`, drive via `gdbus call`. **D-Bus interface casing differs Qt5 vs Qt6:** Qt6 = `local.anthiasviewer.MainWindow` (lowercase), Qt5 (armhf) = `local.AnthiasViewer.MainWindow` (CamelCase); introspect `/Anthias` rather than hard-coding. C++ stderr is NOT in `docker logs` (bounded deque, issue-3138) — the playback-stats file is the visible signal.
- **pi3-64 eglfs capture/fps** (fb0 is dead/all-zero on eglfs — DRM/GBM not fbdev): DRM plane structure via `sudo modetest -M vc4 -p` (active = crtc!=0 && fb!=0; overlay path = 2 planes; `rotation:` value 1=rotate-0, 4=rotate-180, vc4 has no 90/270). Pixel grab via `ffmpeg -f kmsgrab` (primary plane is T-tiled → scrambled but colors intact; SAND-tiled video plane can't be grabbed). Watch `vcgencmd measure_temp`/`get_throttled` (SW rotate heats the passively-cooled Pi 3 into throttle).
- **Content-diff fps measurement needs a full-frame-motion source** (`ffmpeg -f lavfi -i mandelbrot`); a color-bar clip with a thin moving sweep under-counts to ~1fps even when playing fine. Prefer `fpsdisplaysink` over fb0 mmap sampling.

