70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
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())
|