From e0e46d8a573bae424b18edc116cf198233c35d71 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 02:40:31 +0000 Subject: [PATCH] fix(scripts): the port switcher was editing a derived file, so the A/B ran on production openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json, fail-closed, immediately before every launch. So `staging` set the ports, verified them, and the next launch silently reverted them. Caught only because the capture harness cross-checks instead of trusting the screen. The operator reported "the Transfers tile does not show Sold" -- which looked like a clean negative result about the sold counter, and was in fact a reading of their PRODUCTION club, where sold:0 is correct. Evidence chain: * route-log delta contained 5 lines, all of them the harness's own GETs; the client issued nothing to staging at all; * `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130 (production Blaze) plus TIME-WAIT to :8099 (production UTAS); * the hook logged `blaze_redir=42127 blaze_main=42130`; * openfut.cfg mtime was 2s before process start, sha back to the production value. Had the harness reported the tile at face value, the sold counter recovered from CardsDLL would now be recorded as refuted by a run that never reached the code. Fixes: own the launcher config (source) before openfut.cfg (derived), with the same record-before-mutate sidecar discipline on both; refuse to edit while the launcher is running, since it holds config in memory and would write the stale values back; report both files and both guards in `show`. launcher_running() matches the kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has bitten this project before. No production change; staging stack and its variant-A sold row untouched. --- scripts/sold-client-ports.py | 105 ++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 14 deletions(-) diff --git a/scripts/sold-client-ports.py b/scripts/sold-client-ports.py index 94a3768..2a0bc5a 100755 --- a/scripts/sold-client-ports.py +++ b/scripts/sold-client-ports.py @@ -1,27 +1,39 @@ #!/usr/bin/env python3 """Switch the FIFA client's Blaze ports between PRODUCTION and STAGING, reversibly. -The staging sold experiment needs the client to talk to the staging Blaze (which -advertises the staging UTAS base). The ONLY client change required is the two Blaze -port lines in `openfut.cfg`; `host` and `https_port` are left alone. +The staging sold experiment needs the client to talk to the staging Blaze, which is +what advertises the staging UTAS base. Two files are involved and the distinction +matters, because getting it wrong silently runs the whole experiment against +production: + + * `openfut.cfg` in the game directory is what the injected hook reads at connect + time -- but it is a DERIVED artifact. + * `~/.config/openfut-launcher/config.json` is the AUTHORITATIVE source. The + launcher reconciles openfut.cfg from it, fail-closed, immediately before every + launch. An edit to openfut.cfg alone is therefore destroyed by the next launch: + observed live, with openfut.cfg's mtime 2s before the FIFA process start and the + hook logging `blaze_redir=42127 blaze_main=42130` after it had been set to + staging. So this script owns BOTH, source first. Safety properties, in order of importance: - * The production values are recorded to a sidecar file on the client BEFORE the - first edit, and `restore` reads that sidecar rather than assuming what - production was. If the sidecar is missing, restore refuses. - * `restore` is idempotent and safe to run at any time, including after a crash. - * Every operation re-reads the file afterwards and prints it, so the result is + * Production values are recorded to a sidecar next to each file BEFORE the first + edit, and `restore` reads those sidecars rather than assuming what production + was. Missing sidecar means restore refuses. + * `restore` is idempotent and safe at any time, including after a crash. + * Every operation re-reads both files afterwards and prints them, so the result is verified rather than assumed. - * It refuses to edit while a FIFA client is running: the hook reads this file at - connect time, so changing it under a live session is unsafe. - * Only the two known keys are rewritten. Unknown lines are passed through - untouched, and a missing key is an error rather than a silent append. + * It refuses to edit while FIFA is running (the hook reads openfut.cfg at connect + time) AND while the launcher is running (the launcher holds its config in + memory and would write the stale values straight back over ours). + * Only known keys are rewritten. Unknown lines and unrelated JSON keys are passed + through untouched, and a missing key is an error rather than a silent append. python3 scripts/sold-client-ports.py show python3 scripts/sold-client-ports.py staging # 42327 / 42330 python3 scripts/sold-client-ports.py restore # back to the recorded values """ +import json import subprocess import sys @@ -30,6 +42,11 @@ CFG = "/mnt/games/FIFA 17/openfut.cfg" SIDECAR = "/mnt/games/FIFA 17/openfut.cfg.openfut-prod-ports" KEYS = ("blaze_redirector_port", "blaze_main_port") STAGING = {"blaze_redirector_port": "42327", "blaze_main_port": "42330"} +# The launcher is the real owner; openfut.cfg is regenerated from these. +LAUNCHER_CFG = "/home/alex/.config/openfut-launcher/config.json" +LAUNCHER_SIDECAR = "/home/alex/.config/openfut-launcher/config.json.openfut-prod-ports" +LAUNCHER_KEY = {"blaze_redirector_port": "openfut_blaze_redirector_port", + "blaze_main_port": "openfut_blaze_main_port"} def ssh(script): @@ -71,6 +88,52 @@ def fifa_running(): return bool(out) +def launcher_running(): + """True if openfut-launcher is up. Its `comm` is truncated by the kernel to 15 + chars ("openfut-launche"), which has bitten this project before, so match the + truncated form rather than the full name.""" + out = ssh( + "for d in /proc/[0-9]*; do c=$(cat $d/comm 2>/dev/null); " + "case \"$c\" in openfut-launche*) echo ${d#/proc/};; esac; done || true" + ).strip() + return bool(out) + + +def read_launcher(): + return ssh(f'cat "{LAUNCHER_CFG}"') + + +def launcher_ports(text): + d = json.loads(text) + return {k: str(d.get(LAUNCHER_KEY[k])) for k in KEYS} + + +def write_launcher(values): + """Rewrite only the two port keys, preserving every other setting and the file's + formatting conventions, then verify by re-reading.""" + text = read_launcher() + d = json.loads(text) + for k in KEYS: + if LAUNCHER_KEY[k] not in d: + raise SystemExit( + f"key {LAUNCHER_KEY[k]!r} absent from {LAUNCHER_CFG}; refusing to guess") + existing = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip() + if not existing: + rec = "\n".join(f"{k}={d[LAUNCHER_KEY[k]]}" for k in KEYS) + ssh(f'cat > "{LAUNCHER_SIDECAR}" <<\'EOF\'\n{rec}\nEOF') + print(f"recorded launcher production ports to {LAUNCHER_SIDECAR}:\n{rec}") + for k, v in values.items(): + d[LAUNCHER_KEY[k]] = int(v) + body = json.dumps(d, indent=2, sort_keys=True) + ssh(f'cat > "{LAUNCHER_CFG}" <<\'EOF\'\n{body}\nEOF') + after = launcher_ports(read_launcher()) + for k, v in values.items(): + if after.get(k) != str(v): + raise SystemExit( + f"VERIFY FAILED in launcher config: {k} is {after.get(k)!r}, want {v!r}") + print(f"--- launcher config now: {after}") + + def show(): text = read_cfg() print(f"--- {CFG}") @@ -79,16 +142,30 @@ def show(): print("--- blaze ports:", {k: cur.get(k) for k in KEYS}) side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip() print("--- recorded production ports:", side or "(none recorded yet)") + print(f"--- {LAUNCHER_CFG} (authoritative)") + print("--- launcher blaze ports:", launcher_ports(read_launcher())) + lside = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip() + print("--- recorded launcher production ports:", lside or "(none recorded yet)") print("--- FIFA client running:", "YES" if fifa_running() else "no") + print("--- launcher running:", "YES" if launcher_running() else "no") return cur def write_ports(values, label): if fifa_running(): raise SystemExit( - "REFUSING to edit openfut.cfg while a FIFA client is running.\n" - "The hook reads this file at connect time; exit FIFA first." + "REFUSING to edit while a FIFA client is running.\n" + "The hook reads openfut.cfg at connect time; exit FIFA first." ) + if launcher_running(): + raise SystemExit( + "REFUSING to edit while openfut-launcher is running.\n" + "The launcher holds its config in memory and reconciles openfut.cfg from\n" + "it fail-closed before every launch, so it would write the production\n" + "ports straight back over ours. Quit the launcher first." + ) + # Source of truth first, then the derived file. + write_launcher(values) text = read_cfg() cur = parse(text) for k in KEYS: