e0e46d8a57
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.
219 lines
8.7 KiB
Python
Executable File
219 lines
8.7 KiB
Python
Executable File
#!/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 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:
|
|
|
|
* 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 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
|
|
|
|
CLIENT = "alex@10.10.0.105"
|
|
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):
|
|
r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script],
|
|
capture_output=True, text=True, timeout=60)
|
|
if r.returncode != 0:
|
|
raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}")
|
|
return r.stdout
|
|
|
|
|
|
def read_cfg():
|
|
return ssh(f'cat "{CFG}"')
|
|
|
|
|
|
def parse(text):
|
|
out = {}
|
|
for line in text.splitlines():
|
|
if "=" in line and not line.strip().startswith("#"):
|
|
k, _, v = line.partition("=")
|
|
out[k.strip()] = v.strip()
|
|
return out
|
|
|
|
|
|
def fifa_running():
|
|
"""True if a real FIFA 17 process exists on the client.
|
|
|
|
Matches /proc/<pid>/comm exactly rather than `pgrep -f FIFA17.exe`: the pattern
|
|
form self-matched the remote shell running it (the SSH command line contains the
|
|
literal string), so the guard was permanently stuck ON and could never report
|
|
"not running". comm is the executable name, so the shell reads as zsh/bash and
|
|
only a genuine FIFA process matches. Still fail-closed: any read error or
|
|
unexpected output is treated as "running".
|
|
"""
|
|
out = ssh(
|
|
"for d in /proc/[0-9]*; do "
|
|
"[ -r \"$d/comm\" ] && [ \"$(cat $d/comm 2>/dev/null)\" = FIFA17.exe ] "
|
|
"&& echo ${d#/proc/}; done || true"
|
|
).strip()
|
|
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}")
|
|
print(text.rstrip())
|
|
cur = parse(text)
|
|
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 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:
|
|
if k not in cur:
|
|
raise SystemExit(f"key {k!r} is absent from {CFG}; refusing to guess it")
|
|
# Record production values once, before the first mutation, so restore never
|
|
# has to assume what they were.
|
|
existing_sidecar = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
|
if not existing_sidecar and label == "staging":
|
|
rec = "\n".join(f"{k}={cur[k]}" for k in KEYS)
|
|
ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
|
|
print(f"recorded production ports to {SIDECAR}:\n{rec}")
|
|
|
|
out = []
|
|
for line in text.splitlines():
|
|
k = line.partition("=")[0].strip()
|
|
out.append(f"{k}={values[k]}" if k in values else line)
|
|
body = "\n".join(out) + "\n"
|
|
ssh(f'cat > "{CFG}" <<\'EOF\'\n{body.rstrip()}\nEOF')
|
|
after = parse(read_cfg())
|
|
for k, v in values.items():
|
|
if after.get(k) != v:
|
|
raise SystemExit(f"VERIFY FAILED: {k} is {after.get(k)!r}, expected {v!r}")
|
|
print(f"--- now pointing at {label}")
|
|
print(read_cfg().rstrip())
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 2 or sys.argv[1] not in ("show", "staging", "restore"):
|
|
print(__doc__)
|
|
return 2
|
|
cmd = sys.argv[1]
|
|
if cmd == "show":
|
|
show()
|
|
return 0
|
|
if cmd == "staging":
|
|
write_ports(STAGING, "staging")
|
|
return 0
|
|
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
|
if not side:
|
|
raise SystemExit(
|
|
f"no recorded production ports at {SIDECAR}; refusing to guess.\n"
|
|
"Set them by hand and verify with `show`."
|
|
)
|
|
write_ports(parse(side), "production (restored)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|