Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/sweep_collect.py
T
root 70a64e3709 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.
2026-08-10 23:54:04 +00:00

181 lines
6.2 KiB
Python

#!/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())