Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/fut_cards.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

251 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The FUT card pool: the REAL FIFA 17 roster, 17,547 players.
WHAT CHANGED, AND WHY IT MATTERS
--------------------------------
This module used to hold 79 hand-written rows whose asset ids were mostly
invented, on the premise (from an older CARD_SYSTEM.md) that the client's card
map is empty offline so no id could ever render. That premise was wrong.
Card identity does not come from us. The client inserts every item we serve into
its CardsDb map and, just before that, merges in its OWN local players table
keyed on `resourceId & 0xffffff`. An invented id renders as a blank generic card;
a real one renders as a real player. Proven live: a pack showed SILVA and NOWAK
with real names, badges and flags beside three blanks at rating 50.
So the pool is now built from the game's own roster, extracted from a running
FIFA17.exe by tools/dbdata_extract.py into data/roster.json. It was cross-checked
against a completely independent method -- the sweep oracle in
tools/sweep_collect.py, which reads back the identity the CLIENT resolved -- and
573 of 573 overlapping names agreed exactly.
WHICH FIELDS ARE REAL AND WHICH ARE NOT. Be honest about this when reading a card:
playerid REAL data/roster.json
rating REAL same
name REAL resolved by the client from the id; we never send a name
club REAL we send teamid 0 and the client fills its own value
nation REAL we send nation 0, same mechanism
league REAL the client always recomputes leagueid on a DB hit
position PARTLY 59 ids are known (data/positions.json); the rest are
SYNTHETIC, assigned deterministically per id
attributes SYNTH derived from rating and position
The merge fills nation/teamid ONLY when they arrive as zero, and never touches
rating, position or attributes. That asymmetry is the whole design of this file:
send zero for everything the client knows better than us, and send our own value
only where the client has nothing.
THE MISSING COLUMNS, AND THE LEADS FOR THEM
-------------------------------------------
position / nationality / teamId / attributes are NOT in the rating index. Two
live sources exist and BOTH are per-materialised-card caches, not tables:
* the 0x180-stride resolved card records (attributes + names), and
* a 32-byte-stride keyed container, entries {playerId, position | hash<<32,
rating, ?}, found at 0x42e8dbe8 inside the 238 MB heap region.
data/positions.json comes from the second one. Sweeping 5,000 ids did NOT
populate it, so it caches what the game itself materialises rather than what we
ask about. 59 entries survived validation (each entry's rating had to match the
roster's). Anyone extending this: a full-roster position source has not been
found, and the FUT rating index does not contain one.
Position codes are the standard FIFA enum, decoded against our own club cards
read live: GK 0, CB 5, LB 7, CM 14, LM 16, RW 23, ST 25, LW 27.
"""
import json
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_DATA = os.path.join(_HERE, "..", "data")
# Position code -> the string the client's parser expects in preferredPosition.
POSITION_BY_CODE = {
0: "GK", 1: "SW", 2: "RWB", 3: "RB", 4: "CB", 5: "CB", 6: "CB",
7: "LB", 8: "LWB", 9: "CDM", 10: "CDM", 11: "CDM", 12: "RM",
13: "CM", 14: "CM", 15: "CM", 16: "LM", 17: "CAM", 18: "CAM",
19: "CAM", 20: "RF", 21: "CF", 22: "LF", 23: "RW", 24: "ST",
25: "ST", 26: "ST", 27: "LW",
}
# Synthetic-position distribution, shaped like a real squad (one keeper, four at
# the back, four in midfield, three forward) so a random pack looks like a
# football team rather than eleven strikers.
_SYNTH_POSITIONS = (["GK"] +
["RB", "CB", "CB", "LB"] +
["CDM", "CM", "CM", "CAM"] +
["RW", "ST", "LW"])
# Attribute profiles: (pace, shooting, passing, dribbling, defending, physical)
# as multipliers on the rating. The GK profile stands in for the six keeper
# stats the card face shows in that slot instead.
_PROFILE = {
"GK": (0.68, 0.70, 0.40, 0.66, 0.20, 0.68),
"RB": (1.02, 0.72, 0.90, 0.92, 1.00, 0.95),
"LB": (1.02, 0.72, 0.90, 0.92, 1.00, 0.95),
"RWB": (1.05, 0.75, 0.92, 0.95, 0.96, 0.92),
"LWB": (1.05, 0.75, 0.92, 0.95, 0.96, 0.92),
"CB": (0.82, 0.55, 0.78, 0.75, 1.05, 1.05),
"SW": (0.82, 0.55, 0.78, 0.75, 1.05, 1.05),
"CDM": (0.85, 0.75, 0.98, 0.92, 1.00, 1.00),
"CM": (0.90, 0.85, 1.02, 0.98, 0.88, 0.92),
"RM": (1.05, 0.88, 0.98, 1.02, 0.72, 0.85),
"LM": (1.05, 0.88, 0.98, 1.02, 0.72, 0.85),
"CAM": (0.95, 0.92, 1.02, 1.04, 0.62, 0.82),
"RW": (1.08, 0.92, 0.95, 1.05, 0.60, 0.80),
"LW": (1.08, 0.92, 0.95, 1.05, 0.60, 0.80),
"RF": (1.02, 0.98, 0.95, 1.04, 0.58, 0.85),
"LF": (1.02, 0.98, 0.95, 1.04, 0.58, 0.85),
"CF": (1.00, 1.00, 0.95, 1.02, 0.58, 0.88),
"ST": (1.00, 1.05, 0.85, 0.98, 0.45, 0.95),
}
def _load(name, default):
try:
with open(os.path.join(_DATA, name)) as f:
return json.load(f)
except (IOError, ValueError):
return default
ROSTER = _load("roster.json", [])
KNOWN_POSITIONS = {int(k): v for k, v in _load("positions.json", {}).items()}
# data/pool.json -- the REAL thing, and it supersedes everything below it.
#
# Built 2026-08-05 from the game's own resident database (data/tables/*.json, dumped
# read-only by tools/db_dump.py, then tools/build_player_facts.py). Per player it
# carries the MEASURED position (players.preferredposition1), nationality, teamid,
# leagueid (via leagueteamlinks) and the six card attributes.
#
# The six attributes are not columns: they are a weighted sum of the 29 base
# attributes, and the weights come from the game's OWN `playerattributesmapping`
# table rather than from published formulas. The result checks out against real FIFA
# 17 cards: Messi 89/90/86/96/26/61 and Ibrahimovic 72/90/81/85/31/86 are exact,
# Suarez is one off on physical, Ronaldo within two on pace and shooting.
#
# NOTE THE REVERSAL on nation/team/league. When those fields were unknown we sent
# ZERO so the client would fill its own values (the merge fills them only when they
# arrive zero). Now that we hold the game's own numbers there is nothing to gain, and
# zeros actively HURT: our club-stats drill-downs bucket by the item's own nation and
# leagueId, so a club full of zeros would have emptied the per-nation and per-league
# panels that were fixed yesterday. Send the real values.
POOL_FACTS = _load("pool.json", [])
# Hand-checked positions, carried over from the curated pool this file replaces.
# They are KNOWLEDGE, not measurement, which is why they rank below the codes the
# game itself supplied. They exist because a synthetic position is unnoticeable on
# an unknown 62-rated defender and glaring on Neuer, and the famous players are
# precisely the ones a pack shows off.
CURATED_POSITIONS = {int(k): v for k, v in _load("positions_curated.json", {}).items()}
def _position(pid):
"""The real position where the game told us one, else curated, else synthetic.
Deterministic in the id, so a player never changes shape between packs or
between runs.
"""
code = KNOWN_POSITIONS.get(pid)
if code is not None:
return POSITION_BY_CODE.get(code, "ST")
if pid in CURATED_POSITIONS:
return CURATED_POSITIONS[pid]
return _SYNTH_POSITIONS[pid % len(_SYNTH_POSITIONS)]
def _attrs(rating, pos):
prof = _PROFILE.get(pos, _PROFILE["CM"])
out = []
for i, mult in enumerate(prof):
# A small, stable per-player wobble so two 82-rated strikers are not
# byte-identical. Seeded by rating and slot, never by wall-clock, so the
# pool is reproducible.
v = int(round(rating * mult)) + ((rating * 7 + i * 13) % 5) - 2
out.append(max(1, min(99, v)))
return out
def _build():
if POOL_FACTS:
pool = []
for r in POOL_FACTS:
pid, rating = r["id"], r["rating"]
if not pid or rating <= 0:
# A zero id is not a harmless skip: the registrar writes
# *(item+0x10) = 0 and the card view-model dereferences it with no
# null check, so a zero id reaching the card UI is a crash.
continue
pool.append((pid, rating, r["pos"], r["nation"], r["league"], r["team"],
list(r["attrs"])))
return pool
# Fallback: the rating-index roster, with synthetic positions and attributes.
# Kept so the pool still builds if data/pool.json is missing, but everything it
# produces below is a guess where the block above is a measurement.
pool = []
for r in ROSTER:
pid, rating = r["id"], r["rating"]
if not pid or rating <= 0:
# A zero id is not a harmless skip: the registrar writes
# *(item+0x10) = 0 and the card view-model dereferences item+0x10
# with no null check, so a zero id reaching the card UI is a crash.
continue
pos = _position(pid)
# nation / league / team are ZERO on purpose -- that is what makes the
# client fill in the real ones. Do not "improve" this by guessing them.
pool.append((pid, rating, pos, 0, 0, 0, _attrs(rating, pos)))
return pool
POOL = _build()
# Kept for the record: the old hand-written "verified" set. 169193 is in it and
# is NOT a real FIFA 17 player -- the client resolves it to the database's empty
# placeholder row, which reads as "Jamal Blackman" on every card. That is exactly
# the failure this rebuild removes, and two independent methods agreed on it. Do
# not restore this set as a source of truth; it is here so older notes stay
# traceable.
VERIFIED_ASSET_IDS = {
20801, 158023, 176580, 167495, 183907, 155862, 188545, 182521,
183277, 177003, 192985, 190871, 200389, 197445, 202126, 189332,
169193, 184941,
}
NAME_BY_ID = {r["id"]: (r["common"] or ("%s %s" % (r["first"], r["last"])).strip())
for r in ROSTER}
def tier(rating):
return "gold" if rating >= 75 else "silver" if rating >= 65 else "bronze"
def pool_for(tier_name):
"""Players of one tier. Falls back to the whole pool rather than returning []."""
sel = [p for p in POOL if tier(p[1]) == tier_name]
return sel or POOL
def name_of(pid):
"""For LOGS only. The name a player actually sees comes from the client."""
return NAME_BY_ID.get(pid, "?")
if __name__ == "__main__":
print("pool: %d players from the real FIFA 17 roster" % len(POOL))
for name in ("gold", "silver", "bronze"):
sel = pool_for(name)
print(" %-7s %5d ratings %d-%d" % (name, len(sel),
min(p[1] for p in sel),
max(p[1] for p in sel)))
if POOL_FACTS:
print("source: data/pool.json -- positions, nation, club, league and all six "
"attributes MEASURED from the game's own database")
else:
print("source: data/roster.json FALLBACK -- %d real positions, the rest "
"synthetic, attributes derived from rating" % len(KNOWN_POSITIONS))
print("\ntop 10 by rating:")
for p in sorted(POOL, key=lambda x: -x[1])[:10]:
print(" %-7s %-3s %-4s %-26s %s" % (p[0], p[1], p[2], name_of(p[0]), p[6]))