fifa17-python: commit working FUT backend deployment (client/server split)
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.
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