From 4b66906adcccaa29a75ea0c7d74111333c366dbe Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 20:45:45 +0000 Subject: [PATCH] feat(fifa17): definition-coverage guard, and a complete-club staging fixture The guard classifies every definition id the game ships into exactly one of KNOWN_OWNABLE / KNOWN_PRESENTATION_ONLY / KNOWN_UNSUPPORTED / UNKNOWN, and fails when anything lands in UNKNOWN, so a table we cannot place is loud instead of quietly assumed cosmetic. All 149 tables place: UNKNOWN = 0, 20,876 ownable ids. Two tables are KNOWN_UNSUPPORTED with stated reasons rather than guessed at: `fut_storymodehero` (80 ids; the shipped row is only {carddbid, teamid}, so no item can be shaped without inventing one) and `fcc_misccards` (42 ids; these ARE owned items, but their per-subtype semantics are not reverse-engineered). It also counts by SHARED ID SPACE: `fcc_leaguelogos` and `fcc_leaguelogostickers` both start at carddbid 8010000 and all 39 sticker ids collide, so the union is 44 and never 83. The collision is printed so summing cannot regress silently. Staging now seeds the remaining club families (badge, ball, stadium, league logo) from the client's own tables, so the rig exercises EVERY ownable class instead of only the two the real club happens to hold. --- scripts/fifa17-definition-coverage.py | 830 ++++++++++++++++++++++++++ scripts/sold-staging-up.py | 62 ++ 2 files changed, 892 insertions(+) create mode 100755 scripts/fifa17-definition-coverage.py diff --git a/scripts/fifa17-definition-coverage.py b/scripts/fifa17-definition-coverage.py new file mode 100755 index 0000000..b98176e --- /dev/null +++ b/scripts/fifa17-definition-coverage.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""FIFA 17 definition-coverage guard. + +Classifies EVERY table in the FIFA 17 resident-database dump +(`fifa17-recon/data/tables/*.json`, produced by `fifa17-recon/tools/db_dump.py`) +into exactly one coverage bucket, and every definition id those tables ship into +exactly one of them too. A table with no rule lands in UNKNOWN and the run +FAILS: that is the whole point. Adding a newly dumped table, or renaming one, +must force a human to classify it rather than letting it silently disappear into +"presentation". + +This is OFFLINE ANALYSIS TOOLING (an RE/coverage oracle). It is not authoritative +for anything at runtime: the authoritative reader/writer/owner of FUT items is +Rust Core plus the FIFA 17 adapter. + +Authority for the taxonomy below, in precedence order: + 1. `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` (SETTLED; outranks + `fifa17-recon/docs/CARD_SYSTEM.md` wherever the two disagree) + 2. the shipped tables themselves (`--tables`) and + `fifa17-recon/data/consumables.json` (`--consumables`) + 3. the Rust taxonomy that consumes them: + `openfut-adapter-fifa17/src/fut/content_taxonomy.rs` and + `openfut-import-fifa17/src/lib.rs::club_family` + +Buckets +------- +KNOWN_OWNABLE A FUT club can hold an inventory instance of these + definitions, and the class is inside OpenFUT's item + vocabulary (player / staff / consumable / kit / badge / + ball / stadium / league logo). +KNOWN_PRESENTATION_ONLY Shipped and rendered, but never owned as an inventory + instance: match/career content, asset registries, pure + lookup and localisation tables, empty scratch mirrors. + Trophies belong to this class conceptually but ship NO + definition table at all (see TROPHY_NOTE). +KNOWN_UNSUPPORTED A real card-definition class, ownable in the retail + game, that OpenFUT deliberately does not support yet. + Every entry carries a one-line stated reason. +UNKNOWN Cannot be placed from evidence. MUST be zero. + +Usage +----- + python3 scripts/fifa17-definition-coverage.py [--tables DIR] [--json] [-v] + +Exits 0 only when UNKNOWN == 0 and every invariant check passes. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import NamedTuple + +OWNABLE = "KNOWN_OWNABLE" +PRESENTATION = "KNOWN_PRESENTATION_ONLY" +UNSUPPORTED = "KNOWN_UNSUPPORTED" +UNKNOWN = "UNKNOWN" + +BUCKET_ORDER = (OWNABLE, UNSUPPORTED, PRESENTATION, UNKNOWN) + + +class Rule(NamedTuple): + """One table's classification. + + bucket -- which coverage bucket the table belongs to. + id_col -- the column holding the CARD DEFINITION id, or None when the + table ships no definition id of its own (a table may still hold + foreign keys into somebody else's id space; those are not + definition ids and are never counted here). + id_space -- the shared id-space key. Two tables with the same key are + counted as a UNION, never summed (see ID_SPACES). + reason -- why this table is in this bucket. Evidence, not opinion. + """ + + bucket: str + id_col: str | None + id_space: str | None + reason: str + + +def ownable(id_col: str, id_space: str, reason: str) -> Rule: + return Rule(OWNABLE, id_col, id_space, reason) + + +def presentation(reason: str) -> Rule: + # Presentation tables ship no definition id of their own by construction: + # if one did, it would be a card class and would not be in this bucket. + return Rule(PRESENTATION, None, None, reason) + + +def unsupported(id_col: str, id_space: str, reason: str) -> Rule: + return Rule(UNSUPPORTED, id_col, id_space, reason) + + +# --------------------------------------------------------------------------- +# THE ONE CLASSIFICATION TABLE. Audit this dict and you have audited the tool. +# One entry per table in the dump; a table absent from here becomes UNKNOWN. +# --------------------------------------------------------------------------- +RULES: dict[str, Rule] = { + # -- KNOWN_OWNABLE: the 16 real card-definition tables --------------------- + # (15 here + fcc_misccards, which is the 16th definition table but sits in + # KNOWN_UNSUPPORTED; the 15 below + misccards are the settled 16.) + "players": ownable( + "playerid", "player.playerid", + "cardtype 1 player definitions; playerid IS the FUT assetId. The " + "reference ownable class — read/write/ownership already in Core.", + ), + "managercards": ownable( + "carddbid", "staff.manager.carddbid", + "staff cardsubtypeid 4 (manager); cardtype 2 merge FUN_1801356c0. " + "Ownable staff card (carries value/rare/nation/formationid).", + ), + "headcoachcards": ownable( + "carddbid", "staff.headcoach.carddbid", + "staff cardsubtypeid 5 (head coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "gkcoachcards": ownable( + "carddbid", "staff.gkcoach.carddbid", + "staff cardsubtypeid 6 (GK coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "physiocards": ownable( + "carddbid", "staff.physio.carddbid", + "staff cardsubtypeid 7 (physio) in the FUN_1800d8330 family selector; " + "ownable staff card.", + ), + "fitnesscoachcards": ownable( + "carddbid", "staff.fitnesscoach.carddbid", + "staff cardsubtypeid 8 (fitness coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "fcc_contractcards": ownable( + "carddbid", "consumable.contract.carddbid", + "consumable definitions, cardsubtype 201 (player contract) / 202 " + "(manager contract); both in data/consumables.json.", + ), + "fcc_healingcards": ownable( + "carddbid", "consumable.healing.carddbid", + "consumable definitions, cardsubtype 211..218 (healing) + 219/220 " + "(player/squad fitness); all in data/consumables.json.", + ), + "fcc_trainingcards": ownable( + "carddbid", "consumable.training.carddbid", + "consumable definitions: GK/player training 51..67, position 91..110, " + "formation 121..136, chemistry styles 250..273, manager league " + "300..341; all in data/consumables.json.", + ), + "fcc_kitcards": ownable( + "carddbid", "club.kit.carddbid", + "cardtype 7 club item, cardsubtypeid 9 (kit) via resolver FUN_180119bd0; " + "constant cardassetid 35 on all rows. Ownable club customisation.", + ), + "fcc_stadium": ownable( + "carddbid", "club.stadium.carddbid", + "cardtype 7 club item, cardsubtypeid 10 (stadium); constant cardassetid " + "36. Ownable club customisation (stadiumid is a foreign key into the " + "match-venue registry, not this table's identity).", + ), + "fcc_badgecards": ownable( + "carddbid", "club.badge.carddbid", + "cardtype 7 club item, cardsubtypeid 11 (club badge); constant " + "cardassetid 39. Ownable club customisation.", + ), + "fcc_balls": ownable( + "carddbid", "club.ball.carddbid", + "cardtype 9 club item, cardsubtypeid 30 (ball, FUT_UC_BALL caption); " + "constant cardassetid 37. Ownable club customisation.", + ), + "fcc_leaguelogos": ownable( + "carddbid", "club.leaguelogo.carddbid", + "cardtype 9 club item, cardsubtypeid 31 (league logo, by elimination " + "over FUN_1800d8330's cardtype-9 set); constant cardassetid 40. " + "SHARES its carddbid space with fcc_leaguelogostickers — see " + "ID_SPACES/collision report.", + ), + "fcc_leaguelogostickers": ownable( + "carddbid", "club.leaguelogo.carddbid", + "same cardsubtypeid 31 / cardassetid 40 league-logo class; its 39 " + "carddbids ALL collide with fcc_leaguelogos' ids (shipped data bug), so " + "it contributes 0 new definition ids and is counted as a union.", + ), + + # -- KNOWN_UNSUPPORTED: real card classes OpenFUT deliberately defers ------ + "fcc_misccards": unsupported( + "carddbid", "misc.carddbid", + "cardtype 9 cardsubtype {231,232,233,236} = FUN_1800d8330's " + "0xe7..0xe9/0xec block, matched exactly by this table's cardsubtype " + "column. These ARE owned items (the client's TO_TRADE_PILE gate " + "excludes exactly {0xe7,0xe8,0xe9,0xec}, which only means anything for " + "owned inventory), but the per-subtype semantics (cardassetid 43..46, " + "meaning of amount/rating) are NOT reverse-engineered, the subtypes are " + "absent from data/consumables.json's 172, and neither " + "content_taxonomy::consumable_family nor import::club_family maps them. " + "UNSUPPORTED rather than guessed.", + ), + "fut_storymodehero": unsupported( + "carddbid", "storymodehero.carddbid", + "a distinct carddbid space (22800..22879) for The Journey story-mode " + "hero cards, but the shipped row is only {carddbid, teamid}: no name, " + "rating, value, cardassetid or cardsubtypeid, so no FUT item can be " + "shaped from client data without fabricating it. OpenFUT does not " + "support story-mode hero items.", + ), + + # -- KNOWN_PRESENTATION_ONLY ---------------------------------------------- + # Crowd / atmosphere / match-engine lookups. + "BigAttendance": presentation( + "crowd-emotion band lookup (min/max/emotion) for a full stadium; " + "match presentation, not a card definition."), + "NoAttendance": presentation( + "crowd-emotion band lookup for an empty stadium; match presentation."), + "MatchIntensity": presentation( + "score-difference x match-minute AI intensity curve; match engine " + "lookup, no ids."), + "fcc_GrandStandPlayers": presentation( + "single playerid column choosing which player heads populate the FUT " + "hub grandstand. Foreign keys into the player id space (several do not " + "even resolve in the shipped players table); rendered, never owned."), + "celebrations": presentation( + "celebration animation ids; match presentation."), + "videos": presentation( + "per-locale video asset ids; front-end presentation."), + "eatrax": presentation( + "licensed soundtrack rows (song/artist/album); audio presentation."), + "songplaylistlinks": presentation( + "song-to-playlist links; audio presentation."), + "audionation": presentation( + "per-nation commentary/crowd audio bank indices; audio lookup."), + "audiostadium": presentation( + "per-stadium commentary language index; audio lookup."), + "dynamicimages": presentation( + "ad/sponsor dynamic image descriptors; stadium dressing presentation."), + "sponsors": presentation( + "ad sponsor registry (adsponserid); stadium dressing presentation."), + "teamsponsorlinks": presentation("team-to-sponsor links; presentation."), + "competitionsponsorlinks": presentation( + "competition-to-sponsor links; presentation."), + "modeadboardlinks": presentation("mode-to-adboard links; presentation."), + "presentationcompsettings": presentation( + "per-competition presentation feature flags; presentation."), + "presentationmodesettings": presentation( + "per-mode presentation feature flags; presentation."), + "assetcryptokeys": presentation( + "asset decryption keys (empty in this dump); build plumbing, not a " + "card definition."), + + # Asset registries the FUT card tables POINT AT. The ownable definition is + # the fcc_* card; these registries are the rendered asset behind it. + "teams": presentation( + "club/crest asset registry (assetid = team asset). fcc_badgecards' " + "teamid points here; the ownable badge definition is the fcc_ row."), + "teamkits": presentation( + "in-match kit asset registry. fcc_kitcards is the ownable FUT kit " + "definition; these rows are the geometry/colour asset it renders."), + "stadiums": presentation( + "match-venue registry (stadiumid). fcc_stadium is the ownable FUT " + "stadium definition; these rows are the venue it renders."), + "teamballs": presentation( + "in-match ball asset registry (ballid, isavailableinstore). fcc_balls " + "is the ownable FUT ball definition."), + "teamballremapping": presentation( + "ballid remap table (empty in this dump); asset plumbing."), + "dlcballs": presentation( + "DLC ball asset names (empty in this dump); asset registry, and FIFA 17 " + "FUT owns balls through fcc_balls, not through DLC asset rows."), + "dlcboots": presentation( + "DLC boot asset names (empty in this dump). FIFA 17 FUT has no boot " + "item class at all — boots are rendered on players, never owned."), + "playerboots": presentation( + "boot asset registry (shoetype/colour/licensing). Rendered on players; " + "FIFA 17 FUT ships no boot card class."), + "playerbootremapping": presentation( + "shoetype remap table (empty in this dump); asset plumbing."), + "shoecolors": presentation("boot colour palette; asset lookup."), + "manager": presentation( + "career-mode manager PERSONS (managerid, head/suit appearance). The " + "ownable FUT manager card is managercards."), + "physio": presentation( + "career-mode physio PERSONS (physioid, appearance). The ownable FUT " + "physio card is physiocards."), + "referee": presentation( + "referee persons/appearance (refereeid); match presentation, never " + "owned."), + "leaguerefereelinks": presentation("league-to-referee links; lookup."), + + # FUT front-end lookup / calculator tables (fcc_* that are NOT card tables). + "fcc_bonusvalues": presentation( + "chemistry/boost bonus value lookup (bonusid, bonuslevel); pure " + "calculator input, not a card definition."), + "fcc_managerbonusvalues": presentation( + "manager bonus value lookup; pure calculator input."), + "fcc_chemlinkcalc": presentation( + "formation x position chemistry-link matrix; pure calculator input."), + "fcc_nationcalc": presentation( + "nation grouping for chemistry (nationid -> groupid); lookup."), + "fcc_leagues": presentation( + "FUT league name/country lookup (leagueid -> futcountryid). " + "fcc_leaguelogos' leagueid points here; the ownable definition is the " + "fcc_leaguelogos row."), + "fcc_coinrewards": presentation( + "leaderboard objective coin reward lookup; economy table, not a card."), + "fcc_discardcoins": presentation( + "quick-sell price lookup keyed by (cardtype, level, rare). Prices for " + "cards, not card definitions."), + "fcc_myclubs": presentation("MY CLUB tab id/name lookup; UI structure."), + "fcc_myclubscategories": presentation( + "MY CLUB category rows (categoryid, isteamcategory); UI structure."), + "fcc_preferredpositioncalc": presentation( + "preferred-position calculator matrix; calculator input."), + "fcc_preferredformationcalcgk": presentation( + "preferred-formation calculator matrix (GK); calculator input."), + "fcc_preferredformationcalcback": presentation( + "preferred-formation calculator matrix (defence); calculator input."), + "fcc_preferredformationcalcmid": presentation( + "preferred-formation calculator matrix (midfield); calculator input."), + "fcc_preferredformationcalcst": presentation( + "preferred-formation calculator matrix (attack); calculator input."), + "fcc_formationcardspositions_hd": presentation( + "on-screen card X/Y coordinates per formation (HD layout); UI layout."), + "fcc_formationcardspositions_kc": presentation( + "on-screen card X/Y coordinates per formation (KC layout); UI layout."), + "fcc_textposvalues_hd": presentation( + "on-screen text offsets per formation (HD layout); UI layout."), + "fcc_textposvalues_kc": presentation( + "on-screen text offsets per formation (KC layout); UI layout."), + "fcc_navcoords_hd": presentation( + "D-pad navigation graph between squad slots (HD). Its `playerid` column " + "is a 0..11 SLOT INDEX, not a player id; UI navigation."), + "fcc_navcoords_kc": presentation( + "D-pad navigation graph between squad slots (KC); UI navigation."), + + # Squad / formation / tactics data. + "formations": presentation( + "formation definitions (positions, offsets, instructions); tactics " + "data, never owned."), + "formationlayout": presentation("formation-to-formation slot remap; lookup."), + "defformation": presentation("default formation rows; tactics data."), + "customformations": presentation( + "user custom formations (empty in this dump); tactics data."), + "customteamstyles": presentation( + "user custom team styles (empty in this dump); tactics data."), + "teamformationteamstylelinks": presentation( + "team-to-formation/style links (empty in this dump); tactics lookup."), + "teamsheets": presentation("team sheet rows; tactics data."), + "default_teamsheets": presentation("default team sheet rows; tactics data."), + "defaultteamdata": presentation("default per-team tactics; tactics data."), + "teamsheetanalysis": presentation( + "playerid x recorded position analysis rows; tactics lookup."), + "fieldpositionboundingboxes": presentation( + "pitch bounding boxes per position id; match engine geometry."), + "playerpositionzones": presentation( + "position zone polygons; match engine geometry."), + "attributeprefpositionformula": presentation( + "attribute weighting per position; rating calculator input."), + "playerattributesmapping": presentation( + "raw-attribute to displayed-attribute mapping; calculator input."), + "playerattributesmapping_g4": presentation( + "gen-4 variant of the attribute mapping; calculator input."), + "starratingboundaries": presentation( + "overall-rating to star-rating bands; UI lookup."), + + # Reference / geography / competition structure. + "nations": presentation("nation registry (nationid, iso, confederation); lookup."), + "leagues": presentation("league registry (leagueid, level); lookup."), + "leagueteamlinks": presentation("league table state per team; lookup/state."), + "teamnationlinks": presentation("international team-to-nation links; lookup."), + "rowteamnationlinks": presentation("rest-of-world team-to-nation links; lookup."), + "teamstadiumlinks": presentation("team-to-stadium links; lookup."), + "teamstadiumlinkscache": presentation( + "cached team-to-stadium links (empty in this dump); lookup."), + "stadiumassignments": presentation( + "custom stadium name assignments (empty in this dump); lookup."), + "competition": presentation( + "competition definitions and presentation flags (competitionid); " + "match/competition structure, never owned."), + "rivals": presentation("team rivalry pairs; match presentation."), + "fixtures": presentation("fixture rows (empty in this dump); schedule state."), + "factory_teams": presentation( + "single factory/template team row; default data, not a card."), + "fifaGameDefaults": presentation( + "default player/team/ball/referee ids per settings context; defaults " + "lookup."), + "fifaGameSettings": presentation("game settings rows; user settings."), + "version": presentation("database schema/version metadata."), + "server_db_version": presentation("server database version metadata."), + + # Player-adjacent state and career tables (state about players, not new + # definitions; every playerid here is a foreign key into players.playerid). + "teamplayerlinks": presentation( + "player-to-team squad links plus season form/stats; state over " + "players.playerid, not a new definition id."), + "previousteam": presentation( + "player's previous team ids; state over players.playerid."), + "playerloans": presentation( + "loan rows (playerid, teamidloanedfrom); career state."), + "playersuspensions": presentation( + "suspension rows (empty in this dump); career state."), + "restrictedplayers": presentation( + "restricted/replacement player rows (empty in this dump); career state."), + "playerformdiff": presentation( + "player rating deltas (empty in this dump); career state."), + "teamformdiff": presentation( + "team rating deltas (empty in this dump); career state."), + "transfers": presentation("transfer rows (empty in this dump); career state."), + "transactionhistory": presentation( + "transfer history rows (empty in this dump); career state."), + "player_grudgelove": presentation( + "player-to-team emotional affinity; career/story lookup."), + "dna": presentation("player DNA rows (empty in this dump); career data."), + "stories": presentation("career story engine rows (empty in this dump)."), + "career_calendar": presentation("career calendar dates; career data."), + "career_clinchedobjectives": presentation( + "career objective flags (empty in this dump); career state."), + "career_squadranking": presentation( + "career squad ranking (empty in this dump); career state."), + "career_playerlastmatchhistory": presentation( + "career last-match history (empty in this dump); career state."), + "career_playermatchratinghistory": presentation( + "career match rating history (empty in this dump); career state."), + + # Localisation / name string tables. + "playernames": presentation( + "nameid -> player name strings; localisation, not a card definition."), + "dcplayernames": presentation( + "DLC player name strings (empty in this dump); localisation."), + "editedplayernames": presentation( + "user-edited player names (empty in this dump); localisation."), + "commentarynames": presentation("commentary name strings; localisation."), + "clubcommentarynames": presentation("club commentary name strings; localisation."), + "createclubnames": presentation("create-a-club name affixes; localisation."), + "career_commonnames": presentation( + "career common-name strings (empty in this dump); localisation."), + "career_firstnames": presentation( + "career first-name strings (empty in this dump); localisation."), + "career_lastnames": presentation( + "career last-name strings (empty in this dump); localisation."), + "trainingteamplayernames": presentation( + "training/arena team player name strings; localisation."), + + # Create-a-player / customisation input tables. + "createplayer": presentation( + "create-a-player morph sliders (empty in this dump); customisation " + "input, not a card definition."), + "temp_createplayer": presentation( + "scratch mirror of createplayer (empty in this dump)."), + "createplayerpositiontemplates": presentation( + "create-a-player attribute templates per position; customisation input."), + "createplayerviews": presentation( + "create-a-player UI view/attribute mapping; UI structure."), + + # Skill-games / arena / training rosters (fixed non-FUT rosters). + "smplayers": presentation( + "skill-games roster player rows; a fixed non-FUT roster, never owned."), + "smrivals": presentation("skill-games rivalry pairs; presentation."), + "trainingteamplayers": presentation( + "training/arena roster player rows; a fixed non-FUT roster."), + "trainingteamplayerlinks": presentation( + "training/arena roster squad links; lookup."), + + # Empty scratch/staging mirrors (0 rows in this dump; the game copies live + # tables into them). No definition ids of their own by construction. + "temp_players": presentation("empty scratch mirror of players."), + "temp_teams": presentation("empty scratch mirror of teams."), + "temp_teamplayerlinks": presentation("empty scratch mirror of teamplayerlinks."), + "temp_formations": presentation("empty scratch mirror of formations."), + "temp_arenaplayer": presentation("empty scratch arena player table."), + "temp_arenaplayername": presentation("empty scratch arena player name table."), + "temp_arenateam": presentation("empty scratch arena team table."), + "temp_arenateamplayerlinks": presentation("empty scratch arena squad links."), + + # Companion-app / cloud ("cz_") staging tables, all empty in this dump. + "cz_players": presentation( + "companion/cloud staging copy of players (empty in this dump)."), + "cz_teams": presentation( + "companion/cloud staging copy of teams (empty in this dump)."), + "cz_teamkits": presentation( + "companion/cloud staging copy of teamkits (empty in this dump)."), + "cz_leagues": presentation( + "companion/cloud staging copy of leagues (empty in this dump)."), + "cz_assets": presentation( + "companion/cloud asset descriptors (empty in this dump)."), +} + +# Tables that share ONE carddbid space and must therefore be counted as a +# UNION. Each entry is (id_space, expected_union, note) — the note is printed so +# the bug cannot regress silently. +ID_SPACES: dict[str, tuple[int, str]] = { + "club.leaguelogo.carddbid": ( + 44, + "SHIPPED DATA BUG: fcc_leaguelogos (44 rows) and " + "fcc_leaguelogostickers (39 rows) both start their carddbid at " + "8010000, so all 39 sticker ids collide with logo ids. The league-logo " + "definition count is the UNION = 44, never the naive sum 83.", + ), +} + +# The SETTLED inventory: 16 real definition tables holding 20918 distinct ids +# (league logos counted as their union of 44). Checked as an invariant so a +# re-dump that gains or loses definitions is caught here. +SETTLED_DEFINITION_TABLES = ( + "players", "managercards", "headcoachcards", "gkcoachcards", + "fitnesscoachcards", "physiocards", "fcc_contractcards", + "fcc_healingcards", "fcc_trainingcards", "fcc_misccards", "fcc_kitcards", + "fcc_badgecards", "fcc_balls", "fcc_stadium", "fcc_leaguelogos", + "fcc_leaguelogostickers", +) +SETTLED_DEFINITION_ID_TOTAL = 20918 + +# Each club family ships a CONSTANT cardassetid, verified across all shipped +# rows; it is the deserializer's own per-subtype default. +CLUB_CARD_ASSET_IDS = { + "fcc_kitcards": 35, + "fcc_stadium": 36, + "fcc_balls": 37, + "fcc_badgecards": 39, + "fcc_leaguelogos": 40, + "fcc_leaguelogostickers": 40, +} + +# Consumable definition tables whose every cardsubtype must appear in +# data/consumables.json. fcc_misccards is deliberately absent: its subtypes +# {231,232,233,236} are NOT consumables and NOT in that file — that is exactly +# why it is KNOWN_UNSUPPORTED. +CONSUMABLE_DEFINITION_TABLES = ( + "fcc_contractcards", "fcc_healingcards", "fcc_trainingcards", +) + +TROPHY_NOTE = ( + "TROPHIES (cardsubtypeid 0x91..0x96): FUN_180108c00 computes " + "subtype = tournamentType + 0x91 and FUN_1800fed90 is the only function " + "whose case set is exactly {0x91..0x96}. They are rendered from wire " + "FUT::TournamentInfo data and ship NO definition table in this dump, so " + "they contribute 0 definition ids. Presentation-only by evidence, not by " + "convenience." +) + + +def load_table(path: Path) -> dict: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def classify(tables_dir: Path, consumables_path: Path) -> dict: + files = sorted(tables_dir.glob("*.json")) + if not files: + raise SystemExit(f"no *.json tables under {tables_dir}") + + per_table: list[dict] = [] + ids_by_space: dict[str, set[int]] = {} + space_bucket: dict[str, str] = {} + problems: list[str] = [] + + for path in files: + name = path.stem + data = load_table(path) + rows = data.get("rows") or [] + columns = {c["name"] for c in data.get("schema", [])} + rule = RULES.get(name) + if rule is None: + per_table.append({ + "table": name, + "bucket": UNKNOWN, + "rows": len(rows), + "definition_ids": 0, + "id_space": None, + "reason": "NO CLASSIFICATION RULE — classify it in RULES before " + "this dump can be trusted.", + }) + continue + + ids: set[int] = set() + if rule.id_col is not None: + if rule.id_col not in columns: + problems.append( + f"{name}: declared id column {rule.id_col!r} is absent from " + f"the shipped schema" + ) + ids = {r[rule.id_col] for r in rows if rule.id_col in r} + space = rule.id_space + assert space is not None, f"{name}: id column without id space" + prior = space_bucket.setdefault(space, rule.bucket) + if prior != rule.bucket: + problems.append( + f"id space {space!r} spans two buckets ({prior} and " + f"{rule.bucket}); a shared id space must be one class" + ) + ids_by_space.setdefault(space, set()).update(ids) + + per_table.append({ + "table": name, + "bucket": rule.bucket, + "rows": len(rows), + "definition_ids": len(ids), + "id_space": rule.id_space, + "reason": rule.reason, + }) + + # Definition ids per bucket, deduplicated across shared id spaces. + bucket_ids: dict[str, set[int]] = {b: set() for b in BUCKET_ORDER} + for space, ids in ids_by_space.items(): + bucket_ids[space_bucket[space]].update(ids) + + totals = {} + for bucket in BUCKET_ORDER: + entries = [e for e in per_table if e["bucket"] == bucket] + totals[bucket] = { + "tables": len(entries), + "rows": sum(e["rows"] for e in entries), + "definition_ids": len(bucket_ids[bucket]), + } + + collisions = [] + for space, (expected, note) in ID_SPACES.items(): + members = [e["table"] for e in per_table if e["id_space"] == space] + counts = { + e["table"]: e["definition_ids"] + for e in per_table if e["id_space"] == space + } + union = len(ids_by_space.get(space, set())) + collisions.append({ + "id_space": space, + "tables": members, + "per_table_ids": counts, + "naive_sum": sum(counts.values()), + "union": union, + "expected_union": expected, + "ok": union == expected, + "note": note, + }) + + checks = [{ + "check": "leaguelogo carddbid union", + "ok": all(c["ok"] for c in collisions), + "detail": "; ".join( + f"{c['id_space']}: union={c['union']} expected={c['expected_union']} " + f"naive_sum={c['naive_sum']}" for c in collisions + ), + }] + + settled_spaces = { + RULES[t].id_space for t in SETTLED_DEFINITION_TABLES if t in RULES + } + settled_total = sum(len(ids_by_space.get(s, set())) for s in settled_spaces) + checks.append({ + "check": "settled 16 definition tables total", + "ok": settled_total == SETTLED_DEFINITION_ID_TOTAL, + "detail": f"{settled_total} distinct ids across " + f"{len(SETTLED_DEFINITION_TABLES)} tables " + f"({len(settled_spaces)} id spaces); " + f"expected {SETTLED_DEFINITION_ID_TOTAL}", + }) + + asset_detail = [] + asset_ok = True + for table, expected in CLUB_CARD_ASSET_IDS.items(): + path = tables_dir / f"{table}.json" + if not path.exists(): + asset_ok = False + asset_detail.append(f"{table}=MISSING") + continue + seen = {r.get("cardassetid") for r in load_table(path).get("rows") or []} + ok = seen == {expected} + asset_ok = asset_ok and ok + asset_detail.append( + f"{table}={expected}" if ok else f"{table}=UNEXPECTED{sorted(seen)}" + ) + checks.append({ + "check": "constant cardassetid per club family", + "ok": asset_ok, + "detail": ", ".join(asset_detail), + }) + + cons_ok = True + if not consumables_path.exists(): + cons_ok = False + cons_detail = f"{consumables_path} missing — cannot verify" + else: + with consumables_path.open(encoding="utf-8") as fh: + known = {e["cardsubtypeid"] for e in json.load(fh)["subtypes"]} + unlisted: set[int] = set() + for table in CONSUMABLE_DEFINITION_TABLES: + path = tables_dir / f"{table}.json" + if not path.exists(): + cons_ok = False + continue + unlisted |= { + r["cardsubtype"] for r in load_table(path).get("rows") or [] + } - known + cons_ok = cons_ok and not unlisted + cons_detail = ( + f"{len(known)} documented subtypes; unlisted in consumable " + f"definition tables: {sorted(unlisted) if unlisted else 'none'}" + ) + checks.append({ + "check": "consumable subtypes all documented", + "ok": cons_ok, + "detail": cons_detail, + }) + + for problem in problems: + checks.append({"check": "schema/id-space integrity", "ok": False, + "detail": problem}) + + rules_without_table = sorted( + set(RULES) - {e["table"] for e in per_table} + ) + all_ids = set() + for ids in ids_by_space.values(): + all_ids |= ids + + return { + "tables_dir": str(tables_dir), + "table_count": len(per_table), + "per_table": per_table, + "totals": totals, + "definition_ids_total": len(all_ids), + "id_space_collisions": collisions, + "checks": checks, + "rules_without_shipped_table": rules_without_table, + "trophy_note": TROPHY_NOTE, + "unknown": totals[UNKNOWN]["tables"], + "ok": totals[UNKNOWN]["tables"] == 0 and all(c["ok"] for c in checks), + } + + +def render(report: dict, verbose: bool) -> None: + print(f"FIFA 17 definition coverage — {report['tables_dir']}") + print(f"{report['table_count']} shipped tables\n") + + for bucket in BUCKET_ORDER: + entries = [e for e in report["per_table"] if e["bucket"] == bucket] + if not entries: + print(f"{bucket}: (none)\n") + continue + t = report["totals"][bucket] + print(f"{bucket} — {t['tables']} tables, {t['rows']} rows, " + f"{t['definition_ids']} distinct definition ids") + detailed = bucket != PRESENTATION or verbose + for e in sorted(entries, key=lambda e: (-e["definition_ids"], e["table"])): + print(f" {e['table']:<32} rows={e['rows']:>6} " + f"ids={e['definition_ids']:>6}" + + (f" space={e['id_space']}" if e["id_space"] else "")) + if detailed: + for line in wrap(e["reason"], 72): + print(f" {line}") + print() + + print("ID-SPACE COLLISIONS") + for c in report["id_space_collisions"]: + per = ", ".join(f"{k}={v}" for k, v in sorted(c["per_table_ids"].items())) + print(f" {c['id_space']}: {per}") + print(f" naive sum={c['naive_sum']} UNION={c['union']} " + f"expected={c['expected_union']} " + f"{'OK' if c['ok'] else 'MISMATCH'}") + for line in wrap(c["note"], 72): + print(f" {line}") + print() + + print("TROPHIES") + for line in wrap(report["trophy_note"], 74): + print(f" {line}") + print() + + print("INVARIANT CHECKS") + for c in report["checks"]: + print(f" [{'PASS' if c['ok'] else 'FAIL'}] {c['check']}: {c['detail']}") + print() + + if report["rules_without_shipped_table"]: + print("RULES WITH NO SHIPPED TABLE (informational — stale or " + "guard entries)") + for name in report["rules_without_shipped_table"]: + print(f" {name}") + print() + + print("SUMMARY") + for bucket in BUCKET_ORDER: + t = report["totals"][bucket] + print(f" {bucket:<24} {t['tables']:>4} tables " + f"{t['definition_ids']:>6} definition ids") + print(f" {'TOTAL':<24} {report['table_count']:>4} tables " + f"{report['definition_ids_total']:>6} definition ids") + print(f" UNKNOWN = {report['unknown']}") + print(" RESULT: " + ("OK" if report["ok"] else "FAIL")) + + +def wrap(text: str, width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + cur = "" + for w in words: + if cur and len(cur) + 1 + len(w) > width: + lines.append(cur) + cur = w + else: + cur = f"{cur} {w}".strip() + if cur: + lines.append(cur) + return lines + + +def main(argv: list[str] | None = None) -> int: + repo = Path(__file__).resolve().parent.parent + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--tables", type=Path, + default=repo / "fifa17-recon/data/tables", + help="directory of dumped *.json definition tables") + ap.add_argument("--consumables", type=Path, + default=repo / "fifa17-recon/data/consumables.json", + help="consumable subtype evidence file") + ap.add_argument("--json", action="store_true", + help="emit the machine-readable report on stdout") + ap.add_argument("-v", "--verbose", action="store_true", + help="also print the reason for every presentation table") + args = ap.parse_args(argv) + + report = classify(args.tables, args.consumables) + if args.json: + json.dump(report, sys.stdout, indent=2, sort_keys=False) + sys.stdout.write("\n") + else: + render(report, args.verbose) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index d5d3f71..3a9b2ba 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -187,6 +187,24 @@ STAGING_KITS = [ ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), ] +# The remaining club-item families, so the rig exercises EVERY ownable class +# rather than only the two the real club happens to hold. Every value is read +# out of the client's own tables (fifa17-recon/data/tables), never invented: +# fcc_badgecards 6000005 teamid 21 -> the same team as the kits above +# fcc_balls 8120194 cardassetid 37 +# fcc_stadium 6200000 cardassetid 36 (capacity 76200) +# fcc_leaguelogos 8010015 leagueid 53 -> the same league as the manager below +# Each family ships a CONSTANT cardassetid (kit 35, stadium 36, ball 37, +# badge 39, logo 40), which is what the importer gates on. A league logo has no +# equipped slot, so it is owned as generic `misc` content. +STAGING_CLUB_ITEMS = [ + # (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id) + ("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21), + ("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None), + ("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None), + (None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None), +] + # The club manager. FIFA refuses to start a match without one ("your player or # managers contracts have expired"), and NEITHER club owns a manager: the real # import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK), @@ -527,6 +545,37 @@ def materialise(lay: Layout) -> None: "team_id": KIT_TEAM_ID, } + for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS: + if card_id not in existing: + definitions.append({ + "id": card_id, + "name": kind.title(), + "overall": 0, + "position": "", + "nation": "", + "league": "", + "club": "", + "pace": 0, + "shooting": 0, + "passing": 0, + "dribbling": 0, + "defending": 0, + "physical": 0, + "rarity": "bronze", + "image_path": None, + }) + entry = { + "asset_id": resource_id, + "version": 0, + "rareflag": 0, + "kind": kind, + "subtype": subtype, + "card_asset_id": art, + } + if team is not None: + entry["team_id"] = team + catalog["cards"][card_id] = entry + mgr = STAGING_MANAGER if mgr["card_id"] not in existing: definitions.append({ @@ -924,6 +973,11 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: TS, ) ) + owned.extend( + (owned_id, seller_club, card_id, kind, TS) + for _slot, owned_id, card_id, _rid, kind, _st, _art, _team + in STAGING_CLUB_ITEMS + ) if real_club is None: owned = ( [(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS] @@ -960,6 +1014,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: [ (seller_club, f"{slot}_kit", owned_id, TS) for slot, owned_id, _card_id, _resource_id in STAGING_KITS + ] + # badge / ball / stadium are slot-keyed exactly like the kits. + # A league logo has no slot, so it stays owned-but-unequipped — + # which is itself worth exercising. + + [ + (seller_club, slot, owned_id, TS) + for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS + if slot is not None ], )