fifa17-recon: serve consumables, coaches and managers behind flags, default OFF
Wires the three families into club_route as a synthetic OVERLAY. Nothing changes by
default: with all three flags unset every route returns byte-identical bodies, checked
against the live 194-item save (club 194, type=player 194, league=53 drill-down 67,
hub clubPlayers 194, every staff stat 0).
FUT_CONSUMABLES=1 serve on type=contract|training|healing|development
FUT_CONSUMABLES=all ... and on an untyped club fetch with no team=/league=
FUT_COACHES=1 serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
FUT_COACHES=all ... and on type=manager, the one staff request ever observed
FUT_MANAGERS=1 serve on type=manager|staff
WHY AN OVERLAY AND NOT A GRANT. Clearing the flag restores the real club exactly on
the next fetch, with no un-granting and no edit to a save a live client is holding
open. And Store.add_items() uses setdefault("id", ...), so items arriving with an id
already set do NOT advance nextItemId -- granting these would eventually collide two
id spaces. The four overlay bases (9.4e8 consumables, 9.5e8 coaches, 9.6e8 managers,
9.1e8 probe) are asserted disjoint from each other, from the save's 1e8 and from the
sweep's 9e8. The cost is that overlay cards cannot be quick-sold or moved.
WHY EACH FLAG IS TWO-VALUED. The tab-to-?type= binding is UNOBSERVED -- only
type=player, type=manager and type=custom have ever come from this client, and none of
the nine other arm names in FUN_18012ec50 has. So every club fetch now LOGS the arm it
was asked for while any flag is set. That is what makes an empty tab actionable: it
says whether the request reached us and under which name, instead of nothing.
THE MIRROR FILTER, and it is not optional. club_route applied its cardsubtypeid filter
only when `kind and kind not in ("player","custom")`. For type=player, for type=custom
(the by-league / by-team drill-downs) and for an untyped fetch it filtered NOTHING, so
the moment the club held a non-player item it would be served straight into the
players tab and the drill-downs -- and a manager carries nation, leagueId and teamid,
so he would have appeared as a footballer in exactly the MY CLUB rows that were only
just made non-zero. The player branch now filters cardsubtypeid in (0,1,2,3). Provable
no-op today: all 194 items in the save are cardsubtypeid 0.
Same hardening for the three counters that keyed on itemType == "player" (hub
clubPlayers, _club_stat_context, _club_stat_set) -> _is_player(), i.e. the CLIENT's own
definition from FUN_1800d8330. itemType is INERT on the wire (atom 0x173 is parsed into
a stack std::string in FUN_18013fe00 and freed; it never reaches the record), so keying
our own screens on it made their correctness depend on a field the client ignores.
FREE SECOND ORACLE: the five staff sub-type counters (0xb..0xf) are now computed from
the overlay. FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over those five, so
the parent id 0xa alone could never move it. That number moves without the merge being
involved at all, which keeps "our club reports N staff" separable from "the client
resolved the card".
item_def() also learns consumables (gated): answering a consumable resourceId with
cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no miss-fill, i.e. plausible
garbage -- and it carried the same hardcoded rareflag 1 as fut_store._item().
tools/test_card_families.py: 414 offline checks over the item BUILDERS. Deliberately a
separate suite -- test_fut_contract.py talks to a live server over HTTP and imports
nothing from the server's own code, which is what lets it certify a future non-Python
implementation; these must run against the working tree without restarting anything.
Each guard was mutation-checked: reverting the 219 fix, or letting coach_item accept a
miss-fill assetid, or letting consumable_item accept a dead zone, each fails the suite.
Suites after: test_fut_contract 439/0, test_match_rewards 61/0, test_card_families
414/0. utas_server was NOT restarted; this lands on disk only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -578,6 +578,17 @@ PLAYER_DEFS = {
|
||||
|
||||
def item_def(rid):
|
||||
"""Build one FUT item-definition for a requested resourceId."""
|
||||
if CONSUMABLES:
|
||||
# A consumable's definition is NOT a player's. Answering a consumable
|
||||
# resourceId with cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no
|
||||
# miss-fill -- i.e. plausible-looking garbage. Same trap as fut_store._item():
|
||||
# this route also hardcoded cardsubtypeid 0 / rareflag 1, and rareflag 1 on
|
||||
# subtype 219 renders Player Fitness as Squad Fitness (FUN_1801bfac0 case 5).
|
||||
# Gated so the player definition path is untouched by default.
|
||||
import fut_consumables
|
||||
d = fut_consumables.def_for(rid)
|
||||
if d is not None:
|
||||
return d
|
||||
asset = rid & 0xffffff
|
||||
name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
|
||||
asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
|
||||
@@ -990,11 +1001,25 @@ ROUTES = [
|
||||
HUBDATA = os.environ.get("FUT_HUBDATA", "1") == "1"
|
||||
|
||||
|
||||
def _is_player(it):
|
||||
"""The CLIENT's own definition of a footballer, and the only one worth using.
|
||||
|
||||
FUN_1800d8330 maps cardsubtypeid 0..3 -> cardtype 1 (players); 4 is a manager,
|
||||
5/6/7/8 the four coach families, 51..341 the consumables. Three counters used to
|
||||
ask `itemType == "player"` instead -- the hub's clubPlayers tile, the MY CLUB
|
||||
per-nation/league/team buckets and the global stat set. That string is INERT on
|
||||
the wire (atom 0x173 is parsed into a stack std::string in FUN_18013fe00 and
|
||||
freed; it never reaches the record), so keying our own screens on it made their
|
||||
correctness depend on a field the client ignores. Provable no-op on the current
|
||||
save: all 194 items are cardsubtypeid 0 and itemType "player"."""
|
||||
return it.get("cardsubtypeid", 0) in (0, 1, 2, 3)
|
||||
|
||||
|
||||
def hub_data():
|
||||
"""GET ut/%s/hub -- the FUT hub tile counters."""
|
||||
if not HUBDATA:
|
||||
return {}
|
||||
players = len([i for i in STORE.items() if i.get("itemType") == "player"])
|
||||
players = len([i for i in STORE.items() if _is_player(i)])
|
||||
auctions = len(STORE.listings())
|
||||
log(" HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
|
||||
return {"clubPlayers": players, "auctionCount": auctions}
|
||||
@@ -1113,7 +1138,7 @@ def _club_stat_context(kind):
|
||||
force contextValue to 0) and therefore preserves contextValue. `TODO/CONFIRM`
|
||||
whether contextId carries further meaning; nothing read so far gives it one.
|
||||
"""
|
||||
players = [i for i in STORE.items() if i.get("itemType") == "player"]
|
||||
players = [i for i in STORE.items() if _is_player(i)]
|
||||
# kind -> which id the SCREEN's rows are keyed by.
|
||||
# "" the MY CLUB tab strip itself: its nation tiles and the eight-row
|
||||
# panel FUN_180094ce0, which computes PLAYERS_EMPLOYED as
|
||||
@@ -1141,7 +1166,7 @@ def _club_stat_context(kind):
|
||||
def _club_stat_set():
|
||||
"""The complete global stat set, computed from what the club actually holds."""
|
||||
items = STORE.items()
|
||||
players = [i for i in items if i.get("itemType") == "player"]
|
||||
players = [i for i in items if _is_player(i)]
|
||||
rare = [i for i in players if i.get("rareflag")]
|
||||
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/below), NOT something
|
||||
# read out of the binary. Marked as a convention because it is one; if a tile ever
|
||||
@@ -1149,14 +1174,22 @@ def _club_stat_set():
|
||||
gold = [i for i in players if (i.get("rating") or 0) >= 75]
|
||||
silver = [i for i in players if 65 <= (i.get("rating") or 0) < 75]
|
||||
bronze = [i for i in players if 0 < (i.get("rating") or 0) < 65]
|
||||
# Staff the club is CURRENTLY BEING SERVED (the FUT_COACHES / FUT_MANAGERS
|
||||
# overlay). All zero unless a flag is set. This is the free second oracle for the
|
||||
# staff round: the eight-row panel's STAFF_EMPLOYED number moves without the merge
|
||||
# being involved at all, so "our club really holds N staff" stays separable from
|
||||
# "the client resolved the card". Keyed by cardsubtypeid: 4 manager, 5 head coach,
|
||||
# 6 GK coach, 7 physio, 8 fitness coach.
|
||||
_staff = _staff_overlay_counts()
|
||||
counts = [
|
||||
("players", len(players)),
|
||||
("playersGold", len(gold)),
|
||||
("playersSilver", len(silver)),
|
||||
("playersBronze", len(bronze)),
|
||||
("rarePlayers", len(rare)),
|
||||
# Honest zeros: this club holds no non-player items of any kind.
|
||||
("staff", 0),
|
||||
# Honest zeros: this club holds no non-player items of any kind... unless a
|
||||
# staff overlay is armed, in which case _staff below is what it holds.
|
||||
("staff", sum(_staff.values())),
|
||||
("stadia", 0), # 0x14, read directly by STADIA_OWNED
|
||||
("balls", 0), # 0x1e, read directly by BALLS_EARNED
|
||||
("kits", 0),
|
||||
@@ -1167,11 +1200,11 @@ def _club_stat_set():
|
||||
# TROPHIES_WON = +0x800 over 0x33..0x38. Sending the parent ids alone can
|
||||
# never move those two rows. All zero today because the club owns no staff and
|
||||
# has won nothing, but the mapping is what matters when it does.
|
||||
("staffManager", 0), # 0xb
|
||||
("staffHeadCoach", 0), # 0xc
|
||||
("staffGKCoach", 0), # 0xd
|
||||
("staffPhysio", 0), # 0xe
|
||||
("staffFitnessCoach", 0), # 0xf
|
||||
("staffManager", _staff[4]), # 0xb
|
||||
("staffHeadCoach", _staff[5]), # 0xc
|
||||
("staffGKCoach", _staff[6]), # 0xd
|
||||
("staffPhysio", _staff[7]), # 0xe
|
||||
("staffFitnessCoach", _staff[8]), # 0xf
|
||||
("trophiesOffline", 0), # 0x33
|
||||
("trophiesOnline", 0), # 0x34
|
||||
("trophiesFeaturedOffline", 0), # 0x35
|
||||
@@ -1414,6 +1447,87 @@ def sweep_items():
|
||||
return out
|
||||
|
||||
|
||||
# ---- FUT_CONSUMABLES / FUT_COACHES / FUT_MANAGERS: the non-player families -----
|
||||
#
|
||||
# Three whole card families are now derivable offline (docs/plan-2026-08-04-card-
|
||||
# families.md and the 2026-08-05 round): consumables need no id space at all, and
|
||||
# staff ids came out of the 149 tables dumped read-only from the running client into
|
||||
# data/tables/. Each ships behind its own flag, DEFAULT OFF.
|
||||
#
|
||||
# THEY ARE SERVED AS AN OVERLAY, NOT GRANTED INTO THE SAVE. That is deliberate:
|
||||
# * clearing the flag restores the real club exactly, on the very next fetch, with
|
||||
# no un-granting and no edit to a save that a live client is holding open;
|
||||
# * Store.add_items() uses `setdefault("id", ...)`, so items that arrive with an id
|
||||
# already set do NOT advance nextItemId -- granting these would eventually collide
|
||||
# two id spaces. Overlay ids come from 9.4e8/9.5e8, clear of the save's 1e8, the
|
||||
# sweep's 9e8 and each other.
|
||||
# The cost is that overlay cards cannot be quick-sold or moved (they are not in the
|
||||
# save), and the MY CLUB / clubPlayers counters do not see them. The staff COUNTERS
|
||||
# are set from the overlay below, which is deliberate and is the second, independent
|
||||
# oracle: "our club reports N staff" is a different signal from "the client resolved
|
||||
# the card".
|
||||
#
|
||||
# EACH FLAG IS TWO-VALUED because the tab-to-?type= binding is UNOBSERVED. Only
|
||||
# type=player, type=manager and type=custom have ever come from this client, so which
|
||||
# arm the consumables and staff screens ask for is a guess:
|
||||
# FUT_CONSUMABLES=1 serve on type=contract|training|healing|development
|
||||
# FUT_CONSUMABLES=all ... and on an untyped club fetch with no team=/league=
|
||||
# FUT_COACHES=1 serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
|
||||
# FUT_COACHES=all ... and on type=manager, the ONE staff request ever observed
|
||||
# FUT_MANAGERS=1 serve on type=manager|staff
|
||||
# Every club fetch logs the ?type= it was asked for while any of the three is set, so
|
||||
# a null result tells the human WHICH arm to aim at instead of nothing at all.
|
||||
CONSUMABLES = os.environ.get("FUT_CONSUMABLES", "")
|
||||
COACHES = os.environ.get("FUT_COACHES", "")
|
||||
MANAGERS = os.environ.get("FUT_MANAGERS", "")
|
||||
FAMILIES_ON = bool(CONSUMABLES or COACHES or MANAGERS)
|
||||
|
||||
MANAGER_TYPES = ("manager", "staff")
|
||||
|
||||
|
||||
def _family_overlay(kind, has_drilldown):
|
||||
"""The non-player items to serve for this ?type=, or []. Never touches the save."""
|
||||
out = []
|
||||
if CONSUMABLES:
|
||||
import fut_consumables
|
||||
if kind in fut_consumables.TYPE_CATEGORIES:
|
||||
out += fut_consumables.items_for_type(kind)
|
||||
elif CONSUMABLES == "all" and not kind and not has_drilldown:
|
||||
out += fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
|
||||
if COACHES:
|
||||
import fut_coaches
|
||||
if kind in fut_coaches.CLUB_TYPES:
|
||||
out += fut_coaches.items_for_type(kind)
|
||||
elif COACHES == "all" and kind == "manager":
|
||||
# type=manager is the ONE staff request ever observed on the wire (STAFF
|
||||
# tab, 2026-08-04). If the tab only ever asks under that name, this is the
|
||||
# only arm that can put a coach on screen.
|
||||
out += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
|
||||
if MANAGERS:
|
||||
import fut_staff
|
||||
if kind in MANAGER_TYPES:
|
||||
out += [fut_staff.manager_item(fut_staff.OVERLAY_ID_BASE + i, c)
|
||||
for i, c in enumerate(fut_staff.STARTER_MANAGERS)]
|
||||
return out
|
||||
|
||||
|
||||
def _staff_overlay_counts():
|
||||
"""(staffManager, headCoach, gkCoach, physio, fitnessCoach) held by the overlay.
|
||||
|
||||
FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over stat ids 0xb..0xf, so the
|
||||
parent id 0xa can never move it -- the five sub-types are what count. Zero unless a
|
||||
staff flag is set, so the default panel is unchanged."""
|
||||
n = {4: 0, 5: 0, 6: 0, 7: 0, 8: 0}
|
||||
if MANAGERS:
|
||||
import fut_staff
|
||||
n[4] = len(fut_staff.STARTER_MANAGERS)
|
||||
if COACHES:
|
||||
import fut_coaches
|
||||
for it in fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE):
|
||||
n[it["cardsubtypeid"]] = n.get(it["cardsubtypeid"], 0) + 1
|
||||
return n
|
||||
|
||||
|
||||
def club_route(h):
|
||||
# PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other
|
||||
# method keeps the exact body this route served before, so the rename support
|
||||
@@ -1449,6 +1563,7 @@ def club_route(h):
|
||||
# Cristiano Ronaldo showed up under Chelsea, Arsenal and everyone else. Reported
|
||||
# live 2026-08-05. The counts on the stats panel were right all along; it was
|
||||
# only the item list that was unfiltered.
|
||||
has_drilldown = False
|
||||
for param, field in (("team", "teamid"), ("league", "leagueId")):
|
||||
raw = q.get(param)
|
||||
if raw is None:
|
||||
@@ -1457,18 +1572,40 @@ def club_route(h):
|
||||
want = int(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
has_drilldown = True
|
||||
items = [i for i in items if i.get(field) == want]
|
||||
log(" CLUB: %s=%d -> %d item(s)" % (param, want, len(items)))
|
||||
|
||||
if kind and kind not in ("player", "custom"):
|
||||
# cardsubtypeid 0..3 is a player (FUN_1800d8330); everything else is
|
||||
# staff or a manager. We own no staff cards yet, so this is [] today --
|
||||
# staff or a manager. The save holds no non-player items (the families
|
||||
# below are served as an overlay, not granted), so this is [] today --
|
||||
# an empty item list, which is the same shape the parser already accepts.
|
||||
items = [i for i in items if i.get("cardsubtypeid", 0) not in (0, 1, 2, 3)]
|
||||
log(" CLUB: type=%s -> %d item(s) (players filtered out)" % (kind, len(items)))
|
||||
elif CLUB_PAGE:
|
||||
log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare this "
|
||||
"number against the MY CLUB counter on screen]" % len(items))
|
||||
else:
|
||||
# THE MIRROR FILTER. This branch -- type=player, type=custom, and an untyped
|
||||
# fetch -- used to filter NOTHING, so the moment the club held a non-player
|
||||
# item it would be served straight into the players tab and into the by-league
|
||||
# / by-team drill-downs. A manager carries nation, leagueId and teamid, so he
|
||||
# would have appeared as a footballer in exactly the MY CLUB rows that were
|
||||
# only just made non-zero. Provable no-op today: all 194 items in the live save
|
||||
# are cardsubtypeid 0, so this list is unchanged (verified 2026-08-05).
|
||||
items = [i for i in items if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)]
|
||||
if CLUB_PAGE:
|
||||
log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare "
|
||||
"this number against the MY CLUB counter on screen]" % len(items))
|
||||
|
||||
if FAMILIES_ON:
|
||||
# The tab-to-?type= binding is unobserved, so LOG EVERY ARM ASKED FOR. If a
|
||||
# family tab comes back empty this line is what says whether the request even
|
||||
# reached us and under which name -- the difference between "aim at another
|
||||
# arm" and "nothing was asked".
|
||||
overlay = _family_overlay(kind, has_drilldown)
|
||||
log(" CLUB: family overlay armed (consumables=%r coaches=%r managers=%r); "
|
||||
"type=%r drilldown=%s -> +%d item(s)"
|
||||
% (CONSUMABLES, COACHES, MANAGERS, kind, has_drilldown, len(overlay)))
|
||||
items = items + overlay
|
||||
return 200, {"itemData": items}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user