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.
251 lines
11 KiB
Python
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]))
|