Files
OpenFUT/fifa17-recon/tools/ghidra_queries/q_pack_content_9.py
T
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00

134 lines
5.5 KiB
Python

"""D3 run 9: CORRECTION RUN. There IS a reader, and I nearly missed it.
WHAT WENT WRONG IN RUNS 3-8. I triaged the disp32 scan by ADDRESS BAND, treating
everything below ~0x180100000 as "engine noise", and on that basis dismissed
FUN_18002c3c0. It is in fact a pack-record -> view-model adapter that reads all five
packContentInfo slots, plus `start` (+0xb4) and `unopened` (+0xcd). Address band is
not evidence. This run replaces the band heuristic with a FIELD FINGERPRINT.
FINGERPRINT. The pack record's distinctive, disp32-encodable field offsets are
0xb0 state, 0xb4 start, 0xbc quantity, 0xc0, 0xc4, 0xc8 saleType, 0xcc
useDefaultImage, 0xcd unopened, 0xce isPremium, 0xcf dealType-free,
0xd0 dealType-promo, 0x138 visible, 0x13c bonus, 0x140, 0x144 itemQuantity,
0x148 gold, 0x14c silver, 0x150 bronze, 0x154 rare.
Any unrelated struct may collide on one or two of these. Colliding on five or more,
especially on the tight run 0xcd/0xce/0xcf/0xd0, is not chance.
CONTROLS. The known-good members must score at the top: 0x1801340e0 (copy-assign),
0x180133210 (uninitialised_copy), 0x18002c3c0 (the adapter just found).
0x180133af0 (stride 0x168) and 0x180134b50 (extends to +0x163) must NOT, since
their strides prove they are other classes.
THEN follow the view model FUN_18002c3c0 builds: its callers, FUN_18002cc90 which it
tail-calls, and every reader of the view model's copies of the quantities at
vm+0xc0/0xc4/0xc8/0xcc/0xd0, to see whether any of them counts an item list.
"""
import traceback, sys, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/"
os.makedirs(OUT, exist_ok=True)
FP = [0x0b0, 0x0b4, 0x0bc, 0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0cd, 0x0ce, 0x0cf, 0x0d0,
0x138, 0x13c, 0x140, 0x144, 0x148, 0x14c, 0x150, 0x154]
TIGHT = [0x0cd, 0x0ce, 0x0cf, 0x0d0, 0x13c, 0x144, 0x14c, 0x154]
VM = [0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0, 0x084, 0x0b0, 0x0b5, 0x0b6, 0x0b7, 0x0b8]
def dump(tag, va, path, echo=True):
src = dec(va)
hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (
tag, va, fname(va), len(src))
if echo:
print("=" * 78)
print(hdr)
print("=" * 78)
print(src)
with open(path, "w") as fh:
fh.write("// " + hdr + "\n" + src)
return src
def scan_mem(val):
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
tag = "+ %#x]" % val
out = {}
seen = set()
for h in find_all(pat, blocks=(".text",)):
ins = None
for back in range(0, 12):
i2 = listing.getInstructionContaining(addr(h - back))
if i2 is not None:
ins = i2
break
if ins is None:
continue
a = int(ins.getAddress().getOffset())
if a in seen:
continue
seen.add(a)
t = str(ins)
if tag not in t:
continue
f = fm.getFunctionContaining(ins.getAddress())
if f is None or f.getName().startswith("Unwind@"):
continue
out.setdefault(int(f.getEntryPoint().getOffset()), []).append((a, t))
return out
try:
print("### 1. PACK-RECORD FIELD FINGERPRINT OVER ALL OF .text")
hits = {}
for off in FP:
for k in scan_mem(off):
hits.setdefault(k, set()).add(off)
ranked = sorted(hits.items(), key=lambda kv: -len(kv[1]))
print(" functions scoring >=5 fingerprint offsets:")
strong = []
for k, offs in ranked:
if len(offs) < 5:
break
t = sorted(o for o in offs if o in TIGHT)
print(" %#x %-22s score=%2d tight=%d %s"
% (k, fname(k), len(offs), len(t), [hex(x) for x in sorted(offs)]))
strong.append(k)
print("\n CONTROLS: 0x1801340e0 in=%s 0x180133210 in=%s 0x18002c3c0 in=%s"
" | must NOT be strong: 0x180133af0 in=%s 0x180134b50 in=%s"
% (0x1801340e0 in strong, 0x180133210 in strong, 0x18002c3c0 in strong,
0x180133af0 in strong, 0x180134b50 in strong))
print("\n full decompile of every strong function not already understood:")
KNOWN = {0x1801340e0, 0x180133210, 0x1801342d0, 0x18013af30, 0x18002c3c0}
for k in strong:
if k in KNOWN:
continue
dump("STRONG FINGERPRINT", k, OUT + "d3_fp_%x.txt" % k, echo=True)
print("\n### 2. THE ADAPTER AND ITS VIEW MODEL")
print(" callers of adapter 0x18002c3c0: %s"
% [(hex(a), n) for a, n in callers(0x18002c3c0)])
for a, n in callers(0x18002c3c0):
dump("ADAPTER CALLER", a, OUT + "d3_ad_caller_%x.txt" % a, echo=True)
dump("FUN_18002cc90 (tail call from adapter)", 0x18002cc90,
OUT + "d3_vm_18002cc90.txt", echo=True)
print(" callers of 0x18002cc90: %s"
% [(hex(a), n) for a, n in callers(0x18002cc90)])
print("\n### 3. VIEW-MODEL QUANTITY READERS (vm+0xc0..0xd0)")
vmhits = {}
for off in (0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0):
for k, v in scan_mem(off).items():
vmhits.setdefault(k, {})[off] = v
cands = [(k, o) for k, o in vmhits.items() if len(o) >= 4]
print(" functions reading >=4 of vm+0xc0..0xd0: %d" % len(cands))
for k, o in sorted(cands):
print(" %#x %-22s %s" % (k, fname(k), [hex(x) for x in sorted(o)]))
print("\n decompiles:")
for k, o in sorted(cands):
if k in KNOWN:
continue
dump("VM QTY READER", k, OUT + "d3_vm_%x.txt" % k, echo=True)
except Exception:
traceback.print_exc()
sys.stdout.flush()