cat / goq
clone

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

1-n-sigls
goq / docs / fresh-bazzite-host.md
fresh-bazzite-host.md 76.5K 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 boundary8990Each Portal derives a stable client Iroh identity from its security key. Sigil91signs a short-lived invitation bound to its host identity, that exact Portal92peer, an enrollment epoch, a nonce, expiry, and the requested grants. The first93media handshake consumes the invitation atomically; later sessions authenticate94the durably enrolled peer without replaying it. Authorization state supports a95fixed bounded viewer set independently of the concurrent `max_viewers` cap.96View, pointer/keyboard, and gamepad grants are intersected with operational97protocol capabilities on both sides, and committed reductions apply to the98running daemon before the administration operation returns.99100Control v2 is the native-MoQ multi-viewer path. It admits three concurrent101viewers by default and at most eight, all on one host-owned video/audio media102generation. Slot 0 has at most one input-focus holder. Eligibility comes from103pointer/keyboard or gamepad grants; possession comes only from the current104revisioned focus snapshot. Focus handoff, replacement, disconnect, or live105revocation invalidates the old focus generation and neutralizes every virtual106input device before a successor can inject. Control v1 and grouped-v3 remain107strictly exclusive and never coexist with a v2 generation.108109`--dev-connect` remains only a build-time-contained routing bypass in Portal.110It does not grant access to a configured host, and ordinary release builds111reject the option. Only the explicit direct test-pattern proof form of112`sigil serve` omits enrollment; a configured Gamescope appliance fails closed.113Replay, expiry, wrong-peer, cross-host, stale-epoch, and capability-escalation114attempts are rejected before capture starts.115116For each enrollment, use that Portal's **show portal id** action, then issue one117short-lived file on the host. Omit input flags for a view-only spectator:118119```bash120sigil invitation create \121 --config ~/.config/sigil-spark/host.toml \122 --peer PORTAL_PEER_ID \123 --pointer-keyboard \124 --gamepad \125 --output ~/portal.goq-invite126```127128Open the file with Portal and confirm its bounded summary with the controller.129After the first accepted media handshake, verify the bounded durable roster130with `sigil enrollment list --config ~/.config/sigil-spark/host.toml`.131Peer/grant revocation is available through the daemon-owned administration132path; removing input grants defocuses and neutralizes only that viewer, while133removing `VIEW` disconnects it without interrupting survivors. The reset below134remains the explicit all-viewer recovery operation and invalidates outstanding135invitations:136137```bash138fingerprint="$(sigil appliance status \139 --config ~/.config/sigil-spark/host.toml --json | jq -r .identity.host_fingerprint)"140systemctl --user stop sigil-host.service141sigil appliance enrollment-reset \142 --config ~/.config/sigil-spark/host.toml \143 --expected-host-fingerprint "$fingerprint" \144 --json145systemctl --user start sigil-host.service146```147148Enrollment reset needs only the durable per-state lifecycle lock when149`XDG_RUNTIME_DIR` is unavailable, so the stopped-host command also works from150an SSH or recovery shell. If a valid XDG runtime root is present, reset also151takes the per-user global lock. An unsafe or malformed XDG runtime directory is152still rejected rather than ignored.153154Then disconnect Portal and use **client -> reset enrollment** before importing155the replacement invitation. Portal never silently overwrites a redeemed host156profile.157158Do not put an identity seed in an environment variable or command-line159argument. Store it in a mode `0600` file and pass only its path.160161For an authenticated headless hardware probe, create a dedicated persistent162probe identity on the client and bind a short-lived invitation to its printed163public ID. This enrollment is intentionally separate from the ordinary Portal164profile and therefore belongs only in an isolated temporary host state:165166```bash167# On the client:168umask 077169probe_state="$HOME/.local/state/goq-probe"170install -d -m 0700 "$probe_state"171sigil identity init --output "$probe_state/probe.key"172173# On the host's isolated test state/config, using the peer ID printed above:174proof_state="$HOME/.local/state/sigil-spark-hardware-proof"175sigil invitation create \176 --config "$proof_state/host.toml" \177 --peer PROBE_PEER_ID \178 --pointer-keyboard \179 --output "$proof_state/probe.goq-invite"180181# From the client, copy the exact invitation over the authenticated SSH path.182scp tank@umpc:.local/state/sigil-spark-hardware-proof/probe.goq-invite \183 "$probe_state/probe.goq-invite"184chmod 0600 "$probe_state/probe.goq-invite"185186# First connection redeems the one-time invitation.187sigil-probe --node-id HOST_NODE_ID \188 --identity "$probe_state/probe.key" \189 --invitation "$probe_state/probe.goq-invite" \190 --keyframe-smoke --frames 120191192# Later connections use the same identity without replaying the invitation.193sigil-probe --node-id HOST_NODE_ID \194 --identity "$probe_state/probe.key" \195 --keyframe-smoke --frames 120196```197198Both credential files must be regular files owned by the current user with no199group or other permissions. The probe verifies the invitation signature,200host/peer binding, expiry, and requested grants before opening a network201connection, sends it only on the initial media handshake, and never prints its202contents. Delete the exact invitation file only after enrollment is confirmed;203if a later session step fails after redemption, retry ticket-free with the same204identity. After `sigil enrollment show` confirms the temporary probe peer,205remove both exact copies:206207```bash208rm -- "$HOME/.local/state/goq-probe/probe.goq-invite"209ssh tank@umpc 'rm -- "$HOME/.local/state/sigil-spark-hardware-proof/probe.goq-invite"'210```211212## 1. Choose and install Bazzite213214Use the Bazzite download picker with:215216- Home Theater PC / Steam Gaming Mode image.217- Modern AMD GPU.218- x86_64 architecture.219- KDE desktop for recovery and maintenance.220221Use a single AMD GPU where possible. Hybrid-GPU configurations add device222selection ambiguity before the media path is proven.223224During installation:2252261. Use UEFI boot.2272. Connect wired Ethernet.2283. Set the hostname to `sigil-host` or another stable local DNS name.2294. Create the gaming account. This runbook uses `sigil` in examples.2305. Use the entire dedicated disk unless the machine has another explicit role.2316. Boot into Steam Gaming Mode once, then switch to Desktop Mode for setup.232233Choose the disk-unlock model before installation. Passphrase-only LUKS blocks234an unattended headless cold boot. TPM-backed unlock can satisfy the appliance235boot requirement, but only when an offline recovery key is stored and tested.236Record the decision and prove one recovery boot before relying on the host.237238Follow the current [Bazzite installation guide](https://docs.bazzite.gg/General/Installation_Guide/install-guide/)239for image verification, Secure Boot, and installer details.240241## 2. Update once and pin the known-good deployment242243From a host terminal in Desktop Mode:244245```bash246set -euo pipefail247rpm-ostree status -v | tee "$HOME/bazzite-pre-update.txt"248df -h / /var "$HOME"249df --output=pcent /var | tail -1 | tr -dc '0-9' | {250 read -r used251 test "$used" -le 97252}253ujust update254sudo systemctl reboot255```256257After reboot, record and pin the deployment:258259```bash260rpm-ostree status -v261rpm-ostree status -v | grep -q 'ostree-image-signed'262sudo ostree admin pin 0263```264265Bazzite-Deck/HTPC updates are normally applied manually through Steam Gaming266Mode or with `ujust update`. Pinning gives the appliance a known-good bootable267deployment before Sigil-specific work begins.268269Do not layer compilers, Rust, FFmpeg development packages, or build tools with270`rpm-ostree`. Bazzite recommends containers for development workflows and warns271that package layering can interfere with upgrades.272273References:274275- [Bazzite update guide](https://docs.bazzite.gg/Installing_and_Managing_Software/Updates_Rollbacks_and_Rebasing/updating_guide/)276- [Bazzite rollback guide](https://docs.bazzite.gg/Installing_and_Managing_Software/Updates_Rollbacks_and_Rebasing/rolling_back_system_updates/)277- [Bazzite software installation guidance](https://docs.bazzite.gg/Installing_and_Managing_Software/software-intro/)278279## 3. Establish SSH access280281On the Bazzite host, confirm that OpenSSH is present and enable it:282283```bash284sudo systemctl enable --now sshd.service285systemctl status sshd.service --no-pager286```287288If `sshd.service` does not exist, stop and verify the selected Bazzite image289before layering packages. OpenSSH is expected on the chosen host image.290291If `firewalld` is active, inspect its active zones and allow SSH only in the292management interface's zone:293294```bash295management_interface="<wired-management-interface>"296if sudo systemctl is-active --quiet firewalld; then297 management_zone="$(sudo firewall-cmd --get-zone-of-interface="$management_interface")"298 test -n "$management_zone" && test "$management_zone" != no299 sudo firewall-cmd --zone="$management_zone" --permanent --add-service=ssh300 sudo firewall-cmd --reload301fi302```303304At the host console, record its ED25519 fingerprint:305306```bash307sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub308ip -brief address show dev "$management_interface"309```310311Compare that fingerprint when the Mac first connects. If `.local` name312resolution is not available yet, use the displayed management IP. Create a313dedicated management key and install its public half:314315If the host uses Tailscale SSH, its ACL may present a browser check before316OpenSSH authentication. A local or temporary password is not consulted until317that tailnet check succeeds. Complete the displayed URL using the authorized318tailnet identity, then continue with the dedicated key below. Do not weaken319`sshd` or the tailnet ACL merely to bypass the check. For recovery, retain a320separately tested LAN management path or document the tailnet check policy and321its operator account; a physically headless appliance must not depend on an322unknown interactive account flow.323324```bash325test -f "$HOME/.ssh/sigil-bazzite_ed25519" || \326 ssh-keygen -t ed25519 -a 64 -f "$HOME/.ssh/sigil-bazzite_ed25519"327management_key="$(cat "$HOME/.ssh/sigil-bazzite_ed25519.pub")"328printf '%s\n' "$management_key" | ssh sigil@sigil-host.local \329 '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"'330ssh -o IdentitiesOnly=yes \331 -o PasswordAuthentication=no \332 -i "$HOME/.ssh/sigil-bazzite_ed25519" \333 sigil@sigil-host.local hostname334```335336Only after key-only login succeeds, add an SSH hardening drop-in on the host:337338```bash339sudo install -d -m 0755 /etc/ssh/sshd_config.d340sudo tee /etc/ssh/sshd_config.d/10-sigil.conf >/dev/null <<'EOF'341PubkeyAuthentication yes342AuthenticationMethods publickey343PasswordAuthentication no344KbdInteractiveAuthentication no345PermitRootLogin no346AllowUsers sigil347AllowAgentForwarding no348AllowTcpForwarding no349PermitTunnel no350X11Forwarding no351EOF352sudo sshd -t353sudo sshd -T | grep -E '^(pubkeyauthentication|authenticationmethods|passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowusers|allowagentforwarding|allowtcpforwarding|permittunnel|x11forwarding) '354sudo systemctl reload sshd.service355```356357Keep the original session and a local keyboard available until a second358key-only SSH login succeeds.359360## 4. Verify the fresh host before adding Sigil361362Run this while a display is still attached:363364```bash365./scripts/bazzite-inventory.sh | tee "$HOME/bazzite-inventory.txt"366./scripts/bazzite-inventory.sh --smoke | tee "$HOME/bazzite-smoke.txt"367```368369The script selects the first accessible render node that completes a real370one-frame VA-API H.264 encode; it does not assume a driver name or371`renderD128`. The equivalent individual checks are shown below for recovery372and review:373374```bash375hostnamectl376timedatectl377uname -a378rpm-ostree status -v379getenforce380ss -lntup381systemctl list-unit-files --state=enabled382mokutil --sb-state || true383gamescope --version384pipewire --version385ls -l /dev/dri386id387vulkaninfo --summary388render_node="$({389 for node in /dev/dri/renderD*; do390 test -c "$node" && test -r "$node" && test -w "$node" || continue391 ffmpeg -hide_banner -loglevel error \392 -vaapi_device "$node" \393 -f lavfi -i color=size=128x128:rate=1 \394 -vf 'format=nv12,hwupload' \395 -c:v h264_vaapi -frames:v 1 -f null - >/dev/null 2>&1 || continue396 printf '%s\n' "$node"397 done398} | head -n 1)"399test -n "$render_node"400udevadm info --query=property --name="$render_node" | grep -E '^(DEVNAME|ID_PATH|ID_PATH_TAG)='401readlink -f "/sys/class/drm/$(basename "$render_node")/device/driver"402test -r "$render_node" && test -w "$render_node"403vainfo --display drm --device "$render_node"404ffmpeg -hide_banner -encoders 2>/dev/null | grep -E 'h264_(vaapi|amf)'405ffmpeg -hide_banner -encoders 2>/dev/null | grep -q 'libx264'406ffmpeg -hide_banner -loglevel warning \407 -vaapi_device "$render_node" \408 -f lavfi -i testsrc2=size=1280x800:rate=60 \409 -vf 'format=nv12,hwupload' \410 -c:v h264_vaapi \411 -frames:v 600 \412 -f null -413```414415Required observations:416417- The gaming user can open a `/dev/dri/renderD*` render node.418- Vulkan selects the intended gaming GPU.419- VA-API exposes H.264 encoding and completes the 600-frame smoke encode.420- FFmpeg exposes `libx264` for the bounded synthetic Steps 0–2 source.421- PipeWire and Gamescope are installed.422- The clock reports NTP synchronization. NTP is suitable for correlating logs;423 one-way latency measurements still require a same-clock monotonic measurement424 or explicit clock-offset estimation.425426Record the output before continuing. Missing hardware encoding is a host-image427or driver problem, not an application fallback opportunity.428429## 5. Create persistent user-space locations430431As the gaming user:432433```bash434install -d -m 0700 \435 "$HOME/.config/sigil-spark" \436 "$HOME/.local/libexec/sigil-spark" \437 "$HOME/.local/libexec/sigil-spark/releases" \438 "$HOME/.local/share/sigil-spark/identity" \439 "$HOME/.local/state/sigil-spark" \440 "$HOME/.local/state/sigil-spark/runtime" \441 "$HOME/.config/systemd/user"442```443444Use these locations consistently:445446| Content | Location |447| --- | --- |448| Host releases | `~/.local/libexec/sigil-spark/releases/<commit>/sigil` (`sigil-host` is a compatibility copy) |449| Current binary | `~/.local/libexec/sigil-spark/current/sigil` |450| Configuration | `~/.config/sigil-spark/host.toml` |451| Read-only host identity | `~/.local/share/sigil-spark/identity/host.key` |452| Writable runtime state | `~/.local/state/sigil-spark/runtime/` |453| User service | `~/.config/systemd/user/sigil-host.service` |454455This avoids modifying Bazzite's immutable base image.456457Sigil requires the configuration file and its immediate directory to be owned458by the service user and not writable by group or other users. This appliance459layout deliberately uses mode `0700`; a conventional owner-controlled mode460`0755` configuration directory is also valid, while group- or world-writable461directories fail during `config check` and daemon startup before any managed462configuration transaction can begin.463464## 6. Prepare a containerized build environment465466Bazzite recommends Distrobox for development packages. The repository must467first contain a committed `rust-toolchain.toml`, `Cargo.lock`, and a clean468source revision. Determine the Fedora base version, pull the matching toolbox469image, and record its immutable digest:470471```bash472. /etc/os-release473image_tag="registry.fedoraproject.org/fedora-toolbox:${VERSION_ID}"474podman pull "$image_tag"475image_digest="$(podman image inspect --format '{{index .RepoDigests 0}}' "$image_tag")"476printf '%s\n' "$image_digest"477distrobox create \478 --name sigil-dev \479 --image "$image_digest"480distrobox enter sigil-dev481```482483Inside the container:484485```bash486sudo dnf install -y --setopt=install_weak_deps=False \487 clang cmake curl gcc gcc-c++ git make \488 gstreamer1-devel gstreamer1-plugins-base-devel \489 openssl-devel pipewire-devel pkgconf-pkg-config systemd-devel490491curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \492 sh -s -- -y --profile minimal --default-toolchain none493source "$HOME/.cargo/env"494rpm -qa | sort > "$HOME/sigil-dev-rpms.txt"495```496497`gstreamer1-devel` supplies the core API and498`gstreamer1-plugins-base-devel` supplies the app/video APIs linked by the499optional in-process backend. They are build dependencies only; do not copy500headers or development libraries into the Sigil runtime archive.501502The toolchain pinned by the repository must be Rust 1.91 or newer. Save the503container digest and exact RPM NEVRAs with the evidence. Distrobox is504host-integrated and is not a security sandbox. Build only the protocol and pure505host crates on the appliance; the Tauri client remains a Mac build. Remove the506`sigil-dev` box after installing and validating the release binary.507508## 7. Transfer the working tree509510Proof builds use committed, revision-addressed source. They must never copy a511dirty tree, `.env` file, identity, or evidence directory. On the Mac, create a512bundle for the intentional checkpoint:513514```bash515cd ~/Developer/sigil-spark516test -z "$(git status --porcelain)" || {517 echo "Commit or intentionally discard all changes before a proof build." >&2518 exit 1519}520sigil_rev="$(git rev-parse HEAD)"521sigil_short="$(git rev-parse --short=12 HEAD)"522sigil_bundle="/tmp/sigil-spark-${sigil_short}.bundle"523git bundle create "$sigil_bundle" HEAD524git bundle verify "$sigil_bundle"525bundle_sha="$(shasum -a 256 "$sigil_bundle" | awk '{print $1}')"526printf '%s %s\n' "$bundle_sha" "$(basename "$sigil_bundle")" >"$sigil_bundle.sha256"527ssh -i "$HOME/.ssh/sigil-bazzite_ed25519" sigil@sigil-host.local \528 'install -d -m 0700 "$HOME/Developer"'529scp -i "$HOME/.ssh/sigil-bazzite_ed25519" \530 "$sigil_bundle" "$sigil_bundle.sha256" sigil@sigil-host.local:Developer/531ssh -i "$HOME/.ssh/sigil-bazzite_ed25519" sigil@sigil-host.local \532 "cd \"\$HOME/Developer\" && \533 sha256sum -c \"sigil-spark-${sigil_short}.bundle.sha256\" && \534 git bundle list-heads \"sigil-spark-${sigil_short}.bundle\" && \535 mkdir -p \"\$HOME/Developer/sigil-spark-revisions\" && \536 git clone \"\$HOME/Developer/sigil-spark-${sigil_short}.bundle\" \537 \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" && \538 git -C \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" \539 checkout --detach ${sigil_rev} && \540 test \"\$(git -C \"\$HOME/Developer/sigil-spark-revisions/${sigil_short}\" rev-parse HEAD)\" = \"${sigil_rev}\""541```542543Once a shared Git remote exists, replace the bundle with a normal fetch and544detached checkout of the same exact commit.545546Build inside the Distrobox:547548```bash549distrobox enter sigil-dev550sigil_rev="<full-commit>"551sigil_short="${sigil_rev:0:12}"552cd "$HOME/Developer/sigil-spark-revisions/$sigil_short"553source "$HOME/.cargo/env"554toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"555test -n "$toolchain"556rustup toolchain install "$toolchain"557rustup show active-toolchain558rustc -vV559cargo --version560cargo test --locked -p sigil-protocol561cargo build --locked -p sigil-host --release562exit563```564565Install the resulting binary as the gaming user:566567```bash568sigil_rev="<full-commit>"569sigil_short="${sigil_rev:0:12}"570sigil_root="$HOME/.local/libexec/sigil-spark"571sigil_release="$sigil_root/releases/$sigil_rev"572install -d -m 0755 "$sigil_release"573install -m 0755 \574 "$HOME/Developer/sigil-spark-revisions/$sigil_short/target/release/sigil" \575 "$sigil_release/sigil"576install -m 0755 "$sigil_release/sigil" "$sigil_release/sigil-host"577sha256sum "$sigil_release/sigil" "$sigil_release/sigil-host"578ldd "$sigil_release/sigil" | tee "$HOME/sigil-${sigil_short}.ldd"579if grep -q 'not found' "$HOME/sigil-${sigil_short}.ldd"; then580 echo "Host runtime dependency missing." >&2581 exit 1582fi583"$sigil_release/sigil" --version584cd "$sigil_root"585test ! -e current || test -L current586ln -sfnT "releases/$sigil_rev" current587sha256sum current/sigil current/sigil-host588current/sigil --version589```590591For a demo deployment built and hashed on the development Mac, transfer both592`sigil` and `sigil-probe` plus `scripts/stage-bazzite-release.sh`, then use593the stager instead of the manual install block above:594595The thin stager is restricted to an unmanaged development layout. If the596service, audio, rollback, or udev assets are package-managed links that follow597`current`, it fails before staging and instructs you to build the complete598runtime package and run `payload/stage-this-release.sh` instead.599600```bash601scripts/stage-bazzite-release.sh \602 --release-id <commit-or-source-snapshot-sha256> \603 --host-binary <absolute-path> --host-sha256 <sha256> \604 --probe-binary <absolute-path> --probe-sha256 <sha256>605```606607The expected hashes must come from the trusted development machine, not from608an unverified copy on the host. The stager refuses unsafe inputs and install609directories, validates runtime linking and bounded startup, and atomically610updates `current`. It deliberately does not create an identity, install a611hardware configuration, or start/enable the service; those remain the next612separate gates.613614The preferred host artifact is the deterministic, allowlisted runtime package.615It contains the generic Linux host/probe binaries, installer and rollback tool,616systemd/PipeWire/udev assets, license, complete checksums, and build provenance.617It cannot include the worktree, credentials, identity, hardware configuration,618or evidence. Product packaging exports clean tagged `HEAD`, builds both619binaries with locked `cargo-zigbuild` in an isolated target directory, and620never accepts caller-supplied binaries. It fails closed when the worktree is621dirty, the tag does not resolve to `HEAD`, the tag differs from Sigil's Cargo622version, or the output does not use the stable public asset name. The builder623never receives the offline Minisign secret:624625```bash626cd ~/Developer/sigil-spark627source ~/.cargo/env628scripts/package-bazzite-release.sh \629 --release-tag v0.1.0 \630 --output /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz631632# Verify the candidate before it crosses the offline signing boundary.633scripts/verify-sigil-release.sh \634 --tag v0.1.0 \635 --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \636 --source-commit "$(git rev-parse HEAD)" \637 --candidate638639# After the offline signer returns the detached signature, verify with the640# reviewed public key before extraction. See docs/public-release-delivery.md.641scripts/verify-sigil-release.sh \642 --tag v0.1.0 \643 --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \644 --source-commit "$(git rev-parse HEAD)" \645 --public-key-file /absolute/path/to/sigil-minisign.pub646shasum -a 256 -c /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz.sha256647scp /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz tank@umpc:/tmp/648649ssh tank@umpc '650 set -eu651 incoming="$HOME/.local/share/sigil-spark/incoming"652 install -d -m 0700 "$incoming"653 run_dir="$(mktemp -d "$incoming/package.XXXXXX")"654 tar -tzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz655 tar -xzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz -C "$run_dir"656 cd "$run_dir/payload"657 sha256sum -c PACKAGE-SHA256SUMS658 bash -n stage-this-release.sh659 ./stage-this-release.sh660'661```662663The public-key signature must be checked **before extraction**; checksums inside664the archive prove consistency but cannot establish publisher authenticity.665Keep the extracted incoming directory until the deployment is accepted. The666installer locks concurrent operations, verifies the exact payload allowlist,667stages and validates the entire release, then changes `current` last. It records668the former release as `previous`; `sigil-spark-host-rollback` revalidates every669target file before swapping `current`/`previous`. Neither path creates identity670or `host.toml`, changes `/etc`, restarts PipeWire, or starts/enables the service.671Before changing any package-managed user asset, the installer preflights all of672them. It can adopt an operator-owned regular file only when it is byte-identical673to the new release asset; any local modification or unsafe target rejects the674whole asset migration without changing an earlier destination.675676For a temporary dirty and unsigned development package only, omit the release677tag and use both `--allow-dirty --allow-unsigned`. The manifest records that678state and the package prints `publisher_signature=absent-development`; never679publish that artifact. Externally built host/probe binaries are accepted only680as an all-or-none pair with both development flags, and their manifest records681`binary_provenance=caller-supplied-unverified`:682683```bash684scripts/package-bazzite-release.sh \685 --output /tmp/sigil-spark-host-prebuilt-dev.tar.gz \686 --allow-dirty --allow-unsigned \687 --host-binary /absolute/path/to/sigil \688 --probe-binary /absolute/path/to/sigil-probe689```690691## 8. Create the host identity and synthetic configuration692693Create the identity file explicitly with a restrictive umask, then print only694the public node ID:695696```bash697umask 077698identity="$HOME/.local/share/sigil-spark/identity/host.key"699if ! test -e "$identity"; then700 "$HOME/.local/libexec/sigil-spark/current/sigil" identity init \701 --output "$identity"702fi703test ! -L "$identity"704chmod 0600 "$identity"705stat -c '%a %U %G %n' "$identity"706"$HOME/.local/libexec/sigil-spark/current/sigil" identity show \707 --identity "$identity"708```709710`identity init` must use create-new semantics, reject symlinks and existing711files, and set restrictive permissions itself. Rerunning setup must never712silently rotate the host identity.713714For Steps 0–2, configure a 1280×800 synthetic H.264 source. The daemon's strict715TOML schema accepts these values:716717```bash718config="$HOME/.config/sigil-spark/host.toml"719ffmpeg_path="$(readlink -f "$(command -v ffmpeg)")"720test -x "$ffmpeg_path"721ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264722cat >"$config" <<EOF723identity_path = "$HOME/.local/share/sigil-spark/identity/host.key"724state_path = "$HOME/.local/state/sigil-spark/runtime"725source = "test-pattern"726width = 1280727height = 800728framerate = 60729codec = "h264"730input_mode = "log"731ffmpeg_path = "$ffmpeg_path"732EOF733chmod 0600 "$config"734stat -c '%a %U %G %n' "$config"735"$HOME/.local/libexec/sigil-spark/current/sigil" config check \736 --config "$config"737install -m 0600 "$config" \738 "$HOME/.local/libexec/sigil-spark/current/host.toml"739"$HOME/.local/libexec/sigil-spark/current/sigil" serve \740 --config "$config" --max-runtime-seconds 10741```742743The daemon must reject malformed configuration, unknown fields, and identity744files writable by group or other users.745746Use `input_mode = "log"` only for the bounded proof so receipt can be observed747without injecting anything. Return it to `disabled` before leaving the host748unattended.749750The identity path is host-specific. Generate a new identity on the Bazzite751appliance rather than copying the temporary identity from `slate`.752753## 9. Install the user service754755Create `~/.config/systemd/user/sigil-host.service`:756757```ini758[Unit]759Description=Sigil streaming host760Wants=pipewire.socket761After=pipewire.socket762ConditionPathExists=%h/.config/sigil-spark/host.toml763StartLimitIntervalSec=0764765[Service]766Type=simple767Environment=DISPLAY=:0768# Compatibility window: the service uses the byte-identical legacy filename769# so an older rollback helper can still validate and reactivate this release.770ExecStart=%h/.local/libexec/sigil-spark/current/sigil-host serve --config %h/.config/sigil-spark/host.toml771Restart=on-failure772RestartSec=1773TimeoutStopSec=10774KillSignal=SIGINT775UMask=0077776CPUQuota=400%777MemoryHigh=1536M778MemoryMax=2G779TasksMax=256780LimitNOFILE=8192781NoNewPrivileges=true782RestrictSUIDSGID=true783784[Install]785WantedBy=default.target786```787788Verify, load, and start it for the current proof session. Do not enable it yet:789790```bash791systemd-analyze --user verify "$HOME/.config/systemd/user/sigil-host.service"792systemctl --user daemon-reload793systemctl --user start sigil-host.service794systemctl --user status sigil-host.service --no-pager795journalctl --user -u sigil-host.service -n 100 --no-pager796```797798`serve` performs the same strict configuration and live capture/input preflight799before binding its network endpoint, so a second `ExecStartPre=config check`800would only duplicate work. The unlimited start interval and one-second delay801are deliberate: after a cold boot the user manager may be ready before udev802has replaced Bazzite's static `/dev/uinput` node permissions or before803Gamescope publishes its PipeWire node. The unit retries safely without ever804exposing a partially initialized host. Always run the manual `config check`805shown above before the first start and again immediately before cutover.806807Do not add `PrivateTmp`, `ProtectSystem`, or `ProtectHome` to this **user**808unit. On current Bazzite/systemd those mount-namespace directives run the user809service in an unprivileged user namespace where root-owned device nodes appear810owned by the overflow UID/GID. That defeats Sigil's exact `/dev/uinput`811owner/group validation and makes the service fail closed. `NoNewPrivileges`,812`RestrictSUIDSGID`, the resource limits, mode-0600 configuration and identity,813and the dedicated `sigil-uinput` group remain active. A future system-level814unit may reintroduce mount namespacing while running the process as `tank`.815816Do not enable user lingering initially. The Steam Gaming Mode login owns the817PipeWire session. Revisit lingering only if the final service must start before818the gaming account is logged in. Iroh must tolerate initial offline state and819network changes rather than depending on a user-manager `network-online.target`.820821## 10. Use `slate` until the Bazzite cutover822823`slate` is allowed to host only the pure Rust and synthetic-source portions of824Steps 0–2. The repository must provide a pinned `flake.nix` and `flake.lock`825before this proof begins. Nix may add garbage-collectable paths to the shared826Nix store, but Sigil files stay in run-specific directories under827`~/Developer/sigil-spark-slate-probe` and828`~/.local/state/sigil-spark-slate-probe`.829830Do not use `sudo`, change the firewall, change users or groups, install a unit,831or leave an unattended background process on `slate`.832833From the Mac:834835```bash836cd ~/Developer/sigil-spark837test -z "$(git status --porcelain)"838sigil_rev="$(git rev-parse HEAD)"839sigil_short="$(git rev-parse --short=12 HEAD)"840sigil_bundle="/tmp/sigil-spark-${sigil_short}.bundle"841git bundle create "$sigil_bundle" HEAD842shasum -a 256 "$sigil_bundle"843ssh tank@slate \844 'umask 077; install -d -m 0700 "$HOME/Developer/sigil-spark-slate-probe"'845scp "$sigil_bundle" tank@slate:Developer/sigil-spark-slate-probe/846ssh tank@slate \847 "git clone \848 \"\$HOME/Developer/sigil-spark-slate-probe/sigil-spark-${sigil_short}.bundle\" \849 \"\$HOME/Developer/sigil-spark-slate-probe/${sigil_short}\" && \850 git -C \"\$HOME/Developer/sigil-spark-slate-probe/${sigil_short}\" \851 checkout --detach ${sigil_rev}"852```853854Run tests with the repository's pinned Nix environment. Limit build parallelism855because this is a shared host:856857```bash858ssh -t tank@slate859sigil_short="<12-character-commit>"860run_id="${sigil_short}-$(date -u +%Y%m%dT%H%M%SZ)"861probe_src="$HOME/Developer/sigil-spark-slate-probe/$sigil_short"862probe_state="$HOME/.local/state/sigil-spark-slate-probe/$run_id"863umask 077864install -d -m 0700 "$probe_state"865cd "$probe_src"866export CARGO_BUILD_JOBS=4867nix develop --option max-jobs 4 --option cores 2 --command bash -lc \868 'rustc --version && cargo --version && \869 ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264 && \870 cargo fmt --all -- --check && \871 cargo test --locked -p sigil-protocol -p sigil-host && \872 cargo clippy --locked -p sigil-protocol -p sigil-host \873 --all-targets -- -D warnings'874nix develop --option max-jobs 4 --option cores 2 --command \875 cargo run --locked -p sigil-host -- capture probe \876 --source test-pattern --frames 600 --expect-size 1280x800877nix develop --option max-jobs 4 --option cores 2 --command \878 cargo run --locked -p sigil-host -- identity init \879 --output "$probe_state/host.key"880nix develop --option max-jobs 4 --option cores 2 --command \881 cargo run --locked -p sigil-host -- identity show \882 --identity "$probe_state/host.key"883stat -c 'identity_mode=%a' "$probe_state/host.key"884ulimit -n 8192885nix develop --option max-jobs 4 --option cores 2 --command \886 systemd-run --user --scope --collect --quiet \887 -p CPUQuota=400% \888 -p MemoryHigh=1536M \889 -p MemoryMax=2G \890 -p TasksMax=256 \891 timeout --signal=INT --kill-after=10s 2h \892 cargo run --locked -p sigil-host -- serve \893 --identity "$probe_state/host.key" \894 --state-path "$probe_state/runtime" \895 --source test-pattern896```897898Keep this SSH terminal open. `tank` does not have lingering enabled, so the899foreground scope is intentionally tied to the proof session. Do not open a900fixed UDP port or change Slate's firewall; Iroh relay fallback is part of this901stand-in test.902903On the Mac, connect with the node ID printed by `slate`:904905```bash906cd ~/Developer/sigil-spark907source "$HOME/.cargo/env"908cargo run -p portal -- --dev-connect <slate-node-id>909```910911Before opening the UI, prove the same v1 media and input session headlessly:912913```bash914source "$HOME/.cargo/env"915cargo run --locked -p sigil-host --bin sigil-probe -- \916 --node-id <slate-node-id> --frames 300 --timeout-seconds 15917```918919The probe fails unless its first media frame is an IDR with SPS/PPS, it receives920`dimensions=1280x800`, and `sequence_gaps=0`. Its default input event is a921content-free liveness probe that requires a bounded host acknowledgment on the922independent input stream; it does not move the pointer. Record923`input_ack_micros`; the Slate host logs only the event type because proof-mode924input is log-only.925926For the one-client and reconnect gates, keep the Slate foreground host running927and use the already-built probe binary from a second Mac terminal:928929```bash930node_id="<slate-node-id>"931cargo build --locked -p sigil-host --bin sigil-probe932target/debug/sigil-probe --node-id "$node_id" --frames 3600 \933 > /tmp/sigil-primary-probe.log &934primary_pid=$!935sleep 2936if target/debug/sigil-probe --node-id "$node_id" --frames 1; then937 echo "Second client was accepted unexpectedly." >&2938 kill "$primary_pid"939 exit 1940fi941wait "$primary_pid"942grep -E 'probe=ok|frames=3600|sequence_gaps=0' \943 /tmp/sigil-primary-probe.log944945for cycle in $(seq 1 100); do946 target/debug/sigil-probe --node-id "$node_id" --frames 1 \947 > /dev/null || {948 echo "Reconnect failed at cycle $cycle." >&2949 exit 1950 }951done952echo "reconnect_cycles=100"953```954955The second probe must fail with `host already has an active client`; the956primary must still finish with zero sequence gaps. Remove the temporary log957after saving any evidence needed for the demo.958959The 2026-07-20/21 WIP proof completed 600 native Slate-to-Mac frames in 9.61960seconds with 10 keyframes, zero sequence gaps, a direct path, 7.04 ms RTT, and961a 7.81 ms input acknowledgment. A concurrent second client was rejected with962the exact one-active-client error. On 2026-07-21 the final release-profile963binaries completed 100/100 fresh media/input reconnects with deterministic964cleanup after a 600-frame zero-gap primary session. The final raw Tauri channel965then rendered more than 7,900 frames at 59.9 fps. During a clean966one-minute soak its counters remained fixed after startup: transport 0,967frontend 56 while joining mid-GOP, and decoder 2 while configuring WebCodecs.968The handoff is capped at four frames (about 67 ms at 60 fps), WebCodecs at two,969and new diagnostics report the three drop sources independently. Its `DEV970DIRECT-NODE · NOT AUTH` warning remained visible above the stream. The host971waits for a keyframe at session start and after a detected discontinuity, so a972newly connected decoder is never handed a delta frame as its first access973unit. Re-run these gates for the exact source snapshot used in a demo; do not974treat the WIP path as a release.975976Before cutover, the `slate` proof must cover:977978- Golden protocol vectors on macOS arm64 and Linux x86_64.979- Synthetic H.264 reception and WebCodecs decode.980- Independently acknowledged input transport, even though injection is stubbed981 on the stand-in.982- Second-client rejection.983- Clean shutdown and 100 connect/disconnect cycles.984- No unbounded queue or increasing process RSS.985986Do not run Gamescope, change the display manager, configure `uinput`, or install987a persistent Sigil service on `slate`.988989At cutover, confirm there is no running probe before removing only the exact990run-specific paths:991992```bash993pgrep -a sigil && {994 echo "Stop the foreground Sigil probe before cleanup." >&2995 exit 1996}997probe_src="$HOME/Developer/sigil-spark-slate-probe/<12-character-commit>"998probe_state="$HOME/.local/state/sigil-spark-slate-probe/<run-id>"999case "$probe_src" in1000 "$HOME/Developer/sigil-spark-slate-probe/"?*) ;;1001 *) exit 1 ;;1002esac1003case "$probe_state" in1004 "$HOME/.local/state/sigil-spark-slate-probe/"?*) ;;1005 *) exit 1 ;;1006esac1007test "$probe_src" != "$HOME/Developer/sigil-spark-slate-probe"1008test "$probe_state" != "$HOME/.local/state/sigil-spark-slate-probe"1009printf 'removing source=%q\nremoving state=%q\n' "$probe_src" "$probe_state"1010rm -rf -- "$probe_src" "$probe_state"1011```10121013Review the two expanded paths before running the final command. Nix store paths1014are left to the server's normal garbage-collection policy.10151016## 11. Validate the Bazzite Gamescope boundary10171018First validate the stock Bazzite Steam Gaming Mode session while a display is1019attached. From SSH:10201021```bash1022systemctl --user status pipewire.service pipewire.socket wireplumber.service --no-pager1023pgrep -a gamescope1024pgrep -a steam1025pw-cli ls Node1026pw-dump | jq '[1027 .[]1028 | select(.type == "PipeWire:Interface:Node")1029 | .info.props1030 | select(1031 .["node.name"] == "gamescope"1032 and .["media.class"] == "Video/Source"1033 )1034]'1035journalctl --user -b --no-pager | grep -Ei 'gamescope|pipewire.*node'1036```10371038Gamescope should publish a PipeWire stream with both `node.name=gamescope` and1039`media.class=Video/Source`. Select by those properties, not by numeric node ID,1040because node IDs change across boots. Inventory the exact executable paths,1041render-node candidates, and dynamically registered GstVA H.264 factories:10421043```bash1044pw_dump_path="$(readlink -f "$(command -v pw-dump)")"1045gst_launch_path="$(readlink -f "$(command -v gst-launch-1.0)")"1046gst_inspect_path="$(readlink -f "$(command -v gst-inspect-1.0)")"1047ffmpeg_path="$(readlink -f "$(command -v ffmpeg)")"1048test -x "$pw_dump_path"1049test -x "$gst_launch_path"1050test -x "$gst_inspect_path"1051test -x "$ffmpeg_path"10521053for node in /sys/class/drm/renderD*; do1054 test -e "$node/device/driver" || continue1055 printf 'candidate=%s driver=%s pci_device=%s\n' \1056 "/dev/dri/$(basename "$node")" \1057 "$(basename "$(readlink -f "$node/device/driver")")" \1058 "$(basename "$(readlink -f "$node/device")")"1059done1060render_node="<exact encode-capable render node chosen from the inventory>"1061test -n "$render_node"1062test -r "$render_node" && test -w "$render_node"10631064gst-inspect-1.0 | awk '1065 $2 ~ /^va(renderD[0-9]+)?h264(lp)?enc:$/ {1066 sub(/:$/, "", $2)1067 print $21068 }1069' | while read -r encoder; do1070 printf '\n### %s\n' "$encoder"1071 gst-inspect-1.0 "$encoder" | sed -n \1072 -e '/^[[:space:]]*device-path[[:space:]]*:/,+4p' \1073 -e '/^[[:space:]]*rate-control[[:space:]]*:/,+20p'1074done10751076for element in appsink pipewiresrc queue videoconvert videoscale videorate h264parse fdsink; do1077 gst-inspect-1.0 "$element" >/dev/null1078done10791080rpm -q gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-bad-free1081```10821083Record the exact runtime package NEVRAs. `appsink` is mandatory for a binary1084built with the in-process feature, and the package installer must resolve its1085linked GStreamer core/app/video libraries with `ldd`. `pipewiresrc`,1086conversion/parser elements, and the chosen GstVA plugin remain required for1087either video backend. The external `gst-launch` path is also still used for1088audio capture when audio is enabled.10891090### Grant the gaming user only the uinput capability10911092Do not add the gaming user to the broad `input` group and do not run the host1093as root. Create a dedicated group that can open only the kernel's uinput misc1094device:10951096```bash1097getent group sigil-uinput >/dev/null || sudo groupadd --system sigil-uinput1098sudo usermod --append --groups sigil-uinput "$USER"1099sudo tee /etc/udev/rules.d/72-sigil-uinput.rules >/dev/null <<'EOF'1100KERNEL=="uinput", SUBSYSTEM=="misc", TAG-="uaccess"1101EOF1102sudo install -o root -g root -m 0644 \1103 scripts/70-sigil-remote-input.rules \1104 /etc/udev/rules.d/70-sigil-remote-input.rules1105sudo tee /etc/udev/rules.d/99-sigil-uinput.rules >/dev/null <<'EOF'1106KERNEL=="uinput", SUBSYSTEM=="misc", OWNER="root", GROUP="sigil-uinput", MODE="0660", TAG-="uaccess", RUN+="/usr/bin/setfacl --remove-all $env{DEVNAME}"1107EOF1108sudo modprobe uinput1109sudo udevadm control --reload-rules1110sudo udevadm trigger --action=add --subsystem-match=misc --sysname-match=uinput1111```11121113Reboot or fully sign out and back in before continuing so the service receives1114the new supplementary group. Then prove the exact device identity and access:11151116```bash1117uinput_gid="$(getent group sigil-uinput | cut -d: -f3)"1118test -n "$uinput_gid"1119id -G | tr ' ' '\n' | grep -Fx "$uinput_gid"1120test ! -L /dev/uinput1121test -c /dev/uinput1122test -r /dev/uinput && test -w /dev/uinput1123test "$(stat -Lc '%u' /dev/uinput)" -eq 01124test "$(stat -Lc '%g' /dev/uinput)" -eq "$uinput_gid"1125test "$(stat -Lc '%a' /dev/uinput)" = 6601126test "$(stat -Lc '%t:%T' /dev/uinput)" = a:df1127getfacl -cp /dev/uinput1128```11291130The last hexadecimal device pair is Linux misc major 10, uinput minor 223.1131The early rule removes `uaccess` before the seat ACL pass. Current Bazzite also1132ships Sunshine and early-uinput rules that may already have queued the uaccess1133builtin, so the final bounded `setfacl` action removes any materialized ACL1134after permissions settle. The exact kernel/subsystem match and fixed executable1135path keep that action scoped only to `/dev/uinput`.1136The separate remote-input rule runs before Bazzite's seat pass. It gives the1137active Gamescope session access to Sigil's disjoint keyboard and relative-mouse1138event nodes and assigns the same single-purpose group. Without it, correct1139`BTN_LEFT` and `REL_X/Y` events can appear in `evtest` while Gamescope silently1140ignores an inaccessible hot-plugged device.1141The daemon independently opens the configured path three times with1142`O_NOFOLLOW`, verifies that character-device identity and exact1143owner/group/mode, and—under this default Bazzite configuration—requires the1144absence of an extended access ACL on every descriptor. The portable activation1145guide documents the separate, explicit `expected_acl_user_uid` contract for a1146distribution that intentionally owns a one-user seat ACL; it is not enabled by1147the shipped rules. Sigil then creates separate pointer, keyboard, and gamepad1148devices before it binds Iroh. Any change fails closed. Access to1149uinput is equivalent to local keyboard, pointer, and gamepad control, so keep1150this dedicated group single-purpose. Do not add1151`PrivateDevices=true` to the user unit because that would deliberately hide the1152validated device.11531154Set `vaapi_encoder` to one factory whose programmatically queried read-only1155`device-path` exactly equals `render_node`. This is deliberate: GstVA selects a1156DRM device by factory and exposes `device-path` as read-only, so Sigil verifies1157the live element's reported device before starting capture. Set1158`rate_control = "cbr"` only when1159that factory advertises CBR; otherwise select a low-power factory that1160advertises CQP and use `rate_control = "cqp"`. Do not silently substitute a1161different factory, render node, or software encoder.11621163Create a separate probe configuration without changing the running synthetic1164service configuration. The CBR example is:11651166```bash1167vaapi_encoder="<factory verified above>"1168test -n "$vaapi_encoder"1169uinput_gid="$(getent group sigil-uinput | cut -d: -f3)"1170test -n "$uinput_gid"1171probe_config="$HOME/.config/sigil-spark/host-gamescope-probe.toml"1172cat >"$probe_config" <<EOF1173identity_path = "$HOME/.local/share/sigil-spark/identity/host.key"1174state_path = "$HOME/.local/state/sigil-spark/runtime"1175source = "gamescope-pipewire"1176framerate = 601177codec = "h264"1178input_mode = "uinput"1179ffmpeg_path = "$ffmpeg_path"11801181[uinput]1182device_path = "/dev/uinput"1183expected_owner_uid = 01184expected_group_gid = $uinput_gid1185expected_mode = 0o66011861187[gamescope_pipewire]1188node_name = "gamescope"1189media_class = "Video/Source"1190xwayland_display = ":0"1191pw_dump_path = "$pw_dump_path"1192gst_launch_path = "$gst_launch_path"1193gst_inspect_path = "$gst_inspect_path"1194encoder_backend = "external-gst-launch"1195vaapi_encoder = "$vaapi_encoder"1196vaapi_render_node = "$render_node"1197rate_control = "cbr"1198bitrate_kbps = 120001199EOF1200chmod 0600 "$probe_config"1201```12021203With `width` and `height` absent, Sigil uses the single bounded native size1204advertised by the selected Gamescope node. Set both fields only to request an1205explicit same-aspect encoded downscale; partial or distorting overrides are1206rejected. `framerate` is a maximum encoder cadence because current Gamescope1207PipeWire caps advertise `0/1` rather than a trustworthy native rate.12081209Omitting `encoder_backend` has the same external default. After the external1210baseline passes, a locally built Linux binary containing the feature can use:12111212```toml1213encoder_backend = "in-process-gstreamer"1214```12151216That selection is not an availability hint: a binary without the feature, a1217missing `appsink`, or an unavailable encoder factory makes preflight fail. The1218foundation currently requires CBR, and a bitrate property that is not mutable1219while PLAYING also fails the control preflight. Runtime bitrate control is1220acknowledged only after exact property readback. CQP remains on the external1221compatibility backend until its in-process force-keyframe path is implemented1222and proven separately.12231224The configured `:0` is the bootstrap connection. Gamescope builds that publish1225`GAMESCOPE_MOUSE_FOCUS_DISPLAY` may move mouse focus between their `:0` and1226`:1` Xwayland servers; Sigil follows that property and reconnects to the active1227local display. Upstream SteamOS and older Gamescope builds need not ship this1228extension. Sigil detects that absence without creating the atom, logs the1229degraded discovery mode, and safely samples only the explicitly configured1230display instead of disabling pointer feedback.12311232Sigil samples `QueryPointer` at no more than 60 Hz. If the active Xwayland root1233has a different aspect ratio than the native captured surface, Sigil maps it1234into a centered aspect-fit region (letterbox or pillarbox) so coordinates stay1235in the compositor's native pointer space without independent-axis stretching.1236Zero-sized, unrepresentable, off-root, or cross-screen geometry still fails1237closed.12381239Gamescope builds that publish `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` provide the1240compositor's visibility sample. Authoritative pointer motion deliberately keeps1241the client cursor visible for a bounded three seconds even if that sample is1242temporarily false, recovering Gamescope's virtual cursor after remote input.1243When the optional extension is absent or malformed, Sigil logs the fallback and1244uses that same bounded motion window without inventing longer-lived visibility.1245Missing or unreachable Xwayland state during startup still disables the1246separately negotiated pointer feedback capability. After successful startup,1247losing Gamescope publishes pointer feedback as unavailable, then reconnects the1248bootstrap display with a bounded 100 ms to 2 second backoff. A complete1249reconnect re-detects both optional Gamescope properties, validates one1250active-display sample, and only then publishes coordinates again. Relative1251uinput remains available without guessed cursor coordinates. The service1252`DISPLAY=:0` line is retained as an explicit fallback for configurations1253created before `xwayland_display` was added.12541255Audio is optional and must resolve one exact PipeWire sink, never a microphone.1256The appliance owns a persistent 48 kHz stereo null sink so capture does not1257depend on HDMI, speakers, or a physical sound card. Install the repository's1258drop-in as the gaming user, restart only the PulseAudio-compatible PipeWire1259service, and make the new sink the default target:12601261```bash1262audio_dropin_dir="$HOME/.config/pipewire/pipewire-pulse.conf.d"1263install -d -m 0700 "$audio_dropin_dir"1264install -m 0600 scripts/50-sigil-spark-audio.conf \1265 "$audio_dropin_dir/50-sigil-spark-audio.conf"1266systemctl --user restart pipewire-pulse.service12671268audio_sink_id="$(wpctl status -n | awk '1269 $0 ~ /sigil_spark/ {1270 for (field = 1; field <= NF; field++) {1271 if ($field ~ /^[0-9]+\.$/) {1272 gsub("\\.", "", $field)1273 print $field1274 exit1275 }1276 }1277 }1278')"1279test "$audio_sink_id" -gt 01280wpctl set-default "$audio_sink_id"1281pactl get-default-sink | grep -qx sigil_spark1282```12831284Restarting `pipewire-pulse.service` disconnects existing playback streams. Do1285this during provisioning before launching Steam or a game; new streams then1286route to `sigil_spark`. The sink deliberately has no physical playback leg.1287Remote audio continues while the machine has no display or speakers, and local1288speaker mirroring remains a separate opt-in policy rather than part of the1289capture trust boundary.12901291The drop-in makes only this virtual sink an always-processing graph driver and1292disables its idle suspension. Both are required because the host captures its1293monitor continuously: an idle null sink otherwise emits no silent clocked1294buffers and leaves a newly connected client stuck in audio priming until a1295game happens to make sound.12961297Verify that exactly one persistent sink has the expected stable `node.name`;1298do not copy the changing numeric ID or `object.serial` into host configuration:12991300```bash1301pw-dump | jq -r '1302 .[]1303 | select(.type == "PipeWire:Interface:Node")1304 | .info.props1305 | select(."media.class" == "Audio/Sink")1306 | [."object.serial", ."node.name", ."node.description"]1307 | @tsv1308'1309test "$(pw-dump | jq '[1310 .[]1311 | select(.type == "PipeWire:Interface:Node")1312 | .info.props1313 | select(."media.class" == "Audio/Sink" and ."node.name" == "sigil_spark")1314] | length')" -eq 11315audio_sink_name="sigil_spark"1316cat >>"$probe_config" <<EOF13171318[audio]1319node_name = "$audio_sink_name"1320media_class = "Audio/Sink"1321bitrate_bps = 960001322EOF1323chmod 0600 "$probe_config"1324```13251326This monitor capture is fixed at 48 kHz stereo, 20 ms frames, and 96 kbit/s1327restricted-low-delay Opus. It uses a two-packet host queue, QUIC datagrams, a1328three-packet client reorder window, a three-message binary Tauri channel, and a132960 ms AudioWorklet ring; overflow drops old audio instead of increasing1330latency. Audio negotiation and failure are independent from video and input.1331Only audio intentionally routed to the appliance sink is captured. This keeps1332desktop notifications or other audio routed to a different device outside the1333stream, while Steam and games inherit `sigil_spark` from the default selected1334during provisioning.13351336This backend implements a conventional relative mouse with `REL_X`, `REL_Y`,1337three mouse buttons, vertical/horizontal wheel events, and no `ABS_X/Y` axes.1338The client coalesces displacement into one bounded slot rather than dropping1339stale samples, and the host negotiates `relative_pointer` independently from1340keyboard and gamepad control. A third device named `Sigil Spark Virtual1341Gamepad` exposes one1342Xbox-style controller: ABXY, shoulders, back/start/guide, stick clicks, d-pad,1343two sticks, and two analog triggers. Stick values are normalized signed1344integers in `-32767..=32767`, triggers are unsigned integers in1345`0..=32767`, and d-pad axes are `-1..=1`. Each protocol message is a complete1346snapshot and is emitted as one statically bounded uinput report, so stale axis1347or button updates cannot grow an independent queue. The host advertises the1348separate `gamepad` input capability and rejects gamepad snapshots unless that1349capability was negotiated.13501351It releases every held keyboard/mouse transition and sends a fully neutral1352gamepad snapshot when an input session ends. Portal sends a bounded subset of1353W3C `KeyboardEvent.code` physical-position tokens for keyboard input. This1354keeps game and Steam bindings independent of the client's active keyboard1355layout. The current uinput backend advertises keyboard input but does not1356advertise Text; Unicode text and IME composition are therefore unsupported.1357For protocol compatibility, any legacy Text event received by Sigil remains a1358content-free no-op and is acknowledged when ACKs were negotiated. No key,1359text, or gamepad payload is logged. The Linux button/axis assignments follow1360the kernel's1361[gamepad protocol](https://docs.kernel.org/input/gamepad.html) and Xbox `xpad`1362layout.13631364For a factory that advertises only CQP, replace the last two settings with:13651366```toml1367rate_control = "cqp"1368quantizer = 241369```13701371Keep `encoder_backend = "external-gst-launch"` for CQP. The current1372in-process foundation rejects CQP rather than implying runtime control that it1373does not implement.13741375If `pw-dump` exposes an additional stable GPU identity on the Gamescope node,1376add it as an exact match; do not add a changing global ID, node ID, client ID,1377or `object.serial`:13781379```toml1380[gamescope_pipewire.match_properties]1381"device.bus-path" = "<exact stable value observed in pw-dump>"1382```13831384Exact properties and fail-on-ambiguity prevent accidental selection, but they1385do not authenticate a visual source against another process running as the1386same gaming user: a same-UID process can publish spoofed PipeWire properties1387and can already access this user's Sigil configuration. Treat the whole gaming1388UID/session as one trust boundary for this demo. Stronger isolation requires a1389separate service identity plus Node-to-Client PID correlation and verification1390of `/proc/<pid>/exe` and the Gamescope cgroup.13911392`config check` performs a live, bounded preflight. It requires exactly one1393matching PipeWire node, checks every configured executable and required1394GStreamer element, opens the configured render node read/write, proves its1395exact GstVA factory is bound to that node, and verifies all low-latency1396properties used by the pipeline. When audio is configured, it also prints the1397exact resolved sink target:13981399```bash1400"$HOME/.local/libexec/sigil-spark/current/sigil" config check \1401 --config "$probe_config"1402# Expected with audio enabled:1403# audio_pipewire_target_object=<current numeric target>1404# audio_capture_preflight=ok1405```14061407Node enumeration and preflight are not enough. Consume 300 frames and verify a1408decodable H.264 keyframe, the resolved output size, and a changing sequence:14091410```bash1411"$HOME/.local/libexec/sigil-spark/current/sigil" capture probe \1412 --source gamescope-pipewire \1413 --config "$probe_config" \1414 --frames 300 \1415 --minimum-fps 551416```14171418The Gamescope probe acquires the same per-user global lifecycle lock as the1419daemon and holds it through encoder teardown. Stop `sigil-host.service` before1420running it; a second configured Sigil daemon using the same runtime root or1421another capture probe fails before opening PipeWire. The lock cannot exclude1422arbitrary third-party consumers, so inventory non-Sigil PipeWire consumers1423before a benchmark.1424Gamescope may divide vblank delivery across simultaneous consumers, making two1425otherwise healthy 60 fps pipelines appear as roughly 30 fps each. A benchmark1426with an existing Gamescope consumer is invalid, not evidence that CQP or the1427transport is intrinsically half-rate.14281429The probe prints `resolved_encoded_mode=<width>x<height>@<cap>` before capture1430and the dimensions observed on encoded frames afterward. Repeat with1431`--expect-size <width>x<height>` when recording a strict benchmark. The1432pipeline explicitly negotiates and converts its encoder input to NV12 at that1433resolved size and configured cadence ceiling. The capture pipeline bounds the1434PipeWire source pool between one and four buffers and then uses an explicit1435one-buffer, leaky-downstream queue; encoder-internal buffering is separate and1436must be measured on the installed GstVA version. Gamescope's stream is1437damage-driven and may emit no buffers while the image is static. The versioned1438host/client path therefore keeps media and input connected through arbitrary1439frame silence; process exit, stream closure, malformed media, and write failure1440remain terminal. H.264 uses no B-frames, and SPS/PPS are repeated with each1441IDR. A capture or hardware-encoder failure is an error; there is no automatic1442synthetic or software fallback.14431444The probe reports sustained encoded FPS, frames dropped after encode before the1445probe consumer, and maximum post-encode queue age. The current GStreamer stdout1446bridge does not preserve PipeWire capture PTS, so these values do not prove raw1447capture age, pre-encode drops, or glass-to-glass latency. Preserve GStreamer1448buffer metadata through an in-process appsink before making those stronger1449claims; until then, correlate the probe with Gamescope/PipeWire statistics and1450the external sampling in the evidence section.14511452### Validate motion-sensitive resolution14531454This gate applies only to a Linux build with `in-process-gstreamer` and a host1455configuration whose `encoder_backend` is `in-process-gstreamer`. The external1456`gst-launch` compatibility backend intentionally remains fixed-resolution.14571458Connect Portal, enable host debug logs, and repeat at least ten cycles of a1459high-motion 3D scene followed by a static high-frequency test card. For each1460cycle retain the host journal and Portal console. When the resolved native1461dimensions admit an exact even three-quarter tier, the host decision record1462must show that reduced motion/pressure target followed by the resolved native1463recovery target only after at least two seconds of stillness, three fresh clean1464feedback windows, and the three-second transition cooldown. A stale feedback1465window must never restore native resolution. A valid native mode without such1466a tier must remain streamable with motion adaptation visibly disabled.14671468At each boundary verify that the first delivered target-size frame is a1469keyframe with SPS/PPS and a discontinuity, no preceding-resolution delta is1470presented afterward, and Portal stays connected without a WebCodecs error.1471Absolute pointer tests at the canvas center and four corners must continue to1472land on the native Gamescope surface at both encoded resolutions. The Portal1473window must not resize merely because the native and reduced modes have the1474same aspect ratio.14751476Record transition request-to-configured-IDR time and Portal1477configured-IDR-to-first-presentation time separately. These are recovery1478measurements, not glass-to-glass latency. Require ten of ten successful1479downscale/restore cycles, recovery p95 no greater than 900 ms and no individual1480recovery greater than two seconds, video/decode queue p95 no greater than two,1481and no sustained latency growth. Measure true glass-to-glass latency with a1482paired high-speed-camera run comparing fixed-native and adaptive sessions under1483the same controlled bandwidth pressure; do not substitute post-encode queue1484age for that result.14851486Then perform the actual headless gate:148714881. Shut the host down.14892. Disconnect the display; do not use a dummy plug for this test.14903. Cold boot the machine.14914. Make the first SSH connection of that boot.14925. Verify Steam, Gamescope, PipeWire, and the Gamescope stream node.14936. Repeat `config check` and the 300-frame capture probe with `probe_config`.14947. Save the boot and user-session journals.14951496On the first SSH connection, distinguish Gaming Mode auto-login from an SSH1497session that merely started the user manager:14981499```bash1500loginctl list-sessions1501sudo journalctl -b -o short-monotonic --no-pager | \1502 grep -Ei 'gamescope|steam|sshd.*accepted|session.*sigil'1503set -o pipefail1504./scripts/bazzite-inventory.sh --cold-boot | \1505 tee "$HOME/bazzite-cold-boot.txt"1506```15071508The Gamescope session must predate the first accepted SSH login.1509`--cold-boot` is read-only and makes the gate machine-checkable. It exits zero1510only when no DRM connector is connected, an active local gaming-user autologin1511session and the required PipeWire nodes are active, the Sigil service is1512enabled and ready, and Gamescope plus Sigil both predate the first accepted1513SSH login. The gaming1514session check is display-manager-independent: it requires an active, local1515logind session for the gaming user whose exact PAM service name ends in1516`-autologin` (for example, `sddm-autologin` or `gdm-autologin`). A manual1517desktop login, SSH session, or merely started user manager does not satisfy the1518gate. Exit status `1` means observed evidence failed a gate. Exit status `3`1519means the boot journal is unavailable, starts more than five minutes after1520boot, or otherwise cannot establish the first SSH ordering; do not treat that1521as a pass. Run it immediately on the first SSH login so journal rotation1522cannot erase the proof.15231524If the stock session does not start or does not publish a node without a1525physical connector, stop at this gate. This is an expected discovery risk on1526some AMD/KMS combinations, not permission to claim a headless pass. Record the1527installed Gamescope version, its `--help` output, connector state, and DRM logs1528before designing a custom virtual-output/headless-backend unit. Keep a local1529console recovery path. Do not introduce an XDG ScreenCast portal as a1530workaround.15311532Valve documents Gamescope's embedded session model and AMD/Mesa support in the1533[Gamescope repository](https://github.com/ValveSoftware/gamescope). Gamescope1534logs should identify its PipeWire node when the stream becomes available.15351536## 12. Cut over from synthetic video to Gamescope15371538Only after the headless gate passes:153915401. Stop `sigil-host.service`.15412. Re-run the attached and headless `config check` and capture probes against1542 the exact `host-gamescope-probe.toml` that will be installed.15433. Install that validated file as `host.toml`; do not reconstruct or partially1544 merge its strict node, encoder-factory, and render-node selection.15454. Leave width and height absent for native resolution; retain frame rate 60 as1546 the explicit encoder ceiling unless a different measured cap is under test.15475. Start the service and confirm the capture queue is bounded to one frame.15486. Connect from the Mac with the Bazzite host's new development node ID.15497. Enable the service only after the attached and headless capture probes pass.15508. Cold boot headlessly again and prove the unit started before the first SSH1551 login.15521553```bash1554systemctl --user stop sigil-host.service1555install -m 0600 \1556 "$HOME/.config/sigil-spark/host-gamescope-probe.toml" \1557 "$HOME/.config/sigil-spark/host.toml"1558"$HOME/.local/libexec/sigil-spark/current/sigil" config check \1559 --config "$HOME/.config/sigil-spark/host.toml"1560systemctl --user start sigil-host.service1561systemctl --user status sigil-host.service --no-pager1562pointer_sysfs="$(grep -lFx 'Sigil Spark Virtual Pointer' /sys/class/input/event*/device/name)"1563test -n "$pointer_sysfs"1564pointer_node="/dev/input/$(basename "$(dirname "$(dirname "$pointer_sysfs")")")"1565udevadm info "$pointer_node" | grep -F 'ID_INPUT_MOUSE=1'1566sudo libinput list-devices | sed -n \1567 '/Device:[[:space:]]*Sigil Spark Virtual Pointer/,/^$/p'1568sudo libinput debug-events --device "$pointer_node"1569keyboard_sysfs="$(grep -lFx 'Sigil Spark Virtual Keyboard' /sys/class/input/event*/device/name)"1570test -n "$keyboard_sysfs"1571keyboard_node="/dev/input/$(basename "$(dirname "$(dirname "$keyboard_sysfs")")")"1572udevadm info "$keyboard_node" | grep -F 'ID_INPUT_KEYBOARD=1'1573gamepad_sysfs="$(grep -lFx 'Sigil Spark Virtual Gamepad' /sys/class/input/event*/device/name)"1574test -n "$gamepad_sysfs"1575gamepad_node="/dev/input/$(basename "$(dirname "$(dirname "$gamepad_sysfs")")")"1576udevadm info "$gamepad_node" | grep -F 'ID_INPUT_JOYSTICK=1'1577systemctl --user enable sigil-host.service1578sudo systemctl reboot1579```15801581The pointer and keyboard devices must each be classified only for their own1582input role, and the separate gamepad device as a joystick, before the client1583control toggle is tested. `libinput debug-events` must report1584`POINTER_MOTION` and `POINTER_BUTTON`, never `POINTER_MOTION_ABSOLUTE`. For a1585downstream proof, display an isolated Xwayland target in Gamescope and activate1586it remotely:15871588```bash1589DISPLAY=:0 xmessage -center -buttons pass:0 'Sigil pointer probe'1590```15911592The command must exit with status 0 after the remote click; `evtest` alone only1593proves kernel delivery. During the live demo, prove motion to all four frame1594edges, each mouse button, both wheel axes, a modifier chord, ABXY, both1595shoulders, back/start/guide, both stick clicks, every d-pad direction, extrema1596and center on both sticks, and released/full values on both triggers. End with1597a held keyboard key, held gamepad button, displaced stick, and pressed trigger,1598then disconnect the client; `evtest` on all three event nodes must show the1599pointer button and key released and every gamepad button/axis returned to1600neutral.16011602For a deterministic protocol-to-uinput pointer smoke, leave both non-grabbing1603observers running and invoke the probe with `--pointer-smoke`:16041605```bash1606sudo evtest "$pointer_node"1607sudo libinput debug-events --device "$pointer_node"16081609# From the client checkout:1610target/debug/sigil-probe \1611 --node-id <host-node-id> \1612 --frames 120 \1613 --pointer-smoke \1614 --pointer-feedback-smoke1615```16161617The probe must report `pointer_smoke=ok` and `pointer_feedback_smoke=ok`.1618The feedback check requires an immediately available compositor position and1619visibility sample, then polls bounded input acknowledgments until the existing1620Xwayland tracker reports the exact post-sync and post-motion coordinates. An1621unavailable position, a timeout, or any coordinate drift fails the proof. This1622asserts the appliance invariant that Gamescope clamps the first relative jump1623at the surface origin and applies neither acceleration nor scaling to the1624virtual pointer. A generic libinput desktop profile is not assumed to satisfy1625that invariant.16261627The pointer smoke uses the native pointer-surface dimensions negotiated in the1628media `HostHello`, not the potentially downscaled encoded dimensions. On a16292560x1600 Gamescope surface, it must report1630`pointer_sync_position=1280,800` and1631`pointer_motion_position=1312,816`. Before the ordinary motion, evtest must1632show the synchronization sequence:1633`REL_X=-32767`/`REL_Y=-32767`, a `SYN_REPORT`, then1634`REL_X=1280`/`REL_Y=800` and another `SYN_REPORT`. It must then show1635`REL_X=32`, `REL_Y=16`, and a complete `BTN_LEFT` press/release, while libinput1636reports relative `POINTER_MOTION` and `POINTER_BUTTON` events. The probe fails1637closed if the host omits the native pointer-surface dimensions. The interactive1638`xmessage` activation remains the downstream Gamescope/Xwayland proof.16391640For compositor replacement recovery, keep the candidate Sigil daemon running,1641record its PID, and restart `gamescope-session-plus@steam.service` twice. After1642each restart, wait for the `gamescope` PipeWire node and the bootstrap root1643property to return, then rerun the pointer and feedback smoke without replaying1644the redeemed invitation. Both runs must pass, the Sigil PID must remain1645unchanged, and the host log must contain one `Gamescope Xwayland pointer1646feedback reconnected` event per restart. The packaged hardware UAT performs1647this sequence after its fixed-mode session checks. That runner independently1648enumerates accessible DRM render nodes and matches the exact GstVA H.2641649factory by its programmatically queried `device-path` and required CBR/CQP1650properties; it1651never assumes a kernel driver, `renderD128`, or the generic `vah264enc` factory.1652If the panel's resolved native1653mode is the same 1280x800 mode as the fixed performance leg, the native-config1654leg still runs and records that the two pixel sizes are identical instead of1655rejecting the host.16561657For a deterministic pre-demo gamepad proof, leave `evtest "$gamepad_node"`1658running on the host and run this from the client checkout:16591660```bash1661source ~/.cargo/env1662cargo build --locked -p sigil-host --bin sigil-probe1663target/debug/sigil-probe \1664 --node-id <host-node-id> \1665 --frames 120 \1666 --gamepad-smoke1667```16681669The probe must report `gamepad_smoke=ok`. `evtest` must show `BTN_SOUTH`,1670`BTN_TR`, `ABS_X`, `ABS_RY`, both trigger axes, and `ABS_HAT0X` reach their1671non-neutral values, followed by an explicit release/zero for every one of1672them. This proves protocol negotiation, uinput mapping, and neutralization;1673it does not replace the physical-controller client test.16741675On the first SSH login after reboot:16761677```bash1678systemctl --user status sigil-host.service --no-pager1679sudo journalctl -b -o short-monotonic --no-pager | \1680 grep -Ei 'sigil|sshd.*accepted|gamescope|steam'1681```16821683The Sigil unit start must predate the first accepted SSH login; otherwise SSH1684may have started the user manager and produced a false-positive boot test.16851686The synthetic source remains a diagnostic mode, not an automatic production1687fallback. A Gamescope capture failure must be surfaced as an error.16881689## 13. Evidence to save for every host image16901691Create one mode `0700` evidence directory per Bazzite deployment and test date.1692Sanitize journals before exporting them. Save:16931694- Bazzite image and OSTree deployment checksum.1695- Kernel, Mesa, Gamescope, PipeWire, FFmpeg, and Rust versions.1696- GPU PCI ID, Vulkan device, VA-API profiles, and `/dev/dri` permissions.1697- Source commit, Git bundle hash, `Cargo.lock` hash, container/Nix revision,1698 binary hash, and configuration hash with private paths redacted.1699- Host service journal and Gamescope/PipeWire session journal.1700- Direct versus relayed iroh path.1701- Frame counts, queue depth, drops, queue age, RSS, CPU, and GPU utilization.1702- Three-viewer native-MoQ progress, same-peer replacement, focus handoff1703 latency, live revocation, slow-viewer isolation, and final cleanup results.1704- Separate legacy-exclusive second-client rejection results.17051706Never copy the host identity seed into an evidence bundle. Treat node IDs and1707connection metadata as operationally sensitive until client authorization1708exists.17091710Until the daemon exports all of those metrics, collect the missing external1711samples during a bounded probe instead of leaving evidence fields blank:17121713```bash1714pid="$(systemctl --user show -p MainPID --value sigil-host.service)"1715test "$pid" -gt 11716for sample in $(seq 1 30); do1717 date --iso-8601=ns1718 ps -o pid,rss,%cpu,nlwp,etimes,cmd -p "$pid"1719 cat "/proc/$pid/io"1720 radeontop -d - -l 1 2>/dev/null || true1721 sleep 11722done | tee "$evidence_dir/runtime-samples.txt"1723```17241725## 14. Rollback and recovery17261727Stop and disable only the Sigil service:17281729```bash1730systemctl --user disable --now sigil-host.service1731```17321733Return to synthetic video by changing `source` back to `test-pattern`. Preserve1734the identity unless a new host identity is explicitly required.17351736Application releases live independently of the OSTree deployment. To roll back1737the daemon, select a previously validated release and restart the unit:17381739```bash1740sigil_root="$HOME/.local/libexec/sigil-spark"1741previous_commit="<validated-full-commit>"1742previous_config="$sigil_root/releases/$previous_commit/host.toml"1743cd "$sigil_root"1744previous_host="releases/$previous_commit/sigil"1745if [[ ! -x "$previous_host" ]]; then1746 previous_host="releases/$previous_commit/sigil-host"1747fi1748test -x "$previous_host"1749test -f "$previous_config"1750"$previous_host" config check --config "$previous_config"1751test ! -e current || test -L current1752ln -sfnT "releases/$previous_commit" current1753install -m 0600 "$previous_config" "$HOME/.config/sigil-spark/host.toml"1754systemctl --user restart sigil-host.service1755systemctl --user is-active --quiet sigil-host.service1756```17571758Keep a validated backup of `host.toml` with each application release and run1759`sigil config check` before restoring it. An OSTree rollback does not roll1760back binaries or configuration in the user's home directory, and it does not1761necessarily remove the SSH drop-in under `/etc`.17621763If a Bazzite update breaks the appliance, inspect deployments and roll back:17641765```bash1766rpm-ostree status -v1767sudo rpm-ostree rollback1768sudo systemctl reboot1769```17701771The previous deployment is also selectable from the boot menu. Do not reset or1772reinstall the machine until its logs and failing deployment ID have been saved.17731774If SSH hardening itself must be rolled back, do it from the physical console:17751776```bash1777sudo mv /etc/ssh/sshd_config.d/10-sigil.conf \1778 /etc/ssh/sshd_config.d/10-sigil.conf.disabled1779sudo sshd -t1780sudo systemctl reload sshd.service1781```17821783## Completion gates17841785### Steps 0–2 on `slate`17861787- Debug and explicitly feature-gated demo-client bypasses work and are visibly1788 labeled; ordinary release-client parsing rejects `--dev-connect`.1789- Shared protocol tests pass on both architectures.1790- Pure host binary has no Tauri dependency.1791- Synthetic H.264 streams from `slate` to the Mac.1792- The rendered Tauri/WebCodecs path starts on a keyframe and its transport,1793 frontend, and decoder drop counters do not grow after startup during the1794 demo soak.1795- Input transport remains responsive during video load.1796- Three-viewer native-MoQ admission, exclusive slot-0 focus, replacement, and1797 reconnect cycles pass; the separate legacy path still rejects a second client.17981799### Fresh Bazzite host18001801- Key-only SSH survives reboot.1802- The base deployment is pinned and recoverable.1803- AMD Vulkan and H.264 VA-API checks pass.1804- Sigil builds without layering host development packages.1805- The foreground synthetic service passes reconnect tests before Gamescope1806 cutover.1807- A cold, physically headless boot produces a Gamescope PipeWire node.1808- Switching to Gamescope capture does not require a portal or a physical1809 display.1810- Three exact-candidate Portals share one authenticated video/audio generation;1811 one constrained viewer can recover or detach without survivor latency growth.1812- Controller focus handoff, live input/view revocation, same-peer replacement,1813 resource percentiles, and final-viewer cleanup pass, followed by restoration1814 of the ordinary authenticated `sigil-host.service`.