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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-06 12:47:02 -07:00
parent e578443d73
commit a3fd51692f
5 changed files with 356 additions and 17 deletions
+44 -4
View File
@@ -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