diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index be36410..4d31fe8 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -669,6 +669,52 @@ FUT_RS4_CONFIG = ( "IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY", "COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED", )] + # FUT_TRADING: the transfer-market equivalent of the store block above. + # + # WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on + # Transfer Market" are greyed out because the TO_TRADE_PILE predicate + # FUN_1801a7260 needs a service gate at vtable+0x270, which is + # `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it + # reads 0. + # + # Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06: + # the arm is right (case 0x336 writes param_2[10]) and the applier is right + # (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is + # not reachable from the settings deserializer. The decisive measurement: we served + # maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0, + # +0x1fd54=480). Every gate byte is a constructor default. That also explains + # storeEnabled reading 1: a default, never our value. + # + # REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on + # expecting an effect, and do not reason from it. + # + # The reasoning above was wrong in two places and the flag is inert: + # + # 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a + # PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte + # 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under + # that name. The only rip-relative reference to the literal 0x1801fc118 in the + # whole of .text is that lea. There is no comparison against it anywhere, so a + # client-config key of that name cannot be read as an input by anything. The same + # is true of the IS_* store keys above, which means the store block may also be + # inert and its apparent success was never actually attributed. + # 2. The gate byte was briefly measured as 1 and that was over-claimed as a success. + # On a fresh session it reads 0, and a thorough re-measurement read 0 on the very + # pid where it had read 1. Either the first read was transient or something clears + # it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91. + # + # What IS now known, and supersedes the "/settings is dead" claim in the note above: + # FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot + # +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found + # no callers. The real chain is + # settings response -> FUN_180174630 -> FUN_18013c6d0 (deser) + # -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes + # and FUN_180173e00 bails before applying anything unless the int at response+0x1c + # is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing. + # + # Default OFF and it should stay off. + + ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")] + if os.environ.get("FUT_TRADING") else []) # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any # response -- proven inert (wf_96b6c0c5): they are JSON field names that route # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md. diff --git a/fifa17-recon/tools/fut_clubitems.py b/fifa17-recon/tools/fut_clubitems.py index 1d03124..b16e863 100644 --- a/fifa17-recon/tools/fut_clubitems.py +++ b/fifa17-recon/tools/fut_clubitems.py @@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", " 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", 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), + ("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), ] -# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf(). -CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150) +# 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. @@ -71,22 +85,35 @@ def _rows(fname): return [] -def _item(item_id, carddbid, cardassetid, subtype, extra=None): +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, 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.""" + 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, - "itemType": "club", # UNOBSERVED on the wire; see module docstring "itemState": "free", "owners": 1, "untradeable": False, } + # KIT (9) and BADGE (11) display as + TeamName_Abbr15_, so + # without teamid the name comes out as the caption alone. STADIUM (10) reads + # StadiumName_, 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 @@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None): # 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. - picked.append(_item(nid, cid, r.get("cardassetid", art), subtype)) + # teamid is passed but _item only APPLIES it to kits (9) and badges (11), + # which are the two families whose caption is + TeamName_Abbr15_ + # . 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 diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index 2f0ca9e..3aac2c5 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -168,6 +168,34 @@ ITEM_ID_BASE = 100000000 _SQUAD_FITNESS_TRAP = 219 +# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set. +# +# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card, +# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by +# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND +# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does +# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag. +# +# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding +# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the +# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons +# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the +# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and +# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because +# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this. +# +# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the +# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the +# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves +# the field toward the client's own default rather than away from it. +# +# Side effects, both permissive rather than restrictive: item+0x49 also feeds +# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT, +# which gates squad submission in FUN_1800bba10 (today that takes the +# couldNotSubmitSquad branch). +TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1" + + def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00, cardsubtypeid=0, rareflag=1): return _with_discard({ @@ -188,7 +216,7 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00 "attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)], "itemState": "free", "owners": 1, - "untradeable": True, + "untradeable": not TRADEABLE, "contract": 7, "fitness": 99, }) @@ -224,13 +252,25 @@ DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE def _with_discard(it): - """Stamp discardValue when armed. Omits the key entirely when the formula does - not apply, rather than sending 0, because a 0 makes the client fall back to its - own lookup, which is exactly the broken path we are routing around.""" + """Apply the read-path flags to one item. + + Two things, both of which MUST happen on read and not only at creation: the + saved profile holds 246 items minted long before either flag existed, and the + club route serves them straight out of the save. Stamping only in _item() left + the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by + reading the served JSON rather than by unit-testing the factory. + + Callers pass a COPY, so the save is never mutated by a read. + """ if DISCARD_SEND: + # Omit the key entirely when the formula does not apply, rather than sending + # 0: a 0 makes the client fall back to its own lookup, and the tile binds our + # value anyway, so 0 renders as 0. v = discard_value(it) if v: it["discardValue"] = v + if TRADEABLE: + it["untradeable"] = False return it diff --git a/fifa17-recon/tools/ghidra_queries/q_pilesize_1.py b/fifa17-recon/tools/ghidra_queries/q_pilesize_1.py new file mode 100644 index 0000000..a38298a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pilesize_1.py @@ -0,0 +1,93 @@ +"""TRANSFER LIST shows 0/0. What feeds the trade-pile capacity? + +STATE. tradingEnabled (gate byte 0x1fd2e) is now 1, proven live, delivered via the Blaze +client-config store after /settings was proven incapable of reaching the applier. Every +card ships untradeable:false. Both documented gates of TO_TRADE_PILE (FUN_1801a7260) are +therefore satisfied, and yet "Place on Transfer List" and "List on Transfer Market" are +STILL greyed. The header reads TRANSFER LIST 0/0 in red, so the capacity is zero and that +is the obvious third condition. + +We send maximumTradePileSize=77 through /settings. That cannot work: the applier +FUN_18011dc50 has no caller Ghidra can see, is unreachable from the settings deserializer, +and a live probe showed no int gate field carrying 77 (+0x1fd14=0, +0x1fd4c=0, ++0x1fd54=480). So the capacity must arrive some other way, or default to 0. + +CANDIDATE LITERALS found in CardsDLL: TRADE_PILE_SIZE, GetMaxPileSize, NUM_MAX_AUCTIONS, +IS_MAX_AUCTIONS, pileSizeClientData, TradePileFull, maximumTradePileSize. + +QUESTIONS + Q1 Find the reader of the trade-pile capacity. Start from GetMaxPileSize and + TradePileFull and walk back to the field they read. Which struct offset holds it? + Q2 Is that offset the SAME one the settings applier would write (i.e. one of the int + fields at 0x1fd14 / 0x1fd4c / 0x1fd54), or a different home entirely? If it is an + applier field then capacity is unreachable for the same reason tradingEnabled was, + and the Blaze store is the only remaining lever. + Q3 Is TRADE_PILE_SIZE a CLIENT-CONFIG key, read the same way IS_TRADING_ENABLED is? + That is the decisive question, because IS_TRADING_ENABLED via the Blaze store worked + on the first try and the same delivery path would fix this. Find its xrefs and the + lookup that consumes it. Do NOT assume: an UPPER_SNAKE literal could equally be a + localisation key or a telemetry tag. + Q4 pileSizeClientData smells like the ut/%s/clientdata route, which we already serve. + Check whether the capacity is read from a clientdata blob instead. If so that is a + UTAS-side fix and needs no Blaze restart, which makes it much cheaper to test. + +CONTROL: IS_TRADING_ENABLED is known to work through the Blaze client-config path. Find +how IT is consumed, and use that as the template for judging whether TRADE_PILE_SIZE is +consumed the same way. A candidate that is NOT read by the same mechanism is not a +candidate, however plausible the name looks. + +COVERAGE: print decompiles in full with lengths. Enumerate all four dispatch forms before +any absence claim: == , != , case labels, and sub/dec ladders. +""" +import traceback + +LITERALS = [b"TRADE_PILE_SIZE\x00", b"GetMaxPileSize\x00", b"NUM_MAX_AUCTIONS\x00", + b"IS_MAX_AUCTIONS\x00", b"pileSizeClientData\x00", b"TradePileFull\x00", + b"maximumTradePileSize\x00", b"IS_TRADING_ENABLED\x00"] + + +def dump(va, title, limit=None): + try: + f = func(va) + src = dec(va) + print("\n" + "=" * 78) + print("%#x %s body %d / decompile %d chars%s" + % (va, title, f.getBody().getNumAddresses() if f else -1, len(src), + "" if limit is None else " (first %d shown)" % limit)) + print("=" * 78) + print(src if limit is None else src[:limit]) + except Exception: + print("!! failed %#x" % va) + traceback.print_exc() + + +try: + print("=" * 78) + print("LITERAL LOCATIONS AND XREFS") + print("=" * 78) + interesting = {} + for lit in LITERALS: + name = lit[:-1].decode() + hits = find_all(lit) + print("\n%-24s %d hit(s)" % (name, len(hits))) + for h in hits: + print(" at %#x : %r" % (h, rd_str(h, 40))) + xs = xrefs_to(h) + if not xs: + print(" no direct xref (may be reached via a table)") + for frm, typ, fn, ent in xs: + print(" xref %#x in %s (entry %#x)" % (frm, fn, ent)) + if ent: + interesting.setdefault(name, set()).add(ent) + + print("\n" + "=" * 78) + print("CONSUMERS, decompiled") + print("=" * 78) + # IS_TRADING_ENABLED first: it is the working control and the template. + for name in ("IS_TRADING_ENABLED", "TRADE_PILE_SIZE", "pileSizeClientData", + "GetMaxPileSize", "TradePileFull", "NUM_MAX_AUCTIONS"): + for ent in sorted(interesting.get(name, []))[:2]: + dump(ent, "consumer of %s" % name, limit=6000) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_trading_1.py b/fifa17-recon/tools/ghidra_queries/q_trading_1.py new file mode 100644 index 0000000..d292ceb --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_trading_1.py @@ -0,0 +1,127 @@ +"""Why does tradingEnabled stay 0 after we send it? Verify the arm -> index -> byte chain. + +REFUTED LIVE 2026-08-06. We served {"type":"tradingEnabled","value":1} on BOTH delivery +paths (the standalone /settings route and the userMassInfo.settings member), the client +fetched settings four times, and the gate byte 0x1fd2e stayed 0 while the three control +gates stayed 1. So the claim "FUT_SETTINGS=keep opens the trading gate" is wrong. This +file finds out where the chain actually breaks. + +THE CHAIN AS CURRENTLY BELIEVED, and every link is a candidate for being the wrong one: + 1. deser FUN_18013c6d0 hashes the STRING value of "type" and switches, 42 arms wide. + tradingEnabled is atom 0x336. + 2. that arm stores into some index of a settings struct + 3. applier FUN_18011dc50 copies `param_2[10] == 1` into gate byte +0x1fd2e, and that + line was LABELLED tradingEnabled + 4. service vtable slot +0x270 reads 0x1fd2e + +The label in step 3 was inferred from ordering, never verified against step 2. If the +tradingEnabled arm writes an index other than 10, the label is simply wrong and 0x1fd2e +belongs to some other flag we are not sending. + +FOUR HYPOTHESES, and the query is designed to separate them rather than confirm one: + H1 wrong index: the 0x336 arm writes an index the applier does not copy to 0x1fd2e. + H2 the applier never re-runs after a settings response; it runs once, early, from + defaults. That fits this morning's observation that the other three gates read 1 + with an EMPTY configs array, i.e. from constructor defaults, and would mean NO + /settings response can ever move any gate byte. + H3 the applier runs but reads a different settings struct instance than the deser wrote. + H4 the arm needs a different value shape (a string "1", a bool) than our int 1. + +CONTROLS: storeEnabled (0x2f1) is labelled as writing +0x1fd2f from param_2[0xb], and the +store demonstrably works, so whatever is true of tradingEnabled must be consistent with +storeEnabled too. Do the same trace for BOTH and compare. If the store works via a path +that does NOT involve this applier (it is known to arrive through the Blaze client-config +store), then storeEnabled is NOT a valid control and say so. + +COVERAGE: print both functions IN FULL with lengths. No absence claim from a truncation. +Remember the dispatch-form trap: enumerate case labels, == and != forms, and sub/dec +ladders before saying an atom is absent. +""" +import re +import traceback + +DESER = 0x18013C6D0 # /settings deserializer, 42 arms +APPLIER = 0x18011DC50 # the only writer of the gate bytes +A_TRADING = 0x336 +A_STORE = 0x2F1 + + +def dump(va, title): + f = func(va) + src = dec(va) + print("\n" + "=" * 78) + print("%#x %s body %d bytes / decompile %d chars (IN FULL)" + % (va, title, f.getBody().getNumAddresses() if f else -1, len(src))) + print("=" * 78) + print(src) + return src + + +try: + dsrc = dump(DESER, "settings deserializer") + asrc = dump(APPLIER, "gate-byte applier") + + print("\n" + "=" * 78) + print("DISPATCH TARGETS IN THE DESERIALIZER, all four forms") + print("=" * 78) + found = set() + for m in re.finditer(r"case (0x[0-9a-f]+|\d+):", dsrc): + found.add(int(m.group(1), 0)) + for m in re.finditer(r"[!=]= (0x[0-9a-f]+)\b", dsrc): + found.add(int(m.group(1), 16)) + print(" %d targets" % len(found)) + for a, n in (("tradingEnabled", A_TRADING), ("storeEnabled", A_STORE)): + print(" %-16s %#-6x %s" % (a, n, "PRESENT" if n in found else "ABSENT")) + + print("\n--- the tradingEnabled and storeEnabled arm bodies, to read the index each writes ---") + lines = dsrc.split("\n") + for want, label in ((A_TRADING, "tradingEnabled"), (A_STORE, "storeEnabled")): + idx = None + for i, l in enumerate(lines): + if re.match(r"\s*case %s:" % hex(want), l) or ("== %s" % hex(want)) in l: + idx = i + break + print("\n### %s (%#x) ###" % (label, want)) + if idx is None: + print(" not located by either form") + continue + out = [lines[idx]] + for l in lines[idx + 1:]: + if re.match(r"\s*case (0x[0-9a-f]+|\d+):", l): + break + out.append(l) + if len(out) > 14: + break + print("\n".join(out)) + + print("\n" + "=" * 78) + print("APPLIER: every gate-byte write, so the index->byte map can be read directly") + print("=" * 78) + for l in asrc.split("\n"): + if "0x1fd" in l: + print(" " + l.strip()) + + print("\n" + "=" * 78) + print("H2: WHO CALLS THE APPLIER, and is it reachable from the settings deserializer?") + print("=" * 78) + for a, n in callers(APPLIER): + print(" caller %#x %s" % (a, n)) + print("\n callers of the DESERIALIZER:") + for a, n in callers(DESER): + print(" caller %#x %s" % (a, n)) + print("\n does the deserializer (or anything it calls) reach the applier?") + seen, stack, hit = set(), [DESER], [] + while stack: + cur = stack.pop() + if cur in seen: + continue + seen.add(cur) + for a, n in callees(cur): + if a == APPLIER: + hit.append(cur) + if a not in seen and len(seen) < 400: + stack.append(a) + print(" reached from: %s" % (" ".join("%#x" % h for h in hit) or "NOT REACHABLE within 400 nodes")) + +except Exception: + traceback.print_exc()