kits: canonical table-proven kit map, and category is the home/away key (not the id band)

Answers from the extracted client tables, before touching a binary. Every number
is a count over the full table.

  fcc_kitcards 1482 rows, teamkits 2576 rows.

CATEGORY -> ENGINE KIT TYPE, with a test that can actually fail. Asserting
"category 3 means away" because away kits usually exist is not evidence: types
0/1/2 are present for most teams, so it is true by construction. The
discriminating cases are the teams that LACK a type.

  category 2 -> type 0 HOME    54 keys lack type 0, 0 counterexamples
  category 3 -> type 1 AWAY   166 keys lack type 1, 0 counterexamples
  category 5 -> type 2 THIRD  145 keys lack type 2, 0 counterexamples

and there is never more than one card per (team, year, category).

THE ID BAND IS NOT HOME/AWAY. Band 6300000 holds 740 HOME cards AND 88 THIRD
cards; band 6400000 holds the 654 AWAY cards. assetid is fully determined by the
band (14 for 828/828 of 63xxxxx, 15 for 654/654 of 64xxxxx), so it carries no
information the band does not. cardassetid is 35 on all 1482 rows - it is the FUT
card frame, not the kit art.

This matters for openfut-adapter-fifa17: KIT_AWAY_FLOOR splits home from away at
6_400_000, which is right for home vs away but silently classifies all 88 THIRD
kits as HOME. Recorded here, not yet fixed - third kits are not currently
ownable, so nothing observable depends on it.

teamkits.islocked is 0 on all 2576 rows, so the DB lock flag is NOT what makes
the pre-match selector call a kit locked. 6 rows are embargoed.

Team 21, the staging club, resolves exactly:
  6300006 cat 2 HOME  year 0    assetid 14 -> teamkitid 1376
  6400003 cat 3 AWAY  year 0    assetid 15 -> teamkitid 1377
  6300007 cat 2 HOME  year 1972 assetid 14 -> teamkitid 5126
  6300008 cat 5 THIRD year 0    assetid 14 -> teamkitid 1378

so the two kits OpenFUT serves are the correct home/away pair.

NOT recoverable from data/tables: the kit's own name string. fcc_kitcards
name/header/description/biodescription are byte OFFSETS into the table's string
blob (583, 597, 608, 619 on one row), and that blob is not among the extracted
tables.

Adds audit_fifa17_kits.py (the tool, with the discriminating test inline) and
fifa17-kit-map.json (its output) so this is reusable data rather than terminal
scrollback.
This commit is contained in:
funman300
2026-08-23 19:34:06 +00:00
parent 88ae754b0f
commit eefa98c961
2 changed files with 28522 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Canonical FIFA 17 kit map, joined from the extracted client tables.
Authority for every kit question that a table can answer, so nobody has to
reverse a binary for a fact that is sitting in a JSON row. Reads only:
fifa17-recon/data/tables/fcc_kitcards.json the FUT KIT CARD definitions
fifa17-recon/data/tables/teamkits.json the ENGINE kit rows
Everything printed is TABLE_PROVEN unless the line says otherwise: it is a
direct count over the full table, not a sample.
Usage:
python3 audit_fifa17_kits.py human report
python3 audit_fifa17_kits.py --json machine-readable, for tests/tools
python3 audit_fifa17_kits.py --team 21 drill into one team
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
# TABLE_PROVEN, established by this script's own discriminating test (see
# category_type_evidence): a kit CARD's `category` selects the engine kit ROW's
# `teamkittypetechid` at the same (team, year).
CATEGORY_TO_KIT_TYPE = {2: 0, 3: 1, 5: 2}
KIT_TYPE_NAME = {0: "HOME", 1: "AWAY", 2: "THIRD", 3: "FOURTH", 5: "GK", 6: "SPECIAL6", 7: "SPECIAL7"}
def load(name):
with open(os.path.join(TABLES, name), "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, list) else data.get("rows", data)
def band(carddbid: int) -> int:
"""The 6_300_000 / 6_400_000 id band."""
return (carddbid // 100_000) * 100_000
def category_type_evidence(cards, kits):
"""The DISCRIMINATING test behind CATEGORY_TO_KIT_TYPE.
Asserting "category 3 means away" because away kits usually exist is not
evidence -- types 0/1/2 are present for most teams, so the claim is true by
construction. What discriminates is the teams that LACK a type: if category 3
really means type 1, then no category-3 card may exist for a (team, year)
that has no type-1 row. Same for category 5 and type 2.
"""
kits_by = defaultdict(set)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].add(r["teamkittypetechid"])
cards_by = defaultdict(list)
for r in cards:
cards_by[(r["teamid"], r["year"])].append(r)
out = {}
for cat, want in CATEGORY_TO_KIT_TYPE.items():
# keys that HAVE teamkits rows but not the wanted type
lacking = [k for k, t in kits_by.items() if t and want not in t]
counterexamples = [
r["carddbid"] for k in lacking for r in cards_by.get(k, []) if r["category"] == cat
]
out[cat] = {
"kit_type": want,
"name": KIT_TYPE_NAME[want],
"keys_lacking_type": len(lacking),
"counterexamples": counterexamples,
}
return out
def audit():
cards = load("fcc_kitcards.json")
kits = load("teamkits.json")
kits_by = defaultdict(list)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].append(r)
rows = []
for c in cards:
key = (c["teamid"], c["year"])
want = CATEGORY_TO_KIT_TYPE.get(c["category"])
match = next((k for k in kits_by.get(key, []) if k["teamkittypetechid"] == want), None)
rows.append(
{
"carddbid": c["carddbid"],
"band": band(c["carddbid"]),
"teamid": c["teamid"],
"year": c["year"],
"category": c["category"],
"kit_type": want,
"kit_type_name": KIT_TYPE_NAME.get(want, "?"),
"assetid": c["assetid"],
"cardassetid": c["cardassetid"],
"value": c["value"],
"weightrare": c["weightrare"],
# These are BYTE OFFSETS into the table's string blob, not ids.
# The blob is not among the extracted tables, so a kit's own
# name string is NOT recoverable from data/tables alone.
"name_offset": c["name"],
"header_offset": c["header"],
"description_offset": c["description"],
"teamkitid": match["teamkitid"] if match else None,
"teamkit_islocked": match["islocked"] if match else None,
"teamkit_embargoed": match["isembargoed"] if match else None,
}
)
dupes = [k for k, n in Counter((r["teamid"], r["year"], r["category"]) for r in rows).items() if n > 1]
return {
"counts": {"fcc_kitcards": len(cards), "teamkits": len(kits)},
"bands": dict(sorted(Counter(r["band"] for r in rows).items())),
"band_x_assetid": {f"{b}/{a}": n for (b, a), n in
sorted(Counter((r["band"], r["assetid"]) for r in rows).items())},
"band_x_category": {f"{b}/{c}": n for (b, c), n in
sorted(Counter((r["band"], r["category"]) for r in rows).items())},
"category_counts": dict(sorted(Counter(r["category"] for r in rows).items())),
"cardassetid": sorted({r["cardassetid"] for r in rows}),
"category_type_evidence": category_type_evidence(cards, kits),
"unmatched": [r["carddbid"] for r in rows if r["teamkitid"] is None],
"duplicate_team_year_category": dupes,
"teamkits_islocked": dict(Counter(r["islocked"] for r in kits)),
"teamkits_embargoed": dict(Counter(r["isembargoed"] for r in kits)),
"teamkits_types": dict(sorted(Counter(r["teamkittypetechid"] for r in kits).items())),
"rows": rows,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--team", type=int)
args = ap.parse_args()
a = audit()
if args.json:
json.dump(a, sys.stdout, indent=2)
return
print("FIFA 17 kit map — TABLE_PROVEN from the extracted client tables")
print(f" fcc_kitcards rows : {a['counts']['fcc_kitcards']}")
print(f" teamkits rows : {a['counts']['teamkits']}")
print("\nid bands")
for b, n in a["bands"].items():
print(f" {b}: {n}")
print("\nband/assetid (assetid is fully determined by band)")
for k, n in a["band_x_assetid"].items():
print(f" {k}: {n}")
print("\nband/category")
for k, n in a["band_x_category"].items():
print(f" {k}: {n}")
print(f"\ncardassetid values: {a['cardassetid']} (the FUT card frame, not the kit art)")
print("\ncategory -> engine kit type, with the discriminating test")
for cat, ev in a["category_type_evidence"].items():
verdict = "HOLDS" if not ev["counterexamples"] else f"FAILS ({len(ev['counterexamples'])})"
print(f" category {cat} -> type {ev['kit_type']} {ev['name']:6s} "
f"| {ev['keys_lacking_type']:4d} (team,year) keys lack that type, "
f"{len(ev['counterexamples'])} counterexample(s) -> {verdict}")
print("\nengine kit types present in teamkits")
for t, n in a["teamkits_types"].items():
print(f" type {t} {KIT_TYPE_NAME.get(t,'?'):8s}: {n}")
print(f"\nteamkits islocked : {a['teamkits_islocked']} <- every row, so NOT the selector lock")
print(f"teamkits embargoed : {a['teamkits_embargoed']}")
print(f"\nanomalies")
print(f" cards with no matching teamkits row : {len(a['unmatched'])}")
print(f" duplicate (team,year,category) : {len(a['duplicate_team_year_category'])}")
if args.team is not None:
print(f"\n=== team {args.team} ===")
print(f" {'carddbid':10s} {'cat':4s} {'type':7s} {'year':6s} {'assetid':8s} {'teamkitid':10s} locked")
for r in sorted((r for r in a["rows"] if r["teamid"] == args.team), key=lambda r: r["carddbid"]):
print(f" {r['carddbid']:<10} {r['category']:<4} {r['kit_type_name']:<7} {r['year']:<6} "
f"{r['assetid']:<8} {str(r['teamkitid']):<10} {r['teamkit_islocked']}")
if __name__ == "__main__":
main()