fifa17-recon: the transfer market works -- listed a card end to end, no freeze

The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-06 14:33:10 -07:00
parent a3fd51692f
commit 43557989f5
61 changed files with 4126 additions and 35 deletions
+76 -33
View File
@@ -147,6 +147,28 @@ def squad_list_body(squad=None):
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
_UI = os.environ.get("FUT_USERINFO", "roster")
# FUT_TRADING: stop banning our own trading.
#
# userInfo.feature (atom 0x11c) is a RESTRICTION map, not a grant. Sending
# feature={"trade": true} marks TRADE RESTRICTED. Verified at the instruction level
# 2026-08-06 (q_feature_trade.py): FUN_18013ec10 parses feature/trade into
# userInfo+0x17c, and at the massinfo top-level END_OBJECT the client runs
# 0x180174f10 cmp byte [rsi+0x17c], 0
# 0x180174f17 jz 0x180174f20 ; not restricted -> skip
# 0x180174f19 mov dword [rsi+0x50], 0 ; restricted -> zero the trade field
# which feeds applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs
# LAST and unconditionally, which is why the gate byte read 0 all day no matter what
# /settings or the Blaze client-config store sent. We were disabling trading ourselves.
#
# With the flag on we send feature={} (no trade key -> +0x17c stays 0 -> the jz skips
# the zeroing -> the gate keeps its constructor default of 1). Empty object is
# type-safe: feature is an OBJECT and {} parses with no members.
#
# Default OFF for one relaunch only: this is on the critical path into FUT and has
# never been in front of the game. Verify by reading model+0x1fd2e (should become 1)
# and by checking the per-card "Place on Transfer List" entry is no longer greyed.
TRADING = os.environ.get("FUT_TRADING", "0") == "1"
# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
#
@@ -252,7 +274,9 @@ def user_info():
"clubNameChangeAllowed": _CLUB_RENAME,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False, # 0x262 -> bool at +0x68
"feature": {"trade": True},
# {"trade": true} = trade RESTRICTED (see FUT_TRADING note up top). {} lifts
# the restriction. Default keeps the historical value until one live test.
"feature": ({} if TRADING else {"trade": True}),
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
@@ -605,43 +629,62 @@ SETTINGS = _settings_body()
_MI = os.environ.get("FUT_MASSINFO", "full")
# ---- pileSizeClientData: the MY CLUB counter --------------------------------
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
# ---- pileSizeClientData: the TRANSFER LIST + WATCH LIST CAPACITIES -----------
# CORRECTED 2026-08-06 (q_pilesize_keys.py). The old "MY CLUB counter" theory here
# was WRONG. Parser FUN_18013adb0 has EXACTLY two storing arms and no default:
# key(0x177)==2 -> value -> param_2+0x8 -> model+0x1fd1c = TRADE_PILE_SIZE
# key(0x177)==4 -> value -> param_2+0xc -> model+0x1fd20 = watch-list size
# every other key hits the SKIP handler. So this member is the transfer-list and
# watch-list CAPACITIES, not counts and not the club. The real MY CLUB counter is
# the /hub clubPlayers field (model+0x1fd70+0x3c), which we already serve.
#
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
# literals are just atom names in the alphabetical key table). So rather than guess:
# This is THE fix for the red "TRANSFER LIST 0/0" and the "TRANSFER LIST FULL"
# refusal on Place on Transfer List: with this member absent, model+0x1fd1c stays at
# its constructor default of 0, so the list has zero capacity and nothing can be
# listed even though trading is now enabled. Confirmed live: byte read 0, client
# said FULL.
#
# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
# recognisable value (100+key). Whatever number MY CLUB then displays names the
# club pile's key: 103 means key 3. One launch identifies the enum.
# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known.
# key and value both pass through FUN_1800d7b30 (test rcx,rcx / jle -> 0), so values
# must be POSITIVE; -1 does not mean unlimited. 100/50 are the stock FIFA 17
# convention (nothing in the binary carries a default; the ctor zeroes both).
#
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
# member of that parser and carries only ints, so the risk is low -- but "low" is
# what I said about displayGroup before it froze the store, so it ships behind a flag.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed
# Freeze risk: LOW. Documented int-only member of the boot-critical massinfo parser,
# skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad.
# Default OFF for one live test; this adds a member to boot-critical massinfo.
_PILESIZES = os.environ.get("FUT_PILESIZES", "0") == "1"
PILE_KEY_TRADEPILE = 2
PILE_KEY_WATCHLIST = 4
def marketdata_route(h):
"""GET marketdata/pricelimits?defId=a,b,c -- FutGetSuggestedPricing (deser
0x180163ee0). The response is a BARE TOP-LEVEL ARRAY, one element per requested
defId, each {defId, minPrice, maxPrice}, all scalar ints.
FROZE THE CLIENT 2026-08-06: we returned an OBJECT {"minPrice","maxPrice"} where
the deser's root loop reads an ARRAY (while tok != 0xd). Object-where-array is the
type-desync busy loop at 0x1801c7f1a. Confirmed live: listing a card at the price
screen pinned a core. Element atoms verified: defId 0xcf, maxPrice 0x1c2, minPrice
0x1ca, all read via the INT getter 0x1801c79d0, so int values are type-correct.
The container was the whole bug.
defId can be a comma-separated list. Echo each so the client can match the band to
the item it asked about. Bands are a placeholder (150..15000); real per-item
pricing is a later refinement, not a freeze concern.
"""
from urllib.parse import urlparse, parse_qs
q = parse_qs(urlparse(h.path).query)
raw = q.get("defId", [""])[0]
ids = [int(x) for x in raw.split(",") if x.strip().isdigit()]
return 200, [{"defId": d, "minPrice": 150, "maxPrice": 15000} for d in ids]
def pile_size_body():
"""massinfo.pileSizeClientData -- see the note above."""
if _PILESIZES == "probe":
return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
counts = {
"club": len(STORE.items()),
"purchased": len(STORE.purchased()),
"tradepile": len(STORE.listings()),
}
if PILE_KEY_CLUB >= 0:
return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
# No verified key yet -> announce the club count on every candidate key. Crude,
# but every value is truthful, so no pile can be told a wrong number.
return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}
"""massinfo.pileSizeClientData -- transfer-list and watch-list CAPACITIES."""
return {"entries": [
{"key": PILE_KEY_TRADEPILE, "value": 100},
{"key": PILE_KEY_WATCHLIST, "value": 50},
]}
def massinfo():
@@ -1111,7 +1154,7 @@ ROUTES = [
# LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not
# /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings.
(re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)),
(re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
(re.compile(G + r"/marketdata"), lambda m, h: marketdata_route(h)),
# QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All"
# sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the
# client ACCEPTS (no error, session survives) but which paid 0 coins: the user