Files
OpenFUT/fifa17-recon/tools/coach_window.py
T
funman300 9feb577c1c fifa17-recon: the four coach families -- 411 real ids, and a miss that labels itself
headcoachcards 124 rows 2000004..2000328, gkcoachcards 121 rows 9000001..9000324,
physiocards 51 rows 4000002..4000259, fitnesscoachcards 115 rows 3000019..3000328.
All MEASURED from data/tables/, dumped read-only from the running client; rowcount ==
rows_emitted == len(rows) on all four, which is what makes "this id is absent" a claim
about a complete dump rather than about a truncated one. assetid == carddbid on every
row; every id fits in 24 bits.

WHY COACHES ARE THE CHEAPEST FAMILY TO TEST. Their four arms of FUN_180141660 (2,129
bytes, 214-line decompile read to its closing `return`) are the only merges in the
game that label their own failure: on rowcount < 1 each writes firstname = lastname =
"DB Error", rec+0xb4 = 0x32, rec+0x58 = 1 and a TABLE-UNIQUE assetid -- head 2000148,
fitness 3000259, physio 4000146, gkcoach 9000258. Two independent facts make that a
one-glance oracle, both verified by exhaustive scan of all 411 rows: no row in any of
the four tables has value == 50, and no fitnesscoach row is (fieldpos 1, posbonus 7,
amount 1).

CORRECTION to docs/plan-2026-08-04-card-families.md: the miss-fill is NOT uniform.
Only head coach and GK coach write 0xf into the attribute array at rec+0x98. Physio
writes 0xf into a BYTE at rec+0xdd; fitness coach writes no 0xf at all -- rec+0xde =
0x107 and rec+0xdd = 1. So card_identity_probe's attrs column means something
different per family, and its F_NAME_KNOWN=0xdd string read sits directly on top of
physio's, fitness coach's and the manager's raw stat bytes. Use coach_probe.py.

The key is RAW: all four staff branches pass *(u32*)(rec+0x18) unmasked into
`WHERE carddbid == ?`. Players are the only family that masks with & 0xffffff, so a
version byte in the top octet breaks every staff lookup -- silently on a manager,
loudly on a coach.

WHAT WE SEND: id, resourceId, cardsubtypeid, itemType, contract, itemState, owners,
untradeable. Nothing else. rating/rareflag/assetId are overwritten by the merge;
nation/leagueId/teamid would be INVENTED, because none of the four tables has such a
column; preferredPosition (rec+0x146) and attributeList (rec+0x98..) SURVIVE the merge
and are read by the generic view-model FUN_1800d7920, so sending them would hang a
position label and six attribute numbers on a coach. Omission is safe; a scalar where
an object is expected is not.

The starter shelf is one card per (tier, rare) combination per family -- 24 cards --
with two exclusions: the four miss-fill assetids (three of which are REAL rows, so a
hit and a miss would look identical on those cards), and any row whose own stat write
is byte-identical to its family's miss-fill (head/GK attribute 0 amount 15).

tier() is the binary's own tail, not our convention: the shared exit of FUN_180141660
writes rec+0x54 = 3 if rating >= 0x4b else 2 - (rating < 0x41), for every arm
including the miss arms.

Also lands the design round's read-only probe tooling: coach_probe.py (grades a live
record HIT/MISS/WRONG-BRANCH/NO-MERGE against the on-disk rows) and coach_window.py
(builds a mixed-control window; fires nothing).

Not wired into the server in this commit.

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

258 lines
12 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build the MIXED-CONTROL staff window: known-good coach ids interleaved with
known-bad ones, so a correct result and an incorrect one look different in the
same screenshot and in the same probe readback.
THIS FILE FIRES NOTHING. It prints/writes JSON. The integrate agent wires it in;
nobody else touches utas_server.py, fut_cards.py or fut_store.py.
WHY THIS SHAPE
--------------
The four coach branches of FUN_180141660 write a LOUD miss-fill -- firstname and
lastname "DB Error", rating 0x32, rare 1, and a TABLE-UNIQUE fallback assetid.
That gives three separable outcomes instead of two:
HIT real name, rating == the row's `value`, assetId == carddbid
MISS "DB Error", rating 50, assetId == this family's fallback
WRONG-BRANCH "DB Error", rating 50, assetId == ANOTHER family's fallback
NO-MERGE our sentinel rating survives, name empty -> the query never ran
so every failure mode says which one it is. That is what makes the negative
interpretable, and it is the reason coaches are the cheapest family to prove.
Two properties were checked against the on-disk dumps and both hold:
* no row in any of the four tables has value == 50, so rating 50 can only be a
miss -- it can never be a hit that happens to look like one;
* no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), the
fitness miss-fill triple at +0xdd/+0xde/+0xdf, so fitness has a second,
fully independent oracle that does not depend on reading a name at all.
The known-bad ids are ids INSIDE each family's own carddbid band that are absent
from the table -- 195..207 such gaps exist per family, so a bad control is never
an out-of-range value the client might reject for an unrelated reason.
THE SENTINEL. Every item is sent with rating SENTINEL (=1), which no coach row
carries and which the miss-fill never writes. If a card comes back still holding
rating 1, the merge did not run at all; that is the NO-MERGE arm and it is a
different bug from either a hit or a miss.
DO NOT ROUTE THIS THROUGH FUT_ID_SWEEP. sweep_items() keeps itemType "player"
and varies only cardsubtypeid, and club_route answers a sweep BEFORE the ?type=
filter, so a sweep-borne staff experiment is confounded twice over.
WHICH SCREEN FIRES IT. The client's own ?type= taxonomy (FUN_18012ec50, 29 arms
+ default) does contain headcoach / gkcoach / physio / fitnesscoach / staff --
atoms 0x153 / 0x13f / 0x21d / 0x129 / 0x2dc. NONE of those five has ever been
seen on the wire. A grep of every capture and log in this repo finds exactly
three values: type=player (x14), type=manager (x2), type=custom (x1). So the
screen to aim this at is the STAFF tab, which sends type=manager, and the reason
these items reach it is that club_route already filters on cardsubtypeid -- not
on itemType and not on the type string -- keeping everything outside 0..3.
Usage:
python3 coach_window.py # human-readable prediction table
python3 coach_window.py --json items.json
python3 coach_window.py --family headcoach --json one.json
"""
import argparse
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SEEDS = os.path.join(HERE, "..", "data", "coach_seeds.json")
# Item ids for the experiment. Far above fut_store's ITEM_ID_BASE range so a
# stray record in the CardsDb map can always be attributed.
ID_BASE = 950000000
SENTINEL_RATING = 1
# A DISTINCT, unmistakable nation + league per family. This is the SECOND
# question the same window answers, and it is free: the four coach branches of
# FUN_180141660 never write nation (+0x148), leagueId (+0x154), teamid (+0x94) or
# position (+0x146), and none of the four tables even HAS a nation/league/team
# column, so these values cannot change the hit/miss outcome. Whatever a coach
# card shows for country or league therefore came from US.
#
# Both the good and the bad ids of a family carry the same pair, so a "DB Error"
# card flying a Brazilian flag is itself direct proof that the miss-fill leaves
# our fields alone.
#
# nation 14 (England) is deliberately AVOIDED: 14 is what the PLAYER miss-fill
# writes, and a value that doubles as a known failure fingerprint is not a probe.
FACE = { # family -> (nationid, leagueid)
"headcoach": (54, 13), # Brazil, Premier League
"gkcoach": (45, 19), # Spain, Bundesliga
"physio": (27, 16), # Italy, Ligue 1
"fitnesscoach": (21, 53), # Germany, LaLiga Santander
}
def _item(item_id, carddbid, cardsubtypeid, nation=0, league=0):
"""One staff item.
EXACTLY the field set fut_store._item() already builds and that this client
is live-proven to parse. The only changes are cardsubtypeid and the ids. No
new atom is introduced: the wire shape of a real staff item has NEVER been
observed, and inventing one -- a scalar where the parser wants an object --
is the change class that busy-loops the client at 0x1801c7f1a.
resourceId carries NO version nibble. The staff branches compare
`carddbid == *(u32*)(record+0x18)` on the RAW dword; players are the only
family that masks with & 0xffffff. A high byte here breaks every lookup and
does it silently.
itemType stays "player" for the first run. The merge dispatches on
cardsubtypeid alone, and club_route's ?type= filter already keys on
cardsubtypeid (anything not in 0..3 survives a non-player type), so nothing
needs itemType to be changed in order for these to reach the STAFF tab.
Flipping it to the taxonomy name ("headcoach"/"gkcoach"/"physio"/
"fitnesscoach", atoms 0x153/0x13f/0x21d/0x129) is a separate, later,
one-variable experiment.
"""
return {
"id": item_id,
"resourceId": carddbid, # == carddbid, raw, no version byte
"assetId": carddbid,
"cardassetid": carddbid,
"definitionId": carddbid,
"cardsubtypeid": cardsubtypeid,
"itemType": "player",
"rareflag": 1, # overwritten by the merge either way
"rating": SENTINEL_RATING,
"preferredPosition": "ST",
"nation": nation,
"teamid": 0,
"leagueId": league,
"playStyle": 250,
# zeros so that, for head coach and GK coach, the ONE slot the merge
# writes (attrs[row.attribute] = row.amount) stands out against five
# untouched zeros -- that single write verifies two columns at once.
"attributeList": [{"index": i, "value": 0} for i in range(6)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"contract": 7,
"fitness": 99,
}
def build(families=None):
with open(SEEDS) as f:
spec = json.load(f)
fams = spec["families"]
order = [f for f in ("headcoach", "gkcoach", "physio", "fitnesscoach")
if families is None or f in families]
items, predict, n = [], [], 0
for fam in order:
d = fams[fam]
sub, fb = d["cardsubtypeid"], d["miss_fill_assetid"]
nat, lg = FACE[fam]
good = [r["carddbid"] for r in d["seeds"]]
bad = d["bad_controls"]
rows = {r["carddbid"]: r for r in d["seeds"]}
# INTERLEAVE. The client pages the club (start=N&count=11 observed), so
# good and bad must alternate or a page can come back all-good/all-bad
# and prove nothing on its own screenshot.
mixed, gi, bi = [], 0, 0
while gi < len(good) or bi < len(bad):
for _ in range(3):
if gi < len(good):
mixed.append((good[gi], True)); gi += 1
if bi < len(bad):
mixed.append((bad[bi], False)); bi += 1
for cid, is_good in mixed:
iid = ID_BASE + n; n += 1
items.append(_item(iid, cid, sub, nat, lg))
if is_good:
r = rows[cid]
v = r["value"]
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "HIT",
"rating": v, "tier": 3 if v >= 75 else (2 if v >= 65 else 1),
"rare": r["rare"], "assetId": cid, "name": "a real person",
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
if fam in ("headcoach", "gkcoach"):
p["attrs"] = "index %d == %d, other five == 0" % (
r["attribute"], r["amount"])
elif fam == "physio":
p["stat_byte"] = "+%#x == %d" % (0xDD + r["attribute"],
r["amount"])
else:
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == %d/%d/%d" % (
r["fieldpos"], r["posbonus"], r["amount"])
else:
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "MISS",
"rating": 50, "tier": 1, "rare": 1, "assetId": fb,
"name": "DB Error DB Error",
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
if fam in ("headcoach", "gkcoach"):
p["attrs"] = "index 0 == 15, other five == 0"
elif fam == "physio":
p["stat_byte"] = "+0xdd == 15"
else:
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == 1/7/1"
predict.append(p)
# ONE deliberate cross-family item per family: this family's BEST-KNOWN
# good id sent under the NEXT family's cardsubtypeid. It must miss, and
# its fallback assetid must name the OTHER table. That is the only item
# in the window that can distinguish "the subtype picks the table" from
# "the id band picks the table", and it is interpretable in both
# directions: a real name here would refute the dispatch outright.
other = order[(order.index(fam) + 1) % len(order)]
if other != fam:
osub = fams[other]["cardsubtypeid"]
ofb = fams[other]["miss_fill_assetid"]
iid = ID_BASE + n; n += 1
# the cross-family item keeps the SOURCE family's face pair, so if
# it ever renders it is visibly the head-coach flag on a gkcoach slot
items.append(_item(iid, good[0], osub, nat, lg))
predict.append({"id": iid, "family": "%s-id/%s-subtype" % (fam, other),
"carddbid": good[0], "expect": "MISS (cross-family)",
"rating": 50, "tier": 1, "rare": 1, "assetId": ofb,
"name": "DB Error DB Error",
"refutes": "a real name here means cardsubtypeid does "
"NOT select the table"})
return items, predict
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", metavar="PATH", help="write the item array")
ap.add_argument("--family", action="append",
choices=["headcoach", "gkcoach", "physio", "fitnesscoach"])
a = ap.parse_args()
items, predict = build(a.family)
nfam = len({p["family"] for p in predict if "/" not in p["family"]})
print("%d items across %d familie(s), including %d deliberate cross-family "
"controls. Every item carries rating=%d as the NO-MERGE sentinel.\n"
% (len(items), nfam, sum(1 for p in predict if "/" in p["family"]),
SENTINEL_RATING))
print("%-11s %-24s %-9s %-20s %-5s %-4s %-8s %s"
% ("id", "family", "carddbid", "expect", "rat", "tier", "assetId", "extra"))
for p in predict:
extra = p.get("attrs") or p.get("stat_byte") or p.get("stat_bytes") or ""
print("%-11d %-24s %-9d %-20s %-5d %-4d %-8d %s"
% (p["id"], p["family"], p["carddbid"], p["expect"], p["rating"],
p["tier"], p["assetId"], extra))
n_hit = sum(1 for p in predict if p["expect"] == "HIT")
print("\npredicted: HIT=%d MISS=%d (a run where all %d agree is the proof; "
"any single disagreement names its own failure mode)"
% (n_hit, len(predict) - n_hit, len(predict)))
if a.json:
with open(a.json, "w") as f:
json.dump({"itemData": items, "predictions": predict}, f, indent=1)
print("\nwrote %s" % a.json)
return 0
if __name__ == "__main__":
sys.exit(main())