70a64e3709
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.
176 lines
7.0 KiB
Python
Executable File
176 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract the set of REAL FIFA 17 playerids (with names) from the game's own files.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
FUT cards render generic because the client resolves identity from its LOCAL
|
|
`players` table, keyed by `playerid = resourceId & 0xffffff` (see docs/CARD_SYSTEM.md).
|
|
An id that is not in that table produces the MISS fingerprint: rating 0x32 (50),
|
|
teamid 0x78d (1933), nation 0xe (14), position 2, all attributes 1, name " ".
|
|
So the one thing the server needs is a list of playerids that actually exist.
|
|
|
|
WHERE THE IDS COME FROM
|
|
-----------------------
|
|
Frostbite bundle indexes. `Data/Win32/contentsb.{toc,sb}`, `contentlaunchsb.{toc,sb}`
|
|
and their `Update/Patch/` counterparts store asset paths as plain ASCII, and the
|
|
player face assets are named:
|
|
|
|
content/character/player/player_<bucket>/<name>_<playerid>_starhead_brt
|
|
content/character/player/player_<bucket>/<name>_<playerid>/hair_<playerid>_0_0_...
|
|
|
|
<bucket> is floor(playerid/500)*500, which this script uses as a self-check: an id is
|
|
only accepted if it falls inside its own directory's bucket. No decryption, no
|
|
Frostbite parsing, no cas archives -- the paths are literally in the clear in the
|
|
index files. dbdata.dll is NOT involved (its single export `getTableData` is an
|
|
anti-tamper attestation routine; see dbdata_probe.c).
|
|
|
|
COVERAGE, STATED HONESTLY
|
|
-------------------------
|
|
This yields every player who has a scanned STARHEAD (real face) asset: 1677 ids in
|
|
this install. That is NOT the whole `players` table (~18k rows including generic-face
|
|
players) -- it is the subset with real faces, which is also the subset whose cards
|
|
look best. Getting the full table needs the encrypted dbdata.dll payload or a live
|
|
memory read, neither of which this script attempts.
|
|
|
|
The extracted names are ASSET FILE names (lowercase, ASCII-folded, e.g.
|
|
`cristiano_ronaldo`), not the client's display names. You do not need them for the
|
|
wire: on a DB hit the client writes the display name, the face, and -- if you send
|
|
them as ZERO -- the nation and teamid itself. Only rating, position and the six
|
|
attributes are left as the server sent them. So the id alone buys a correct card.
|
|
|
|
ANCHOR CHECK
|
|
------------
|
|
playerid 20801 must map to cristiano_ronaldo. The script fails loudly if it does not.
|
|
|
|
WHAT THIS ALREADY CORRECTED IN fut_cards.VERIFIED_ASSET_IDS
|
|
----------------------------------------------------------
|
|
16 of the 18 ids there are confirmed by this extract. Two are not:
|
|
* 169193 was labelled "Alonso". This build ships
|
|
`player_45000/xabi_alonso_45197_launch_starhead_brt`, so Xabi Alonso is 45197
|
|
here. 169193 is not him; it may or may not be some other valid row.
|
|
* 200389 was labelled "Oblak". No `oblak` asset and no `200389` string appears
|
|
anywhere in the bundle indexes, so it is unconfirmed.
|
|
Absence from this list is NOT proof an id is invalid -- players without a scanned
|
|
face have no starhead asset but are still in the `players` table. This list is a
|
|
lower bound on the valid id set, not the id set.
|
|
|
|
Also note playerid 0 (`chris_head`) is a developer placeholder head; drop it before
|
|
using the list as a card pool.
|
|
|
|
USAGE
|
|
-----
|
|
./extract_player_ids.py # summary + anchor check
|
|
./extract_player_ids.py --tsv out.tsv # playerid<TAB>asset_name
|
|
./extract_player_ids.py --json out.json
|
|
FIFA17_DIR=/path/to/FIFA\\ 17 ./extract_player_ids.py
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
GAME_DIR = os.environ.get("FIFA17_DIR", "/mnt/games/FIFA 17")
|
|
|
|
# Only the bundle indexes hold plaintext paths; the 30GB of .cas archives do not
|
|
# need to be touched.
|
|
INDEX_SUFFIXES = (".toc", ".sb")
|
|
SCAN_ROOTS = ("Data", "Update")
|
|
|
|
PLAYER_PATH = re.compile(rb"content/character/player/player_(\d+)/([a-z0-9_\-\.]+)")
|
|
NAMED_LEAF = re.compile(r"^([a-z][a-z_\-\.]*?)_(\d+)(?:_launch)?(?:_starhead_brt)?$")
|
|
HAIR_LEAF = re.compile(r"^hair_(\d+)_")
|
|
|
|
BUCKET = 500 # player_9500/ holds playerids 9500..9999
|
|
|
|
ANCHOR = (20801, "cristiano_ronaldo")
|
|
|
|
|
|
def index_files(game_dir):
|
|
out = []
|
|
for root_name in SCAN_ROOTS:
|
|
base = os.path.join(game_dir, root_name)
|
|
if not os.path.isdir(base):
|
|
continue
|
|
for root, _dirs, files in os.walk(base):
|
|
for f in files:
|
|
if f.endswith(INDEX_SUFFIXES):
|
|
out.append(os.path.join(root, f))
|
|
return sorted(out)
|
|
|
|
|
|
def extract(game_dir):
|
|
"""-> (names: {playerid: asset_name}, faceless: set[playerid], stats: dict)"""
|
|
names, faceless = {}, set()
|
|
leaves = set()
|
|
files = index_files(game_dir)
|
|
for path in files:
|
|
try:
|
|
data = open(path, "rb").read()
|
|
except OSError as exc:
|
|
print(" skip %s: %s" % (path, exc), file=sys.stderr)
|
|
continue
|
|
for m in PLAYER_PATH.finditer(data):
|
|
leaves.add((int(m.group(1)), m.group(2).decode("ascii", "replace")))
|
|
|
|
unmatched = 0
|
|
for bucket, leaf in leaves:
|
|
m = NAMED_LEAF.match(leaf)
|
|
if m and bucket <= int(m.group(2)) < bucket + BUCKET:
|
|
names.setdefault(int(m.group(2)), m.group(1))
|
|
continue
|
|
m = HAIR_LEAF.match(leaf)
|
|
if m and bucket <= int(m.group(1)) < bucket + BUCKET:
|
|
faceless.add(int(m.group(1)))
|
|
continue
|
|
unmatched += 1
|
|
|
|
stats = {"index_files": len(files), "leaf_paths": len(leaves),
|
|
"unmatched_leaves": unmatched}
|
|
return names, faceless - set(names), stats
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--tsv")
|
|
ap.add_argument("--json")
|
|
ap.add_argument("--dir", default=GAME_DIR)
|
|
args = ap.parse_args()
|
|
|
|
if not os.path.isdir(args.dir):
|
|
sys.exit("game dir not found: %s (set FIFA17_DIR)" % args.dir)
|
|
|
|
names, extra, stats = extract(args.dir)
|
|
if not names:
|
|
sys.exit("no player asset paths found under %s -- wrong dir?" % args.dir)
|
|
|
|
pid, expect = ANCHOR
|
|
got = names.get(pid)
|
|
if got != expect:
|
|
sys.exit("ANCHOR CHECK FAILED: playerid %d -> %r, expected %r. "
|
|
"The parse is wrong, not the game." % (pid, got, expect))
|
|
|
|
print("scanned %d bundle index files, %d player asset leaf paths"
|
|
% (stats["index_files"], stats["leaf_paths"]))
|
|
print("playerids with a real starhead: %d (id range %d..%d)"
|
|
% (len(names), min(names), max(names)))
|
|
print("hair-only playerids (no named face asset): %d" % len(extra))
|
|
print("unmatched leaf paths: %d" % stats["unmatched_leaves"])
|
|
print("anchor OK: %d -> %s" % (pid, got))
|
|
|
|
if args.tsv:
|
|
with open(args.tsv, "w") as fh:
|
|
fh.write("playerid\tasset_name\n")
|
|
for k in sorted(names):
|
|
fh.write("%d\t%s\n" % (k, names[k]))
|
|
print("wrote %s" % args.tsv)
|
|
if args.json:
|
|
with open(args.json, "w") as fh:
|
|
json.dump({str(k): names[k] for k in sorted(names)}, fh, indent=1)
|
|
print("wrote %s" % args.json)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|