diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..899f352 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# The glossary and the writing rules are approved by the project owner, entry by entry. +# Branch protection requires a code-owner review for these paths, so no change lands without him. +docs/glossary.md @eins78 +docs/lint-coinages.tsv @eins78 +docs/lint-allow.txt @eins78 +AGENTS.md @eins78 diff --git a/.github/workflows/dexd.yml b/.github/workflows/dexd.yml new file mode 100644 index 0000000..58dd98d --- /dev/null +++ b/.github/workflows/dexd.yml @@ -0,0 +1,650 @@ +# Build the dexd Debian package for Raspberry Pi gallery devices. +# +# WHY A CONTAINER ON AN ARM RUNNER +# -------------------------------- +# The package's whole purpose is that `Depends:` is derived from the sonames +# the binary actually links (dpkg-shlibdeps, see Cargo.toml's `depends = +# "$auto"`), so that apt refuses a libmpv ABI mismatch at install time on a +# bench rather than letting it surface as a black screen in a gallery. +# +# That guarantee is only worth anything if the build environment IS the target +# environment. `ubuntu-24.04-arm` gives us native arm64 (free on public repos, +# no qemu), but it is Ubuntu -- linking against Ubuntu's libmpv and then +# installing on Debian trixie would produce exactly the mismatch this package +# exists to prevent. So: arm64 runner for the architecture, debian:trixie +# container for the ABI. +# +# Verify the pairing whenever the fleet moves to a new Debian release: +# ssh '. /etc/os-release; echo $VERSION_CODENAME; dpkg -s libmpv2' +# +# TOOLCHAIN: DEBIAN'S RUSTC, NOT RUSTUP +# ------------------------------------ +# rustc and cargo are installed with apt, from the same archive the devices +# use, so CI compiles with the compiler the fleet actually has (trixie: 1.85). +# This was originally rustup stable, which was inconsistent with the paragraph +# above: a container that supplies the target's libraries but not its compiler +# is only half a target environment. +# +# What it buys: the Pi is a development host, and it can only keep building its +# own software while the crate stays inside trixie's Rust. With rustup here, +# "builds in CI" and "builds on the device" are separate claims, and the day +# they diverge is the day someone discovers it by hand -- which is how the +# cargo-deb MSRV gap below was found. +# +# The cost is real and deliberate: Debian's rustc lags, so a dependency needing +# a newer compiler cannot be adopted. That constraint should be felt HERE, on a +# red build, rather than at deploy time. `rust-version` in Cargo.toml states +# the same floor; this job is what enforces it. +name: dexd deb + +# NOTE ON TRIGGERS. This originally fired only on `dexd-v*` tags, plus +# workflow_dispatch — which meant it could not run AT ALL: +# * workflow_dispatch only registers for workflows present on the DEFAULT +# branch, and this file lives on the experiment branch, so it never appeared +# in the Actions UI or `gh workflow list`; +# * and a branch push matched nothing, because only `tags:` was listed. +# Four jobs that never execute report the same green nothing as four jobs that +# pass. Branch pushes are also what CI is FOR: linting and packaging checks that +# only run at release time cannot prevent the release. +# NO PATH FILTERS HERE, DELIBERATELY. A path-filtered workflow that does not run +# reports NOTHING, and a required status check that never reports blocks a merge +# forever -- the classic monorepo trap. So the workflow always runs, the `changes` +# job decides what is relevant, and the `gate` job at the end reports a single +# verdict that is safe to mark "required" in branch protection. +on: + workflow_dispatch: + push: + branches: ["main", "experiment/**", "feature/**"] + tags: ["dexd-v*"] + pull_request: + +# Cancel a superseded run rather than paying for it. Grouped per ref, so pushes +# to a branch supersede each other while a TAG build never cancels a branch build +# (and vice versa) -- a tag build produces the release artifact, so losing one to +# an unrelated push would be the wrong economy. +concurrency: + group: dexd-deb-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CRATE_DIR: packages/dexd + # build.rs embeds this as the binary's build identity. An ENV VAR, not the + # .dex-build-id file, because `rerun-if-changed` on a path that did not exist + # when the cached build ran is "never changed" — so with a warm target/ cache + # the stamp file is written and then IGNORED, producing a package that still + # reports (nogit). Observed exactly that on 2026-08-16. Cargo compares the + # VALUE of a `rerun-if-env-changed` variable, which has no such hole. + DEX_BUILD_ID: ${{ github.sha }} + +jobs: + # What actually changed? Computed here rather than by path filters, so the + # answer is available to every job AND to the gate. No third-party action: + # `git diff` against the right base is a few lines and one less dependency in + # a workflow that installs a Debian package onto gallery hardware. + changes: + runs-on: ubuntu-latest + outputs: + crate: ${{ steps.detect.outputs.crate }} + docs: ${{ steps.detect.outputs.docs }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need history to diff against a base + - id: detect + run: | + set -eu + # A tag build or a manual dispatch always runs everything: a release + # must never be decided by a diff, and a human pressing the button + # means "run it". + case "${{ github.event_name }}" in + workflow_dispatch) echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=manual dispatch" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + case "${{ github.ref }}" in + refs/tags/*) echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=tag build" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + base="${{ github.event.before }}" + fi + # A new branch, a force-push, or a first commit gives an unusable base + # (all-zeros or a missing object). Fail OPEN -- run everything -- because + # skipping on an unknown diff is how a real change slips through. + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=no usable diff base, running everything" >> "$GITHUB_OUTPUT"; exit 0 + fi + + changed=$(git diff --name-only "$base" HEAD) + echo "changed files:"; echo "$changed" | sed 's/^/ /' + crate=false; docs=false; reason="" + if echo "$changed" | grep -qE '^(packages/dexd/|\.github/workflows/dexd\.yml$)'; then + crate=true; reason="crate or this workflow changed" + fi + # The documentation gate: the public docs, the writing rules, the + # glossary and the lint tool itself. + if echo "$changed" | grep -qE '^(docs/|AGENTS\.md$|CLAUDE\.md$|scripts/docs-lint|\.github/workflows/dexd\.yml$)'; then + docs=true; reason="${reason:+$reason; }docs, rules or the docs lint changed" + fi + echo "crate=$crate" >> "$GITHUB_OUTPUT" + echo "docs=$docs" >> "$GITHUB_OUTPUT" + echo "reason=${reason:-nothing relevant changed}" >> "$GITHUB_OUTPUT" + + build: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + + steps: + - name: Install build dependencies + run: | + set -eux + apt-get update + # libmpv-dev is what the crate links; dpkg-dev provides + # dpkg-shlibdeps, which is what turns those links into Depends. + # rustc/cargo come from DEBIAN, not rustup -- see "Toolchain" below. + # ffmpeg supplies ffmpeg and ffprobe, which tests/sidecar_write.rs + # needs: it makes real HEVC streams to run `dex-sidecar write` + # against, and skips itself when they are missing. Without this line + # those tests would skip here and the writer would ship untested. + apt-get install -y --no-install-recommends \ + build-essential pkg-config ca-certificates git \ + rustc cargo rust-clippy libmpv-dev dpkg-dev lintian ffmpeg + # Record what we built and linked against. When a future .deb refuses + # to install on a device, these lines in the log are the first thing + # to compare against the device's own `dpkg -s libmpv2` / rustc -V. + dpkg -s libmpv2 | grep -E '^(Package|Version):' + rustc --version + cargo --version + # Fails the step if ffprobe is missing, so the sidecar-writer tests + # can never quietly skip their way to a green run. + ffprobe -version | head -n1 + + - uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + ${{ env.CRATE_DIR }}/target + key: dexd-deb-${{ hashFiles('packages/dexd/Cargo.lock') }} + restore-keys: dexd-deb- + + # Pinned to the 2.x line: cargo-deb 3.7 uses let-chains and needs rustc + # >= 1.88, which trixie does not have. The pin is a CONSEQUENCE of the + # toolchain decision, not an independent choice -- if this ever has to be + # unpinned, the toolchain question is what actually changed. + # + # Guard on the FILE, not on PATH. `command -v cargo-deb` was wrong in a way + # that only appears once the cache is warm: cargo installs to + # ~/.cargo/bin, which is NOT on PATH here (cargo comes from apt, there is + # no rustup to add it), so on a cache hit the guard said "missing" while + # cargo said "binary already exists in destination" and exited 101. Both + # were right about different questions. `cargo deb` itself still works + # because cargo searches $CARGO_HOME/bin for subcommands regardless of PATH. + - name: Install cargo-deb + run: test -x "$HOME/.cargo/bin/cargo-deb" || cargo install --locked cargo-deb --version "^2" + + # The package is only worth shipping if the code it contains passes. The + # full suite runs here because this container HAS libmpv -- the Mac + # checkout cannot link the binary at all, so `cargo test --lib` is the + # most a developer machine can do. + - name: Clippy + working-directory: ${{ env.CRATE_DIR }} + run: cargo clippy --all-targets -- -D warnings + + - name: Test + working-directory: ${{ env.CRATE_DIR }} + run: cargo test --release + + # C1 live-fire regression gate: forces a tier-0 in-place recovery + # against a REAL mpv core (software HEVC decode, vo=null, no display) + # and asserts the process SURVIVES its own recovery's + # END_FILE(reason=stop) -- the exact event-shape that killed every + # recovery attempt before the C1 fix. #[ignore]d in tests/cli.rs so it + # never runs in a default `cargo test` (a Pi mid-soak must not pick it + # up); this step is the one place it runs automatically. + # + # THE GREP IS THE GATE: libtest exits 0 when a filter matches NOTHING + # ("0 passed; 0 failed"), so a renamed/deleted test would leave this + # step green while running nothing. Asserting "1 passed" makes that + # loud instead of silent. + # + # What this proves and what it does not: the process survives its own + # recovery and time-pos resumes advancing, under software decode with + # no display. It does NOT prove the picture comes back on real + # hardware -- that needs hwdec=drm / drmprime-overlay / the DRM plane + # swap, none of which this container can exercise (no DRM, no GPU). + # That claim is still the on-Pi bench checklist item's job; see the + # test's own doc comment in tests/cli.rs. + - name: C1 live-fire (forced recovery vs real mpv) + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + out=$(cargo test --release --test cli -- --ignored --exact \ + force_recovery_survives_against_real_mpv 2>&1) || { echo "$out"; exit 1; } + echo "$out" + echo "$out" | grep -q '1 passed' \ + || { echo "::error::C1 gate did not actually run (filter matched nothing?)"; exit 1; } + + # Every CI build must produce a DISTINCT, ORDERED version, or apt refuses + # to install it over an identical one -- silently. Observed 2026-08-16: + # `apt install` reported "dexd is already the newest version (0.1.0-1)" + # and did nothing, so the device kept running an older binary while + # `dpkg -l` showed the expected version. Every signal agreed and all of + # them were wrong; only the startup line caught it. + # + # Scheme: +g. The run number LEADS because dpkg + # compares digit runs numerically, so 9 < 10 and ordering follows time. + # A commit-only revision does NOT work -- verified with + # `dpkg --compare-versions`: 0.1.0-1+gzz999999 sorts ABOVE + # 0.1.0-1+g000aaaaa, so an older build could outrank a newer one and apt + # would refuse the real upgrade as a downgrade. + - name: Build package + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + short=$(printf '%s' "$GITHUB_SHA" | cut -c1-12) + cargo deb --deb-revision "${GITHUB_RUN_NUMBER}+g${short}" + + # Print the derived Depends into the log. If dpkg-shlibdeps ever silently + # stops resolving libmpv, the package would still build and would still + # install -- onto a device with no libmpv, failing at runtime. Asserting + # it here keeps that from being a silent success. + - name: Verify derived dependencies + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + deb=$(find target/debian -name '*.deb' -print -quit) + test -n "$deb" + dpkg-deb --field "$deb" Package Version Architecture Depends + dpkg-deb --contents "$deb" + dpkg-deb --field "$deb" Depends | grep -q libmpv \ + || { echo "::error::Depends does not mention libmpv — dpkg-shlibdeps did not resolve it"; exit 1; } + # The BEHAVIOURAL floor, which dpkg-shlibdeps cannot derive: left to + # $auto alone the answer is `libmpv2 (>= 0.19.0)`, the oldest version + # exporting the symbols we call. What we actually require is 0.40 + # behaviour (drmprime-overlay; END_FILE(reason=stop) on a loadfile + # replace). Asserted here because deleting the explicit constraint in + # Cargo.toml would still produce a package that builds and installs. + dpkg-deb --field "$deb" Depends | grep -qE 'libmpv2 \(>= 0\.(4[0-9]|[5-9][0-9])' \ + || { echo "::error::Depends lost its explicit libmpv2 >= 0.40 floor"; exit 1; } + # The binary must be able to say WHICH build it is. `(nogit)` means + # build.rs found neither a stamp file nor a usable git, and every + # device carrying that package becomes unidentifiable in the field. + # Asserted rather than trusted: the stamp step above is one `printf` + # away from silently doing nothing. + # dpkg-deb -x rather than piping --fsys-tarfile into tar: the tarfile's + # members have no './' prefix, so `tar -xO ./usr/bin/dexd` finds + # nothing and exits 2. Extracting the tree sidesteps the path-prefix + # question entirely. + extract=$(mktemp -d) + dpkg-deb -x "$deb" "$extract" + test -x "$extract/usr/bin/dexd" + # 2>&1 is load-bearing: --version writes to STDERR. Without it $ver + # is empty, `case "" in *nogit*)` matches nothing, and the assertion + # reports success having observed nothing at all — which is precisely + # what it did when first added. + ver=$("$extract/usr/bin/dexd" --version 2>&1 | head -1) + echo "packaged binary reports: $ver" + case "$ver" in + *nogit*) echo "::error::packaged binary reports (nogit) — build identity not stamped"; exit 1 ;; + esac + # Positive assertion, because "does not contain nogit" is also true of + # the empty string. The build identity must be 12 hex characters and + # must match the commit CI is building. + # `cut`, not ${VAR:0:12}: container run: steps execute under /bin/sh + # (dash), where bash substring expansion is a "Bad substitution" error. + # The same bashism bit the lifecycle job earlier with diff <(...). + short=$(printf '%s' "$GITHUB_SHA" | cut -c1-12) + echo "$ver" | grep -qE "\($short(\+dirty)?\)" \ + || { echo "::error::version '$ver' does not carry this commit ($short)"; exit 1; } + + # The PACKAGE version must be distinct per build, not just the binary's + # internal string: apt decides whether to install by version alone. + pkgver=$(dpkg-deb --field "$deb" Version) + echo "package version: $pkgver" + case "$pkgver" in + *"$GITHUB_RUN_NUMBER+g$short"*) : ;; + *) echo "::error::package version '$pkgver' lacks the run number/commit — apt will refuse to reinstall it"; exit 1 ;; + esac + + # Policy check on the built package. --fail-on error,warning is the point: + # a linter that reports and exits 0 is a linter nobody reads. Accepted + # tags live in deploy/lintian-overrides WITH their reasoning, so silencing + # one is a reviewable diff rather than a flag on a command line. + - name: Lintian + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + deb=$(find target/debian -name '*.deb' -print -quit) + lintian --tag-display-limit 0 --fail-on error,warning "$deb" + + - uses: actions/upload-artifact@v4 + with: + name: dexd-deb + path: ${{ env.CRATE_DIR }}/target/debian/*.deb + if-no-files-found: error + + # Static checks on what we SHIP but do not compile: the systemd unit and the + # shell. Separate job so it reports independently of the package build and + # runs even when the build breaks -- these findings are usually the cause. + lint: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + steps: + - name: Install linters + run: | + set -eux + apt-get update + # devscripts provides checkbashisms; systemd provides systemd-analyze. + apt-get install -y --no-install-recommends \ + shellcheck devscripts systemd git ca-certificates reuse + + - uses: actions/checkout@v4 + + # THE reason this job exists. systemd silently ignores directives it does + # not recognise in a given section, so a typo or a section-migrated key is + # invisible at runtime -- the unit starts fine and simply does less than + # it says. That is how StartLimitIntervalSec=0 sat in [Service] doing + # nothing (fixed 2026-08-15): the most safety-critical line in the unit, + # ignored, while reading as though it worked. + # + # The two "Command ... is not executable" notices are expected here: the + # binaries live in the package, which is not installed in this container. + - name: systemd-analyze verify + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + out=$(systemd-analyze verify deploy/dexd.service 2>&1 || true) + echo "$out" + # Fail on anything that is NOT the known not-installed noise. + if echo "$out" | grep -vE 'is not executable|^$' | grep -q .; then + echo "::error::systemd-analyze reported problems with dexd.service" + echo "$out" | grep -vE 'is not executable|^$' + exit 1 + fi + + # Maintainer scripts run as root on every device, and run under /bin/sh -- + # dash on Debian, not bash. A bashism there fails an install in the field, + # on a machine nobody is sitting at. + - name: Shell checks + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + shellcheck deploy/dex-wait-hdmi deploy/maintainer-scripts/* + checkbashisms deploy/maintainer-scripts/* + + # REUSE (reuse.software): every file must carry copyright and licence + # information, machine-readably. dex aims to be installable from all the + # usual repos, and distro packagers are precisely the people who need a + # precise per-file answer -- this makes it checkable rather than asserted. + # + # Scoped to the crate with --root. Repo-wide compliance is the eventual + # goal but is blocked on the GPL-inherited packages (pi-gen -> dex-os), + # whose licensing is not settled; annotating those unilaterally would be + # making a claim rather than recording one. + - name: REUSE lint + working-directory: ${{ env.CRATE_DIR }} + run: reuse --root . lint + + # Turns SPEC §5c's dependency policy into a gate: advisories, a trimmed + # licence allow-list, and a ban on the proc-macro toolchain (see deny.toml — + # the "serde without derive" decision is otherwise only a comment). + # + # Runs on the bare runner with rustup, NOT in the trixie container. That is + # consistent with the toolchain rule rather than an exception to it: the rule + # binds what BUILDS THE SHIPPED ARTIFACT, so it stays inside the container. + # cargo-deny produces nothing that ships and needs a newer rustc than trixie + # has, exactly like cargo-deb — the difference is that cargo-deb builds the + # package, so it had to be pinned instead. + deny: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + - name: Install cargo-deny + run: | + set -eux + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: cargo deny check + working-directory: ${{ env.CRATE_DIR }} + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo install --locked cargo-deny + cargo deny check + + # Install / remove / purge lifecycle. This is the class of bug that only + # appears when a package is really installed and removed — maintainer scripts + # that fail, files that outlive a purge, users never created — and no static + # linter reaches it. + # + # This started as piuparts and is not, because piuparts has no installation + # candidate on Ubuntu noble/arm64 (it is simply not published for that arch), + # and inside a container it could not have used --docker-image anyway. The + # container IS a throwaway environment, which is the only thing piuparts's + # chroot was providing here, so the test runs directly. + # + # What is genuinely lost by not using piuparts: upgrade testing from a + # previous version (there is none yet) and its much broader leftover + # heuristics. The filesystem diff below is a deliberately narrow substitute. + lifecycle: + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + needs: [changes, build] + steps: + - uses: actions/download-artifact@v4 + with: + name: dexd-deb + + - name: Install / remove / purge + run: | + set -eux + apt-get update + deb=$(find . -name '*.deb' -print -quit); test -n "$deb" + + # Snapshot the filesystem so a purge can be checked for leftovers. + snap() { find / -xdev \( -path /proc -o -path /sys -o -path /run -o -path /tmp \ + -o -path /var/log -o -path /var/lib/apt -o -path /var/cache \ + -o -path /var/lib/dpkg -o -path /github \) -prune -o -print 2>/dev/null | sort; } + + # `apt install ./dexd.deb` pulls in every dependency and + # Recommends apt derives for it -- libmpv2's own libs, aria2 and + # yt-dlp (libmpv2 Recommends both), systemd and dbus (a systemd + # unit ships), and more. Not container noise: README.md documents + # production install as this exact plain `apt install + # ./dexd.deb`, so a stock Pi picks up the identical chain. + # Most of it autoremoves cleanly once dexd is purged, but + # systemd/dbus/policykit are Debian-protected and never + # autoremove -- AND their own postinst scripts create dynamic + # state (dbus's machine-id, systemd's catalog database, + # deb-systemd-helper's enablement markers...) that dpkg's static + # file list never mentions either. There is no way to tell "apt + # settling a protected dependency" apart from "dexd's postrm + # forgot to clean something" by inspecting paths after the fact. + # + # On a real device this whole paragraph is moot: systemd, dbus + # and policykit are already part of the base OS image, so + # installing dexd adds nothing new there. Reproduced here by + # running the FULL install/remove/purge/autoremove cycle once, + # unobserved, before the real run: that settles every protected + # package's one-time setup exactly as a real device's base image + # already has it, so the "before" snapshot below starts from the + # same place a real Pi does, and the leftovers diff at the end + # needs nothing cleverer than a plain diff to stay honest. + apt-get install -y "./${deb#./}" + apt-get remove -y dexd + apt-get purge -y dexd + apt-get autoremove --purge -y + + # /tmp, not /: snap() walks the whole filesystem, and a snapshot + # written to / would then be a directory entry snap's own find + # sees -- including itself as a "new" file relative to whichever + # snapshot didn't exist yet when it was taken. /tmp is already in + # snap()'s prune list for the same class of reason (excluding the + # runner's own transient state), so scratch output belongs there. + snap > /tmp/before.txt + + # --- the real, asserted run --- + apt-get install -y "./${deb#./}" + + # --- postinst did what it promises --- + test -x /usr/bin/dexd + test -x /usr/bin/dex-wait-hdmi + getent passwd dex # the service user exists + + # postinst only joins dex to a group that EXISTS in the target + # environment (see deploy/maintainer-scripts/postinst's own + # `getent group "$g"` guard) -- a real Pi has both video and render + # (verified on device: groups=105(dex),44(video),992(render)), but + # this minimal debian:trixie container has no udev, so render is + # never created here at all. Asserting unconditional membership in + # both groups was asserting something postinst never promised: THE + # TEST was wrong here, not the package. + # + # Mirror postinst's own conditional rather than hard-coding the + # group names twice, but don't let that degrade into a no-op: video + # is a static base-passwd group present in every Debian environment + # including this container, so its existence -- and dex's + # membership in it -- is asserted unconditionally below, not just + # conditionally skipped like render. That keeps at least one real + # assertion in force even in a future container where render is + # ALSO absent, so this loop can never silently pass on both groups. + getent group video >/dev/null \ + || { echo "::error::video group unexpectedly absent from this container -- the membership check below would be vacuous"; exit 1; } + for g in video render; do + if getent group "$g" >/dev/null; then + id -nG dex | grep -qw "$g" \ + || { echo "::error::postinst did not add dex to the existing '$g' group"; exit 1; } + else + echo "group '$g' does not exist in this container (expected: no udev here) -- skipping membership check" + fi + done + + test -d /opt/dex # asset directory created + test -f /lib/systemd/system/dexd.service + test -f /usr/share/man/man1/dexd.1.gz + # The package must NOT ship an asset: artwork is content, not software. + test ! -e /opt/dex/loop.265 + # Nor an exhibit config, and no /etc/dex at all. The exhibit config + # lives beside the video in /opt/dex and an operator writes it; a + # package-installed conffile is what this asserts is gone. + test ! -e /etc/dex + test ! -e /opt/dex/exhibit.yaml + test ! -e /opt/dex/exhibit.json + + apt-get remove -y dexd + test ! -e /usr/bin/dexd # binary gone + getent passwd dex # user RETAINED, by design + test -d /opt/dex # artwork RETAINED, by design + + apt-get purge -y dexd + test ! -e /lib/systemd/system/dexd.service + getent passwd dex # still retained after purge + test -d /opt/dex + test ! -e /etc/dex # never created, so nothing to purge + + apt-get autoremove --purge -y + + # --- leftovers --- + # Exactly two are intended, both deliberate in postrm: /opt/dex holds + # artwork the package never shipped, and `dex` may be referenced by + # anything the operator wrote. A THIRD leftover is a bug, so this + # diffs rather than trusting the exit code. The "before" snapshot at + # the top of this script was taken after an IDENTICAL, unobserved + # install/remove/purge/autoremove cycle for exactly this reason: every + # dependency apt settles for dexd -- protected packages included, + # the dynamic state their own postinst scripts create included -- is + # already present on BOTH sides and cancels out, leaving only what + # THIS run's postinst/postrm are actually responsible for. + # + # Filtered to temp files rather than `diff <(...) <(...)`: run steps + # in a `container:` job execute under /bin/sh (dash on trixie), not + # bash, and process substitution is a bashism dash does not have -- + # confirmed by CI itself once the group-membership fix above let the + # job reach this far for the first time ("Syntax error: ( unexpected"). + snap > /tmp/after.txt + grep -v '^/opt/dex' /tmp/before.txt > /tmp/before.filtered.txt + grep -v '^/opt/dex' /tmp/after.txt > /tmp/after.filtered.txt + if ! diff /tmp/before.filtered.txt /tmp/after.filtered.txt > /tmp/leftovers.diff; then + echo "::error::purge left files behind beyond the two intended:" + cat /tmp/leftovers.diff + exit 1 + fi + echo "lifecycle OK — install, remove and purge all behave as designed" + + # THE REQUIRED CHECK. Mark exactly this one as required in branch protection. + # + # The writing gate. docs/, the glossary and AGENTS.md must pass + # scripts/docs-lint.mjs (plan codes, private references, coinages, caps + # emphasis, first person, dates as structure ... see AGENTS.md). Scope widens + # to the crate's own comments and README when their rewrite lands; until + # then those are linted locally, not here, so this job can be required from + # day one without being red for the whole rewrite. + docs-lint: + needs: changes + if: needs.changes.outputs.docs == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Lint tool self-test + run: node --test scripts/docs-lint.test.mjs + - name: Lint the public docs and the rules + run: node scripts/docs-lint.mjs --coinages docs/lint-coinages.tsv docs AGENTS.md + + # It always runs (if: always()), so it always reports -- which is the whole + # point: a check that can be skipped can never be required. It then decides + # PROGRAMMATICALLY rather than by GitHub's own skip semantics, which lets it + # distinguish "legitimately skipped because nothing relevant changed" from + # "failed" from "cancelled". Anything more it should assert in future goes + # here, in shell, where it is readable. + gate: + name: required + if: always() + needs: [changes, build, lint, deny, lifecycle, docs-lint] + runs-on: ubuntu-latest + steps: + - name: Decide + run: | + set -eu + printf '%s' '${{ toJSON(needs) }}' > /tmp/needs.json + echo "relevant: crate=${{ needs.changes.outputs.crate }} docs=${{ needs.changes.outputs.docs }} (${{ needs.changes.outputs.reason }})" + jq -r 'to_entries[] | " \(.key): \(.value.result)"' /tmp/needs.json + + # failure or cancelled is a hard no. `skipped` is only acceptable when + # `changes` said nothing relevant changed -- a job skipped for any other + # reason (a failed dependency skips its dependents) must NOT pass. + if jq -e 'any(.[]; .result == "failure" or .result == "cancelled")' /tmp/needs.json >/dev/null; then + echo "::error::a required job failed or was cancelled"; exit 1 + fi + # Each job is relevant to one flag: docs-lint to `docs`, the rest to + # `crate`. A skip is legitimate only when its flag is false. + if jq -e --arg crate "${{ needs.changes.outputs.crate }}" --arg docs "${{ needs.changes.outputs.docs }}" \ + 'any(to_entries[]; .key != "changes" and .value.result == "skipped" + and ((.key == "docs-lint" and $docs == "true") or (.key != "docs-lint" and $crate == "true")))' \ + /tmp/needs.json >/dev/null; then + echo "::error::a job was skipped although its inputs changed -- that is a dependency failure, not a legitimate skip" + exit 1 + fi + echo "gate: PASS" diff --git a/.gitmodules b/.gitmodules index 508ef42..65b70c7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "packages/pi-gen"] path = packages/pi-gen url = https://github.com/eins78/pi-gen -[submodule "packages/dexd"] - path = packages/dexd - url = https://github.com/eins78/dexd [submodule "packages/branding"] path = packages/branding url = https://github.com/KTE/dex-branding diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..24ecaba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,214 @@ +# Writing and working rules for the dex repository + +These rules apply to everything a reader outside the project can see: the documentation under `docs/`, `README.md` files, man pages, `--help` and error text, code comments and doc-comments, commit messages that will survive a squash, and the Debian changelog. They were derived from a review of the project's own earlier text and are enforced by `scripts/docs-lint.mjs` where a machine can check them and by review where it cannot. + +Agents: read this file and `docs/glossary.md` before writing or editing any of the above. When a rule and your instinct disagree, the rule wins; when a rule is wrong, change the rule in a pull request rather than working around it. + +## Who the text is for + +- **User documentation** (`docs/guides/`, `packages/dexd/README.md`, man pages, messages the player prints): a venue technician or an artist's helper who can use a terminal and follow a recipe. Assume no knowledge of video codecs, Linux graphics or Rust. Every technical term is either plain English or a glossary entry marked *user*. +- **Developer documentation** (`docs/design/`, code comments, doc-comments): a competent Linux or Rust developer who has never seen this project. Assume general technical knowledge; every video, display or Raspberry Pi specific term is a glossary entry. +- Text that reads only to someone who followed the project's history is a defect, however accurate. + +## The rules + +1. Plan codes stay private. Names such as `F6`, `T7`, `M5`, `C1`, `§5c`, `A3` never appear in public text, identifiers, test names or messages. Name the thing (`the exhibit config`, `the sidecar check`, `the forced-recovery test`). *(lint)* +2. Private documents are not citations. `SPEC.md`, `PLAN.md`, `IMPLEMENTATION-PLAN.md`, the experiment log, `the story`, `the plan`, `the review`, `principle N`, `bug #N` are not citations. State the fact, or link a file under `docs/design/`. *(lint)* +3. Dates are provenance, not structure or justification. No date in a heading; no `as of YYYY-MM-DD` describing current behaviour. History goes to the changelog or the measurement record, with the date there. *(lint)* +4. Emphasis by word order, not typography. `ALL-CAPS` only for acronyms in the glossary, constants and environment variables; bold only for a literal the reader must type or will see on screen. *(lint)* +5. House intensifiers go. `deliberately`, `honest(ly)`, `genuinely`, `load-bearing`, `the whole point / story / trick`, `measured not argued`, `exactly` (unless before a number). If the sentence loses nothing without the word, the word goes. *(lint)* +6. Say what it is, not what it isn't. `Not X, it is Y` is allowed only when the reader plausibly believes X. Otherwise state Y. *(reader; density warned by lint)* +7. Use the field's word; coinages are replaced or defined once. The retired-words table below and `docs/lint-coinages.tsv` list the project's own coinages and what to write instead. Any term that is neither plain English nor in `docs/glossary.md` is a defect. *(lint)* +8. Introduce every referent in the document that uses it. No `the bench Pi`, `the Dell`, `the capture card`, `the box test`, `today's session`. Say what the device or event is the first time. *(lint for codenames; reader for the rest)* +9. Third person, no confession. No `I` / `we` / `us` / `our`, no `we measured`, `the honest answer`. State the fact and its provenance label. *(lint)* +10. Shipped text carries no session, review or revision talk. `three reviews`, `an earlier draft`, `this originally`, `used to be`, `at review time` belong in the changelog or a private log. Shipped text describes current behaviour only. *(lint for the keyword list; reader for the rest)* +11. Doc-comments describe; design docs argue. A doc-comment gives what the item does, its contract, and at most one sentence of why, with a link. Rationale longer than three sentences, rejected alternatives and incident stories move to `docs/design/`. *(reader; length warned by lint)* +12. Every *this / that / it / here* has its noun in the same or the previous sentence. When in doubt, repeat the noun. *(reader)* +13. One qualifier per claim, and a label instead of adverbs. Use *measured / decided / documented / derived / assumed / not tested*, once. *(reader)* +14. Structure by subject; status in words. Headings name what, never when; no emoji as status; no "(later)" splits. *(lint)* +15. A rule, not an aphorism; a mechanism, not a metaphor. If a sentence could be printed on a poster, replace it with the instruction it stands for. *(reader)* + +Numbers carry their unit and conditions ("29.1 fps at 3840×2160, 30 fps, Raspberry Pi 4"). Measured values say so briefly and link `docs/design/measurements.md` for the full conditions rather than repeating them. + +## Tone + +The reference points are a user manual written by the project owner (short paragraphs, one idea each; an unfamiliar notion explained in half a sentence; the reader addressed as "you"; steps as imperatives) and a component reference he considers well written (definition → basic usage → examples → `Important:` / `Note:` callouts → reference list; bullets for parallel behaviours; no history). Match them: + +- **A verb with a clear subject.** dexd, the player, the file, you. Not a nominalised event in the passive: `dexd logs the reason and exits`, not `a refusal is written`; `the exhibit config defines the video, the display mode and the connector`, not `it holds what the installation owns`. +- **Describe the behaviour, never personify or dramatise it.** `If the mode is wrong, dexd does not report it and the display stays black`, not `getting the mode wrong is silent`. `The seek causes the pause`, not `the seek as the cause`. No `holds`, `owns`, `trusts`, `believes`, `honest`, `quietly`, `silently` as characterisation. +- **One idea per paragraph, one to four sentences.** Three or more parallel items become a list; key–value material becomes a table. Sentences average under 25 words. +- **Lead with what to do or what it is; keep the why to one sentence**, or one short `Why` subsection at the end of the page when the reader needs it to decide. Do not stack a reason on a reason. +- **No history in shipped text.** Not what was tried on which day, not the shell loop that proved something, not who found what. A compact `Other options` list — one line per option, name and outcome — is allowed where it helps the reader choose. Longer history belongs in a project log, if one is created later. +- **Length: as short as completeness allows.** A page is finished when every must-cover claim is present once; if it is longer than that, cut. Do not restate conditions or caveats sentence after sentence — state them once, where they matter. +- **User guides say "you" and use imperatives** (`Copy the file`, `Run`, `Check`). Design documents use a neutral third person, still direct. + +Bad → good, from the first drafts: + +| Draft | Rewrite | +|---|---| +| the reason is measured | measured on a Raspberry Pi 4 (see the measurement record) | +| a refusal is written | dexd logs the reason and exits | +| Getting the mode wrong is silent | If the mode is wrong, dexd does not report it | +| It holds what the installation owns: the mode, the force flag and the connector | It defines the display mode, the forced mode and the connector | +| the seek as the cause | the seek causes the pause | + +### Comments and configuration files + +A comment is a note for the next reader or editor: what this is, why it is this way, what to check before changing it. It is not a report of how the project arrived here. The same rules as above, plus: + +- **State what is used and why, in one sentence.** `# Debian's rustc and cargo, because the package is built for Debian and must build with the compiler the devices have.` Not a shouted header, a paragraph of history and a reference to "the paragraph above". +- No history. `originally`, `this was`, `used to`, `we found`, `at review time`, `today` — cut, or move the fact to the design docs if it still matters. `# This was originally rustup stable, which was inconsistent…` says nothing a future editor needs. +- **No dramatisation, no verdicts on the code's own virtue.** `the cost is real and deliberate`, `what it buys`, `only worth anything if`, `not a routine bump`, `DECLARED, not avoided`, `CHECKED rather than merely written down` — delete the judgement, keep the instruction or the fact. +- **Give the editor an action.** `# Only raise this after confirming the target devices can still build the package.` Not `# Raising this floor is a decision about whether devices can still build their own software, not a routine bump.` +- **Comments in configuration files are short.** One or two lines above the setting they explain; a paragraph only for a rule that is not obvious from the setting itself. A configuration file that reads like an essay is a design document in the wrong place. + +Bad → good, from the CI workflow and Cargo.toml: + +| Draft | Rewrite | +|---|---| +| `That guarantee is only worth anything if the build environment IS the target environment.` | That can only be guaranteed if the build environment is the target environment. | +| `TOOLCHAIN: DEBIAN'S RUSTC, NOT RUSTUP — This was originally rustup stable, which was inconsistent with the paragraph above… What it buys: … The cost is real and deliberate` | Debian's rustc and cargo, not rustup: the package is built for Debian, so it is built with the compiler the devices have. Dependencies that need a newer compiler cannot be adopted; that is intended. | +| `Raising this floor is a decision about whether devices can still build their own software, not a routine bump.` | Only raise this after confirming the target devices can still build the package. | +| `Dependencies are DECLARED, not avoided — SPEC §5c. … a rule that forbade four small cargo crates while linking that was bookkeeping, not restraint.` | Dependencies: keep the set small enough to read; no procedural macros. Each entry below says what it is for. | +| `THE POINT OF THE PACKAGE. "$auto" runs dpkg-shlibdeps over the built binary, so Depends is DERIVED … and cannot drift from reality the way a hand-written list would. Never replace this with a literal list.` | `$auto` derives Depends from the libraries the binary links, so a libmpv version mismatch fails at install time. Keep it; add explicit floors below it for behaviour that no symbol expresses. | + +## Vocabulary + +`docs/glossary.md` is the only list of technical terms the documentation may use without explaining them. Its entries were approved one by one by the project owner. Rules for the file: + +- **Agents propose, never approve.** To add or change an entry, open a pull request that touches `docs/glossary.md`; `.github/CODEOWNERS` routes it to the owner. Do not merge glossary changes yourself, and do not paraphrase an existing definition. +- A user-tier definition must be understandable with no other entry. A developer-tier definition may reference other entries with "(see …)". +- The retired words below are never used in public text, whatever the tier. `scripts/docs-lint.mjs` reports the ones a machine can catch. + +### Names that were decided + +| Use | Not | +|---|---| +| `dexd` — the package, the binary, the service; the player | `dex-loop`, `dex_loop`, "the looper" | +| `dex-sidecar write` / `dex-sidecar check` | `make-sidecar.sh`, `sidecar-check` | +| `dex-exhibit-apply`, `dex-wait-hdmi` | — | +| **exhibit config** — the file `/etc/dex/exhibit.json` or `.yaml` | "the exhibit" on its own | +| **asset** / **video asset** — the video file; the **artwork** is the whole installation it plays in | "the artwork" for the file | +| **forced display mode** in prose; `kms_force` only as the literal config key | "kms force", "KMS forcing" | +| **system log** in prose; `journalctl -u dexd` in commands | "the journal" | +| **dex card** — the SD card that makes a Raspberry Pi a player | `player card` | +| **video container** | "container" alone | +| **loop point**; **gapless** / **seamless** (property); **a held frame**, **a freeze**, **the picture is stuck** (defect) | `seam`, `the wrap`, `hold`, `wrap point` | +| **long-running test**, **24-hour test** | `soak` | +| **test video** (an encoded test file); **test card** (the synthetic picture it is made from) | `bench asset` | +| **test rig** — one test setup; the **bench** — the development workstation and its hardware | "bench" for a setup | +| `--test-rig-no-sidecar`, `--test-rig-hang-after-secs`, `--test-rig-force-recovery-after-secs`; `(test rig only)` | `--bench-*`, `BENCH ONLY`, `wedge` | +| **unresponsive**, **hangs**, **is hanging** | `wedged`, `hung` | +| **supervisor thread** | `event thread` | +| `loops=` in the heartbeat; **loop count** or **loop iterations** in prose | `wraps=`, `wrap count` | +| **check** — the sidecar check, the asset check, the cmdline check | `gate` | +| **prepare the video** (user text and messages); *ingest* only in developer text | `re-ingest` | +| **refuses to start rather than guess** (user text); *fail-closed* only in developer text | `fail-closed in a guide` | +| **frame-duration histogram** | `dwell histogram` | +| **written into**, **stored in**, **saved copy of the EDID**, **build-id file** | `baked`, `baked-in`, `stamped`, `stamp file` | +| **in-place recovery**, **process restart by systemd**, **reboot escalation (planned)** | `tier 0 / 1 / 2 / 3` | +| **the pass criteria** | `the bar`, `the pass bar` | +| dexOS (the brand); `dex-os` (the repository) | `Dexbian` | + + +### Retired words — the full table + +| Retired | Write instead | +|---|---| +| `soak` / `soak test` / `24 h soak` / `soak run` / `soak harness` / `thermal soak` | long-running test / 24-hour test / long-term test (name the duration where it matters); 'the long-running-test harness' | +| `seam` / `the seam` / `seamless-loop as noun` / `'no seam'` / `'a seam'` | place: 'the loop point'; property: 'gapless' or 'seamless'; defect: 'a visible pause / a held frame / a stutter at the loop point' | +| `the wrap` / `wrap point` / `at the wrap` / `wrap-join` / `wrap transition` / `wr` | 'the loop point' (place); 'one loop' / 'one repeat' (the pass); 'loop count' (the counter); 'loop-position arithmetic' (the code) | +| `hold` / `holds` / `hold at the wrap` / `held (as noun)` | 'a freeze' / 'the picture is stuck at the loop point' / 'the frame stays on screen for N ms' — describe the defect plainly | +| `bench asset` / `bench-ready asset` / `the card (meaning the encoded video)` | 'test video' / 'the reference test video used for measurements' (a test video made from a test card) | +| `gaplessness premise` / `loop-ability` | 'the requirement that the loop is gapless' / 'whether a file can loop gaplessly' | +| `tier 0` / `tier-0` / `tier 1` / `tier 2` / `tier 3` | 'in-place recovery' (0), 'process restart by systemd' (1), 'reboot escalation (planned)' (2), 'hardware watchdog (planned)' (3) | +| `fail closed (user tier)` / `fail-closed contract` / `fail-silent` | user tier: 'refuses to start rather than guess'; developer tier: 'fail-closed' is a glossary term | +| `live-fire` / `live-fire probe` / `live-fire test` | 'against a real mpv instance' / 'on real hardware' / 'the forced-recovery test' | +| `wedged` / `wedge` / `core-wedge` / `display-wedged` / `'the wedge check'` | 'unresponsive' / 'hangs' / 'is hanging' / 'stopped responding while the process stays alive' — never `hung`; the flag becomes --test-rig-hang-after-se | +| `pinned (a behaviour is 'pinned' by a test)` | 'locked in by a test' / 'a test enforces' | +| `the loser` / `delete the loser` | 'the unwanted config file' / 'delete the one you do not mean' | +| `drift generator` | 'would make the boot config and the player's config diverge' | +| `black-wall time` | 'the worst-case time the screen can stay dark' | +| `spins hot` | 'busy-loops, using a full CPU core' | +| `belt-and-braces` | 'a fallback' / 'a second safeguard' | +| `the honest count` / `'honest' as an intensifier` | state the number: 'the binary links 228 shared objects' | +| `green CI` | 'CI passes' / 'a passing CI run' | +| `trap point` | 'the point inside mpv where the wait would unblock' | +| `event-shape` | 'the sequence of events' / 'this event' | +| `the classic monorepo trap` | 'a required check that can silently never run, blocking every merge' | +| `the crux` | 'the central tension: dexOS is buster, the player needs trixie' | +| `the box` / `the box test` / `'shares the box'` | 'the device' / 'the sealed-case thermal test' | +| `the rig` / `capture rig` / `'Bench = …'` | 'the measurement setup (a Pi 4, an HDMI capture device and the analysis scripts)' | +| `the wrong-panel case` | 'a resolution the connected display cannot show' | +| `venue truth, not asset truth` | 'the display mode belongs to the installation, not to the video file' | +| `the mains switch is the shutdown path` | 'there is no graceful shutdown; power is simply cut, and the player is built to survive that' | +| `field journal` / `field failure` / `in the field` / `on site` / `gallery devic` | 'the log' / 'a failure at the venue' / 'at the venue' / 'deployed players' | +| `deploy path` / `bench escape hatch` | 'normal startup (sidecar required)' / 'the test-rig-only override (`--test-rig-no-sidecar --fps`)' | +| `the binding` / `asset+fps binding` / `F3 gate` / `sidecar gate` / `NAL gate` / `` | 'the sidecar's checksum match' / 'the sidecar check' / 'the asset check' / 'the cmdline check' — 'check' in prose; 'gate' allowed as alias (? — needs | +| `THE EXTENSION DECIDES THE PARSER (all caps)` / `BENCH ONLY` / `ARMED (shou` | sentence case: 'the file extension selects the parser'; the literal warning line stays as shipped | +| `escalation ladder` / `'escalate per the fixed ladder'` | 'the pre-committed fallback order (pivid, then GStreamer, then a custom player)' | +| `annulus` / `fps honesty` / `matched wrap` / `'the wrap is matched by constru` | 'ring-shaped region' / 'how far a detected frame rate can be trusted' / plain description | +| `cleanroom extraction` / `cleanroom` | 'rewritten from scratch for publication' | +| `buster ceiling` | 'the buster limitation' / describe: 'gapless hardware playback only on buster (32-bit), so no upgrades and no Pi 5' | +| `the rotation trap` | 'sideways video from phone footage: the container's rotation flag is lost on extraction' (see elementary stream) | +| `(nogit)` / `+dirty as prose` | 'an unidentified build' / 'a build from uncommitted changes' — the literal version-string markers stay | +| `hello_video positive control` / `dexOS card` / `'the dexOS positive contro` | 'the known-good reference (the legacy hello_video player on its own test video)' | +| `mp_dispatch_lock` / `run_locked` / `mp_cond_wait` / `mp_dispatch_queue_proce` | describe the behaviour ('a synchronous property read waits with no timeout for mpv's core thread'); cite the mpv source location in a footnote if prov | +| `Rust identifiers used as prose nouns (HealthMonitor, ObservedCounter,` | in docs: describe the behaviour and name the module once ('the health policy in health.rs'); identifiers belong in code and API docs, not in guides | +| `supervisor thread (health.rs) vs event thread (heartbeat.rs, watchdog.` | 'supervisor thread' everywhere (one thread) | +| `gst1223` / `+rpt2 check` / `'the rpt2 criterion' as bare labels` | 'a GStreamer 1.22 attempt' / 'whether Raspberry Pi's patched ffmpeg build (+rpt2) is required on the Pi 5 — unresolved' | +| `USV` | 'battery backup (`UPS`)' | +| `starved feed` / `'signature of a starved feed'` | 'the data source not keeping up (frames held at random points, not at the loop point)' | +| `the linger bug` | 'the tmux session died with the last SSH login (systemd user session not lingering)' — an operations note for the private record, not dexd | +| `kiosk (flags` / `mode)` / `argv` / `'the working argv'` | 'fullscreen with no on-screen controls' / 'the mpv command line' | +| `baked` / `baked EDID` / `baked-in` / `stamped` / `stamp file` / `build stamp` | 'written into' / 'stored in' / 'saved copy of the EDID' / 'build-id file' — the words `baked` and `stamped` appear nowhere | +| `hung` | 'hangs' / 'is hanging' / 'unresponsive' — never `hung` | +| `--bench-no-sidecar` / `--bench-wedge-after-secs` / `--force-recovery-after` | `--test-rig-no-sidecar` / `--test-rig-hang-after-secs` / `--test-rig-force-recovery-after-secs` / `(test rig only)` | +| `wraps=` / `WRAP_COUNT` / `wrap count` | loops= (heartbeat field, code rename) / 'loop count' / 'loop iterations' in prose | +| `event thread` | 'supervisor thread' | +| `gate (as the noun for a startup refusal)` / `F3 gate` / `cmdline gate` / `NA` | 'check' — the sidecar check, the asset check, the cmdline check | +| `fail-closed` / `fail closed (user tier)` | 'refuses to start rather than guess' at user tier; developer tier keeps the glossary entry fail-closed | +| `kms_force (in prose)` | 'forced display mode' in prose; `kms_force` only as the literal config key | +| `journal` / `the journal (in prose)` | 'system log' in prose; `journalctl` in commands | +| `dwell` / `dwell histogram` / `dwell counts` | 'frame duration' / 'frame-duration histogram' | +| `player card` | 'dex card' | +| `container (alone)` | 'video container' | +| `re-ingest the asset (shipped message)` | 'prepare the video again with dex-sidecar write' | +| `ingest (user tier)` | 'prepare the video' / 'preparing a video'; developer tier may say ingest | + +### Names of people, places and things + +- No artist names, artwork titles, venues, exhibition names, SD-card ids, hostnames of development machines, or the owner's name in public text. `The project decided` replaces a person's name. +- A forum handle may appear in prose when the person's real name is unknown or the account is pseudonymous — always marked and explained: `*Foo*, a Raspberry Pi engineer on the official forums, …`. Otherwise `a Raspberry Pi engineer on the official forums`, with the link as the citation. +- The measurement instrument may be named once, as the instrument's identity, in `docs/design/measurements.md` (`an Elgato Cam Link 4K HDMI capture device`); one display model may serve as a worked example of a forced display mode. Everywhere else: `the capture device`, `a 2560×1440 monitor`. + +## Provenance labels + +Every measured number, decision and assumption in the documentation traces to a source. In text use one label, once: *measured* (say on what: "measured on a Raspberry Pi 4"), *decided*, *documented* (name the manual or spec), *derived*, *assumed*, *not tested*. The measurement record (`docs/design/measurements.md`) holds the conditions; other documents link it. + +## The mechanical gate + +``` +node scripts/docs-lint.mjs # default paths: packages/dexd, docs, .github/workflows/dexd.yml, AGENTS.md, README.md +node scripts/docs-lint.mjs docs/guides/prepare-video.md +``` + +Errors fail the run; warnings are printed. It reads `docs/glossary.md` (the acronym allow-set), `docs/lint-allow.txt` (per-token or per-path exceptions — every entry needs a reason after `#`, or the tool refuses to start) and `docs/lint-coinages.tsv` (retired words). It runs in CI on `docs/`, `AGENTS.md` and `README.md` files; the crate's comments join the gate when their rewrite lands. Fix an error by rewording; add an allowlist entry only for a true false positive, with the reason. + +## Where things go + +| Content | Place | +|---|---| +| How to build a dex card, prepare a video, configure the exhibit, run and troubleshoot | `docs/guides/` (user tier) | +| Reference: config keys, options, exit codes, every refusal message and its fix | `docs/guides/reference.md` | +| Why the player is built the way it is; how it fails and recovers; packaging and CI; measurements | `docs/design/` (developer tier) | +| The one-page front door | `packages/dexd/README.md` | +| Terms | `docs/glossary.md` | +| What changed between releases | `packages/dexd/deploy/changelog` (Debian format) | +| Rationale moved out of a code comment | the `docs/design/` page the comment links | + +## Commits and code + +- Commit subjects: `E:` for code and packaging, `D:` for documentation, imperative, ≤ 72 characters; the *why* in the body. No AI attribution lines. +- No behaviour change rides along with a wording change. Renames that the vocabulary requires (a flag, a heartbeat field, an identifier) are their own commit with tests updated. +- Test names are prose: `an_fps_that_contradicts_the_stream_is_refused`, not `f6_bad_fps`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/design/endless-stream.md b/docs/design/endless-stream.md new file mode 100644 index 0000000..ea3ebb2 --- /dev/null +++ b/docs/design/endless-stream.md @@ -0,0 +1,105 @@ +# Why an endless stream + +dexd does not ask mpv to loop a file. It registers its own stream protocol and hands mpv a byte stream that never reports end-of-file: after the last byte of the video comes byte 0 again, forever. mpv plays one file, once, and never finishes it. + +The reason is measured. Every looping mechanism on offer — in mpv, and in ffmpeg's direct-to-DRM output — holds a frame on screen every loop. mpv's mechanisms hold that frame at the loop point, because each makes the decoder re-enter the file, by a seek or by opening it again; ffmpeg's direct-to-DRM output holds one at the video's keyframes whether or not it re-enters. The endless stream removes the re-entry, and mpv is the decoder that takes a keyframe without stalling. + +## The held frame at the loop point + +With `--loop-file=inf` on the 3-second, 30 fps test video (90 frames), the last frame of the loop stays on screen for 83.3 ms against 33.3 ms for every other frame — an extra 50 ms, one and a half frame times, once per loop. A nominally 3.000-second video therefore repeats every 3.050 seconds. The held frame is always the same one, the video's last, in ten of ten loops with nothing skipped; a later, longer run held it in 61 of 61 loops across 11302 captured frames. Measured on a Raspberry Pi 4 against a capture of its 1920×1080, 60 Hz output, which samples each 30 fps source frame twice, so a held frame is directly observable; [measurements.md](measurements.md) holds the conditions. + +Nothing is dropped. Every drop counter mpv reports reads zero through such a run, which is why the defect is invisible to the player's own statistics and shows only in a capture of the HDMI output. + +## The seek as the cause + +A file that reached the same point without seeking held nothing, which locates the defect in the seek. Three configurations were run on one setup: `--loop-file=inf`, which seeks at the end of the file; `--ab-loop-a=0 --ab-loop-b=2.99`, which seeks before the end; and a file holding the video concatenated twelve times, played straight through with no seek at all. The two seeking configurations each held the last frame seven times over seven loop points. The concatenated file held nothing: 597 displayed frames, each occupying two captures. + +That `--ab-loop` behaves identically rules out mpv's end-of-file handling as the explanation — both configurations seek, and both keep the last frame on screen. At the loop point mpv seeks and must decode a fresh IDR, and the frame already on screen stays there while that happens. + +Two consequences follow. The Raspberry Pi 4 presents 30 fps content with correct frame timing using the same file, decoder and output path, so the hardware is not the limit. And seek-based looping is the defect, so a fix has to decode the loop's start before playback reaches the end, or have the next pass already decoding before the current one ends. + +## What each looping mechanism costs + +Every player that re-enters the file stalls, and the stall lands wherever that player does its work — an end-of-file seek, a seek before the end, or a file open. ffmpeg's direct-to-DRM output is the exception: its stalls fall at the video's keyframes, and they stay there when the end of the file is taken away. The figures below were measured on a Raspberry Pi 4 with the same capture setup, 1920×1080, 60 Hz output; [measurements.md](measurements.md) holds the conditions. + +| Mechanism | Where the held frame lands | Held frame | +|---|---|---| +| `--loop-file=inf` | at its end-of-file seek | 83 ms, once per loop | +| `--ab-loop-a` / `--ab-loop-b` | at its seek before the end | 83 ms, once per loop | +| `--playlist` with `--prefetch-playlist=yes` | at its open of the next entry | 117–133 ms, once per loop | +| `ffmpeg -stream_loop -1` into its direct-to-DRM output, `vout_drm` | at each keyframe, independent of the loop | 217 ms, three per loop | +| a concatenated file, one continuous decode | nowhere | none, across seven loop points | + +`--prefetch-playlist` exists to make playlist transitions gapless and makes the stall worse. The ffmpeg row was run on the expectation that its decode headroom — it is the fastest raw decoder measured, 1.92× realtime (see vout_drm in the glossary and [measurements.md](measurements.md)) — would absorb the transition; it did not, and its three stalls per loop land near the video's keyframe boundaries, about a second apart, rather than at the loop point. + +The two players fail in different places, and only mpv on an endless stream avoids both. mpv decodes an IDR mid-stream without stalling but stalls 83 ms on loop re-entry. ffmpeg's direct-to-DRM output stalls 67–217 ms on IDR frames, three per loop, but is clean at the loop point when fed the same endless stream. mpv on the endless stream has neither. + +## What the endless stream delivers + +Feeding mpv a stream that never ends removes the held frame entirely. Same Raspberry Pi 4, same video, same player, same display mode, only the feed differing: at 3840×2160, 30 fps the seeking configuration held its last frame ten times across nineteen loop points (the 4K capture samples below the display rate, so it catches only some; see capture deficit in the glossary), and the endless stream held nothing across nineteen. Confirmed at 1920×1080, 60 Hz output, where the capture samples every source frame twice and the answer is deterministic: nothing held across eight loop points. Measured; [measurements.md](measurements.md) holds the conditions. + +## Why byte 0 after the last byte is not a seek + +Concatenation worked because the decoder never re-initialises, not because the file was long. The video begins with the parameter sets followed by an IDR, and its GOPs are closed, so delivering byte 0 straight after the last byte hands the decoder another IDR, an ordinary mid-stream event that costs nothing. Annex-B HEVC concatenates at the bitstream level, so repeating a file's bytes produces a valid stream of any length. + +The video is small enough to repeat from memory: the 3-second test video is 1.3 MB at 1920×1080 and 14.8 MB at 3840×2160. dexd reads it once at startup and keeps the bytes in memory — the payload — for the life of the process, which takes the filesystem out of the path entirely: no re-open, no page-cache dependency, no read stalling at the loop point. + +## What the video must be + +The video must be a raw Annex-B HEVC elementary stream — a .265 file, taken out of its video container once when the video is prepared — and it must have a closed GOP with an IDR at frame 0. An open GOP would make frame 0 depend on pictures that no longer exist when byte 0 comes round again, manufacturing a defect — a stall or a glitch — silently, at every loop point, roughly 29000 times a day for a 3-second video. The requirement holds even for content that does not visibly loop, because byte 0 is re-presented either way. + +dexd enforces it at startup: the asset check reads the head of the stream and requires the parameter sets to appear before the first slice, and that slice to be an IDR. A CRA keyframe is refused, and the message names it as an open GOP. A stream with no Annex-B start code at all is refused as not being a raw HEVC stream. The checks, their messages and the exit-code contract are in [startup-checks.md](startup-checks.md); the checksum that binds the bytes to the prepared video is in [sidecar.md](sidecar.md). + +A raw stream also carries no timestamps, so the frame rate travels beside the video: dexd passes the sidecar's frame-rate string to mpv's `container-fps-override` and, in its default option set, turns off `correct-pts`. Without both, mpv guesses a rate and plays at the wrong speed with every metric nominal. Preparing the video is where resolution, frame rate, GOP structure and keyframe placement are all normalised — see [../guides/prepare-video.md](../guides/prepare-video.md). + +## The shell pipeline that proved the endless stream + +`while true; do cat loop.265; done | mpv -`, with `--no-correct-pts --container-fps-override=30` and the Raspberry Pi display options listed in [architecture.md](architecture.md), is the endless stream in one line, and it works: zero held frames across nineteen loop points at 3840×2160, 30 fps, with flat memory over 3.5 hours (measured). It is not shippable for two reasons. It spawns a process per loop — roughly 29000 a day for a 3-second video, derived from a day divided by the loop length. And if mpv ever exits, `cat` is killed by the broken pipe and the shell loop busy-loops, using a full CPU core: a player that goes from a stopped video to a processor core at full load with no further warning. + +## The loop:// stream + +dexd does in one process what the pipeline did with a shell loop, `cat` and mpv. It registers a `loop://` protocol with libmpv through `mpv_stream_cb_add_ro` before initialising the library, so the protocol exists by the time playback is requested, then plays the URL `loop://endless`. The open callback ignores the URL — the video is fixed at startup, so there is nothing to parse and nothing that can fail there. + +The read callback never returns 0. To mpv a read of 0 bytes is final end-of-file, the one event this player exists to prevent, so instead of reporting the end of the buffer the callback wraps its offset back to 0 and keeps copying. The reader's position lives in the per-stream state mpv hands back on every call, so it carries across calls instead of resetting on each one. + +The seek callback reports the stream unseekable and the size callback reports the size unknown, both with `MPV_ERROR_UNSUPPORTED` (-18), as a pipe does. An mpv that believes it can seek will seek, and seeking is the operation that costs the frame; refusing leaves "keep reading forwards" as the only available behaviour. Reporting the payload length would let mpv compute a duration and a progress position for a stream that has neither, and invite it to treat the end of the buffer as the end of the media. `MPV_ERROR_UNSUPPORTED` is the documented sentinel for stream callbacks; `-1` also happens to work, but only because mpv 0.40 tests the sign of the return value. + +Seeking is therefore gone by construction. That costs dexd nothing, since it only ever loops, and it rules this configuration out as a general-purpose player. + +## Loop-position arithmetic + +All of the decision-making lives in one pure function, `next_chunk(len, pos, want)`, which the read callback surrounds with the one memory copy the pure function cannot perform. Given the payload length, the reader's position and the requested byte count, it answers with an offset to copy from, a byte count and the position afterwards: + +``` +start = pos if pos < len else 0 +n = min(want, len - start) +next_pos = 0 if start + n == len else start + n +``` + +Three properties follow, and each is locked in by a test. + +- A short read is legal, per mpv's stream-callback header, so the loop point is never stitched across a single call: a request larger than the bytes remaining is answered with the tail now and the head on the next call. +- The position returns to 0 eagerly: when a copy reaches the end of the payload, `next_pos` is 0 and never the payload length, so between calls the position is always below the payload length. +- No answer is ever zero bytes. `next_chunk` returns nothing at all — which the caller turns into an mpv error, never 0 — in the two cases where it can produce no bytes without lying: a request for zero bytes, or an empty payload. + +Together they are what the read callback's unsafe copy rests on: at least one byte, never more than mpv asked for, a source range that never leaves the payload, and a destination buffer that cannot overlap it. + +Neither case, a zero-byte request or an empty payload, can happen in service: mpv 0.40 guards a zero-length request before calling in, and an empty video is refused at startup. The read callback's error-return branch is kept as a sentinel so dexd's own diagnostics can tell "asked for nothing" apart from "ran out of things to give"; a negative return would end the stream just as 0 does, since mpv maps any value at or below 0 to end-of-file. If it ever did fire on a later mpv, the result is an ordinary `END_FILE`, a fatal exit and a restart by systemd — not a held frame ([failure-handling.md](failure-handling.md)). + +The zero-byte property is the one a change could break invisibly. A callback that returned 0 for a zero-length request would leave every test in the crate passing except the one asserting an mpv error, and no run on hardware could catch it, because mpv never issues a zero-length read. + +The request size arrives from mpv as a 64-bit count and is converted with `usize::try_from`, falling back to the largest `usize`. On a 32-bit target a plain cast would turn a request that is an exact multiple of 2^32 into zero bytes and so into a spurious end-of-file; saturating can only shrink a request, and a short read is legal. dexd's target, 64-bit Raspberry Pi OS on a Pi 4, always succeeds at the conversion, so the branch is not exercised on target hardware (not tested); the test locks the contract against a plain cast. + +The property that matters most is tested directly: driving `next_chunk` repeatedly reproduces the payload repeated endlessly, byte for byte, over six request schedules against a 997-byte payload, a prime length — one byte at a time, the payload length, one short of it, one past it, 4096 bytes and a 2000-step pseudo-random schedule of sizes 1 to 300 from a fixed generator. Splitting the arithmetic out this way is what lets it be tested without libmpv and without a Raspberry Pi; the split is described in [architecture.md](architecture.md). + +## The loop counter and the read-ahead + +The heartbeat's `loops=` field counts completed passes over the payload. The read callback increments it whenever a copy lands back at position 0, on the demuxer's thread; the heartbeat reads it on the supervisor thread with relaxed ordering, a monotonic diagnostic rather than a synchronisation point. It counts demuxer passes, which run about one second ahead of the picture, so a heartbeat line names a slightly later loop than the screen shows. The heartbeat's other fields are in [failure-handling.md](failure-handling.md). + +That one second is the read-ahead: `demuxer-readahead-secs` is set to 1.0, which is the prefetch depth across the loop point where the gapless claim is decided. `demuxer-max-bytes` is set to 64 MiB as a second safeguard — mpv enables its stream cache by default only for streams it classifies as network, which a stream-callback stream is not, so the flat memory measured over 3.5 hours comes from the read-ahead setting. + +## Players considered before this design + +A survey of Raspberry Pi video players in 2024 found no player that looped gaplessly on a Raspberry Pi OS newer than buster; hello_video, on buster, did. omxplayer, the general-purpose alternative of the day, leaves about 100 ms of black between plays (assumed: no instrument recorded), which for a looping artwork is the defect that matters. The players below were tried by eye on a Raspberry Pi Zero 2 W (assumed: no instrument). `cvlc`, the VideoLAN player's command-line form, was not gapless, and gapless behaviour was expected only in its then-unreleased version 4. mplayer, another general-purpose player, did not work as installed. hello_drmprime, run with a repeat count, was not gapless. A GStreamer attempt stuck on the first frame, was not gapless, and dropped the display between plays. Cog, the minimal launcher for the embedded WebKit engine, played an H.264 test card gapless with occasional hiccups but stuttered on HEVC and logged a decoder warning about frames left undrained. pivid built and looped a 1-second video on a Raspberry Pi 4 (whether gaplessly was not recorded); it ran on a Raspberry Pi Zero 2 W only unstably, and its 32-bit builds later failed (see pivid in the glossary). Locking the output to 1920×1080, 30 Hz changed nothing for cvlc, mplayer or Cog, which ruled out 4K output as the cause. + +What remained was hello_video, which loops seamlessly when the same video is played more than once but plays raw H.264 only and needs Debian buster. The dex project's earlier player image, dexOS, is built on it, trading away audio and container-format support for a loop with no visible break, and gapless looping on anything newer than buster stayed an open problem. The endless stream closes it, and makes a general-purpose player usable in its place (derived): with a preparation step, the raw elementary stream that reads as hello_video's limitation is simply the output format. Where that leaves dexOS is in [roadmap.md](roadmap.md). diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..9411100 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,1181 @@ +# Glossary + +Terms used in the dex documentation. Every entry was reviewed and approved by the project owner; changes go through a pull request that CODEOWNERS routes to him. Entries marked **user** are the only technical terms the user guides use without explanation; **developer** entries may appear in the design documents. + +Writers: a term that is not in this list is either plain English or must be explained in the sentence that uses it. See `AGENTS.md` for the writing rules and the list of retired words. + +## User-tier terms + +### .265 file + +A video file that holds only the compressed HEVC pictures, with no wrapper such as MP4 around them. dexd plays this format and nothing else, so every video is converted to a .265 file before it goes on the player. + +Also written: raw HEVC stream · raw Annex-B file · .h265 · elementary-stream file + +Tier: user + +### .deb package + +The installable package file for Debian-based systems such as Raspberry Pi OS. dexd is delivered as one .deb, installed with apt, which also pulls in the mpv library it needs and sets up the service that starts at boot. + +Also written: Debian package · the .deb · dexd__arm64.deb + +Tier: user + +### asset + +The one video file the player loops — the video asset — named by the `asset` line of the exhibit config, as a file name next to the config (for example `artwork.265`) or an absolute path. If no asset is named, dexd refuses to start rather than guess. The artwork is the whole installation the asset plays in. + +Also written: video asset · the video · `asset` (config key) + +Tier: user + +### checksum + +A short code computed from every byte of a file; change one byte and the code changes. The sidecar stores the video's checksum, so dexd can tell a stale, wrong or half-copied file from the prepared one, and refuses it. + +Also written: SHA-256 · sha256 · hash · fingerprint + +Tier: user + +### cmdline.txt + +The one-line file on the Raspberry Pi's boot partition that holds the start-up options for the operating system, including a forced display mode. dex-exhibit-apply edits it from the exhibit config; do not hand-edit it, and reboot after it changes. + +Also written: /boot/firmware/cmdline.txt · kernel command line · boot options file + +Tier: user + +### connector + +The name the operating system gives each physical video output; on a Raspberry Pi 4 the two HDMI ports are HDMI-A-1 and HDMI-A-2. The exhibit config names the connector the display is plugged into (default HDMI-A-1), and every display check uses it. + +Also written: `connector` (config key) · HDMI-A-1 · HDMI-A-2 · HDMI port + +Tier: user + +### dex card + +An SD card holding Raspberry Pi OS, dexd, the exhibit config and the video, which turns a Raspberry Pi into a player the moment it boots. Building one is the setup task; a spare card is the fastest repair at a venue. + +Also written: player card · card · SD card · exhibition card + +Tier: user + +### dex-exhibit-apply + +A helper command installed with dexd, run with sudo after editing the exhibit config. It writes the forced display mode from the config into cmdline.txt, changing nothing else, and prints REBOOT REQUIRED only when the file actually changed. + +Also written: exhibit-apply · `sudo dex-exhibit-apply` + +Tier: user + +### dex-sidecar + +The command that writes a video's sidecar (`dex-sidecar write`) and checks an existing one against its video (`dex-sidecar check`), run on a workstation before copying to the player. It uses dexd's own reader, so what passes here plays there. + +Also written: dex-sidecar write · dex-sidecar check · sidecar-check (old name) · make-sidecar.sh (old script) + +Tier: user + +### dex-wait-hdmi + +A helper that runs before dexd and waits, up to two minutes, for a display to report it is connected. Projectors can wake slower than the Raspberry Pi boots; without the wait the system picks a fallback resolution and never corrects it. + +Also written: DEX_HDMI_TIMEOUT (its timeout setting) + +Tier: user + +### dexd + +The player program: it plays one .265 video on a Raspberry Pi in an endless gapless loop, starts at boot as a system service, checks its own health and restarts itself. Package, command and service share the name; helpers keep the dex- prefix. + +Also written: dex-loop (name during development) · dexd.service · the player + +Tier: user + +### display mode + +The picture size and refresh rate the player asks the display for, written as WIDTHxHEIGHT@RATE (for example 3840x2160@30) or `auto`. Set it in the exhibit config to match the display; a mode the display cannot show makes dexd refuse to start. + +Also written: `display_mode` (config key) · WxH@R · resolution and refresh · auto + +Tier: user + +### EDID + +The information a display sends over the HDMI cable describing itself and the modes it can show. dexd and the Raspberry Pi rely on it to choose a mode; some displays send it late or wrongly, which is why kms_force and dex-wait-hdmi exist. + +Also written: display identification · the display's self-description · edid-decode (tool that prints it) + +Tier: user + +### exhibit config + +The one file on each player that says which video plays (`asset`), which display mode to use and which connector. It lives next to the video, as `/opt/dex/exhibit.yaml` or `exhibit.json`; the package installs none. dexd reads it at every start; the command line may cross-check it but never override it. + +Also written: /opt/dex/exhibit.yaml · /opt/dex/exhibit.json · exhibit.yaml · exhibit.json · exhibit file · exhibit · the installation · venue setup · `venue` / `display` / `note` (informational config keys) + +Tier: user + +### exit codes + +The number dexd returns when it stops. 2 means it refused to start because something in the setup is wrong (fix the config or files; a restart will not help); 1 means playback failed while running, and the service manager restarts it automatically. + +Also written: exit-code contract · exit 1 · exit 2 + +Tier: user + +### ffmpeg + +A command-line tool that converts video between formats. The prepare-video guide uses it to encode HEVC with the settings dexd needs and to extract the .265 file; ffprobe, from the same toolkit, reports a file's size, frame rate and codec. + +Also written: ffprobe (its inspection tool) · libx265 / x265 (its HEVC encoder) · hevc_mp4toannexb (its MP4-to-.265 filter) + +Tier: user + +### forced display mode + +An exhibit-config setting (`kms_force`) that makes the Raspberry Pi output a fixed mode from boot (for example 3840x2160@30) instead of trusting what the display announces, or `none`. Needed for displays that announce 4K but never get it unforced; dex-exhibit-apply writes it into cmdline.txt. + +Also written: `kms_force` (config key) · WxH@R / WxH@RD + +Tier: user + +### frame rate + +How many pictures per second a video shows, for example 30 or 29.97 (written 30000/1001). A .265 file does not record it, so dexd takes it from the sidecar and refuses to start without one rather than play at the wrong speed. + +Also written: fps · frames per second · `fps` (sidecar field) · `--fps` + +Tier: user + +### gapless + +Playback that repeats with no visible break: no black frame, no held frame, no stutter between the last picture and the first. It is the property dexd exists to deliver, and what every measurement in the design record checks. + +Also written: seamless · seamless loop · perfect loop · loops seamlessly + +Tier: user + +### hardware decoding + +Turning compressed video back into pictures using a dedicated block in the chip instead of the main processor. A Raspberry Pi 4 can play 4K HEVC smoothly only this way, so dexd is built around it; software decoding is the slow fallback. + +Also written: hardware decode · HW decode · hardware-accelerated video · hwdec (mpv's option for it) + +Tier: user + +### heartbeat + +A status line dexd writes to the system log at start and every ten minutes: loop count (`loops=`), uptime, chip temperature, dropped and late frames, playback-position age, watchdog state. While it keeps coming the player is alive. It reports; the health check repairs; the watchdog restarts. + +Also written: heartbeat line · `loops=` (was `wraps=`) · `temp=` · `frame-drops=` · `vo-delayed=` · `pos=` · `pos-age=` · `watchdog=` + +Tier: user + +### HEVC + +The video compression format dexd plays, also called H.265. The Raspberry Pi 4 has a hardware decoder for it and for nothing newer, so 4K playback depends on the video being HEVC; other formats must be re-encoded first. + +Also written: H.265 · High Efficiency Video Coding + +Tier: user + +### keyframe + +A picture in a compressed video that is complete on its own, not described as changes from earlier pictures. A video for dexd must begin with one and must not let later pictures refer back across the start, or the loop cannot restart cleanly. + +Also written: intra frame · I-frame · IDR (the exact HEVC term, developer glossary) + +Tier: user + +### loop point + +The moment playback returns from the video's last picture to its first. Everything about a gapless loop is decided here: a pause, a held frame or a flash at the loop point is the defect dexd is designed to avoid and its measurements look for. + +Also written: restart of the loop · wrap point (retired wording) · the wrap (retired wording) · seam (retired wording) + +Tier: user + +### mpv + +The open-source media player whose engine dexd uses to decode and show video. dexd does not run the mpv program; it embeds mpv's library and feeds it the video, which is why installing dexd also installs the mpv library package (libmpv2). + +Also written: mpv 0.40 (the verified version) + +Tier: user + +### one-file rule + +Only one exhibit config may exist on a player: exhibit.json or exhibit.yaml, never both. If both are present dexd refuses to start and names both, so a venue never runs yesterday's settings from the file nobody edited. + +Also written: exactly one exhibit config · the extension decides the parser + +Tier: user + +### Raspberry Pi Imager + +The official program that writes Raspberry Pi OS onto an SD card and lets you set the hostname, user and SSH key before first boot. The player-card guide starts with it and sets the SSH key here. + +Also written: Imager + +Tier: user + +### sidecar + +A small text file next to the video, named like the video plus `.json`, holding its frame rate and checksum. dexd refuses to start unless it is present and matches, so a wrong frame rate or a half-copied video is caught before anything shows. + +Also written: