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()