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.
197 lines
9.5 KiB
Python
197 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Club items: balls, stadia, badges, kits and league logos.
|
|
|
|
Built from the game's OWN tables, dumped read-only into data/tables/ by db_dump.py:
|
|
|
|
fcc_balls 42 rows carddbid 8120194+ cardassetid 37
|
|
fcc_stadium 78 rows carddbid 6200000+ cardassetid 36
|
|
fcc_badgecards 656 rows carddbid 6000000+ cardassetid 39
|
|
fcc_kitcards 1482 rows carddbid 6300000+ cardassetid 35
|
|
fcc_leaguelogos 44 rows carddbid 8010000+ cardassetid 40
|
|
|
|
TWO ID COLUMNS, AND THEY ARE NOT INTERCHANGEABLE. Every fcc_ row carries BOTH
|
|
carddbid and cardassetid. carddbid is the database key the merge would use;
|
|
cardassetid is the ART id the card draws from. fut_store._item copies resourceId
|
|
into cardassetid, which is right for players and wrong for every other family --
|
|
that is exactly what produced the green "NOT FOUND" placeholder on consumables
|
|
(external/ion_fut/artAssets/.../notfound.swf) until it was fixed on 2026-08-05.
|
|
So this module sets both explicitly and never lets one default to the other.
|
|
|
|
WHAT IS NOT KNOWN YET, AND IS NOT GUESSED HERE
|
|
----------------------------------------------
|
|
cardtype 9 (the club-item family) has NO arm in the merge FUN_180141660: no table
|
|
query and no miss-fill. So unlike a player or a coach, a club item's identity does
|
|
NOT come from the local card DB, and a wrong id cannot announce itself. The
|
|
cardsubtypeid values that reach cardtype 9 are the eight-value set
|
|
{30, 31, 145, 146, 147, 148, 149, 150}, and WHICH of those means ball versus
|
|
stadium versus badge is assigned nowhere in the 149 dumped tables.
|
|
|
|
Rather than guess, SUBTYPE is a per-family constant below with an explicit
|
|
"unverified" marker, and probe_shelf() serves one item per candidate subtype so the
|
|
screen itself can say which is which. The counts do not need any of this: a count is
|
|
just a number, which is why counts come first.
|
|
|
|
THE COUNT IS THE GATE. Proven on consumables the same day: the client does not ask
|
|
for an item list until club/stats reports a non-zero count for that family. The
|
|
CLUB tab reads global stat ids 1 (players), 0x1e (balls), 0x28 (kits), 0x14
|
|
(stadia), 0x0a (staff) and 0x32 (trophies), so making those non-zero is what makes
|
|
the client reveal the item route it uses. Nothing here should be believed to work
|
|
until that route is observed in the log.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
|
|
|
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
|
|
|
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
|
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
|
|
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
|
|
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
|
|
# families were pointed at the trophy range.
|
|
#
|
|
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
|
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
|
|
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
|
|
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
|
|
# club-item id "cannot announce itself", and for these three that is false. A wrong
|
|
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
|
FAMILIES = [
|
|
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
|
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
|
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
|
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
|
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
|
]
|
|
|
|
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
|
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
|
|
# probe route the docs preferred would have spent a launch and returned nothing for
|
|
# three of the five families.
|
|
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
|
|
|
# How many of each family the starter club owns. Small on purpose: the point is to
|
|
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
|
STARTER_N = {"balls": 6, "stadia": 4, "badges": 8, "kits": 8, "leaguelogos": 4}
|
|
|
|
|
|
def _rows(fname):
|
|
try:
|
|
with open(os.path.join(_DATA, fname)) as f:
|
|
return json.load(f).get("rows") or []
|
|
except (IOError, ValueError):
|
|
return []
|
|
|
|
|
|
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
|
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
|
no nation, no league. A club item has none of those, and sending a field the
|
|
family does not have is how a wrong shape gets accepted and does nothing."""
|
|
it = {
|
|
"id": item_id,
|
|
"resourceId": carddbid,
|
|
"assetId": carddbid,
|
|
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
|
"cardsubtypeid": subtype,
|
|
"itemState": "free",
|
|
"owners": 1,
|
|
"untradeable": False,
|
|
}
|
|
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
|
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
|
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
|
|
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
|
|
# record +0x94: an established scalar field, not a new shape.
|
|
#
|
|
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
|
# response that crashed the client, and it was never bisected. `value` is the
|
|
# established suspect, because it is an OBJECT member elsewhere and a scalar where
|
|
# an object is expected is the 0x1801c7f1a busy loop, and that response also
|
|
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
|
|
# ONE family, with the subtypes now corrected. That is the narrow test the crash
|
|
# denied us, and it is why families are served one at a time.
|
|
if teamid is not None and subtype in (9, 11):
|
|
it["teamid"] = teamid
|
|
if extra:
|
|
it.update(extra)
|
|
return it
|
|
|
|
|
|
def shelf(next_id=CLUBITEM_ID_BASE, families=None):
|
|
"""The starter club-item shelf, {family: [item]}.
|
|
|
|
`families` limits which are built. The combined `equippables` view is what
|
|
crashed the client: 30 items across FIVE unverified subtypes in one response is
|
|
the widest possible blast radius for a wrong shape. One family at a time is the
|
|
only way to learn which subtype is wrong.
|
|
"""
|
|
out, nid = {}, next_id
|
|
for name, table, art, _sid, _sname, subtype in FAMILIES:
|
|
if families is not None and name not in families:
|
|
out[name] = []
|
|
continue
|
|
rows = _rows(table)
|
|
picked = []
|
|
for r in rows[:STARTER_N.get(name, 4)]:
|
|
cid = r.get("carddbid")
|
|
if not cid:
|
|
continue
|
|
# NO EXTRAS. An earlier version copied teamid/leagueid/value straight
|
|
# out of the fcc row and the game hung and then CRASHED on the first
|
|
# equippables fetch (2026-08-05). `value` is the prime suspect: it
|
|
# appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
|
|
# and a scalar where an object is expected is the type-desync busy loop
|
|
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
|
# and then dies. Omission is safe; an unestablished field is not. None of
|
|
# the three was needed to draw a card.
|
|
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
|
|
# which are the two families whose caption is <name> + TeamName_Abbr15_
|
|
# <teamid>. It is the one field from the fcc row being reintroduced after
|
|
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
|
|
# the note in _item(). value and leagueid stay omitted.
|
|
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
|
|
teamid=r.get("teamid")))
|
|
nid += 1
|
|
out[name] = picked
|
|
return out
|
|
|
|
|
|
def counts(next_id=CLUBITEM_ID_BASE):
|
|
"""[(stat name, count)] for the club panel."""
|
|
s = shelf(next_id)
|
|
return [(sname, len(s.get(name, [])))
|
|
for name, _t, _a, _sid, sname, _st in FAMILIES]
|
|
|
|
|
|
def probe_shelf(family, next_id=CLUBITEM_ID_BASE):
|
|
"""One item per CANDIDATE cardsubtypeid, same carddbid, for the live oracle.
|
|
|
|
Which of {30,31,145..150} means which family is unknown and unguessable from the
|
|
dumped tables. Serving all eight and looking at the screen is the cheapest way to
|
|
find out, and unlike a sweep it is READABLE: the family that draws real artwork
|
|
names its own subtype.
|
|
"""
|
|
entry = next((f for f in FAMILIES if f[0] == family), None)
|
|
if entry is None:
|
|
return []
|
|
_n, table, art, _sid, _sname, _st = entry
|
|
rows = _rows(table)
|
|
if not rows:
|
|
return []
|
|
r = rows[0]
|
|
return [_item(next_id + i, r.get("carddbid"), r.get("cardassetid", art), st)
|
|
for i, st in enumerate(CARDTYPE9_SUBTYPES)]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
s = shelf()
|
|
for name, items in s.items():
|
|
print("%-12s %2d item(s)" % (name, len(items)))
|
|
if items:
|
|
i = items[0]
|
|
print(" resourceId=%-9s cardassetid=%-4s subtype=%s"
|
|
% (i["resourceId"], i["cardassetid"], i["cardsubtypeid"]))
|
|
print("\ncounts:", counts())
|