diff --git a/fifa17-recon/docs/ENDPOINT_MAP.md b/fifa17-recon/docs/ENDPOINT_MAP.md index fb6bc04..805397b 100644 --- a/fifa17-recon/docs/ENDPOINT_MAP.md +++ b/fifa17-recon/docs/ENDPOINT_MAP.md @@ -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`) diff --git a/fifa17-recon/tools/ghidra_queries/objdump_atom_ladder.py b/fifa17-recon/tools/ghidra_queries/objdump_atom_ladder.py new file mode 100644 index 0000000..35fb42d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/objdump_atom_ladder.py @@ -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, "?"))) diff --git a/fifa17-recon/tools/openfut-fut.sh b/fifa17-recon/tools/openfut-fut.sh index 829bd39..b4a78b8 100755 --- a/fifa17-recon/tools/openfut-fut.sh +++ b/fifa17-recon/tools/openfut-fut.sh @@ -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 diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 3ef8f17..49ff8d2 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -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 ------