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:
funman300
2026-08-06 18:28:52 -07:00
parent 245c22161b
commit 31fc590b99
4 changed files with 156 additions and 13 deletions
+37 -7
View File
@@ -1329,14 +1329,44 @@ this absence is asserted over the whole function, not a slice.
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
`off` restores the historical `{"configs": []}`.
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
- **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
- **HTTP:** `GET ut/%s/hub`
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
`call [rdx+0x1f8]`), NOT an inline atom ladder no static field ladder to
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
its own deser. Empty `{}` is tolerated (fields default).
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
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
- **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, "?")))
+1 -1
View File
@@ -20,7 +20,7 @@ SERVERS=(
"lsx lsx_responder_v2.py 4216 OPENFUT_LSX_EVENT_COUNT=100000"
"blaze blaze_responder_v3b.py 42127,42130,42131 -"
"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 - -"
# 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
+25 -5
View File
@@ -1157,8 +1157,13 @@ ROUTES = [
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
# /tradePile/counts (GetAuctionCount) MUST precede /tradePile: the latter's regex
# 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)),
(re.compile(G + r"/tradePile"), lambda m, h: tradepile_route(h)),
# CASE-INSENSITIVE (live 2026-08-06): the FUT-hub Transfer List TILE polls the
# 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"/watchList"), lambda m, h: watchlist_route(h)),
(re.compile(G + r"/auctionhouse"), lambda m, h: auctionhouse_route(h)),
@@ -1252,13 +1257,28 @@ def _is_player(it):
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:
return {}
players = len([i for i in STORE.items() if _is_player(i)])
auctions = len(STORE.listings())
log(" HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
return {"clubPlayers": players, "auctionCount": auctions}
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, 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 ------