Files
OpenFUT/fifa17-recon/tools/sweep_collect.py
T
funman300 e9e6f203c2 fifa17-recon: the card pool is now the REAL FIFA 17 roster, 17547 players
The old pool was 79 hand-written rows whose asset ids were mostly invented, on
the premise that the client's card map is empty offline so no id could render.
That premise was refuted by a live screenshot, and this replaces its consequence.

Source: tools/dbdata_extract.py reads FIFA's own rating-sorted index out of a
running process (0x40 stride, self-validating {begin,end,end+1} name-pointer
triple, anchored on 20801 = Ronaldo 94) -> data/roster.json. dbdata.dll was a
dead end and is documented as such: its single export getTableData is an
anti-tamper attestation routine, not a data accessor.

Cross-validated against a completely independent method. tools/sweep_collect.py
serves candidate ids as a synthetic club and reads back the identity the CLIENT
resolved through its own merge. 573 of 573 overlapping names agreed exactly, and
the single id present in one and not the other is 26501, the target of the
documented 22800..22879 Legends remap -- which is also what produced 'Alex Hunter
x80' in a sweep and had looked like a bug.

Field honesty, because half of these are real and half are not:
  playerid/rating/name  REAL   the roster
  club/nation/league    REAL   we send zeros and the CLIENT fills them (the merge
                               only fills those fields when they arrive as zero)
  position              PARTLY 59 from the game's own per-card cache, 17 curated
                               by hand, the rest synthetic but deterministic
  attributes            SYNTH  derived from rating and position

169193 is dropped from the curated set: it was in VERIFIED_ASSET_IDS and is not a
real player. The client resolves it to the database's empty placeholder row, which
renders as 'Jamal Blackman'. Two independent methods agreed.

NOTE BEFORE PUSHING ANYWHERE PUBLIC: data/roster.json is EA's player data,
extracted from your own installation. Fine locally; think twice about publishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:08:52 -07: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())