Files
OpenFUT/fifa17-recon/tools/test_card_families.py
T
funman300 0e4bc13db4 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
2026-08-05 10:05:32 -07:00

283 lines
14 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Offline regression tests for the non-player card families.
WHY THIS IS A SEPARATE SUITE. tools/test_fut_contract.py talks to a LIVE server over
HTTP and imports nothing from the server's own code -- that is what lets it certify a
future non-Python implementation of the same reversed spec. These checks are the
opposite kind: they are unit tests of the item BUILDERS, they must run against the
working tree rather than against whatever process happens to be listening on 8099, and
they must not require the live client to be restarted. Mixing them into the contract
suite would have broken both properties.
Run: python3 tools/test_card_families.py (exit 0 = all pass, stdlib only)
"""
import os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
_fail = []
_pass = 0
def check(name, cond, detail=""):
global _pass
if cond:
_pass += 1
else:
_fail.append("%s %s" % (name, detail))
def raises(name, fn, *a, **kw):
try:
fn(*a, **kw)
except Exception:
check(name, True)
return
check(name, False, "did NOT raise")
# ---------------------------------------------------------------- the rareflag trap
def test_item_rareflag_trap():
"""fut_store._item() must not stamp rareflag 1 on a Player Fitness card.
FUN_1801bfac0 case 5 takes the SQUAD-fitness branch when
`(cardsubtypeid == 0xdc) || (*(int *)(rec + 0x58) == 1)`, and rec+0x58 IS the
rareflag atom 0x271. So a rare 219 renders as a Squad Fitness card -- a silent
corruption of the first fitness card we would ever serve.
"""
import fut_store
d = fut_store._item(100000001, 20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80])
# 1. THE PLAYER DICT IS BYTE-IDENTICAL TO BEFORE THE FIX. Key order included:
# every existing caller passes 8 positional args, so both new keyword params
# take their defaults.
check("player item key ORDER unchanged",
list(d) == ["id", "resourceId", "assetId", "cardassetid", "definitionId",
"cardsubtypeid", "itemType", "rareflag", "rating",
"preferredPosition", "nation", "teamid", "leagueId", "playStyle",
"attributeList", "itemState", "owners", "untradeable",
"contract", "fitness"], repr(list(d)))
check("player item VALUES unchanged",
d == {"id": 100000001, "resourceId": 20801, "assetId": 20801,
"cardassetid": 20801, "definitionId": 20801, "cardsubtypeid": 0,
"itemType": "player", "rareflag": 1, "rating": 94,
"preferredPosition": "LW", "nation": 38, "teamid": 243, "leagueId": 53,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in
enumerate([90, 93, 82, 91, 33, 80])],
"itemState": "free", "owners": 1, "untradeable": True,
"contract": 7, "fitness": 99}, repr(d))
# 2. THE GUARD ITSELF. This is the assertion that fails without the fix.
f = fut_store._item(1, 5002001, 55, "LW", 0, 0, 0, [0] * 6,
cardsubtypeid=219, rareflag=1)
check("subtype 219 forced to rareflag 0 (the squad-fitness trap)",
f["rareflag"] == 0, repr(f["rareflag"]))
check("subtype 219 keeps its cardsubtypeid", f["cardsubtypeid"] == 219,
repr(f["cardsubtypeid"]))
# 3. THE GUARD IS SCOPED. It must not touch any neighbouring subtype.
for sub in (218, 220, 4, 5, 0):
g = fut_store._item(1, 1, 50, "LW", 0, 0, 0, [0] * 6,
cardsubtypeid=sub, rareflag=1)
check("subtype %d keeps rareflag 1 (guard is 219-only)" % sub,
g["rareflag"] == 1, repr(g["rareflag"]))
# ------------------------------------------------------------------- consumables
def test_consumables():
import fut_consumables as fc
check("172 cardtype-6 subtypes", len(fc.SUBTYPES) == 172, len(fc.SUBTYPES))
dead = [r for r in fc.SUBTYPES if r["kind"] == "DEAD_ZONE"]
check("28 dead zones", len(dead) == 28, len(dead))
check("dead zones are exactly the known set",
sorted(r["cardsubtypeid"] for r in dead) ==
[58, 59, 60, 68, 69, 70, 87, 88, 89, 90, 111, 112, 113, 114, 115, 116, 117,
118, 119, 120, 203, 204, 205, 206, 207, 208, 209, 210])
# A dead-zone card renders as a plausible Squad Training (Pace) card with amount
# 0 -- there is no DB-Error analogue -- so the builder must refuse it outright.
raises("consumable_item refuses a dead zone (89)", fc.consumable_item, 1, 89)
raises("consumable_item refuses a non-cardtype-6 subtype", fc.consumable_item, 1, 4)
# Omitting `amount` stamps (byte)-1 into rec+0xbf, and FUN_1801a8040 sign-extends,
# so the card reads "-1" rather than 0. Refuse rather than ship that.
raises("training without amount is refused", fc.consumable_item, 1, 61)
raises("healing without amount is refused", fc.consumable_item, 1, 211)
raises("contract without contract= is refused", fc.consumable_item, 1, 201)
raises("rare Player Fitness (219) is refused", fc.consumable_item, 1, 219,
amount=20, rareflag=1)
# 220 is ALWAYS squad fitness (0xdc is the first half of the branch test), so a
# rare one is merely rare, not corrupted.
check("rare Squad Fitness (220) is allowed",
fc.consumable_item(1, 220, amount=10, rareflag=1)["rareflag"] == 1)
shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE)
check("starter shelf is non-empty", len(shelf) > 0, len(shelf))
check("no dead zone on the shelf",
not [i for i in shelf
if fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] == "DEAD_ZONE"])
check("shelf ids are unique", len({i["id"] for i in shelf}) == len(shelf))
check("shelf ids are clear of the save's 1e8 space",
all(i["id"] >= fc.CONSUMABLE_ID_BASE for i in shelf))
check("no 219 on the shelf is rare",
all(i["rareflag"] == 0 for i in shelf if i["cardsubtypeid"] == 219))
for i in shelf:
r = fc.BY_SUBTYPE[i["cardsubtypeid"]]
if "amount" in r["needs"]:
check("subtype %d carries amount" % i["cardsubtypeid"], "amount" in i)
if "contract" in r["needs"]:
check("subtype %d carries contract" % i["cardsubtypeid"], "contract" in i)
# Player-only fields must never appear: rec+0x146 and rec+0x98.. survive and
# are read by the generic view-model.
check("subtype %d sends no player-only fields" % i["cardsubtypeid"],
not ({"preferredPosition", "attributeList", "nation", "leagueId",
"teamid", "playStyle", "fitness"} & set(i)), repr(sorted(i)))
# The excluded families, each for a named reason (see CORE_KINDS).
kinds = {fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] for i in shelf}
for banned in ("manager_formation_mod", "formation_mod", "manager_league"):
check("%s is NOT shipped" % banned, banned not in kinds)
# ?type= routing
check("type=contract -> only categories 2/3",
{fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
for i in fc.items_for_type("contract")} <= {2, 3})
check("type=training -> only category 0",
{fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
for i in fc.items_for_type("training")} == {0})
check("an unknown type gets nothing", fc.items_for_type("player") == [])
check("def_for resolves an EA carddbid", fc.def_for(5001001) is not None)
check("def_for returns None for a player asset", fc.def_for(20801) is None)
# ----------------------------------------------------------------- coach families
def test_coaches():
import fut_coaches as cc
check("four coach families", len(cc.FAMILIES) == 4)
for fam, (sub, ct, table, miss) in cc.FAMILIES.items():
rows = cc.COACHES[fam]
check("%s row count" % fam,
len(rows) == {"headcoach": 124, "gkcoach": 121, "physio": 51,
"fitnesscoach": 115}[fam], len(rows))
# The oracle: rating 0x32 can ONLY ever be a miss, in every family.
check("%s has no row with value 50 (the miss-fill rating)" % fam,
not [r for r in rows if r["rating"] == 50])
check("%s ids are all < 2^24 (the key is a raw u32 but the artwork masks)" % fam,
all(r["carddbid"] < (1 << 24) for r in rows))
check("%s carddbids are unique" % fam,
len({r["carddbid"] for r in rows}) == len(rows))
# fitness coach's miss triple must not exist as a real row, or its second oracle
# would be ambiguous.
check("no fitnesscoach row is (fieldpos 1, posbonus 7, amount 1)",
not [r for r in cc.COACHES["fitnesscoach"]
if (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1)])
# tier() is the binary's own tail, not a convention.
check("tier boundaries", (cc.tier(64), cc.tier(65), cc.tier(74), cc.tier(75))
== (1, 2, 2, 3))
raises("coach_item refuses a non-coach subtype", cc.coach_item, 1, 4, 1000509)
for miss in cc.MISS_FILL_IDS:
raises("coach_item refuses miss-fill assetid %d" % miss,
cc.coach_item, 1, 5, miss)
seeds = cc.starter_coaches(cc.COACH_ID_BASE)
check("24 starter coaches (six per family)", len(seeds) == 24, len(seeds))
check("starter ids unique", len({i["id"] for i in seeds}) == len(seeds))
for i in seeds:
check("starter coach %d is a real row" % i["resourceId"],
(i["cardsubtypeid"], i["resourceId"]) in cc.BY_ID)
check("starter coach %d has a non-zero id (no id -> NO merge at all)"
% i["resourceId"], i["id"] != 0)
check("coach %d sends no invented nation/league/team" % i["resourceId"],
not ({"nation", "leagueId", "teamid", "preferredPosition",
"attributeList", "rating", "rareflag", "assetId"} & set(i)),
repr(sorted(i)))
subs = {i["cardsubtypeid"] for i in seeds}
check("all four families represented", subs == {5, 6, 7, 8}, repr(sorted(subs)))
# ?type= routing: each family's own arm serves only that family; staff (arm 10)
# serves all four.
for fam, (sub, _ct, _tbl, _miss) in cc.FAMILIES.items():
got = cc.items_for_type(fam)
check("type=%s serves only subtype %d" % (fam, sub),
got and {i["cardsubtypeid"] for i in got} == {sub},
repr({i["cardsubtypeid"] for i in got}))
check("type=staff serves all four families",
{i["cardsubtypeid"] for i in cc.items_for_type("staff")} == {5, 6, 7, 8})
check("an unknown type gets nothing", cc.items_for_type("player") == [])
# Ids must not shift depending on which arm asked, or the same card would enter
# the client's CardsDb map twice under two handles.
check("item ids are stable across arms",
{i["resourceId"]: i["id"] for i in cc.items_for_type("staff")} ==
{i["resourceId"]: i["id"]
for fam in cc.FAMILIES for i in cc.items_for_type(fam)})
# --------------------------------------------------------------------- managers
def test_managers():
import fut_staff as fs
check("417 manager cards", len(fs.MANAGERS) == 417, len(fs.MANAGERS))
check("carddbid == assetid band 1000001..1001552",
(fs.MANAGERS[0]["carddbid"], fs.MANAGERS[-1]["carddbid"]) == (1000001, 1001552))
# The `manager` join: 747 rows but 746 distinct managerids -- managerid 107 is
# duplicated with an empty-name row, which used to win the dict comprehension.
check("297 managers carry a real name (the 107 duplicate resolved)",
sum(1 for m in fs.MANAGERS if m["name"]) == 297,
sum(1 for m in fs.MANAGERS if m["name"]))
check("carddbid 1000107 keeps Slutskiy, not the empty row",
fs.BY_ID[1000107]["name"] and fs.BY_ID[1000107]["teamid"] == 315,
repr(fs.BY_ID[1000107]))
check("ten starter managers", len(fs.STARTER_MANAGERS) == 10)
for c in fs.STARTER_MANAGERS:
m = fs.BY_ID[c]
check("starter manager %d has a name" % c, bool(m["name"]))
check("starter manager %d has nation/league/team" % c,
m["nation"] and m["leagueId"] and m["teamid"])
it = fs.manager_item(1, 1000509)
check("manager resourceId is the RAW carddbid (the merge does not mask)",
it["resourceId"] == 1000509)
check("manager subtype 4", it["cardsubtypeid"] == 4)
check("manager sends nation/leagueId (rec+0xde/+0xe0 are OURS alone)",
it["nation"] == 45 and it["leagueId"] == 53, repr(it))
check("manager sends no rating/rareflag/position/attrs (all overwritten or read)",
not ({"rating", "rareflag", "preferredPosition", "attributeList",
"assetId", "definitionId"} & set(it)), repr(sorted(it)))
# ------------------------------------------------------- overlay id-space hygiene
def test_id_spaces():
"""The four id spaces must not overlap: a collision would make two different cards
share an item id, and the client keys its CardsDb map on it."""
import fut_consumables as fc, fut_coaches as cc, fut_staff as fs
spaces = {
"save": (100000000, 100999999),
"consumable": (fc.CONSUMABLE_ID_BASE, fc.CONSUMABLE_ID_BASE + 999999),
"coach": (cc.COACH_ID_BASE, cc.COACH_ID_BASE + 999999),
"manager": (fs.OVERLAY_ID_BASE, fs.OVERLAY_ID_BASE + 999999),
"probe": (fs.PROBE_ID_BASE, fs.PROBE_ID_BASE + 999999),
"sweep": (900000000, 900999999),
}
names = sorted(spaces)
for i, a in enumerate(names):
for b in names[i + 1:]:
lo1, hi1 = spaces[a]
lo2, hi2 = spaces[b]
check("%s and %s id spaces are disjoint" % (a, b),
hi1 < lo2 or hi2 < lo1, "%r %r" % (spaces[a], spaces[b]))
def main():
for t in (test_item_rareflag_trap, test_consumables, test_coaches, test_managers,
test_id_spaces):
try:
t()
except Exception as e:
_fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e))
print("\n%d checks passed, %d failed" % (_pass, len(_fail)))
for f in _fail:
print(" FAIL:", f)
return 0 if not _fail else 1
if __name__ == "__main__":
sys.exit(main())