cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / docs / fresh-bazzite-host.md
fresh-bazzite-host.md 74.8K rendered
1# Fresh Bazzite host engineering setup23This document is the maintainer lab log and hardware-acceptance source. It4intentionally contains historical machine names, checkout paths, evidence, and5development workflows. A user activating an installed package should follow6the portable [Sigil host activation guide](sigil-host-activation.md) shipped in7that package instead.89This runbook takes a dedicated x86_64 machine with VA-API H.264 encoding from a10fresh Bazzite install to a remotely managed Sigil host. The proven reference11machine uses AMD graphics, but host admission is based on encoder capability,12not a kernel-driver name. It also defines how `slate` is used as13the temporary Linux stand-in while Steps 0–2 are being built.1415The dedicated Bazzite machine is the only target for Gamescope, PipeWire,16hardware encoding, `uinput`, or appliance-level changes. Do not make those17changes on `slate`.1819## Scope and machine roles2021| Machine | Role | Allowed work |22| --- | --- | --- |23| Development Mac | Portal client and primary source tree | Client development, tests, evidence collection |24| `tank@slate` | Temporary Linux stand-in | Protocol tests, pure Rust host, synthetic H.264, iroh transport, reconnect tests |25| Dedicated Bazzite host | Final appliance | Everything above plus Gamescope, PipeWire, hardware H.264, audio, and `uinput` |2627Steps 0–2 do not require Gamescope. They use a synthetic encoded source so the28host boundary can be proven independently. The cutover to Gamescope starts at29Step 3.3031## Implemented host command contract3233The repository provides these interfaces:3435```text36sigil identity init --output <path>37sigil identity show --identity <path>38sigil config check --config <path>39sigil capture probe --source test-pattern --frames <count> --expect-size 1280x80040sigil capture probe --source gamescope-pipewire --config <path> --frames <count>41sigil serve --identity <path> --source test-pattern42sigil serve --config <path>43sigil-probe --node-id <host-node-id> --frames <count>44sigil-probe --node-id <host-node-id> --identity <probe.key> \45 [--invitation <probe.goq-invite>] --frames <count>46portal --dev-connect <host-node-id>47```4849The Gamescope source uses GStreamer's `pipewiresrc` and GstVA H.264 encoder,50because stock upstream FFmpeg does not expose a PipeWire video input device.51It resolves exactly one live node from configured PipeWire properties and52fails closed if the node, encoder factory, render-node binding, or required53low-latency encoder properties do not match. This implementation still needs54the attached-display and headless appliance gates below before it is proven on55the target Bazzite image.5657The compatibility backend launches the video pipeline through the configured58`gst-launch-1.0` executable and remains the runtime default when59`encoder_backend` is omitted. Published Sigil packages also include the60`in-process-gstreamer` backend so a host can opt into the bounded appsink path61without replacing its package. Sigil must never silently fall back to the62external backend when that explicit configuration is unavailable.6364The host identity file is part of the normal daemon design. The client-side65`--dev-connect` passkey bypass is accepted by debug builds and by an explicitly66feature-gated optimized demo build. Ordinary release clients reject that67option, and every accepting client must show a prominent development-mode68indicator while the bypass is active. Build the optimized temporary client69with `cargo tauri build --features demo-direct-node`; do not ship that feature70in a production package.7172`--dev-connect` alone does not authorize its ephemeral Portal identity. For an73explicit configured-host hardware test without a security key, run Sigil with74the explicit development feature:7576```bash77cargo run -p sigil-host --features demo-auth-bypass -- serve \78 --config <host.toml> --dev-allow-unauthorized --max-runtime-seconds 180079```8081An optimized test binary requires the same feature. This host flag82admits any peer that knows the node ID, grants all session capabilities, prints83a startup warning, and deliberately avoids reading or mutating enrollment84state. It requires `--max-runtime-seconds` between 1 and 3600 so the exposure85expires automatically. Stop that process immediately after the bounded test.86Ordinary release builds and shipped Sigil packages reject the flag.8788### Current security boundary8990Portal derives a stable client Iroh identity from the security key. Sigil signs91a short-lived invitation bound to its host identity, that exact Portal peer,92an enrollment epoch, a nonce, expiry, and the requested grants. The first media93handshake consumes the invitation atomically; later sessions authenticate the94durably enrolled peer without replaying it. View, pointer/keyboard, and gamepad95grants are intersected with operational protocol capabilities on both sides.9697`--dev-connect` remains only a build-time-contained routing bypass in Portal.98It does not grant access to a configured host, and ordinary release builds99reject the option. Only the explicit direct test-pattern proof form of100`sigil serve` omits enrollment; a configured Gamescope appliance fails closed.101Replay, expiry, wrong-peer, cross-host, stale-epoch, and capability-escalation102attempts are rejected before capture starts.103104For first enrollment, use Portal's **show portal id** action, then issue the105short-lived file on the host:106107```bash108sigil invitation create \109 --config ~/.config/sigil-spark/host.toml \110 --peer PORTAL_PEER_ID \111 --pointer-keyboard \112 --gamepad \113 --output ~/portal.goq-invite114```115116Open the file with Portal and confirm its bounded summary with the controller.117After the first accepted media handshake, verify the durable grant with118`sigil enrollment show --config ~/.config/sigil-spark/host.toml`. Revocation is119explicit and invalidates all outstanding invitations:120121```bash122fingerprint="$(sigil appliance status \123 --config ~/.config/sigil-spark/host.toml --json | jq -r .identity.host_fingerprint)"124systemctl --user stop sigil-host.service125sigil appliance enrollment-reset \126 --config ~/.config/sigil-spark/host.toml \127 --expected-host-fingerprint "$fingerprint" \128 --json129systemctl --user start sigil-host.service130```131132Enrollment reset needs only the durable per-state lifecycle lock when133`XDG_RUNTIME_DIR` is unavailable, so the stopped-host command also works from134an SSH or recovery shell. If a valid XDG runtime root is present, reset also135takes the per-user global lock. An unsafe or malformed XDG runtime directory is136still rejected rather than ignored.137138Then disconnect Portal and use **client -> reset enrollment** before importing139the replacement invitation. Portal never silently overwrites a redeemed host140profile.141142Do not put an identity seed in an environment variable or command-line143argument. Store it in a mode `0600` file and pass only its path.144145For an authenticated headless hardware probe, create a dedicated persistent146probe identity on the client and bind a short-lived invitation to its printed147public ID. This enrollment is intentionally separate from the ordinary Portal148profile and therefore belongs only in an isolated temporary host state:149150```bash151# On the client:152umask 077153probe_state="$HOME/.local/state/goq-probe"154install -d -m 0700 "$probe_state"155sigil identity init --output "$probe_state/probe.key"156157# On the host's isolated test state/config, using the peer ID printed above:158proof_state="$HOME/.local/state/sigil-spark-hardware-proof"159sigil invitation create \160 --config "$proof_state/host.toml" \161 --peer PROBE_PEER_ID \162 --pointer-keyboard \163 --output "$proof_state/probe.goq-invite"164165# From the client, copy the exact invitation over the authenticated SSH path.166scp tank@umpc:.local/state/sigil-spark-hardware-proof/probe.goq-invite \167 "$probe_state/probe.goq-invite"168chmod 0600 "$probe_state/probe.goq-invite"169170# First connection redeems the one-time invitation.171sigil-probe --node-id HOST_NODE_ID \172 --identity "$probe_state/probe.key" \173 --invitation "$probe_state/probe.goq-invite" \174 --keyframe-smoke --frames 120175176# Later connections use the same identity without replaying the invitation.177sigil-probe --node-id HOST_NODE_ID \178 --identity "$probe_state/probe.key" \179 --keyframe-smoke --frames 120180```181182Both credential files must be regular files owned by the current user with no183group or other permissions. The probe verifies the invitation signature,184host/peer binding, expiry, and requested grants before opening a network185connection, sends it only on the initial media handshake, and never prints its186contents. Delete the exact invitation file only after enrollment is confirmed;187if a later session step fails after redemption, retry ticket-free with the same188identity. After `sigil enrollment show` confirms the temporary probe peer,189remove both exact copies:190191```bash192rm -- "$HOME/.local/state/goq-probe/probe.goq-invite"193ssh tank@umpc 'rm -- "$HOME/.local/state/sigil-spark-hardware-proof/probe.goq-invite"'194```195196## 1. Choose and install Bazzite197198Use the Bazzite download picker with:199200- Home Theater PC / Steam Gaming Mode image.201- Modern AMD GPU.202- x86_64 architecture.203- KDE desktop for recovery and maintenance.204205Use a single AMD GPU where possible. Hybrid-GPU configurations add device206selection ambiguity before the media path is proven.207208During installation:2092101. Use UEFI boot.2112. Connect wired Ethernet.2123. Set the hostname to `sigil-host` or another stable local DNS name.2134. Create the gaming account. This runbook uses `sigil` in examples.2145. Use the entire dedicated disk unless the machine has another explicit role.2156. Boot into Steam Gaming Mode once, then switch to Desktop Mode for setup.216217Choose the disk-unlock model before installation. Passphrase-only LUKS blocks218an unattended headless cold boot. TPM-backed unlock can satisfy the appliance219boot requirement, but only when an offline recovery key is stored and tested.220Record the decision and prove one recovery boot before relying on the host.221222Follow the current [Bazzite installation guide](https://docs.bazzite.gg/General/Installation_Guide/install-guide/)223for image verification, Secure Boot, and installer details.224225## 2. Update once and pin the known-good deployment226227From a host terminal in Desktop Mode:228229```bash230set -euo pipefail231rpm-ostree status -v | tee "$HOME/bazzite-pre-update.txt"232df -h / /var "$HOME"233df --output=pcent /var | tail -1 | tr -dc '0-9' | {234 read -r used235 test "$used" -le 97236}237ujust update238sudo systemctl reboot239```240241After reboot, record and pin the deployment:242243```bash244rpm-ostree status -v245rpm-ostree status -v | grep -q 'ostree-image-signed'246sudo ostree admin pin 0247```248249Bazzite-Deck/HTPC updates are normally applied manually through Steam Gaming250Mode or with `ujust update`. Pinning gives the appliance a known-good bootable251deployment before Sigil-specific work begins.252253Do not layer compilers, Rust, FFmpeg development packages, or build tools with254`rpm-ostree`. Bazzite recommends containers for development workflows and warns255that package layering can interfere with upgrades.256257References:258259- [Bazzite update guide](https://docs.bazzite.gg/Installing_and_Managing_Software/Updates_Rollbacks_and_Rebasing/updating_guide/)260- [Bazzite rollback guide](https://docs.bazzite.gg/Installing_and_Managing_Software/Updates_Rollbacks_and_Rebasing/rolling_back_system_updates/)261- [Bazzite software installation guidance](https://docs.bazzite.gg/Installing_and_Managing_Software/software-intro/)262263## 3. Establish SSH access264265On the Bazzite host, confirm that OpenSSH is present and enable it:266267```bash268sudo systemctl enable --now sshd.service269systemctl status sshd.service --no-pager270```271272If `sshd.service` does not exist, stop and verify the selected Bazzite image273before layering packages. OpenSSH is expected on the chosen host image.274275If `firewalld` is active, inspect its active zones and allow SSH only in the276management interface's zone:277278```bash279management_interface="<wired-management-interface>"280if sudo systemctl is-active --quiet firewalld; then281 management_zone="$(sudo firewall-cmd --get-zone-of-interface="$management_interface")"282 test -n "$management_zone" && test "$management_zone" != no283 sudo firewall-cmd --zone="$management_zone" --permanent --add-service=ssh284 sudo firewall-cmd --reload285fi286```287288At the host console, record its ED25519 fingerprint:289290```bash291sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub292ip -brief address show dev "$management_interface"293```294295Compare that fingerprint when the Mac first connects. If `.local` name296resolution is not available yet, use the displayed management IP. Create a297dedicated management key and install its public half:298299If the host uses Tailscale SSH, its ACL may present a browser check before300OpenSSH authentication. A local or temporary password is not consulted until301that tailnet check succeeds. Complete the displayed URL using the authorized302tailnet identity, then continue with the dedicated key below. Do not weaken303`sshd` or the tailnet ACL merely to bypass the check. For recovery, retain a304separately tested LAN management path or document the tailnet check policy and305its operator account; a physically headless appliance must not depend on an306unknown interactive account flow.307308```bash309test -f "$HOME/.ssh/sigil-bazzite_ed25519" || \310 ssh-keygen -t ed25519 -a 64 -f "$HOME/.ssh/sigil-bazzite_ed25519"311management_key="$(cat "$HOME/.ssh/sigil-bazzite_ed25519.pub")"312printf '%s\n' "$management_key" | ssh sigil@sigil-host.local \313 'umask 077; IFS= read -r management_key; install -d -m 0700 "$HOME/.ssh"; touch "$HOME/.ssh/authorized_keys"; chmod 0600 "$HOME/.ssh/authorized_keys"; grep -qxF "$management_key" "$HOME/.ssh/authorized_keys" || printf "%s\n" "$management_key" >> "$HOME/.ssh/authorized_keys"'314ssh -o IdentitiesOnly=yes \315 -o PasswordAuthentication=no \316 -i "$HOME/.ssh/sigil-bazzite_ed25519" \317 sigil@sigil-host.local hostname318```319320Only after key-only login succeeds, add an SSH hardening drop-in on the host:321322```bash323sudo install -d -m 0755 /etc/ssh/sshd_config.d324sudo tee /etc/ssh/sshd_config.d/10-sigil.conf >/dev/null <<'EOF'325PubkeyAuthentication yes326AuthenticationMethods publickey327PasswordAuthentication no328KbdInteractiveAuthentication no329PermitRootLogin no330AllowUsers sigil331AllowAgentForwarding no332AllowTcpForwarding no333PermitTunnel no334X11Forwarding no335EOF336sudo sshd -t337sudo sshd -T | grep -E '^(pubkeyauthentication|authenticationmethods|passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowusers|allowagentforwarding|allowtcpforwarding|permittunnel|x11forwarding) '338sudo systemctl reload sshd.service339```340341Keep the original session and a local keyboard available until a second342key-only SSH login succeeds.343344## 4. Verify the fresh host before adding Sigil345346Run this while a display is still attached:347348```bash349./scripts/bazzite-inventory.sh | tee "$HOME/bazzite-inventory.txt"350./scripts/bazzite-inventory.sh --smoke | tee "$HOME/bazzite-smoke.txt"351```352353The script selects the first accessible render node that completes a real354one-frame VA-API H.264 encode; it does not assume a driver name or355`renderD128`. The equivalent individual checks are shown below for recovery356and review:357358```bash359hostnamectl360timedatectl361uname -a362rpm-ostree status -v363getenforce364ss -lntup365systemctl list-unit-files --state=enabled366mokutil --sb-state || true367gamescope --version368pipewire --version369ls -l /dev/dri370id371vulkaninfo --summary372render_node="$({373 for node in /dev/dri/renderD*; do374 test -c "$node" && test -r "$node" && test -w "$node" || continue375 ffmpeg -hide_banner -loglevel error \376 -vaapi_device "$node" \377 -f lavfi -i color=size=128x128:rate=1 \378 -vf 'format=nv12,hwupload' \379 -c:v h264_vaapi -frames:v 1 -f null - >/dev/null 2>&1 || continue380 printf '%s\n' "$node"381 done382} | head -n 1)"383test -n "$render_node"384udevadm info --query=property --name="$render_node" | grep -E '^(DEVNAME|ID_PATH|ID_PATH_TAG)='385readlink -f "/sys/class/drm/$(basename "$render_node")/device/driver"386test -r "$render_node" && test -w "$render_node"387vainfo --display drm --device "$render_node"388ffmpeg -hide_banner -encoders 2>/dev/null | grep -E 'h264_(vaapi|amf)'389ffmpeg -hide_banner -encoders 2>/dev/null | grep -q 'libx264'390ffmpeg -hide_banner -loglevel warning \391 -vaapi_device "$render_node" \392 -f lavfi -i testsrc2=size=1280x800:rate=60 \393 -vf 'format=nv12,hwupload' \394 -c:v h264_vaapi \395 -frames:v 600 \396 -f null -397```398399Required observations:400401- The gaming user can open a `/dev/dri/renderD*` render node.402- Vulkan selects the intended gaming GPU.403- VA-API exposes H.264 encoding and completes the 600-frame smoke encode.404- FFmpeg exposes `libx264` for the bounded synthetic Steps 0–2 source.405- PipeWire and Gamescope are installed.406- The clock reports NTP synchronization. NTP is suitable for correlating logs;407 one-way latency measurements still require a same-clock monotonic measurement408 or explicit clock-offset estimation.409410Record the output before continuing. Missing hardware encoding is a host-image411or driver problem, not an application fallback opportunity.412413## 5. Create persistent user-space locations414415As the gaming user:416417```bash418install -d -m 0700 \419 "$HOME/.config/sigil-spark" \420 "$HOME/.local/libexec/sigil-spark" \421 "$HOME/.local/libexec/sigil-spark/releases" \422 "$HOME/.local/share/sigil-spark/identity" \423 "$HOME/.local/state/sigil-spark" \424 "$HOME/.local/state/sigil-spark/runtime" \425 "$HOME/.config/systemd/user"426```427428Use these locations consistently:429430| Content | Location |431| --- | --- |432| Host releases | `~/.local/libexec/sigil-spark/releases/<commit>/sigil` (`sigil-host` is a compatibility copy) |433| Current binary | `~/.local/libexec/sigil-spark/current/sigil` |434| Configuration | `~/.config/sigil-spark/host.toml` |435| Read-only host identity | `~/.local/share/sigil-spark/identity/host.key` |436| Writable runtime state | `~/.local/state/sigil-spark/runtime/` |437| User service | `~/.config/systemd/user/sigil-host.service` |438439This avoids modifying Bazzite's immutable base image.440441Sigil requires the configuration file and its immediate directory to be owned442by the service user and not writable by group or other users. This appliance443layout deliberately uses mode `0700`; a conventional owner-controlled mode444`0755` configuration directory is also valid, while group- or world-writable445directories fail during `config check` and daemon startup before any managed446configuration transaction can begin.447448## 6. Prepare a containerized build environment449450Bazzite recommends Distrobox for development packages. The repository must451first contain a committed `rust-toolchain.toml`, `Cargo.lock`, and a clean452source revision. Determine the Fedora base version, pull the matching toolbox453image, and record its immutable digest:454455```bash456. /etc/os-release457image_tag="registry.fedoraproject.org/fedora-toolbox:${VERSION_ID}"458podman pull "$image_tag"459image_digest="$(podman image inspect --format '{{index .RepoDigests 0}}' "$image_tag")"460printf '%s\n' "$image_digest"461distrobox create \462 --name sigil-dev \463 --image "$image_digest"464distrobox enter sigil-dev465```466467Inside the container:468469```bash470sudo dnf install -y --setopt=install_weak_deps=False \471 clang cmake curl gcc gcc-c++ git make \472 gstreamer1-devel gstreamer1-plugins-base-devel \473 openssl-devel pipewire-devel pkgconf-pkg-config systemd-devel474475curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \476 sh -s -- -y --profile minimal --default-toolchain none477source "$HOME/.cargo/env"478rpm -qa | sort > "$HOME/sigil-dev-rpms.txt"479```480481`gstreamer1-devel` supplies the core API and482`gstreamer1-plugins-base-devel` supplies the app/video APIs linked by the483optional in-process backend. They are build dependencies only; do not copy484headers or development libraries into the Sigil runtime archive.485486The toolchain pinned by the repository must be Rust 1.91 or newer. Save the487container digest and exact RPM NEVRAs with the evidence. Distrobox is488host-integrated and is not a security sandbox. Build only the protocol and pure489host crates on the appliance; the Tauri client remains a Mac build. Remove the490`sigil-dev` box after installing and validating the release binary.491492## 7. Transfer the working tree493494Proof builds use committed, revision-addressed source. They must never copy a495dirty tree, `.env` file, identity, or evidence directory. On the Mac, create a496bundle for the intentional checkpoint:497498```bash499cd ~/Developer/sigil-spark500test -z "$(git status --porcelain)" || {501 echo "Commit or intentionally discard all changes before a proof build." >&2502 exit 1503}504sigil_rev="$(git rev-parse HEAD)"505sigil_short="$(git rev-parse --short=12 HEAD)"506sigil_bundle="/tmp/sigil-spark-${sigil_short}.bundle"507git bundle create "$sigil_bundle" HEAD508git bundle verify "$sigil_bundle"509bundle_sha="$(shasum -a 256 "$sigil_bundle" | awk '{print $1}')"510printf '%s %s\n' "$bundle_sha" "$(basename "$sigil_bundle")" >"$sigil_bundle.sha256"511ssh -i "$HOME/.ssh/sigil-bazzite_ed25519" sigil@sigil-host.local \512 'install -d -m 0700 "$HOME/Developer"'513scp -i "$HOME/.ssh/sigil-bazzite_ed25519" \514 "$sigil_bundle" "$sigil_bundle.sha256" sigil@sigil-host.local:Developer/515ssh -i "$HOME/.ssh/sigil-bazzite_ed25519" sigil@sigil-host.local \516 "cd \"\$HOME/Developer\" && \517 sha256sum -c \"sigil-spark-${sigil_short}.bundle.sha256\" && \518 git bundle list-heads \"sigil-spark-${sigil_short}.bundle\" && \519 mkdir -p \"\$HOME/Developer/sigil-spark-revisions\" && \520 git clone \"\$HOME/Developer/sigil-spark-${sigil_short}.bundle\" \521 \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" && \522 git -C \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" \523 checkout --detach ${sigil_rev} && \524 test \"\$(git -C \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" rev-parse HEAD)\" = \"${sigil_rev}\""525```526527Once a shared Git remote exists, replace the bundle with a normal fetch and528detached checkout of the same exact commit.529530Build inside the Distrobox:531532```bash533distrobox enter sigil-dev534sigil_rev="<full-commit>"535sigil_short="${sigil_rev:0:12}"536cd "$HOME/Developer/sigil-spark-revisions/$sigil_short"537source "$HOME/.cargo/env"538toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"539test -n "$toolchain"540rustup toolchain install "$toolchain"541rustup show active-toolchain542rustc -vV543cargo --version544cargo test --locked -p sigil-protocol545cargo build --locked -p sigil-host --release546exit547```548549Install the resulting binary as the gaming user:550551```bash552sigil_rev="<full-commit>"553sigil_short="${sigil_rev:0:12}"554sigil_root="$HOME/.local/libexec/sigil-spark"555sigil_release="$sigil_root/releases/$sigil_rev"556install -d -m 0755 "$sigil_release"557install -m 0755 \558 "$HOME/Developer/sigil-spark-revisions/$sigil_short/target/release/sigil" \559 "$sigil_release/sigil"560install -m 0755 "$sigil_release/sigil" "$sigil_release/sigil-host"561sha256sum "$sigil_release/sigil" "$sigil_release/sigil-host"562ldd "$sigil_release/sigil" | tee "$HOME/sigil-${sigil_short}.ldd"563if grep -q 'not found' "$HOME/sigil-${sigil_short}.ldd"; then564 echo "Host runtime dependency missing." >&2565 exit 1566fi567"$sigil_release/sigil" --version568cd "$sigil_root"569test ! -e current || test -L current570ln -sfnT "releases/$sigil_rev" current571sha256sum current/sigil current/sigil-host572current/sigil --version573```574575For a demo deployment built and hashed on the development Mac, transfer both576`sigil` and `sigil-probe` plus `scripts/stage-bazzite-release.sh`, then use577the stager instead of the manual install block above:578579The thin stager is restricted to an unmanaged development layout. If the580service, audio, rollback, or udev assets are package-managed links that follow581`current`, it fails before staging and instructs you to build the complete582runtime package and run `payload/stage-this-release.sh` instead.583584```bash585scripts/stage-bazzite-release.sh \586 --release-id <commit-or-source-snapshot-sha256> \587 --host-binary <absolute-path> --host-sha256 <sha256> \588 --probe-binary <absolute-path> --probe-sha256 <sha256>589```590591The expected hashes must come from the trusted development machine, not from592an unverified copy on the host. The stager refuses unsafe inputs and install593directories, validates runtime linking and bounded startup, and atomically594updates `current`. It deliberately does not create an identity, install a595hardware configuration, or start/enable the service; those remain the next596separate gates.597598The preferred host artifact is the deterministic, allowlisted runtime package.599It contains the generic Linux host/probe binaries, installer and rollback tool,600systemd/PipeWire/udev assets, license, complete checksums, and build provenance.601It cannot include the worktree, credentials, identity, hardware configuration,602or evidence. Product packaging exports clean tagged `HEAD`, builds both603binaries with locked `cargo-zigbuild` in an isolated target directory, and604never accepts caller-supplied binaries. It fails closed when the worktree is605dirty, the tag does not resolve to `HEAD`, the tag differs from Sigil's Cargo606version, or the output does not use the stable public asset name. The builder607never receives the offline Minisign secret:608609```bash610cd ~/Developer/sigil-spark611source ~/.cargo/env612scripts/package-bazzite-release.sh \613 --release-tag v0.1.0 \614 --output /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz615616# Verify the candidate before it crosses the offline signing boundary.617scripts/verify-sigil-release.sh \618 --tag v0.1.0 \619 --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \620 --source-commit "$(git rev-parse HEAD)" \621 --candidate622623# After the offline signer returns the detached signature, verify with the624# reviewed public key before extraction. See docs/public-release-delivery.md.625scripts/verify-sigil-release.sh \626 --tag v0.1.0 \627 --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \628 --source-commit "$(git rev-parse HEAD)" \629 --public-key-file /absolute/path/to/sigil-minisign.pub630shasum -a 256 -c /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz.sha256631scp /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz tank@umpc:/tmp/632633ssh tank@umpc '634 set -eu635 incoming="$HOME/.local/share/sigil-spark/incoming"636 install -d -m 0700 "$incoming"637 run_dir="$(mktemp -d "$incoming/package.XXXXXX")"638 tar -tzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz639 tar -xzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz -C "$run_dir"640 cd "$run_dir/payload"641 sha256sum -c PACKAGE-SHA256SUMS642 bash -n stage-this-release.sh643 ./stage-this-release.sh644'645```646647The public-key signature must be checked **before extraction**; checksums inside648the archive prove consistency but cannot establish publisher authenticity.649Keep the extracted incoming directory until the deployment is accepted. The650installer locks concurrent operations, verifies the exact payload allowlist,651stages and validates the entire release, then changes `current` last. It records652the former release as `previous`; `sigil-spark-host-rollback` revalidates every653target file before swapping `current`/`previous`. Neither path creates identity654or `host.toml`, changes `/etc`, restarts PipeWire, or starts/enables the service.655Before changing any package-managed user asset, the installer preflights all of656them. It can adopt an operator-owned regular file only when it is byte-identical657to the new release asset; any local modification or unsafe target rejects the658whole asset migration without changing an earlier destination.659660For a temporary dirty and unsigned development package only, omit the release661tag and use both `--allow-dirty --allow-unsigned`. The manifest records that662state and the package prints `publisher_signature=absent-development`; never663publish that artifact. Externally built host/probe binaries are accepted only664as an all-or-none pair with both development flags, and their manifest records665`binary_provenance=caller-supplied-unverified`:666667```bash668scripts/package-bazzite-release.sh \669 --output /tmp/sigil-spark-host-prebuilt-dev.tar.gz \670 --allow-dirty --allow-unsigned \671 --host-binary /absolute/path/to/sigil \672 --probe-binary /absolute/path/to/sigil-probe673```674675## 8. Create the host identity and synthetic configuration676677Create the identity file explicitly with a restrictive umask, then print only678the public node ID:679680```bash681umask 077682identity="$HOME/.local/share/sigil-spark/identity/host.key"683if ! test -e "$identity"; then684 "$HOME/.local/libexec/sigil-spark/current/sigil" identity init \685 --output "$identity"686fi687test ! -L "$identity"688chmod 0600 "$identity"689stat -c '%a %U %G %n' "$identity"690"$HOME/.local/libexec/sigil-spark/current/sigil" identity show \691 --identity "$identity"692```693694`identity init` must use create-new semantics, reject symlinks and existing695files, and set restrictive permissions itself. Rerunning setup must never696silently rotate the host identity.697698For Steps 0–2, configure a 1280×800 synthetic H.264 source. The daemon's strict699TOML schema accepts these values:700701```bash702config="$HOME/.config/sigil-spark/host.toml"703ffmpeg_path="$(readlink -f "$(command -v ffmpeg)")"704test -x "$ffmpeg_path"705ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264706cat >"$config" <<EOF707identity_path = "$HOME/.local/share/sigil-spark/identity/host.key"708state_path = "$HOME/.local/state/sigil-spark/runtime"709source = "test-pattern"710width = 1280711height = 800712framerate = 60713codec = "h264"714input_mode = "log"715ffmpeg_path = "$ffmpeg_path"716EOF717chmod 0600 "$config"718stat -c '%a %U %G %n' "$config"719"$HOME/.local/libexec/sigil-spark/current/sigil" config check \720 --config "$config"721install -m 0600 "$config" \722 "$HOME/.local/libexec/sigil-spark/current/host.toml"723"$HOME/.local/libexec/sigil-spark/current/sigil" serve \724 --config "$config" --max-runtime-seconds 10725```726727The daemon must reject malformed configuration, unknown fields, and identity728files writable by group or other users.729730Use `input_mode = "log"` only for the bounded proof so receipt can be observed731without injecting anything. Return it to `disabled` before leaving the host732unattended.733734The identity path is host-specific. Generate a new identity on the Bazzite735appliance rather than copying the temporary identity from `slate`.736737## 9. Install the user service738739Create `~/.config/systemd/user/sigil-host.service`:740741```ini742[Unit]743Description=Sigil streaming host744Wants=pipewire.socket745After=pipewire.socket746ConditionPathExists=%h/.config/sigil-spark/host.toml747StartLimitIntervalSec=0748749[Service]750Type=simple751Environment=DISPLAY=:0752# Compatibility window: the service uses the byte-identical legacy filename753# so an older rollback helper can still validate and reactivate this release.754ExecStart=%h/.local/libexec/sigil-spark/current/sigil-host serve --config %h/.config/sigil-spark/host.toml755Restart=on-failure756RestartSec=1757TimeoutStopSec=10758KillSignal=SIGINT759UMask=0077760CPUQuota=400%761MemoryHigh=1536M762MemoryMax=2G763TasksMax=256764LimitNOFILE=8192765NoNewPrivileges=true766RestrictSUIDSGID=true767768[Install]769WantedBy=default.target770```771772Verify, load, and start it for the current proof session. Do not enable it yet:773774```bash775systemd-analyze --user verify "$HOME/.config/systemd/user/sigil-host.service"776systemctl --user daemon-reload777systemctl --user start sigil-host.service778systemctl --user status sigil-host.service --no-pager779journalctl --user -u sigil-host.service -n 100 --no-pager780```781782`serve` performs the same strict configuration and live capture/input preflight783before binding its network endpoint, so a second `ExecStartPre=config check`784would only duplicate work. The unlimited start interval and one-second delay785are deliberate: after a cold boot the user manager may be ready before udev786has replaced Bazzite's static `/dev/uinput` node permissions or before787Gamescope publishes its PipeWire node. The unit retries safely without ever788exposing a partially initialized host. Always run the manual `config check`789shown above before the first start and again immediately before cutover.790791Do not add `PrivateTmp`, `ProtectSystem`, or `ProtectHome` to this **user**792unit. On current Bazzite/systemd those mount-namespace directives run the user793service in an unprivileged user namespace where root-owned device nodes appear794owned by the overflow UID/GID. That defeats Sigil's exact `/dev/uinput`795owner/group validation and makes the service fail closed. `NoNewPrivileges`,796`RestrictSUIDSGID`, the resource limits, mode-0600 configuration and identity,797and the dedicated `sigil-uinput` group remain active. A future system-level798unit may reintroduce mount namespacing while running the process as `tank`.799800Do not enable user lingering initially. The Steam Gaming Mode login owns the801PipeWire session. Revisit lingering only if the final service must start before802the gaming account is logged in. Iroh must tolerate initial offline state and803network changes rather than depending on a user-manager `network-online.target`.804805## 10. Use `slate` until the Bazzite cutover806807`slate` is allowed to host only the pure Rust and synthetic-source portions of808Steps 0–2. The repository must provide a pinned `flake.nix` and `flake.lock`809before this proof begins. Nix may add garbage-collectable paths to the shared810Nix store, but Sigil files stay in run-specific directories under811`~/Developer/sigil-spark-slate-probe` and812`~/.local/state/sigil-spark-slate-probe`.813814Do not use `sudo`, change the firewall, change users or groups, install a unit,815or leave an unattended background process on `slate`.816817From the Mac:818819```bash820cd ~/Developer/sigil-spark821test -z "$(git status --porcelain)"822sigil_rev="$(git rev-parse HEAD)"823sigil_short="$(git rev-parse --short=12 HEAD)"824sigil_bundle="/tmp/sigil-spark-${sigil_short}.bundle"825git bundle create "$sigil_bundle" HEAD826shasum -a 256 "$sigil_bundle"827ssh tank@slate \828 'umask 077; install -d -m 0700 "$HOME/Developer/sigil-spark-slate-probe"'829scp "$sigil_bundle" tank@slate:Developer/sigil-spark-slate-probe/830ssh tank@slate \831 "git clone \832 \"\$HOME/Developer/sigil-spark-slate-probe/sigil-spark-${sigil_short}.bundle\" \833 \"\$HOME/Developer/sigil-spark-slate-probe/${sigil_short}\" && \834 git -C \"\$HOME/Developer/sigil-spark-slate-probe/${sigil_short}\" \835 checkout --detach ${sigil_rev}"836```837838Run tests with the repository's pinned Nix environment. Limit build parallelism839because this is a shared host:840841```bash842ssh -t tank@slate843sigil_short="<12-character-commit>"844run_id="${sigil_short}-$(date -u +%Y%m%dT%H%M%SZ)"845probe_src="$HOME/Developer/sigil-spark-slate-probe/$sigil_short"846probe_state="$HOME/.local/state/sigil-spark-slate-probe/$run_id"847umask 077848install -d -m 0700 "$probe_state"849cd "$probe_src"850export CARGO_BUILD_JOBS=4851nix develop --option max-jobs 4 --option cores 2 --command bash -lc \852 'rustc --version && cargo --version && \853 ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264 && \854 cargo fmt --all -- --check && \855 cargo test --locked -p sigil-protocol -p sigil-host && \856 cargo clippy --locked -p sigil-protocol -p sigil-host \857 --all-targets -- -D warnings'858nix develop --option max-jobs 4 --option cores 2 --command \859 cargo run --locked -p sigil-host -- capture probe \860 --source test-pattern --frames 600 --expect-size 1280x800861nix develop --option max-jobs 4 --option cores 2 --command \862 cargo run --locked -p sigil-host -- identity init \863 --output "$probe_state/host.key"864nix develop --option max-jobs 4 --option cores 2 --command \865 cargo run --locked -p sigil-host -- identity show \866 --identity "$probe_state/host.key"867stat -c 'identity_mode=%a' "$probe_state/host.key"868ulimit -n 8192869nix develop --option max-jobs 4 --option cores 2 --command \870 systemd-run --user --scope --collect --quiet \871 -p CPUQuota=400% \872 -p MemoryHigh=1536M \873 -p MemoryMax=2G \874 -p TasksMax=256 \875 timeout --signal=INT --kill-after=10s 2h \876 cargo run --locked -p sigil-host -- serve \877 --identity "$probe_state/host.key" \878 --state-path "$probe_state/runtime" \879 --source test-pattern880```881882Keep this SSH terminal open. `tank` does not have lingering enabled, so the883foreground scope is intentionally tied to the proof session. Do not open a884fixed UDP port or change Slate's firewall; Iroh relay fallback is part of this885stand-in test.886887On the Mac, connect with the node ID printed by `slate`:888889```bash890cd ~/Developer/sigil-spark891source "$HOME/.cargo/env"892cargo run -p portal -- --dev-connect <slate-node-id>893```894895Before opening the UI, prove the same v1 media and input session headlessly:896897```bash898source "$HOME/.cargo/env"899cargo run --locked -p sigil-host --bin sigil-probe -- \900 --node-id <slate-node-id> --frames 300 --timeout-seconds 15901```902903The probe fails unless its first media frame is an IDR with SPS/PPS, it receives904`dimensions=1280x800`, and `sequence_gaps=0`. Its default input event is a905content-free liveness probe that requires a bounded host acknowledgment on the906independent input stream; it does not move the pointer. Record907`input_ack_micros`; the Slate host logs only the event type because proof-mode908input is log-only.909910For the one-client and reconnect gates, keep the Slate foreground host running911and use the already-built probe binary from a second Mac terminal:912913```bash914node_id="<slate-node-id>"915cargo build --locked -p sigil-host --bin sigil-probe916target/debug/sigil-probe --node-id "$node_id" --frames 3600 \917 > /tmp/sigil-primary-probe.log &918primary_pid=$!919sleep 2920if target/debug/sigil-probe --node-id "$node_id" --frames 1; then921 echo "Second client was accepted unexpectedly." >&2922 kill "$primary_pid"923 exit 1924fi925wait "$primary_pid"926grep -E 'probe=ok|frames=3600|sequence_gaps=0' \927 /tmp/sigil-primary-probe.log928929for cycle in $(seq 1 100); do930 target/debug/sigil-probe --node-id "$node_id" --frames 1 \931 > /dev/null || {932 echo "Reconnect failed at cycle $cycle." >&2933 exit 1934 }935done936echo "reconnect_cycles=100"937```938939The second probe must fail with `host already has an active client`; the940primary must still finish with zero sequence gaps. Remove the temporary log941after saving any evidence needed for the demo.942943The 2026-07-20/21 WIP proof completed 600 native Slate-to-Mac frames in 9.61944seconds with 10 keyframes, zero sequence gaps, a direct path, 7.04 ms RTT, and945a 7.81 ms input acknowledgment. A concurrent second client was rejected with946the exact one-active-client error. On 2026-07-21 the final release-profile947binaries completed 100/100 fresh media/input reconnects with deterministic948cleanup after a 600-frame zero-gap primary session. The final raw Tauri channel949then rendered more than 7,900 frames at 59.9 fps. During a clean950one-minute soak its counters remained fixed after startup: transport 0,951frontend 56 while joining mid-GOP, and decoder 2 while configuring WebCodecs.952The handoff is capped at four frames (about 67 ms at 60 fps), WebCodecs at two,953and new diagnostics report the three drop sources independently. Its `DEV954DIRECT-NODE · NOT AUTH` warning remained visible above the stream. The host955waits for a keyframe at session start and after a detected discontinuity, so a956newly connected decoder is never handed a delta frame as its first access957unit. Re-run these gates for the exact source snapshot used in a demo; do not958treat the WIP path as a release.959960Before cutover, the `slate` proof must cover:961962- Golden protocol vectors on macOS arm64 and Linux x86_64.963- Synthetic H.264 reception and WebCodecs decode.964- Independently acknowledged input transport, even though injection is stubbed965 on the stand-in.966- Second-client rejection.967- Clean shutdown and 100 connect/disconnect cycles.968- No unbounded queue or increasing process RSS.969970Do not run Gamescope, change the display manager, configure `uinput`, or install971a persistent Sigil service on `slate`.972973At cutover, confirm there is no running probe before removing only the exact974run-specific paths:975976```bash977pgrep -a sigil && {978 echo "Stop the foreground Sigil probe before cleanup." >&2979 exit 1980}981probe_src="$HOME/Developer/sigil-spark-slate-probe/<12-character-commit>"982probe_state="$HOME/.local/state/sigil-spark-slate-probe/<run-id>"983case "$probe_src" in984 "$HOME/Developer/sigil-spark-slate-probe/"?*) ;;985 *) exit 1 ;;986esac987case "$probe_state" in988 "$HOME/.local/state/sigil-spark-slate-probe/"?*) ;;989 *) exit 1 ;;990esac991test "$probe_src" != "$HOME/Developer/sigil-spark-slate-probe"992test "$probe_state" != "$HOME/.local/state/sigil-spark-slate-probe"993printf 'removing source=%q\nremoving state=%q\n' "$probe_src" "$probe_state"994rm -rf -- "$probe_src" "$probe_state"995```996997Review the two expanded paths before running the final command. Nix store paths998are left to the server's normal garbage-collection policy.9991000## 11. Validate the Bazzite Gamescope boundary10011002First validate the stock Bazzite Steam Gaming Mode session while a display is1003attached. From SSH:10041005```bash1006systemctl --user status pipewire.service pipewire.socket wireplumber.service --no-pager1007pgrep -a gamescope1008pgrep -a steam1009pw-cli ls Node1010pw-dump | jq '[1011 .[]1012 | select(.type == "PipeWire:Interface:Node")1013 | .info.props1014 | select(1015 .["node.name"] == "gamescope"1016 and .["media.class"] == "Video/Source"1017 )1018]'1019journalctl --user -b --no-pager | grep -Ei 'gamescope|pipewire.*node'1020```10211022Gamescope should publish a PipeWire stream with both `node.name=gamescope` and1023`media.class=Video/Source`. Select by those properties, not by numeric node ID,1024because node IDs change across boots. Inventory the exact executable paths,1025render-node candidates, and dynamically registered GstVA H.264 factories:10261027```bash1028pw_dump_path="$(readlink -f "$(command -v pw-dump)")"1029gst_launch_path="$(readlink -f "$(command -v gst-launch-1.0)")"1030gst_inspect_path="$(readlink -f "$(command -v gst-inspect-1.0)")"1031ffmpeg_path="$(readlink -f "$(command -v ffmpeg)")"1032test -x "$pw_dump_path"1033test -x "$gst_launch_path"1034test -x "$gst_inspect_path"1035test -x "$ffmpeg_path"10361037for node in /sys/class/drm/renderD*; do1038 test -e "$node/device/driver" || continue1039 printf 'candidate=%s driver=%s pci_device=%s\n' \1040 "/dev/dri/$(basename "$node")" \1041 "$(basename "$(readlink -f "$node/device/driver")")" \1042 "$(basename "$(readlink -f "$node/device")")"1043done1044render_node="<exact encode-capable render node chosen from the inventory>"1045test -n "$render_node"1046test -r "$render_node" && test -w "$render_node"10471048gst-inspect-1.0 | awk '1049 $2 ~ /^va(renderD[0-9]+)?h264(lp)?enc:$/ {1050 sub(/:$/, "", $2)1051 print $21052 }1053' | while read -r encoder; do1054 printf '\n### %s\n' "$encoder"1055 gst-inspect-1.0 "$encoder" | sed -n \1056 -e '/^[[:space:]]*device-path[[:space:]]*:/,+4p' \1057 -e '/^[[:space:]]*rate-control[[:space:]]*:/,+20p'1058done10591060for element in appsink pipewiresrc queue videoconvert videoscale videorate h264parse fdsink; do1061 gst-inspect-1.0 "$element" >/dev/null1062done10631064rpm -q gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-bad-free1065```10661067Record the exact runtime package NEVRAs. `appsink` is mandatory for a binary1068built with the in-process feature, and the package installer must resolve its1069linked GStreamer core/app/video libraries with `ldd`. `pipewiresrc`,1070conversion/parser elements, and the chosen GstVA plugin remain required for1071either video backend. The external `gst-launch` path is also still used for1072audio capture when audio is enabled.10731074### Grant the gaming user only the uinput capability10751076Do not add the gaming user to the broad `input` group and do not run the host1077as root. Create a dedicated group that can open only the kernel's uinput misc1078device:10791080```bash1081getent group sigil-uinput >/dev/null || sudo groupadd --system sigil-uinput1082sudo usermod --append --groups sigil-uinput "$USER"1083sudo tee /etc/udev/rules.d/72-sigil-uinput.rules >/dev/null <<'EOF'1084KERNEL=="uinput", SUBSYSTEM=="misc", TAG-="uaccess"1085EOF1086sudo install -o root -g root -m 0644 \1087 scripts/70-sigil-remote-input.rules \1088 /etc/udev/rules.d/70-sigil-remote-input.rules1089sudo tee /etc/udev/rules.d/99-sigil-uinput.rules >/dev/null <<'EOF'1090KERNEL=="uinput", SUBSYSTEM=="misc", OWNER="root", GROUP="sigil-uinput", MODE="0660", TAG-="uaccess", RUN+="/usr/bin/setfacl --remove-all $env{DEVNAME}"1091EOF1092sudo modprobe uinput1093sudo udevadm control --reload-rules1094sudo udevadm trigger --action=add --subsystem-match=misc --sysname-match=uinput1095```10961097Reboot or fully sign out and back in before continuing so the service receives1098the new supplementary group. Then prove the exact device identity and access:10991100```bash1101uinput_gid="$(getent group sigil-uinput | cut -d: -f3)"1102test -n "$uinput_gid"1103id -G | tr ' ' '\n' | grep -Fx "$uinput_gid"1104test ! -L /dev/uinput1105test -c /dev/uinput1106test -r /dev/uinput && test -w /dev/uinput1107test "$(stat -Lc '%u' /dev/uinput)" -eq 01108test "$(stat -Lc '%g' /dev/uinput)" -eq "$uinput_gid"1109test "$(stat -Lc '%a' /dev/uinput)" = 6601110test "$(stat -Lc '%t:%T' /dev/uinput)" = a:df1111getfacl -cp /dev/uinput1112```11131114The last hexadecimal device pair is Linux misc major 10, uinput minor 223.1115The early rule removes `uaccess` before the seat ACL pass. Current Bazzite also1116ships Sunshine and early-uinput rules that may already have queued the uaccess1117builtin, so the final bounded `setfacl` action removes any materialized ACL1118after permissions settle. The exact kernel/subsystem match and fixed executable1119path keep that action scoped only to `/dev/uinput`.1120The separate remote-input rule runs before Bazzite's seat pass. It gives the1121active Gamescope session access to Sigil's disjoint keyboard and relative-mouse1122event nodes and assigns the same single-purpose group. Without it, correct1123`BTN_LEFT` and `REL_X/Y` events can appear in `evtest` while Gamescope silently1124ignores an inaccessible hot-plugged device.1125The daemon independently opens the configured path three times with1126`O_NOFOLLOW`, verifies that character-device identity and exact1127owner/group/mode, and—under this default Bazzite configuration—requires the1128absence of an extended access ACL on every descriptor. The portable activation1129guide documents the separate, explicit `expected_acl_user_uid` contract for a1130distribution that intentionally owns a one-user seat ACL; it is not enabled by1131the shipped rules. Sigil then creates separate pointer, keyboard, and gamepad1132devices before it binds Iroh. Any change fails closed. Access to1133uinput is equivalent to local keyboard, pointer, and gamepad control, so keep1134this dedicated group single-purpose. Do not add1135`PrivateDevices=true` to the user unit because that would deliberately hide the1136validated device.11371138Set `vaapi_encoder` to one factory whose programmatically queried read-only1139`device-path` exactly equals `render_node`. This is deliberate: GstVA selects a1140DRM device by factory and exposes `device-path` as read-only, so Sigil verifies1141the live element's reported device before starting capture. Set1142`rate_control = "cbr"` only when1143that factory advertises CBR; otherwise select a low-power factory that1144advertises CQP and use `rate_control = "cqp"`. Do not silently substitute a1145different factory, render node, or software encoder.11461147Create a separate probe configuration without changing the running synthetic1148service configuration. The CBR example is:11491150```bash1151vaapi_encoder="<factory verified above>"1152test -n "$vaapi_encoder"1153uinput_gid="$(getent group sigil-uinput | cut -d: -f3)"1154test -n "$uinput_gid"1155probe_config="$HOME/.config/sigil-spark/host-gamescope-probe.toml"1156cat >"$probe_config" <<EOF1157identity_path = "$HOME/.local/share/sigil-spark/identity/host.key"1158state_path = "$HOME/.local/state/sigil-spark/runtime"1159source = "gamescope-pipewire"1160framerate = 601161codec = "h264"1162input_mode = "uinput"1163ffmpeg_path = "$ffmpeg_path"11641165[uinput]1166device_path = "/dev/uinput"1167expected_owner_uid = 01168expected_group_gid = $uinput_gid1169expected_mode = 0o66011701171[gamescope_pipewire]1172node_name = "gamescope"1173media_class = "Video/Source"1174xwayland_display = ":0"1175pw_dump_path = "$pw_dump_path"1176gst_launch_path = "$gst_launch_path"1177gst_inspect_path = "$gst_inspect_path"1178encoder_backend = "external-gst-launch"1179vaapi_encoder = "$vaapi_encoder"1180vaapi_render_node = "$render_node"1181rate_control = "cbr"1182bitrate_kbps = 120001183EOF1184chmod 0600 "$probe_config"1185```11861187With `width` and `height` absent, Sigil uses the single bounded native size1188advertised by the selected Gamescope node. Set both fields only to request an1189explicit same-aspect encoded downscale; partial or distorting overrides are1190rejected. `framerate` is a maximum encoder cadence because current Gamescope1191PipeWire caps advertise `0/1` rather than a trustworthy native rate.11921193Omitting `encoder_backend` has the same external default. After the external1194baseline passes, a locally built Linux binary containing the feature can use:11951196```toml1197encoder_backend = "in-process-gstreamer"1198```11991200That selection is not an availability hint: a binary without the feature, a1201missing `appsink`, or an unavailable encoder factory makes preflight fail. The1202foundation currently requires CBR, and a bitrate property that is not mutable1203while PLAYING also fails the control preflight. Runtime bitrate control is1204acknowledged only after exact property readback. CQP remains on the external1205compatibility backend until its in-process force-keyframe path is implemented1206and proven separately.12071208The configured `:0` is the bootstrap connection. Gamescope builds that publish1209`GAMESCOPE_MOUSE_FOCUS_DISPLAY` may move mouse focus between their `:0` and1210`:1` Xwayland servers; Sigil follows that property and reconnects to the active1211local display. Upstream SteamOS and older Gamescope builds need not ship this1212extension. Sigil detects that absence without creating the atom, logs the1213degraded discovery mode, and safely samples only the explicitly configured1214display instead of disabling pointer feedback.12151216Sigil samples `QueryPointer` at no more than 60 Hz. If the active Xwayland root1217has a different aspect ratio than the native captured surface, Sigil maps it1218into a centered aspect-fit region (letterbox or pillarbox) so coordinates stay1219in the compositor's native pointer space without independent-axis stretching.1220Zero-sized, unrepresentable, off-root, or cross-screen geometry still fails1221closed.12221223Gamescope builds that publish `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` provide the1224compositor's visibility sample. Authoritative pointer motion deliberately keeps1225the client cursor visible for a bounded three seconds even if that sample is1226temporarily false, recovering Gamescope's virtual cursor after remote input.1227When the optional extension is absent or malformed, Sigil logs the fallback and1228uses that same bounded motion window without inventing longer-lived visibility.1229Missing or unreachable Xwayland state during startup still disables the1230separately negotiated pointer feedback capability. After successful startup,1231losing Gamescope publishes pointer feedback as unavailable, then reconnects the1232bootstrap display with a bounded 100 ms to 2 second backoff. A complete1233reconnect re-detects both optional Gamescope properties, validates one1234active-display sample, and only then publishes coordinates again. Relative1235uinput remains available without guessed cursor coordinates. The service1236`DISPLAY=:0` line is retained as an explicit fallback for configurations1237created before `xwayland_display` was added.12381239Audio is optional and must resolve one exact PipeWire sink, never a microphone.1240The appliance owns a persistent 48 kHz stereo null sink so capture does not1241depend on HDMI, speakers, or a physical sound card. Install the repository's1242drop-in as the gaming user, restart only the PulseAudio-compatible PipeWire1243service, and make the new sink the default target:12441245```bash1246audio_dropin_dir="$HOME/.config/pipewire/pipewire-pulse.conf.d"1247install -d -m 0700 "$audio_dropin_dir"1248install -m 0600 scripts/50-sigil-spark-audio.conf \1249 "$audio_dropin_dir/50-sigil-spark-audio.conf"1250systemctl --user restart pipewire-pulse.service12511252audio_sink_id="$(wpctl status -n | awk '1253 $0 ~ /sigil_spark/ {1254 for (field = 1; field <= NF; field++) {1255 if ($field ~ /^[0-9]+\.$/) {1256 gsub("\\.", "", $field)1257 print $field1258 exit1259 }1260 }1261 }1262')"1263test "$audio_sink_id" -gt 01264wpctl set-default "$audio_sink_id"1265pactl get-default-sink | grep -qx sigil_spark1266```12671268Restarting `pipewire-pulse.service` disconnects existing playback streams. Do1269this during provisioning before launching Steam or a game; new streams then1270route to `sigil_spark`. The sink deliberately has no physical playback leg.1271Remote audio continues while the machine has no display or speakers, and local1272speaker mirroring remains a separate opt-in policy rather than part of the1273capture trust boundary.12741275The drop-in makes only this virtual sink an always-processing graph driver and1276disables its idle suspension. Both are required because the host captures its1277monitor continuously: an idle null sink otherwise emits no silent clocked1278buffers and leaves a newly connected client stuck in audio priming until a1279game happens to make sound.12801281Verify that exactly one persistent sink has the expected stable `node.name`;1282do not copy the changing numeric ID or `object.serial` into host configuration:12831284```bash1285pw-dump | jq -r '1286 .[]1287 | select(.type == "PipeWire:Interface:Node")1288 | .info.props1289 | select(."media.class" == "Audio/Sink")1290 | [."object.serial", ."node.name", ."node.description"]1291 | @tsv1292'1293test "$(pw-dump | jq '[1294 .[]1295 | select(.type == "PipeWire:Interface:Node")1296 | .info.props1297 | select(."media.class" == "Audio/Sink" and ."node.name" == "sigil_spark")1298] | length')" -eq 11299audio_sink_name="sigil_spark"1300cat >>"$probe_config" <<EOF13011302[audio]1303node_name = "$audio_sink_name"1304media_class = "Audio/Sink"1305bitrate_bps = 960001306EOF1307chmod 0600 "$probe_config"1308```13091310This monitor capture is fixed at 48 kHz stereo, 20 ms frames, and 96 kbit/s1311restricted-low-delay Opus. It uses a two-packet host queue, QUIC datagrams, a1312three-packet client reorder window, a three-message binary Tauri channel, and a131360 ms AudioWorklet ring; overflow drops old audio instead of increasing1314latency. Audio negotiation and failure are independent from video and input.1315Only audio intentionally routed to the appliance sink is captured. This keeps1316desktop notifications or other audio routed to a different device outside the1317stream, while Steam and games inherit `sigil_spark` from the default selected1318during provisioning.13191320This backend implements a conventional relative mouse with `REL_X`, `REL_Y`,1321three mouse buttons, vertical/horizontal wheel events, and no `ABS_X/Y` axes.1322The client coalesces displacement into one bounded slot rather than dropping1323stale samples, and the host negotiates `relative_pointer` independently from1324keyboard and gamepad control. A third device named `Sigil Spark Virtual1325Gamepad` exposes one1326Xbox-style controller: ABXY, shoulders, back/start/guide, stick clicks, d-pad,1327two sticks, and two analog triggers. Stick values are normalized signed1328integers in `-32767..=32767`, triggers are unsigned integers in1329`0..=32767`, and d-pad axes are `-1..=1`. Each protocol message is a complete1330snapshot and is emitted as one statically bounded uinput report, so stale axis1331or button updates cannot grow an independent queue. The host advertises the1332separate `gamepad` input capability and rejects gamepad snapshots unless that1333capability was negotiated.13341335It releases every held keyboard/mouse transition and sends a fully neutral1336gamepad snapshot when an input session ends. Portal sends a bounded subset of1337W3C `KeyboardEvent.code` physical-position tokens for keyboard input. This1338keeps game and Steam bindings independent of the client's active keyboard1339layout. The current uinput backend advertises keyboard input but does not1340advertise Text; Unicode text and IME composition are therefore unsupported.1341For protocol compatibility, any legacy Text event received by Sigil remains a1342content-free no-op and is acknowledged when ACKs were negotiated. No key,1343text, or gamepad payload is logged. The Linux button/axis assignments follow1344the kernel's1345[gamepad protocol](https://docs.kernel.org/input/gamepad.html) and Xbox `xpad`1346layout.13471348For a factory that advertises only CQP, replace the last two settings with:13491350```toml1351rate_control = "cqp"1352quantizer = 241353```13541355Keep `encoder_backend = "external-gst-launch"` for CQP. The current1356in-process foundation rejects CQP rather than implying runtime control that it1357does not implement.13581359If `pw-dump` exposes an additional stable GPU identity on the Gamescope node,1360add it as an exact match; do not add a changing global ID, node ID, client ID,1361or `object.serial`:13621363```toml1364[gamescope_pipewire.match_properties]1365"device.bus-path" = "<exact stable value observed in pw-dump>"1366```13671368Exact properties and fail-on-ambiguity prevent accidental selection, but they1369do not authenticate a visual source against another process running as the1370same gaming user: a same-UID process can publish spoofed PipeWire properties1371and can already access this user's Sigil configuration. Treat the whole gaming1372UID/session as one trust boundary for this demo. Stronger isolation requires a1373separate service identity plus Node-to-Client PID correlation and verification1374of `/proc/<pid>/exe` and the Gamescope cgroup.13751376`config check` performs a live, bounded preflight. It requires exactly one1377matching PipeWire node, checks every configured executable and required1378GStreamer element, opens the configured render node read/write, proves its1379exact GstVA factory is bound to that node, and verifies all low-latency1380properties used by the pipeline. When audio is configured, it also prints the1381exact resolved sink target:13821383```bash1384"$HOME/.local/libexec/sigil-spark/current/sigil" config check \1385 --config "$probe_config"1386# Expected with audio enabled:1387# audio_pipewire_target_object=<current numeric target>1388# audio_capture_preflight=ok1389```13901391Node enumeration and preflight are not enough. Consume 300 frames and verify a1392decodable H.264 keyframe, the resolved output size, and a changing sequence:13931394```bash1395"$HOME/.local/libexec/sigil-spark/current/sigil" capture probe \1396 --source gamescope-pipewire \1397 --config "$probe_config" \1398 --frames 300 \1399 --minimum-fps 551400```14011402The Gamescope probe acquires the same per-user global lifecycle lock as the1403daemon and holds it through encoder teardown. Stop `sigil-host.service` before1404running it; a second configured Sigil daemon using the same runtime root or1405another capture probe fails before opening PipeWire. The lock cannot exclude1406arbitrary third-party consumers, so inventory non-Sigil PipeWire consumers1407before a benchmark.1408Gamescope may divide vblank delivery across simultaneous consumers, making two1409otherwise healthy 60 fps pipelines appear as roughly 30 fps each. A benchmark1410with an existing Gamescope consumer is invalid, not evidence that CQP or the1411transport is intrinsically half-rate.14121413The probe prints `resolved_encoded_mode=<width>x<height>@<cap>` before capture1414and the dimensions observed on encoded frames afterward. Repeat with1415`--expect-size <width>x<height>` when recording a strict benchmark. The1416pipeline explicitly negotiates and converts its encoder input to NV12 at that1417resolved size and configured cadence ceiling. The capture pipeline bounds the1418PipeWire source pool between one and four buffers and then uses an explicit1419one-buffer, leaky-downstream queue; encoder-internal buffering is separate and1420must be measured on the installed GstVA version. Gamescope's stream is1421damage-driven and may emit no buffers while the image is static. The versioned1422host/client path therefore keeps media and input connected through arbitrary1423frame silence; process exit, stream closure, malformed media, and write failure1424remain terminal. H.264 uses no B-frames, and SPS/PPS are repeated with each1425IDR. A capture or hardware-encoder failure is an error; there is no automatic1426synthetic or software fallback.14271428The probe reports sustained encoded FPS, frames dropped after encode before the1429probe consumer, and maximum post-encode queue age. The current GStreamer stdout1430bridge does not preserve PipeWire capture PTS, so these values do not prove raw1431capture age, pre-encode drops, or glass-to-glass latency. Preserve GStreamer1432buffer metadata through an in-process appsink before making those stronger1433claims; until then, correlate the probe with Gamescope/PipeWire statistics and1434the external sampling in the evidence section.14351436### Validate motion-sensitive resolution14371438This gate applies only to a Linux build with `in-process-gstreamer` and a host1439configuration whose `encoder_backend` is `in-process-gstreamer`. The external1440`gst-launch` compatibility backend intentionally remains fixed-resolution.14411442Connect Portal, enable host debug logs, and repeat at least ten cycles of a1443high-motion 3D scene followed by a static high-frequency test card. For each1444cycle retain the host journal and Portal console. When the resolved native1445dimensions admit an exact even three-quarter tier, the host decision record1446must show that reduced motion/pressure target followed by the resolved native1447recovery target only after at least two seconds of stillness, three fresh clean1448feedback windows, and the three-second transition cooldown. A stale feedback1449window must never restore native resolution. A valid native mode without such1450a tier must remain streamable with motion adaptation visibly disabled.14511452At each boundary verify that the first delivered target-size frame is a1453keyframe with SPS/PPS and a discontinuity, no preceding-resolution delta is1454presented afterward, and Portal stays connected without a WebCodecs error.1455Absolute pointer tests at the canvas center and four corners must continue to1456land on the native Gamescope surface at both encoded resolutions. The Portal1457window must not resize merely because the native and reduced modes have the1458same aspect ratio.14591460Record transition request-to-configured-IDR time and Portal1461configured-IDR-to-first-presentation time separately. These are recovery1462measurements, not glass-to-glass latency. Require ten of ten successful1463downscale/restore cycles, recovery p95 no greater than 900 ms and no individual1464recovery greater than two seconds, video/decode queue p95 no greater than two,1465and no sustained latency growth. Measure true glass-to-glass latency with a1466paired high-speed-camera run comparing fixed-native and adaptive sessions under1467the same controlled bandwidth pressure; do not substitute post-encode queue1468age for that result.14691470Then perform the actual headless gate:147114721. Shut the host down.14732. Disconnect the display; do not use a dummy plug for this test.14743. Cold boot the machine.14754. Make the first SSH connection of that boot.14765. Verify Steam, Gamescope, PipeWire, and the Gamescope stream node.14776. Repeat `config check` and the 300-frame capture probe with `probe_config`.14787. Save the boot and user-session journals.14791480On the first SSH connection, distinguish Gaming Mode auto-login from an SSH1481session that merely started the user manager:14821483```bash1484loginctl list-sessions1485sudo journalctl -b -o short-monotonic --no-pager | \1486 grep -Ei 'gamescope|steam|sshd.*accepted|session.*sigil'1487set -o pipefail1488./scripts/bazzite-inventory.sh --cold-boot | \1489 tee "$HOME/bazzite-cold-boot.txt"1490```14911492The Gamescope session must predate the first accepted SSH login.1493`--cold-boot` is read-only and makes the gate machine-checkable. It exits zero1494only when no DRM connector is connected, an active local gaming-user autologin1495session and the required PipeWire nodes are active, the Sigil service is1496enabled and ready, and Gamescope plus Sigil both predate the first accepted1497SSH login. The gaming1498session check is display-manager-independent: it requires an active, local1499logind session for the gaming user whose exact PAM service name ends in1500`-autologin` (for example, `sddm-autologin` or `gdm-autologin`). A manual1501desktop login, SSH session, or merely started user manager does not satisfy the1502gate. Exit status `1` means observed evidence failed a gate. Exit status `3`1503means the boot journal is unavailable, starts more than five minutes after1504boot, or otherwise cannot establish the first SSH ordering; do not treat that1505as a pass. Run it immediately on the first SSH login so journal rotation1506cannot erase the proof.15071508If the stock session does not start or does not publish a node without a1509physical connector, stop at this gate. This is an expected discovery risk on1510some AMD/KMS combinations, not permission to claim a headless pass. Record the1511installed Gamescope version, its `--help` output, connector state, and DRM logs1512before designing a custom virtual-output/headless-backend unit. Keep a local1513console recovery path. Do not introduce an XDG ScreenCast portal as a1514workaround.15151516Valve documents Gamescope's embedded session model and AMD/Mesa support in the1517[Gamescope repository](https://github.com/ValveSoftware/gamescope). Gamescope1518logs should identify its PipeWire node when the stream becomes available.15191520## 12. Cut over from synthetic video to Gamescope15211522Only after the headless gate passes:152315241. Stop `sigil-host.service`.15252. Re-run the attached and headless `config check` and capture probes against1526 the exact `host-gamescope-probe.toml` that will be installed.15273. Install that validated file as `host.toml`; do not reconstruct or partially1528 merge its strict node, encoder-factory, and render-node selection.15294. Leave width and height absent for native resolution; retain frame rate 60 as1530 the explicit encoder ceiling unless a different measured cap is under test.15315. Start the service and confirm the capture queue is bounded to one frame.15326. Connect from the Mac with the Bazzite host's new development node ID.15337. Enable the service only after the attached and headless capture probes pass.15348. Cold boot headlessly again and prove the unit started before the first SSH1535 login.15361537```bash1538systemctl --user stop sigil-host.service1539install -m 0600 \1540 "$HOME/.config/sigil-spark/host-gamescope-probe.toml" \1541 "$HOME/.config/sigil-spark/host.toml"1542"$HOME/.local/libexec/sigil-spark/current/sigil" config check \1543 --config "$HOME/.config/sigil-spark/host.toml"1544systemctl --user start sigil-host.service1545systemctl --user status sigil-host.service --no-pager1546pointer_sysfs="$(grep -lFx 'Sigil Spark Virtual Pointer' /sys/class/input/event*/device/name)"1547test -n "$pointer_sysfs"1548pointer_node="/dev/input/$(basename "$(dirname "$(dirname "$pointer_sysfs")")")"1549udevadm info "$pointer_node" | grep -F 'ID_INPUT_MOUSE=1'1550sudo libinput list-devices | sed -n \1551 '/Device:[[:space:]]*Sigil Spark Virtual Pointer/,/^$/p'1552sudo libinput debug-events --device "$pointer_node"1553keyboard_sysfs="$(grep -lFx 'Sigil Spark Virtual Keyboard' /sys/class/input/event*/device/name)"1554test -n "$keyboard_sysfs"1555keyboard_node="/dev/input/$(basename "$(dirname "$(dirname "$keyboard_sysfs")")")"1556udevadm info "$keyboard_node" | grep -F 'ID_INPUT_KEYBOARD=1'1557gamepad_sysfs="$(grep -lFx 'Sigil Spark Virtual Gamepad' /sys/class/input/event*/device/name)"1558test -n "$gamepad_sysfs"1559gamepad_node="/dev/input/$(basename "$(dirname "$(dirname "$gamepad_sysfs")")")"1560udevadm info "$gamepad_node" | grep -F 'ID_INPUT_JOYSTICK=1'1561systemctl --user enable sigil-host.service1562sudo systemctl reboot1563```15641565The pointer and keyboard devices must each be classified only for their own1566input role, and the separate gamepad device as a joystick, before the client1567control toggle is tested. `libinput debug-events` must report1568`POINTER_MOTION` and `POINTER_BUTTON`, never `POINTER_MOTION_ABSOLUTE`. For a1569downstream proof, display an isolated Xwayland target in Gamescope and activate1570it remotely:15711572```bash1573DISPLAY=:0 xmessage -center -buttons pass:0 'Sigil pointer probe'1574```15751576The command must exit with status 0 after the remote click; `evtest` alone only1577proves kernel delivery. During the live demo, prove motion to all four frame1578edges, each mouse button, both wheel axes, a modifier chord, ABXY, both1579shoulders, back/start/guide, both stick clicks, every d-pad direction, extrema1580and center on both sticks, and released/full values on both triggers. End with1581a held keyboard key, held gamepad button, displaced stick, and pressed trigger,1582then disconnect the client; `evtest` on all three event nodes must show the1583pointer button and key released and every gamepad button/axis returned to1584neutral.15851586For a deterministic protocol-to-uinput pointer smoke, leave both non-grabbing1587observers running and invoke the probe with `--pointer-smoke`:15881589```bash1590sudo evtest "$pointer_node"1591sudo libinput debug-events --device "$pointer_node"15921593# From the client checkout:1594target/debug/sigil-probe \1595 --node-id <host-node-id> \1596 --frames 120 \1597 --pointer-smoke \1598 --pointer-feedback-smoke1599```16001601The probe must report `pointer_smoke=ok` and `pointer_feedback_smoke=ok`.1602The feedback check requires an immediately available compositor position and1603visibility sample, then polls bounded input acknowledgments until the existing1604Xwayland tracker reports the exact post-sync and post-motion coordinates. An1605unavailable position, a timeout, or any coordinate drift fails the proof. This1606asserts the appliance invariant that Gamescope clamps the first relative jump1607at the surface origin and applies neither acceleration nor scaling to the1608virtual pointer. A generic libinput desktop profile is not assumed to satisfy1609that invariant.16101611The pointer smoke uses the native pointer-surface dimensions negotiated in the1612media `HostHello`, not the potentially downscaled encoded dimensions. On a16132560x1600 Gamescope surface, it must report1614`pointer_sync_position=1280,800` and1615`pointer_motion_position=1312,816`. Before the ordinary motion, evtest must1616show the synchronization sequence:1617`REL_X=-32767`/`REL_Y=-32767`, a `SYN_REPORT`, then1618`REL_X=1280`/`REL_Y=800` and another `SYN_REPORT`. It must then show1619`REL_X=32`, `REL_Y=16`, and a complete `BTN_LEFT` press/release, while libinput1620reports relative `POINTER_MOTION` and `POINTER_BUTTON` events. The probe fails1621closed if the host omits the native pointer-surface dimensions. The interactive1622`xmessage` activation remains the downstream Gamescope/Xwayland proof.16231624For compositor replacement recovery, keep the candidate Sigil daemon running,1625record its PID, and restart `gamescope-session-plus@steam.service` twice. After1626each restart, wait for the `gamescope` PipeWire node and the bootstrap root1627property to return, then rerun the pointer and feedback smoke without replaying1628the redeemed invitation. Both runs must pass, the Sigil PID must remain1629unchanged, and the host log must contain one `Gamescope Xwayland pointer1630feedback reconnected` event per restart. The packaged hardware UAT performs1631this sequence after its fixed-mode session checks. That runner independently1632enumerates accessible DRM render nodes and matches the exact GstVA H.2641633factory by its programmatically queried `device-path` and required CBR/CQP1634properties; it1635never assumes a kernel driver, `renderD128`, or the generic `vah264enc` factory.1636If the panel's resolved native1637mode is the same 1280x800 mode as the fixed performance leg, the native-config1638leg still runs and records that the two pixel sizes are identical instead of1639rejecting the host.16401641For a deterministic pre-demo gamepad proof, leave `evtest "$gamepad_node"`1642running on the host and run this from the client checkout:16431644```bash1645source ~/.cargo/env1646cargo build --locked -p sigil-host --bin sigil-probe1647target/debug/sigil-probe \1648 --node-id <host-node-id> \1649 --frames 120 \1650 --gamepad-smoke1651```16521653The probe must report `gamepad_smoke=ok`. `evtest` must show `BTN_SOUTH`,1654`BTN_TR`, `ABS_X`, `ABS_RY`, both trigger axes, and `ABS_HAT0X` reach their1655non-neutral values, followed by an explicit release/zero for every one of1656them. This proves protocol negotiation, uinput mapping, and neutralization;1657it does not replace the physical-controller client test.16581659On the first SSH login after reboot:16601661```bash1662systemctl --user status sigil-host.service --no-pager1663sudo journalctl -b -o short-monotonic --no-pager | \1664 grep -Ei 'sigil|sshd.*accepted|gamescope|steam'1665```16661667The Sigil unit start must predate the first accepted SSH login; otherwise SSH1668may have started the user manager and produced a false-positive boot test.16691670The synthetic source remains a diagnostic mode, not an automatic production1671fallback. A Gamescope capture failure must be surfaced as an error.16721673## 13. Evidence to save for every host image16741675Create one mode `0700` evidence directory per Bazzite deployment and test date.1676Sanitize journals before exporting them. Save:16771678- Bazzite image and OSTree deployment checksum.1679- Kernel, Mesa, Gamescope, PipeWire, FFmpeg, and Rust versions.1680- GPU PCI ID, Vulkan device, VA-API profiles, and `/dev/dri` permissions.1681- Source commit, Git bundle hash, `Cargo.lock` hash, container/Nix revision,1682 binary hash, and configuration hash with private paths redacted.1683- Host service journal and Gamescope/PipeWire session journal.1684- Direct versus relayed iroh path.1685- Frame counts, queue depth, drops, queue age, RSS, CPU, and GPU utilization.1686- Reconnect and second-client rejection results.16871688Never copy the host identity seed into an evidence bundle. Treat node IDs and1689connection metadata as operationally sensitive until client authorization1690exists.16911692Until the daemon exports all of those metrics, collect the missing external1693samples during a bounded probe instead of leaving evidence fields blank:16941695```bash1696pid="$(systemctl --user show -p MainPID --value sigil-host.service)"1697test "$pid" -gt 11698for sample in $(seq 1 30); do1699 date --iso-8601=ns1700 ps -o pid,rss,%cpu,nlwp,etimes,cmd -p "$pid"1701 cat "/proc/$pid/io"1702 radeontop -d - -l 1 2>/dev/null || true1703 sleep 11704done | tee "$evidence_dir/runtime-samples.txt"1705```17061707## 14. Rollback and recovery17081709Stop and disable only the Sigil service:17101711```bash1712systemctl --user disable --now sigil-host.service1713```17141715Return to synthetic video by changing `source` back to `test-pattern`. Preserve1716the identity unless a new host identity is explicitly required.17171718Application releases live independently of the OSTree deployment. To roll back1719the daemon, select a previously validated release and restart the unit:17201721```bash1722sigil_root="$HOME/.local/libexec/sigil-spark"1723previous_commit="<validated-full-commit>"1724previous_config="$sigil_root/releases/$previous_commit/host.toml"1725cd "$sigil_root"1726previous_host="releases/$previous_commit/sigil"1727if [[ ! -x "$previous_host" ]]; then1728 previous_host="releases/$previous_commit/sigil-host"1729fi1730test -x "$previous_host"1731test -f "$previous_config"1732"$previous_host" config check --config "$previous_config"1733test ! -e current || test -L current1734ln -sfnT "releases/$previous_commit" current1735install -m 0600 "$previous_config" "$HOME/.config/sigil-spark/host.toml"1736systemctl --user restart sigil-host.service1737systemctl --user is-active --quiet sigil-host.service1738```17391740Keep a validated backup of `host.toml` with each application release and run1741`sigil config check` before restoring it. An OSTree rollback does not roll1742back binaries or configuration in the user's home directory, and it does not1743necessarily remove the SSH drop-in under `/etc`.17441745If a Bazzite update breaks the appliance, inspect deployments and roll back:17461747```bash1748rpm-ostree status -v1749sudo rpm-ostree rollback1750sudo systemctl reboot1751```17521753The previous deployment is also selectable from the boot menu. Do not reset or1754reinstall the machine until its logs and failing deployment ID have been saved.17551756If SSH hardening itself must be rolled back, do it from the physical console:17571758```bash1759sudo mv /etc/ssh/sshd_config.d/10-sigil.conf \1760 /etc/ssh/sshd_config.d/10-sigil.conf.disabled1761sudo sshd -t1762sudo systemctl reload sshd.service1763```17641765## Completion gates17661767### Steps 0–2 on `slate`17681769- Debug and explicitly feature-gated demo-client bypasses work and are visibly1770 labeled; ordinary release-client parsing rejects `--dev-connect`.1771- Shared protocol tests pass on both architectures.1772- Pure host binary has no Tauri dependency.1773- Synthetic H.264 streams from `slate` to the Mac.1774- The rendered Tauri/WebCodecs path starts on a keyframe and its transport,1775 frontend, and decoder drop counters do not grow after startup during the1776 demo soak.1777- Input transport remains responsive during video load.1778- One-client enforcement and 100 reconnect cycles pass.17791780### Fresh Bazzite host17811782- Key-only SSH survives reboot.1783- The base deployment is pinned and recoverable.1784- AMD Vulkan and H.264 VA-API checks pass.1785- Sigil builds without layering host development packages.1786- The foreground synthetic service passes reconnect tests before Gamescope1787 cutover.1788- A cold, physically headless boot produces a Gamescope PipeWire node.1789- Switching to Gamescope capture does not require a portal or a physical1790 display.