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
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user