9feb577c1c
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
258 lines
12 KiB
Python
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())
|