diff --git a/fifa17-recon/tools/fut_clubitems.py b/fifa17-recon/tools/fut_clubitems.py new file mode 100644 index 0000000..91f9233 --- /dev/null +++ b/fifa17-recon/tools/fut_clubitems.py @@ -0,0 +1,150 @@ +#!/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) +FAMILIES = [ + ("balls", "fcc_balls.json", 37, 0x1E, "balls", 149), + ("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148), + ("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145), + ("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146), + ("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150), +] + +# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf(). +CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150) + +# 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, extra=None): + """One club item. Deliberately narrow: no rating, no position, no attributes, + no nation, no league, no team. 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, + "itemType": "club", # UNOBSERVED on the wire; see module docstring + "itemState": "free", + "owners": 1, + "untradeable": False, + } + if extra: + it.update(extra) + return it + + +def shelf(next_id=CLUBITEM_ID_BASE): + """The starter club-item shelf, {family: [item]}.""" + out, nid = {}, next_id + for name, table, art, _sid, _sname, subtype in FAMILIES: + rows = _rows(table) + picked = [] + for r in rows[:STARTER_N.get(name, 4)]: + cid = r.get("carddbid") + if not cid: + continue + extra = {} + for k in ("teamid", "leagueid", "value"): + if r.get(k): + extra[k] = r[k] + picked.append(_item(nid, cid, r.get("cardassetid", art), subtype, extra)) + 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()) diff --git a/fifa17-recon/tools/fut_consumables.py b/fifa17-recon/tools/fut_consumables.py index 726add8..e3b194e 100644 --- a/fifa17-recon/tools/fut_consumables.py +++ b/fifa17-recon/tools/fut_consumables.py @@ -86,6 +86,34 @@ CORE_KINDS = ("player_contract", "manager_contract", "healing", "player_fitness", "squad_fitness", "gk_training", "player_training", "position_mod", "player_playstyle", "gk_playstyle") +# carddbid -> cardassetid, the ART id, read from the game's own fcc_ tables. +# +# THE TWO IDS ARE NOT INTERCHANGEABLE and this cost a live debugging round. A card +# draws its artwork from cardassetid, which is a SMALL id (3 training, 7 contract, +# 10 healing, 45 misc), not the carddbid. Copying resourceId into cardassetid is +# right for players and wrong here: the client looked up art 5003001, found nothing, +# and drew external/ion_fut/artAssets/.../notfound.swf -- a green NOT FOUND box on +# every consumable card until 2026-08-05. +_ART_BY_CARDDBID = None + + +def art_id(carddbid, default=None): + global _ART_BY_CARDDBID + if _ART_BY_CARDDBID is None: + import glob + d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables") + m = {} + for f in glob.glob(os.path.join(d, "fcc_*.json")): + try: + rows = json.load(open(f)).get("rows") or [] + except Exception: + continue + for r in rows: + if "carddbid" in r and "cardassetid" in r: + m[r["carddbid"]] = r["cardassetid"] + _ART_BY_CARDDBID = m + return _ART_BY_CARDDBID.get(carddbid, default) + CONSUMABLE_ID_BASE = 940000000 # distinct from the save (1e8), the sweep (9e8) # and the staff overlay (9.5e8) @@ -164,7 +192,8 @@ def consumable_item(item_id, subtype, amount=None, contract=None, rating=None, "id": item_id, "resourceId": resource_id, "assetId": resource_id, - "cardassetid": resource_id, + # THE ART ID, not a copy of resourceId -- see art_id() above. + "cardassetid": art_id(resource_id, resource_id), "cardsubtypeid": subtype, "itemType": "player", "rareflag": rareflag, diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index d05cc0d..0e2ae90 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -367,14 +367,27 @@ class Store: import random if not self.spend(price): return None + # A real FUT pack is not eleven footballers. It is mostly players with a + # couple of consumables and the occasional staff card, which is what + # FUT_PACK_MIX reproduces. Kept as a RATIO of the pack size rather than a + # fixed number so it scales from a 5-card bronze to an 11-card premium. + n_extra = 0 + extras = [] + if PACK_MIX and count >= 5: + n_extra = max(1, count // 4) + extras = _pack_extras(n_extra, self) + n_extra = len(extras) + n_players = max(1, count - n_extra) if tiers: picks = [random.choice(fut_cards.pool_for(random.choice(tiers))) - for _ in range(count)] + for _ in range(n_players)] else: pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL - picks = [random.choice(pool) for _ in range(count)] + picks = [random.choice(pool) for _ in range(n_players)] items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) for (a, r, p, n, lg, tm, at) in picks] + items += extras + random.shuffle(items) with _LOCK: p = self.load() p.setdefault("purchased", []).extend(items) @@ -386,6 +399,53 @@ class Store: return self.load().get("purchased", []) + +# FUT_PACK_MIX: put non-player cards in packs. +# +# Consumables and staff are included because both are LIVE-PROVEN to render (staff on +# 2026-08-05 with zero DB Error, consumables the same day with real artwork once +# cardassetid was fixed). Club items are NOT included: cardtype 9 has no arm in the +# merge, so which cardsubtypeid means "ball" versus "stadium" is still unverified, and +# a pack is the worst place to discover that a subtype was wrong -- the card lands in +# the save and has to be cleaned out by hand. +PACK_MIX = os.environ.get("FUT_PACK_MIX", "1") == "1" + + +def _pack_extras(n, store): + """n non-player cards for a pack: mostly consumables, occasionally staff.""" + import random + out = [] + for _ in range(n): + want_staff = random.random() < 0.25 + it = None + if want_staff: + try: + import fut_staff + pool = list(fut_staff.STARTER_MANAGERS) + try: + import fut_coaches + pool += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE) + except Exception: + pass + if pool: + it = dict(random.choice(pool)) + except Exception: + it = None + if it is None: + try: + import fut_consumables as fc + shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE) + if shelf: + it = dict(random.choice(shelf)) + except Exception: + it = None + if it is None: + continue + it["id"] = store.new_item_id() # a pack card needs its OWN item id + out.append(it) + return out + + # Card pool for packs. Now lives in fut_cards.py (79 players across three rating # tiers, 7 leagues, 20 nations, 18 teams, every outfield position plus GK). The old # 18-entry list below is kept ONLY as the starter-squad source and as the fallback diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index bffa2da..8c5e0a7 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -1193,6 +1193,19 @@ CONSUM_STATS = os.environ.get("FUT_CONSUM_STATS", "1") == "1" # Serve consumables as TRADEABLE by default; see _consumable_stacks for why. CONSUM_UNTRADEABLE = os.environ.get("FUT_CONSUM_UNTRADEABLE", "0") == "1" +# FUT_CLUBITEMS: balls, stadia, badges, kits and league logos. +# +# Counts FIRST, deliberately. The consumables round proved that the client does not +# request an item list until club/stats reports a non-zero count for that family, and +# the CLUB tab reads exactly these ids: 0x1e balls, 0x28 kits, 0x14 stadia. So arming +# the counters is what makes the client name the item route it uses -- which is the +# one thing no amount of static reading has produced for this family, because +# cardtype 9 has no arm in the merge at all. +# +# Default OFF: nothing here has ever been requested by the client, so unlike the +# consumables stat fix this is not correcting a demonstrably wrong answer. +CLUBITEMS = os.environ.get("FUT_CLUBITEMS", "0") == "1" + def _consumable_stat_rows(): """[(stat name, count)] for the consumables panel, counted from the shelf.""" @@ -1261,6 +1274,18 @@ def _club_stat_set(): ("trophiesSeasonOffline", 0), # 0x37 ] counts += _consumable_stat_rows() + if CLUBITEMS: + import fut_clubitems + ci = fut_clubitems.counts() + # REPLACE the honest zeros above rather than appending a second row per name: + # the deserializer writes store[contextValue][statId] = typeValue, so a later + # row silently wins and two rows for one id is a coin toss. + have = dict(ci) + counts = [(t, have.get(t, v)) for t, v in counts] + for t, v in ci: + if t not in [c[0] for c in counts]: + counts.append((t, v)) + log(" CLUBITEMS: %s" % ", ".join("%s=%d" % kv for kv in ci)) # All four keys in every element -- see note 3 above. return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)} for t, v in counts]