From a3fd51692f880deae8827de324c46253e937fdbe Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 6 Aug 2026 12:47:02 -0700 Subject: [PATCH] fifa17-recon: the trading gate is still shut, and two of yesterday's conclusions were wrong Ships the club-item subtype correction and the tradeable plumbing, and records two refutations of claims made earlier in the same session. Nothing here is a working fix for trading; the honest state is that the gate is still closed and we now know more about why. REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at 0x18006ccc6 it calls [rax+0x270] to READ 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 all of .text is that lea; there is no comparison against it anywhere, so no client-config key of that name can be read as an input. That also undermines the IS_* store keys shipped beside it: their apparent success was never actually attributed to them. REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0 on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00 being 0x1fd2e). Either the first read was transient or something clears it after login. The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it. AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call search found no callers because Ghidra does not resolve virtual calls, which is the same dispatch-form trap that has now produced seven wrong verdicts here. The real chain is settings response -> FUN_180174630 -> FUN_18013c6d0 (deser) -> completion callback FUN_180173e00 -> vt+0x988 and 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. The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and +0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open. Recovered and worth keeping: an authoritative slot-to-name table from the publisher. vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0. WHAT IS ACTUALLY SHIPPED HERE, all default off: * FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored INVERTED by case 0x361) on a live club record. Applied on every READ path, not only in _item(), because the save holds 246 items minted before the flag existed and the club route serves them straight from the save. That gap was caught by reading the served JSON, not by unit-testing the factory. * FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept only as a record of the refutation, with the reasoning inline so nobody retries it. * fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7, not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs preferred could never have answered this for three of five families. * Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash (which was never bisected; value is the established suspect and that response also carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and never copied into the record. Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer market still refuses with zero requests and the menu entries are still greyed. Co-Authored-By: Claude Opus 5 (1M context) --- fifa17-recon/tools/blaze_responder_v3b.py | 46 +++++++ fifa17-recon/tools/fut_clubitems.py | 59 ++++++-- fifa17-recon/tools/fut_store.py | 48 ++++++- .../tools/ghidra_queries/q_pilesize_1.py | 93 +++++++++++++ .../tools/ghidra_queries/q_trading_1.py | 127 ++++++++++++++++++ 5 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 fifa17-recon/tools/ghidra_queries/q_pilesize_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_trading_1.py 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()