fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Accumulate FUT_ID_SWEEP results into a real player database.
|
||||
|
||||
READ-ONLY against the game (it drives card_identity_probe, which only reads
|
||||
/proc/PID/mem). It writes exactly one file: data/players.json in this repo.
|
||||
|
||||
HOW THE ORACLE WORKS, AND ITS ONE FALSE ANSWER
|
||||
----------------------------------------------
|
||||
utas_server's FUT_ID_SWEEP serves a window of candidate playerids as a synthetic
|
||||
club. The client merges its OWN local players table into every item it parses, so
|
||||
after one club fetch the resolved identity is sitting in the CardsDb map.
|
||||
|
||||
A candidate is REAL when the client gives it a name. The false answer to guard
|
||||
against is the DB's default row: ids with no entry come back named "Jamal
|
||||
Blackman", byte-identical every time (first "Jamal", last "Blackman"). Six of our
|
||||
own pool ids hit this, including one that was in VERIFIED_ASSET_IDS -- which is
|
||||
why the old "verified" list cannot be trusted and this tool exists.
|
||||
|
||||
So DEFAULT_NAME below is a rejection filter, not a curiosity. If a genuine Jamal
|
||||
Blackman is ever needed, take him from his real id, not from this sweep.
|
||||
|
||||
Usage:
|
||||
sweep_collect.py probe now, merge into data/players.json
|
||||
sweep_collect.py --show summarise the accumulated database
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import card_identity_probe as P # noqa: E402
|
||||
import watch_club_model as W # noqa: E402
|
||||
|
||||
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "data", "players.json")
|
||||
DEFAULT_NAME = ("Jamal", "Blackman") # the DB's empty-row placeholder
|
||||
SENTINEL_RATING = 7 # what utas_server serves in a sweep
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
with open(DB) as f:
|
||||
return {int(k): v for k, v in json.load(f).items()}
|
||||
except (IOError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save(db):
|
||||
os.makedirs(os.path.dirname(DB), exist_ok=True)
|
||||
with open(DB, "w") as f:
|
||||
json.dump({str(k): v for k, v in sorted(db.items())}, f,
|
||||
indent=1, ensure_ascii=False)
|
||||
|
||||
|
||||
def probe():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return None
|
||||
base = W.dll_base(pid)
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE)) if base else None
|
||||
if not obj:
|
||||
print("CardsDb is not loaded (no FUT session).")
|
||||
return None
|
||||
cards = [c for c in (P.read_card(mem, n) for n in P.nodes(mem, obj)) if c]
|
||||
print("read %d card record(s), failed reads=%d" % (len(cards), mem.fails))
|
||||
return cards
|
||||
|
||||
|
||||
def merge(db, cards):
|
||||
added = skipped = placeholder = 0
|
||||
for c in cards:
|
||||
first, last = c["first"], c["last"]
|
||||
if (first, last) == DEFAULT_NAME:
|
||||
placeholder += 1
|
||||
continue
|
||||
if not (first or last or c["known"]):
|
||||
skipped += 1
|
||||
continue
|
||||
pid = c["playerid"]
|
||||
row = {
|
||||
"first": first, "last": last, "known": c["known"],
|
||||
"teamid": c["teamid"], "nation": c["nation"], "league": c["league"],
|
||||
"position": c["position"],
|
||||
}
|
||||
# A sweep card carries our sentinel rating, so the DB never learns a rating
|
||||
# from it. Ratings from OWNED cards are ours too -- the merge never
|
||||
# overwrites a nonzero rating. Rating therefore stays out of this file
|
||||
# rather than being recorded as if the game had supplied it.
|
||||
if pid not in db:
|
||||
added += 1
|
||||
db[pid] = row
|
||||
return added, skipped, placeholder
|
||||
|
||||
|
||||
def show(db):
|
||||
print("%d player(s) in %s" % (len(db), os.path.normpath(DB)))
|
||||
if not db:
|
||||
return
|
||||
ids = sorted(db)
|
||||
print("id range %d..%d" % (ids[0], ids[-1]))
|
||||
teams = len({r["teamid"] for r in db.values() if r["teamid"]})
|
||||
nats = len({r["nation"] for r in db.values() if r["nation"]})
|
||||
lgs = len({r["league"] for r in db.values() if r["league"]})
|
||||
print("%d club(s), %d nation(s), %d league(s)" % (teams, nats, lgs))
|
||||
for pid in ids[:10]:
|
||||
r = db[pid]
|
||||
print(" %-7s %-28s team=%-6s nat=%-4s league=%s"
|
||||
% (pid, ("%s %s" % (r["first"], r["last"])).strip()[:28],
|
||||
r["teamid"], r["nation"], r["league"]))
|
||||
|
||||
|
||||
def watch(db, interval, seconds):
|
||||
"""Merge continuously while the game pages through an auto sweep.
|
||||
|
||||
THE MAP DOES NOT ACCUMULATE. Every club fetch wipes it and repopulates from
|
||||
that response alone, so a single probe at the end of an auto sweep sees only
|
||||
the LAST chunk -- which is how 34,000 candidates were nearly thrown away. A
|
||||
pass costs 0.03s and chunks are ~12s apart, so polling catches all of them.
|
||||
"""
|
||||
import time
|
||||
t0 = time.time()
|
||||
last = -1
|
||||
while time.time() - t0 < seconds:
|
||||
cards = probe_quiet()
|
||||
if cards:
|
||||
added, _, _ = merge(db, cards)
|
||||
if added:
|
||||
save(db)
|
||||
if len(db) != last:
|
||||
last = len(db)
|
||||
print("[%4ds] %d player(s)" % (time.time() - t0, len(db)), flush=True)
|
||||
time.sleep(interval)
|
||||
save(db)
|
||||
return 0
|
||||
|
||||
|
||||
def probe_quiet():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
return None
|
||||
base = W.dll_base(pid)
|
||||
if not base:
|
||||
return None
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
return None
|
||||
return [c for c in (P.read_card(mem, n) for n in P.nodes(mem, obj)) if c]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--show", action="store_true")
|
||||
ap.add_argument("--watch", type=float, metavar="SECS",
|
||||
help="poll continuously for SECS while an auto sweep runs")
|
||||
ap.add_argument("--interval", type=float, default=1.0)
|
||||
a = ap.parse_args()
|
||||
db = load()
|
||||
if a.show:
|
||||
show(db)
|
||||
return 0
|
||||
if a.watch:
|
||||
return watch(db, a.interval, a.watch)
|
||||
cards = probe()
|
||||
if cards is None:
|
||||
return 1
|
||||
added, skipped, placeholder = merge(db, cards)
|
||||
save(db)
|
||||
print("added %d new, %d placeholder row(s) rejected, %d unnamed skipped"
|
||||
% (added, placeholder, skipped))
|
||||
show(db)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user