b0bbc2a07f
The oracle is three-valued, and all three fingerprints are now confirmed live
against a running client rather than read out of Ghidra:
NAMED our sentinel rating 7 survives and a real name appears. The id is
real, and teamid/nation/leagueId come back FILLED by the game
because we send them as zero.
placeholder rating 7 survives but the name is 'Jamal Blackman', team 0. The
players-table row exists and is an empty slot. This is the trap:
169193 does this and it was in VERIFIED_ASSET_IDS.
MISS rating 0x32, teamid 0x78d, nation 0xe, position 2, name ' '. That
is the binary's miss-fill, byte for byte, and it is exactly the
blank card photographed in a pack today.
Scale: 5000 candidates per response ingests cleanly; 20000 was served and then
silently not ingested (the map did not change at all), so the ceiling is between
them and auto chunks default to 5000.
Auto-advance: the client PAGES the club, so one visit yields several fetches.
'auto:lo-hi:step' hands out the next chunk per fetch. Item ids derive from the
candidate's offset in the WHOLE range, not its index in the chunk, so chunks never
collide and results accumulate across fetches for a single probe at the end.
sweep_collect.py accumulates into data/players.json and rejects the placeholder
name as a matter of course. Yield in 20000-24999 was 19 real ids per 5000, which
is why auto-advance matters: the real roster clusters in 150000-240000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
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()
|