fifa17-recon: the FUT-hub Transfer List tile counts, and the hub parser is NOT reflection
The Transfer List hub tile read "0 items / Selling 0" while a card was actively
listed. Enumerating the /hub parser FUN_180139610 straight from the on-disk
CardsDLL (objdump) refutes the old ENDPOINT_MAP claim that it uses C++ reflection
with "no atom ladder, nothing to enumerate": it has an ordinary running-sum atom
ladder reading 18 atoms. The tile is fed by hub.tradePile (0x333), a nested object
(sub-deser 0x18013ead0) reading count/selling/sold as scalar ints -- the same
scheme as GetAuctionCount, so serving it in the hub body is freeze-safe. The tile
never re-polls the standalone /tradePile/counts, which is why fixing that endpoint
alone did not move the tile.
Also: the hub tile polls LOWERCASE tradepile/counts while the Transfer List screen
uses camelCase tradePile; our case-sensitive routes matched only the screen, so the
tile's counts call fell through to /trade and got a shape the counts deser skips.
Made the tradePile routes case-insensitive.
And bake the proven transfer-market flags (FUT_TRADING/PILESIZES/TRADEABLE/
DISCARD_TABLE/DISCARD_SEND) into openfut-fut.sh so a plain `start` brings up the
working state instead of regressing trading to greyed-out.
- tools/utas_server.py: hub_data() serves tradePile:{count,selling,sold};
tradePile routes now re.I
- tools/openfut-fut.sh: utas launched with the working flag set
- docs/ENDPOINT_MAP.md: full 18-atom hub map + tile map, correction of the
reflection claim
- tools/ghidra_queries/objdump_atom_ladder.py: the objdump-based atom-ladder
decoder used to derive the above
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
This commit is contained in:
@@ -1329,14 +1329,44 @@ this absence is asserted over the whole function, not a slice.
|
|||||||
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
|
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
|
||||||
`off` restores the historical `{"configs": []}`.
|
`off` restores the historical `{"configs": []}`.
|
||||||
|
|
||||||
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
|
||||||
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
- **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
|
||||||
- **HTTP:** `GET ut/%s/hub`
|
- **HTTP:** `GET ut/%s/hub`
|
||||||
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
|
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
|
||||||
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to
|
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
|
||||||
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
|
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
|
||||||
its own deser. Empty `{}` is tolerated (fields default).
|
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
|
||||||
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
|
It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
|
||||||
|
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
|
||||||
|
per-sub-object dispatch one indirection deeper, not the field read itself.
|
||||||
|
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
|
||||||
|
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
|
||||||
|
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
|
||||||
|
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
|
||||||
|
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
|
||||||
|
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
|
||||||
|
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
|
||||||
|
`watchlist`(0x381).
|
||||||
|
- **TILE MAP (which atom drives which hub tile):**
|
||||||
|
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
|
||||||
|
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
|
||||||
|
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
|
||||||
|
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
|
||||||
|
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
|
||||||
|
one SKIP, object field loop; no array/nested object → no type-desync surface).
|
||||||
|
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
|
||||||
|
`count == selling == len(listings)`, `sold == 0`.**
|
||||||
|
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
|
||||||
|
yet populated; empty watch list defaults to 0, which is correct today).
|
||||||
|
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
|
||||||
|
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
|
||||||
|
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
|
||||||
|
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
|
||||||
|
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
|
||||||
|
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
|
||||||
|
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
|
||||||
|
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
|
||||||
|
which is correct while those modes are unpopulated.
|
||||||
|
|
||||||
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
||||||
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode the running-sum atom ladders in /hub parser FUN_180139610 and name each
|
||||||
|
atom from docs/fut_atoms.tsv.
|
||||||
|
|
||||||
|
The dispatch is `sub ecx,d0 / sub ecx,d1 / .../ cmp ecx,dN`: the atom that each
|
||||||
|
branch handles is the CUMULATIVE sum of the deltas up to and including that step
|
||||||
|
(a jz after each sub tests atom==running_sum). Plus there are direct `cmp esi,imm`.
|
||||||
|
"""
|
||||||
|
import subprocess, re
|
||||||
|
|
||||||
|
DLL = "/tmp/fut/cardsdll.dll"
|
||||||
|
TSV = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
|
||||||
|
FUNC, STOP = 0x180139610, 0x18013e600
|
||||||
|
|
||||||
|
atoms = {}
|
||||||
|
for line in open(TSV):
|
||||||
|
p = line.rstrip("\n").split("\t")
|
||||||
|
if len(p) >= 3:
|
||||||
|
try: atoms[int(p[1], 16)] = p[2]
|
||||||
|
except ValueError: pass
|
||||||
|
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["objdump", "-d", "-M", "intel",
|
||||||
|
"--start-address=%#x" % FUNC, "--stop-address=%#x" % STOP, DLL], text=True)
|
||||||
|
|
||||||
|
# linear list of (addr, mnem, dest_reg, imm) for sub/cmp on 32-bit regs, stop at int3 pad
|
||||||
|
seq = []
|
||||||
|
int3 = 0
|
||||||
|
for ln in out.splitlines():
|
||||||
|
parts = ln.split("\t")
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
addr_s = parts[0].strip().rstrip(":")
|
||||||
|
try:
|
||||||
|
addr = int(addr_s, 16)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
instr = parts[2].strip()
|
||||||
|
bits = instr.split(None, 1)
|
||||||
|
mnem = bits[0]
|
||||||
|
ops = bits[1].strip() if len(bits) > 1 else ""
|
||||||
|
if mnem == "int3":
|
||||||
|
int3 += 1
|
||||||
|
if int3 >= 4: break
|
||||||
|
continue
|
||||||
|
int3 = 0
|
||||||
|
mo = re.match(r"(e?[a-d]x|e?si|e?di|e?bp|r\d+d?),\s*(0x[0-9a-f]+)$", ops)
|
||||||
|
if mnem in ("sub", "cmp") and mo:
|
||||||
|
seq.append((addr, mnem, mo.group(1), int(mo.group(2), 16)))
|
||||||
|
|
||||||
|
# walk ladders: consecutive sub/cmp on the SAME register form one ladder; the running
|
||||||
|
# sum at each element is the atom that element dispatches. A `cmp` closes the ladder.
|
||||||
|
found = {} # atom -> (addr, kind)
|
||||||
|
i = 0
|
||||||
|
while i < len(seq):
|
||||||
|
addr, mnem, reg, imm = seq[i]
|
||||||
|
# a ladder starts on a sub
|
||||||
|
if mnem == "sub":
|
||||||
|
run = 0
|
||||||
|
j = i
|
||||||
|
while j < len(seq) and seq[j][2] == reg and seq[j][1] in ("sub", "cmp"):
|
||||||
|
run += seq[j][3]
|
||||||
|
found.setdefault(run, (seq[j][0], "ladder"))
|
||||||
|
if seq[j][1] == "cmp":
|
||||||
|
j += 1
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
# a lone cmp reg,imm on an atom-holding reg is a direct atom test
|
||||||
|
if 0 < imm <= 0x400:
|
||||||
|
found.setdefault(imm, (addr, "direct"))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
TOKENS = {0x1, 0x6, 0x7, 0x9, 0xa, 0xb, 0xc, 0xd} # SAX token enum, not atoms
|
||||||
|
print("Atoms dispatched by hub parser FUN_%#x:" % FUNC)
|
||||||
|
print("=" * 70)
|
||||||
|
for a in sorted(found):
|
||||||
|
if a in TOKENS:
|
||||||
|
continue
|
||||||
|
tag = " <-- TOKEN?" if a < 0x10 else ""
|
||||||
|
print(" %#06x %-28s (%s @ %#x)%s" %
|
||||||
|
(a, atoms.get(a, "?"), found[a][1], found[a][0], tag))
|
||||||
|
|
||||||
|
print("\nKnown tile counters for reference: 0x33=auctionCount, 0x90=clubPlayers")
|
||||||
|
print("\nName-based tile-count candidates:")
|
||||||
|
KEYS = ("sell","sold","trade","auction","pile","list","count","num","offer",
|
||||||
|
"won","outbid","target","watch","transfer","active","unassigned")
|
||||||
|
for a in sorted(found):
|
||||||
|
if a in TOKENS: continue
|
||||||
|
n = atoms.get(a, "").lower()
|
||||||
|
if any(k in n for k in KEYS):
|
||||||
|
print(" %#06x %s" % (a, atoms.get(a, "?")))
|
||||||
@@ -20,7 +20,7 @@ SERVERS=(
|
|||||||
"lsx lsx_responder_v2.py 4216 OPENFUT_LSX_EVENT_COUNT=100000"
|
"lsx lsx_responder_v2.py 4216 OPENFUT_LSX_EVENT_COUNT=100000"
|
||||||
"blaze blaze_responder_v3b.py 42127,42130,42131 -"
|
"blaze blaze_responder_v3b.py 42127,42130,42131 -"
|
||||||
"roster roster_server.py 8081 -"
|
"roster roster_server.py 8081 -"
|
||||||
"utas utas_server.py 8099 -"
|
"utas utas_server.py 8099 FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
|
||||||
"autopatch autopatch.py - -"
|
"autopatch autopatch.py - -"
|
||||||
# POW/EASFC — the "EA FC servers unreachable" layer. Harmless when idle: it just
|
# POW/EASFC — the "EA FC servers unreachable" layer. Harmless when idle: it just
|
||||||
# binds 8094/8080 and nothing points at it unless FUT_POW=1 makes blaze serve the
|
# binds 8094/8080 and nothing points at it unless FUT_POW=1 makes blaze serve the
|
||||||
|
|||||||
@@ -1157,8 +1157,13 @@ ROUTES = [
|
|||||||
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
|
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
|
||||||
# /tradePile/counts (GetAuctionCount) MUST precede /tradePile: the latter's regex
|
# /tradePile/counts (GetAuctionCount) MUST precede /tradePile: the latter's regex
|
||||||
# also matches the /counts path, and the two responses are different shapes.
|
# also matches the /counts path, and the two responses are different shapes.
|
||||||
(re.compile(G + r"/tradePile/counts"), lambda m, h: auction_counts_route(h)),
|
# CASE-INSENSITIVE (live 2026-08-06): the FUT-hub Transfer List TILE polls the
|
||||||
(re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)),
|
# LOWERCASE `tradepile`/`tradepile/counts`, while the Transfer List SCREEN uses
|
||||||
|
# camelCase `tradePile`. Case-sensitive routes matched only the screen, so the
|
||||||
|
# tile fell through to /trade (which contains "trade") and got a shape the counts
|
||||||
|
# deser skips -> the tile read "Selling: 0" while a card was actively listed.
|
||||||
|
(re.compile(G + r"/tradePile/counts", re.I), lambda m, h: auction_counts_route(h)),
|
||||||
|
(re.compile(G + r"/tradePile", re.I), lambda m, h: tradepile_route(h)),
|
||||||
(re.compile(G + r"/trade"), lambda m, h: trade_route(h)),
|
(re.compile(G + r"/trade"), lambda m, h: trade_route(h)),
|
||||||
(re.compile(G + r"/watchList"), lambda m, h: watchlist_route(h)),
|
(re.compile(G + r"/watchList"), lambda m, h: watchlist_route(h)),
|
||||||
(re.compile(G + r"/auctionhouse"), lambda m, h: auctionhouse_route(h)),
|
(re.compile(G + r"/auctionhouse"), lambda m, h: auctionhouse_route(h)),
|
||||||
@@ -1252,13 +1257,28 @@ def _is_player(it):
|
|||||||
|
|
||||||
|
|
||||||
def hub_data():
|
def hub_data():
|
||||||
"""GET ut/%s/hub -- the FUT hub tile counters."""
|
"""GET ut/%s/hub -- the FUT hub tile counters.
|
||||||
|
|
||||||
|
The hub parser FUN_180139610 reads 18 atoms; two of them (auctionCount 0x33,
|
||||||
|
clubPlayers 0x90) we already serve. The FUT-hub 'TRANSFER LIST' tile
|
||||||
|
(items / Selling / Sold) is fed by a THIRD atom we were omitting: tradePile
|
||||||
|
(0x333), a nested object parsed by sub-deser 0x18013ead0. That sub-parser reads
|
||||||
|
count(0xbc), notification(0x1da), selling(0x2b8), sold(0x2c9) -- the same atom
|
||||||
|
scheme as GetAuctionCount (/tradePile/counts) -- each a SCALAR INT via the int
|
||||||
|
getter 0x1801c79d0 (5 int reads, one SKIP, an object field loop; no array, no
|
||||||
|
nested object => no type-desync surface). Confirmed 2026-08-06 straight from the
|
||||||
|
on-disk CardsDLL via objdump (scratchpad/hub_ladder.py).
|
||||||
|
LIVE SYMPTOM this fixes: a card was actively listed (auctionCount 1, Listed Items
|
||||||
|
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
|
||||||
|
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
|
||||||
|
listings are 'selling'; none are 'sold'. count == selling == number of listings."""
|
||||||
if not HUBDATA:
|
if not HUBDATA:
|
||||||
return {}
|
return {}
|
||||||
players = len([i for i in STORE.items() if _is_player(i)])
|
players = len([i for i in STORE.items() if _is_player(i)])
|
||||||
auctions = len(STORE.listings())
|
auctions = len(STORE.listings())
|
||||||
log(" HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
|
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions))
|
||||||
return {"clubPlayers": players, "auctionCount": auctions}
|
return {"clubPlayers": players, "auctionCount": auctions,
|
||||||
|
"tradePile": {"count": auctions, "selling": auctions, "sold": 0}}
|
||||||
|
|
||||||
|
|
||||||
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
|
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
|
||||||
|
|||||||
Reference in New Issue
Block a user