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`).
|
||||
`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, "?")))
|
||||
@@ -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
|
||||
|
||||
@@ -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 ------
|
||||
|
||||
Reference in New Issue
Block a user