Files
OpenFUT/fifa17-recon/tools/build_player_facts.py
T
funman300 9a0a76c9f4 fifa17-recon: real positions, clubs and the six card attributes for all 17,563 players
The pool now comes from the game's OWN resident database, not from a rating index
plus guesses. tools/db_dump.py walked the client's self-describing table catalog
read-only and wrote data/tables/ (149 tables, 55MB); tools/build_player_facts.py
turned it into data/player_facts.json; data/pool.json is the compact form fut_cards
loads.

MEASURED, per player: 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 are read out of the game's own playerattributesmapping
table rather than from published formulas. Checked 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. Keepers come out directly
from the gk* columns.

What this fixes on screen: Kaka was a CDM, Bale a CM, Suarez a GK, and every
attribute was derived from the rating. Now Bale is RW, Boateng is a CB with 90
defending, De Gea is a GK, and a bronze pack deals real bronze players in real
positions.

REVERSAL, deliberate: nation/team/league were being sent as ZERO so the client would
fill its own values (the merge fills those three only when they arrive zero). Now
that we hold the game's own numbers there is nothing to gain from zeros, and they
actively hurt -- club-stats drill-downs bucket by the item's own nation and leagueId,
so a club full of zeros would have quietly emptied the per-nation and per-league
panels fixed the day before. Send the real values.

The old rating-index path is kept as a fallback so the pool still builds without
data/pool.json, and it now says out loud which of the two it used, because one is a
measurement and the other is a guess.

439 + 61 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 09:08:29 -07:00

162 lines
6.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Turn the raw table dumps written by db_dump.py into ONE file the FUT card
pool can consume: data/player_facts.json.
Input data/tables/{players,teamplayerlinks,teams,leagues,nations,
leagueteamlinks,playerattributesmapping}.json
Output data/player_facts.json
{"meta": {...},
"players": [ {"id":20801,"rating":94,"pos":27,"pos2":16,"pos3":25,"pos4":-1,
"nation":38,"team":243,"league":53,
"attrs":[90,93,82,91,33,80], # PAC SHO PAS DRI DEF PHY
"gk":false}, ... ]}
THE SIX CARD ATTRIBUTES ARE NOT COLUMNS. `players` stores the 29 base
attributes; the six numbers a FUT card shows are a weighted sum of them. The
weights are not guessed here -- they are read out of the game's own
`playerattributesmapping` table, which maps each base attribute id to its
percentage contribution to speed / shooting / passing / dribbling / defending /
physical (and to the five gk* stats). Every column of that table sums to
exactly 100.
The one inference this file makes is attributeid -> players column name, and it
is safe for this purpose: wherever two attributes could be swapped (marking vs
standingtackle at 30 each, shotpower vs longshots at 20 each, ...) they carry
EQUAL weight, so the computed six are identical either way. The assignments
that actually move a number -- 45/55 acceleration/sprintspeed, 45 finishing,
50 dribbling, 35 shortpassing, 30 ballcontrol, 50 strength, 25 stamina,
20 aggression, 20 interceptions, 15 longpassing -- are each the unique
attribute with that weight.
VALIDATION (see the report): overallrating in `players` agrees with
data/roster.json, extracted from a completely different memory structure, on
17547 of 17547 shared players.
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(HERE, '..', 'data', 'tables')
OUT = os.path.join(HERE, '..', 'data', 'player_facts.json')
# FUT card slot -> [(players column, percent)], read off playerattributesmapping.
OUTFIELD = [
('pace', [('acceleration', 45), ('sprintspeed', 55)]),
('shooting', [('finishing', 45), ('shotpower', 20), ('longshots', 20),
('positioning', 5), ('volleys', 5), ('penalties', 5)]),
('passing', [('shortpassing', 35), ('vision', 20), ('crossing', 20),
('longpassing', 15), ('freekickaccuracy', 5), ('curve', 5)]),
('dribbling', [('dribbling', 50), ('ballcontrol', 30), ('agility', 10),
('balance', 5), ('reactions', 5)]),
('defending', [('marking', 30), ('standingtackle', 30), ('interceptions', 20),
('headingaccuracy', 10), ('slidingtackle', 10)]),
('physical', [('strength', 50), ('stamina', 25), ('aggression', 20),
('jumping', 5)]),
]
# Keeper card face. The five gk* columns are used at 100% -- they ARE the card
# numbers, no arithmetic. The speed slot is the only weighted one.
GK = [
('diving', [('gkdiving', 100)]),
('handling', [('gkhandling', 100)]),
('kicking', [('gkkicking', 100)]),
('reflexes', [('gkreflexes', 100)]),
('speed', [('acceleration', 60), ('sprintspeed', 40)]),
('positioning', [('gkpositioning', 100)]),
]
POSITION_NAMES = ['GK', 'SW', 'RWB', 'RB', 'RCB', 'CB', 'LCB', 'LB', 'LWB',
'RDM', 'CDM', 'LDM', 'RM', 'RCM', 'CM', 'LCM', 'LM',
'RAM', 'CAM', 'LAM', 'RF', 'CF', 'LF', 'RW', 'RS', 'ST',
'LS', 'LW']
def load(name):
with open(os.path.join(TABLES, name + '.json')) as fh:
return json.load(fh)
def weighted(row, spec):
return int(round(sum(row[c] * w for c, w in spec) / 100.0))
def main():
players = load('players')['rows']
tpl = load('teamplayerlinks')['rows']
teams = {t['teamid']: t for t in load('teams')['rows']}
nations = {n['nationid']: n['nationname'] for n in load('nations')['rows']}
# A player appears in teamplayerlinks once per club AND once per national
# side. The club is the row whose team is not a national team; nations
# and teams share an id space only through teamnationlinks, so the cheap,
# reliable discriminator is: the club link is the one with the lowest
# teamid that is not the player's own nation-team. Keep every link too.
nat_team = set()
for t in load('teamnationlinks')['rows']:
nat_team.add(t['teamid'])
clubs = {}
alllinks = {}
for l in tpl:
alllinks.setdefault(l['playerid'], []).append(l)
if l['teamid'] in nat_team:
continue
prev = clubs.get(l['playerid'])
if prev is None or l['teamid'] < prev['teamid']:
clubs[l['playerid']] = l
out = []
for p in players:
pid = p['playerid']
gk = p['preferredposition1'] == 0
spec = GK if gk else OUTFIELD
club = clubs.get(pid)
out.append({
'id': pid,
'rating': p['overallrating'],
'potential': p['potential'],
'pos': p['preferredposition1'],
'pos2': p['preferredposition2'],
'pos3': p['preferredposition3'],
'pos4': p['preferredposition4'],
'posname': POSITION_NAMES[p['preferredposition1']]
if 0 <= p['preferredposition1'] < len(POSITION_NAMES) else None,
'nation': p['nationality'],
'nationname': nations.get(p['nationality']),
'team': club['teamid'] if club else 0,
'teamname': teams.get(club['teamid'], {}).get('teamname') if club else None,
'jersey': club['jerseynumber'] if club else 0,
'foot': p['preferredfoot'],
'skillmoves': p['skillmoves'],
'weakfoot': p['weakfootabilitytypecode'],
'height': p['height'],
'weight': p['weight'],
'gk': gk,
'attrs': [weighted(p, s) for _, s in spec],
})
doc = {
'meta': {
'source': 'FIFA17.exe resident database, via tools/db_dump.py',
'players': len(out),
'attr_order_outfield': [k for k, _ in OUTFIELD],
'attr_order_gk': [k for k, _ in GK],
'weights_outfield': {k: dict(v) for k, v in OUTFIELD},
'weights_gk': {k: dict(v) for k, v in GK},
'position_enum': POSITION_NAMES,
},
'players': out,
}
with open(OUT, 'w') as fh:
json.dump(doc, fh, ensure_ascii=False, separators=(',', ':'))
sys.stderr.write("wrote %s: %d players (%d keepers)\n"
% (OUT, len(out), sum(1 for x in out if x['gk'])))
return 0
if __name__ == '__main__':
sys.exit(main())