diff --git a/openfut-adapter-fifa17/src/blaze/client_config.rs b/openfut-adapter-fifa17/src/blaze/client_config.rs index e194c11..4cb873d 100644 --- a/openfut-adapter-fifa17/src/blaze/client_config.rs +++ b/openfut-adapter-fifa17/src/blaze/client_config.rs @@ -76,6 +76,23 @@ pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> { .collect() } +/// Fingerprint of the bundled config table. +/// +/// The table is generated data, so "which binary is this?" is only half the +/// question — "which data does it carry?" is the other half. A running host +/// logs this at startup so a live FIFA trace can be tied to an exact table, and +/// a rebuild that silently picked up regenerated fixtures is visible. +/// +/// FNV-1a, not a security hash and never used as one. +pub fn table_fingerprint() -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in TABLE_JSON.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + hash +} + /// Every CFID with its own section. Unknown CFIDs are still valid requests. pub fn known_sections() -> Vec<&'static str> { table() diff --git a/openfut-blaze-host/README.md b/openfut-blaze-host/README.md index 3a2667e..66400e8 100644 --- a/openfut-blaze-host/README.md +++ b/openfut-blaze-host/README.md @@ -113,25 +113,79 @@ Automated, re-runnable now: Requiring a FIFA client on the game machine — **not yet done**: 5. ⬜ FIFA reaches FUT with Rust Blaze. -6. ⬜ Close FIFA completely. -7. ⬜ FIFA reaches FUT a second time. -8. ⬜ Restore Python Blaze and confirm rollback works. -9. ⬜ Switch back to Rust and confirm again. -10. ⬜ Only then is Rust Blaze a viable runtime replacement. +6. ⬜ Open a pack — exercise a known-working FUT action, not just bootstrap. +7. ⬜ Close FIFA completely. +8. ⬜ Repeat 5–6 with Rust Blaze. +9. ⬜ Switch back to Python Blaze and verify FUT still works. +10. ⬜ Switch to Rust once more and verify again. -Gates 8 and 9 matter as much as 5: `Python → Rust → Python → Rust` proves the -rollback path rather than asserting one exists. +Gates 9 and 10 matter as much as 5: `Python → Rust → Rust → Python → Rust` +proves the rollback path rather than asserting one exists. -### Running gate 5 +## Process and switch safety -Keep the Python container exactly as it is. On the game machine, redirect **only -the Blaze destination** to the sidecar's port; the redirector, Nucleus, roster, -UTAS and POW keep hitting Python. The single changed variable is then Python -Blaze vs Rust Blaze. +Both were built after real incidents, not speculatively. -The redirector is what tells the client where Blaze lives, so the cleanest -switch is to point the Python redirector's advertised Blaze port at the sidecar -rather than reconfiguring the client. +* **Orphaned sidecars.** A previous session's mutation runs left four sidecars + listening, two serving deliberately broken builds. `sidecar.sh` refuses to + start when any sidecar is already running, and `stop` verifies both that the + PID is gone and that the port is free — failing if either check does not hold. + Orphan detection matches the resolved *executable*, not the command line: + `pgrep -f` was tried first and matched any shell whose arguments merely + mentioned the name. +* **A rollback that lied.** `blaze-switch.sh off` once reported success while + two rules remained active, because it matched `--comment "tag"` with quotes + that this iptables does not emit — and the verification used the same broken + matcher, so it confirmed its own failure. Rules are now matched on the bare + tag string, and `off` verifies with `iptables-save` plus a tag-independent + check that nothing still redirects the port. + +The general lesson, now applied throughout: **a verification must not share the +failure mode of the thing it verifies.** + +### Running gates 5–10 + +The Python redirector advertises a hardcoded `BLAZE_PORT = 42130` +(`blaze_responder_v3b.py:173`), so redirecting Blaze by reconfiguring it would +mean editing the frozen oracle and rebuilding the container. `blaze-switch.sh` +does it with a scoped NAT rule instead: no Python change, instant rollback. + +Rules match only `:42130`. Traffic to `127.0.0.1:42130` is deliberately +left alone, so Python stays directly reachable on loopback and the A/B keeps +comparing real Python against real Rust. + +```bash +cargo build -p openfut-blaze-host + +export OPENFUT_ADVERTISE= OPENFUT_BIND=0.0.0.0 \ + POW_CONTENT_HOST=:8085 \ + OPENFUT_BLAZE_HOST_BIND=0.0.0.0 OPENFUT_BLAZE_HOST_PORT= \ + OPENFUT_BLAZE_TRACE=/tmp/rust-blaze.trace + +./sidecar.sh start # refuses if an orphan or the port is busy +./blaze-switch.sh on # Blaze -> Rust +./blaze-switch.sh status # confirm before launching FIFA + +# … launch FIFA, reach FUT, OPEN A PACK, close FIFA, repeat … + +./blaze-switch.sh off # Blaze -> Python (rollback) +./sidecar.sh stop # refuses while the switch is on +``` + +`sidecar.sh stop` **refuses** while the switch is on: stopping the sidecar then +would leave Blaze pointed at a dead port. Use `stop --force` only deliberately. + +Evidence to keep from each live run: + +* `/tmp/rust-blaze.trace` — the normalized trace, which begins with the build + banner, so a session is attributable to an exact binary and config table +* the sidecar log — connection accepted, preAuth, login, the three + notifications, subsequent commands, close reason +* whether the pack opened, not just whether the hub loaded + +Then compare the Rust trace against a Python session trace. Message numbers and +timestamps are session-dependent and masked; the semantic sequence and payload +shapes must match. ## Diagnostics diff --git a/openfut-blaze-host/blaze-switch.sh b/openfut-blaze-host/blaze-switch.sh new file mode 100755 index 0000000..0bbfafe --- /dev/null +++ b/openfut-blaze-host/blaze-switch.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Switch the Blaze hop between the Python backend and the Rust sidecar, +# WITHOUT modifying the Python backend. +# +# blaze-switch.sh status +# blaze-switch.sh on Blaze -> Rust +# blaze-switch.sh off Blaze -> Python (rollback) +# +# WHY NAT RATHER THAN RECONFIGURING THE REDIRECTOR +# +# The Python redirector advertises a hardcoded `BLAZE_PORT = 42130` +# (blaze_responder_v3b.py:173), so pointing the client at another port would +# mean editing the frozen oracle and rebuilding the container. A scoped NAT rule +# changes nothing in Python, applies instantly, and rolls back with one command +# — exactly the property the A/B needs. +# +# SCOPE, deliberately narrow +# +# Rules match ONLY traffic to :42130: +# * PREROUTING — the FIFA client on the game machine (the real path) +# * OUTPUT — this host's own connections, so the switch can be smoke +# tested locally before FIFA is involved +# +# Traffic to 127.0.0.1:42130 is deliberately NOT matched, so Python stays +# directly reachable on loopback while the switch is on. That is what lets +# check-live-parity.sh compare real Python against real Rust rather than +# accidentally comparing Rust against itself. +# +# MATCHING RULES BY THE BARE TAG, ON PURPOSE +# +# An earlier version matched `--comment "tag"` with quotes. This iptables emits +# the comment unquoted, so removal silently found nothing — and because the +# post-removal verification used the SAME matcher, it confirmed its own failure +# and reported a successful rollback that had not happened. A rollback that lies +# is worse than one that fails. +# +# Two lessons are baked in below: match the bare tag string so no output-format +# assumption can be wrong, and verify with a predicate that does not share the +# removal's failure mode. +set -uo pipefail + +TAG="openfut-blaze-switch" +PY_PORT=42130 +SUDO="" +[[ $EUID -eq 0 ]] || SUDO=sudo + +die() { echo "blaze-switch: $*" >&2; exit 1; } + +# Full rule specs carrying our tag, as `-A CHAIN ...` lines. +tagged_specs() { + $SUDO iptables -t nat -S 2>/dev/null | grep -F -- "$TAG" || true +} + +# Independent verifier: a different command and a different output format from +# the one used to build delete commands, so a parsing bug cannot hide itself. +count_tagged() { + $SUDO iptables-save -t nat 2>/dev/null | grep -cF -- "$TAG" || true +} + +# Behavioural check: is anything still redirecting our port? +redirects_to() { + $SUDO iptables -t nat -S 2>/dev/null \ + | grep -E -- "--dport ${PY_PORT}\b" \ + | grep -F -- "REDIRECT" || true +} + +cmd_status() { + local specs count + specs="$(tagged_specs)" + count="$(count_tagged)" + + if [[ -z "$specs" && "$count" == "0" ]]; then + echo "Blaze is served by PYTHON (no switch rules)" + else + echo "Blaze is redirected to the RUST sidecar:" + [[ -n "$specs" ]] && echo "$specs" | sed 's/^/ /' + fi + + # Disagreement between the two views means one of them is parsing wrongly — + # report it rather than trusting either. + local n_specs + n_specs="$(printf '%s' "$specs" | grep -c . || true)" + if [[ "$n_specs" != "$count" ]]; then + echo " WARNING: rule views disagree (specs=$n_specs, save=$count)" >&2 + fi + + local other + other="$(redirects_to | grep -vF -- "$TAG" || true)" + if [[ -n "$other" ]]; then + echo " note: other REDIRECT rules also touch port $PY_PORT:" >&2 + echo "$other" | sed 's/^/ /' >&2 + fi + return 0 +} + +remove_rules() { + local removed=0 spec + while IFS= read -r spec; do + [[ -n "$spec" ]] || continue + # `-A CHAIN args…` -> `-D CHAIN args…` + # shellcheck disable=SC2086 + if $SUDO iptables -t nat -D ${spec#-A } 2>/dev/null; then + removed=$((removed + 1)) + else + echo "blaze-switch: failed to delete: $spec" >&2 + fi + done < <(tagged_specs) + echo "$removed" +} + +cmd_on() { + local ip="${1:-}" port="${2:-}" + [[ -n "$ip" && -n "$port" ]] || die "usage: blaze-switch.sh on " + + # Never stack rules: start from a known state. + remove_rules >/dev/null + + $SUDO iptables -t nat -I PREROUTING 1 -p tcp -d "$ip" --dport "$PY_PORT" \ + -m comment --comment "$TAG" -j REDIRECT --to-ports "$port" \ + || die "failed to add PREROUTING rule" + $SUDO iptables -t nat -I OUTPUT 1 -p tcp -d "$ip" --dport "$PY_PORT" \ + -m comment --comment "$TAG" -j REDIRECT --to-ports "$port" \ + || die "failed to add OUTPUT rule" + + local count + count="$(count_tagged)" + [[ "$count" == "2" ]] || die "expected 2 rules after 'on', found $count" + + echo "Blaze -> RUST: $ip:$PY_PORT now lands on local port $port" + echo " 127.0.0.1:$PY_PORT still reaches PYTHON (unmatched by design)" + echo " roll back with: $0 off" + echo + echo " NOTE: while this is on, the sidecar MUST stay up. Stopping it without" + echo " switching off leaves Blaze pointing at a dead port." +} + +cmd_off() { + local before removed after + before="$(count_tagged)" + removed="$(remove_rules)" + after="$(count_tagged)" + + if [[ "$after" != "0" ]]; then + echo "FAILED: $after switch rule(s) still present after removing $removed" >&2 + tagged_specs | sed 's/^/ /' >&2 + return 1 + fi + # Independent of the tag entirely: nothing should still be redirecting the + # Blaze port. Catches a rule that lost its comment somehow. + local stray + stray="$(redirects_to)" + if [[ -n "$stray" ]]; then + echo "FAILED: a REDIRECT rule still targets port $PY_PORT:" >&2 + echo "$stray" | sed 's/^/ /' >&2 + return 1 + fi + + echo "Blaze -> PYTHON: removed $removed rule(s) (was $before), verified none remain" + return 0 +} + +case "${1:-}" in + status) shift; cmd_status "$@" ;; + on) shift; cmd_on "$@" ;; + off) shift; cmd_off "$@" ;; + *) sed -n '2,10p' "$0" | sed 's/^# \?//'; exit 2 ;; +esac diff --git a/openfut-blaze-host/build.rs b/openfut-blaze-host/build.rs new file mode 100644 index 0000000..ec34228 --- /dev/null +++ b/openfut-blaze-host/build.rs @@ -0,0 +1,46 @@ +//! Stamp build identity into the binary. +//! +//! A live FIFA trace has to be attributable to an exact binary. During the +//! mutation runs for the previous step, four sidecars were left listening — +//! two of them serving deliberately broken builds — and nothing in their output +//! said so. A later A/B against one of those would have read as a genuine +//! parity failure. +//! +//! So the host prints its commit, working-tree cleanliness and profile at +//! startup, and `dirty` is the important one: a mutation-tested build is a +//! dirty build, and now it announces itself. + +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn main() { + let commit = git(&["rev-parse", "--short=7", "HEAD"]).unwrap_or_else(|| "unknown".into()); + + // Tracked modifications only: untracked scratch files are not a build + // difference, but an edited source file certainly is. + let dirty = match git(&["status", "--porcelain", "--untracked-files=no"]) { + Some(s) if !s.is_empty() => "DIRTY", + Some(_) => "clean", + None => "unknown", + }; + + println!("cargo:rustc-env=OPENFUT_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=OPENFUT_BUILD_DIRTY={dirty}"); + + // Re-stamp when HEAD moves. Working-tree edits are not tracked by cargo's + // dependency graph, so `dirty` can go stale until something forces a + // rebuild — the startup banner says which commit it was stamped from, and + // the sidecar script re-checks the working tree independently at launch. + for p in ["../.git/HEAD", "../.git/index"] { + if std::path::Path::new(p).exists() { + println!("cargo:rerun-if-changed={p}"); + } + } +} diff --git a/openfut-blaze-host/sidecar.sh b/openfut-blaze-host/sidecar.sh new file mode 100755 index 0000000..280b548 --- /dev/null +++ b/openfut-blaze-host/sidecar.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Lifecycle manager for the Blaze sidecar. +# +# sidecar.sh start start in the background, wait until it is listening +# sidecar.sh stop stop it, then VERIFY it is gone +# sidecar.sh status report +# sidecar.sh check-orphans fail if any sidecar is listening unexpectedly +# sidecar.sh with -- CMD… start, run CMD, always stop and verify +# +# WHY THIS EXISTS +# +# A previous session's mutation runs left four sidecars listening, two of them +# serving deliberately broken builds, because `kill %1` does not carry across +# shell invocations. A later A/B against one of those would have looked like a +# genuine parity failure. Ad-hoc backgrounding is not good enough before a live +# FIFA test. +# +# So stopping is not "send a signal and hope". It kills, waits, and then proves +# both that the PID is gone AND that the port is no longer listening. If either +# check fails, this script fails — a leaked sidecar must never be silent. +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +RUNDIR="${OPENFUT_SIDECAR_RUNDIR:-${TMPDIR:-/tmp}/openfut-sidecar}" +PIDFILE="$RUNDIR/sidecar.pid" +PORTFILE="$RUNDIR/sidecar.port" +LOGFILE="${OPENFUT_SIDECAR_LOG:-$RUNDIR/sidecar.log}" + +BIN="$ROOT/target/debug/openfut-blaze-host" +[[ -x "$BIN" ]] || BIN="$ROOT/target/release/openfut-blaze-host" + +die() { echo "sidecar: $*" >&2; exit 1; } + +port_listening() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltn 2>/dev/null | grep -qE "[:.]${port}[[:space:]]" + elif command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + else + # No way to check is not the same as "it is clean" — refuse to guess. + die "neither ss nor lsof available; cannot verify port state" + fi +} + +pid_alive() { kill -0 "$1" 2>/dev/null; } + +# ---------------------------------------------------------------- orphans + +# Any sidecar process at all, whether or not this script started it. +# +# Matches the resolved EXECUTABLE, not the command line. `pgrep -f` was tried +# first and was wrong: it matched any process whose arguments merely mentioned +# the name — including the shell running this script, and any editor or script +# with the string in it. That is a false positive that refuses legitimate +# starts, which during a FIFA test is worse than the leak it guards against. +# +# `pgrep -x` is also unusable here: Linux truncates the process name to 15 +# characters, so the binary appears as "openfut-blaze-h". +list_sidecars() { + local self=$$ pid exe + for d in /proc/[0-9]*; do + pid="${d#/proc/}" + [[ "$pid" == "$self" ]] && continue + exe="$(readlink -f "$d/exe" 2>/dev/null)" || continue + [[ "${exe##*/}" == "openfut-blaze-host" ]] && echo "$pid" + done + return 0 +} + +cmd_check_orphans() { + local found + found="$(list_sidecars)" + if [[ -z "$found" ]]; then + echo "no sidecar processes running" + return 0 + fi + echo "ORPHANED SIDECAR PROCESS(ES) FOUND:" >&2 + for p in $found; do + echo " pid $p: $(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || echo '?')" >&2 + done + echo >&2 + echo "Refusing to proceed: a stale sidecar may be serving a mutated build," >&2 + echo "and an A/B against it would read as a real parity failure." >&2 + echo "Stop them with: pkill -f openfut-blaze-host" >&2 + return 1 +} + +# ------------------------------------------------------------------ start + +cmd_start() { + [[ -x "$BIN" ]] || die "binary not built; run: cargo build -p openfut-blaze-host" + : "${OPENFUT_BLAZE_HOST_PORT:?set OPENFUT_BLAZE_HOST_PORT (no default, so the sidecar cannot collide with the Python backend)}" + : "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE to the address the game machine uses to reach this host}" + + cmd_check_orphans >/dev/null 2>&1 || { cmd_check_orphans; die "clean up first"; } + + if port_listening "$OPENFUT_BLAZE_HOST_PORT"; then + die "port $OPENFUT_BLAZE_HOST_PORT is already in use" + fi + + mkdir -p "$RUNDIR" + echo "$OPENFUT_BLAZE_HOST_PORT" > "$PORTFILE" + + "$BIN" >"$LOGFILE" 2>&1 & + local pid=$! + echo "$pid" > "$PIDFILE" + + # Wait for the listener rather than sleeping a guess. + local waited=0 + while (( waited < 100 )); do + if ! pid_alive "$pid"; then + echo "sidecar died during startup; log:" >&2 + tail -20 "$LOGFILE" >&2 + rm -f "$PIDFILE" + return 1 + fi + if port_listening "$OPENFUT_BLAZE_HOST_PORT"; then + echo "sidecar started: pid $pid, port $OPENFUT_BLAZE_HOST_PORT" + grep -m1 'openfut-blaze-host v' "$LOGFILE" 2>/dev/null | sed 's/^/ /' + if grep -q 'WARNING: built from a modified working tree' "$LOGFILE" 2>/dev/null; then + echo " !! DIRTY BUILD — results are not parity evidence" >&2 + fi + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + + echo "sidecar did not begin listening within 10s; log:" >&2 + tail -20 "$LOGFILE" >&2 + kill "$pid" 2>/dev/null + rm -f "$PIDFILE" + return 1 +} + +# ------------------------------------------------------------------- stop +# +# Kill, wait, then PROVE it. Both conditions must hold or this fails. + +cmd_stop() { + local rc=0 + local pid="" port="" + [[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")" + [[ -f "$PORTFILE" ]] && port="$(cat "$PORTFILE")" + + # Stopping the sidecar while the Blaze switch is still on leaves the client + # pointed at a dead port — Blaze breaks and nothing says why. This exact state + # was created once during development, so this REFUSES rather than warning: + # a warning on stderr that is followed by doing the dangerous thing anyway is + # not a safeguard. + if [[ "${1:-}" != "--force" && -x "$HERE/blaze-switch.sh" ]]; then + if "$HERE/blaze-switch.sh" status 2>/dev/null | grep -q "redirected to the RUST"; then + echo "REFUSING to stop: the Blaze switch is still ON." >&2 + echo " Stopping now would leave Blaze pointing at a dead port." >&2 + echo " Roll back first: ./blaze-switch.sh off" >&2 + echo " Or override: ./sidecar.sh stop --force" >&2 + return 1 + fi + fi + + if [[ -n "$pid" ]] && pid_alive "$pid"; then + kill "$pid" 2>/dev/null + local waited=0 + while pid_alive "$pid" && (( waited < 50 )); do sleep 0.1; waited=$((waited+1)); done + if pid_alive "$pid"; then + echo "sidecar $pid ignored SIGTERM; escalating to SIGKILL" >&2 + kill -9 "$pid" 2>/dev/null + waited=0 + while pid_alive "$pid" && (( waited < 50 )); do sleep 0.1; waited=$((waited+1)); done + fi + fi + + # Verification, not optimism. + if [[ -n "$pid" ]] && pid_alive "$pid"; then + echo "FAILED to stop sidecar pid $pid" >&2 + rc=1 + fi + if [[ -n "$port" ]] && port_listening "$port"; then + echo "FAILED: port $port is still listening after stop" >&2 + rc=1 + fi + local strays + strays="$(list_sidecars)" + if [[ -n "$strays" ]]; then + echo "FAILED: sidecar process(es) still running: $strays" >&2 + rc=1 + fi + + rm -f "$PIDFILE" "$PORTFILE" + if [[ $rc -eq 0 ]]; then + echo "sidecar stopped and verified gone${pid:+ (pid $pid)}${port:+, port $port free}" + fi + return $rc +} + +cmd_status() { + if [[ -f "$PIDFILE" ]] && pid_alive "$(cat "$PIDFILE")"; then + echo "running: pid $(cat "$PIDFILE"), port $(cat "$PORTFILE" 2>/dev/null || echo '?')" + else + echo "not running (per pidfile)" + fi + local strays + strays="$(list_sidecars)" + [[ -n "$strays" ]] && echo "sidecar processes on this host: $strays" + return 0 +} + +# ------------------------------------------------------------------- with +# +# Start, run a command, and stop+verify no matter how the command exits. + +cmd_with() { + cmd_start || return 1 + # shellcheck disable=SC2317 + cleanup() { cmd_stop || echo "sidecar: CLEANUP VERIFICATION FAILED" >&2; } + trap cleanup EXIT INT TERM + + "$@" + local rc=$? + + trap - EXIT INT TERM + cmd_stop || { echo "sidecar: cleanup verification failed" >&2; return 1; } + return $rc +} + +case "${1:-}" in + start) shift; cmd_start "$@" ;; + stop) shift; cmd_stop "$@" ;; + status) shift; cmd_status "$@" ;; + check-orphans) shift; cmd_check_orphans "$@" ;; + with) + shift + [[ "${1:-}" == "--" ]] && shift + [[ $# -gt 0 ]] || die "usage: sidecar.sh with -- COMMAND [ARGS…]" + cmd_with "$@" + ;; + *) + sed -n '2,10p' "$0" | sed 's/^# \?//' + exit 2 + ;; +esac diff --git a/openfut-blaze-host/src/lib.rs b/openfut-blaze-host/src/lib.rs index c18f95b..04a4b5c 100644 --- a/openfut-blaze-host/src/lib.rs +++ b/openfut-blaze-host/src/lib.rs @@ -76,6 +76,40 @@ impl std::fmt::Debug for Hooks { } } +/// Build identity, stamped by `build.rs` and printed at startup. +/// +/// `dirty` is the load-bearing field: a mutation-tested build is a dirty build, +/// and a live trace must never be attributable to one by accident. +pub struct BuildIdentity { + pub commit: &'static str, + pub dirty: &'static str, + pub profile: &'static str, + pub version: &'static str, +} + +pub const BUILD: BuildIdentity = BuildIdentity { + commit: env!("OPENFUT_BUILD_COMMIT"), + dirty: env!("OPENFUT_BUILD_DIRTY"), + profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + version: env!("CARGO_PKG_VERSION"), +}; + +/// One line naming the binary and the generated data it carries. +pub fn build_banner() -> String { + format!( + "openfut-blaze-host v{} commit={} tree={} profile={} config-table={:016x}", + BUILD.version, + BUILD.commit, + BUILD.dirty, + BUILD.profile, + openfut_adapter_fifa17::blaze::client_config::table_fingerprint() + ) +} + /// A bound listener plus everything a connection needs. pub struct Server { pub local_addr: SocketAddr, @@ -110,10 +144,20 @@ impl Server { /// the Python oracle and keeps the host readable; a work-stealing runtime /// would be weight without benefit. pub fn run(self) -> io::Result<()> { + trace::log(&build_banner()); + if BUILD.dirty == "DIRTY" { + trace::log( + "WARNING: built from a modified working tree — do NOT treat results from \ + this binary as parity evidence", + ); + } trace::log(&format!( - "blaze-host listening on {} (advertise={}, config-bind={})", + "listening on {} (advertise={}, config-bind={})", self.local_addr, self.cfg.adapter.endpoints.advertise, self.cfg.adapter.endpoints.bind )); + // The trace is the artefact a live FIFA session is judged from, so it + // must carry the same identity as the log. + self.tracer.write_line(&format!("# {}", build_banner())); if self.tracer.enabled() { trace::log(&format!( "structural trace -> {}",