afdbb364ca
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>
147 lines
6.3 KiB
Python
147 lines
6.3 KiB
Python
"""D3 Q2/Q3/Q4: who READS the pack record's packContentInfo slots, `start` and
|
|
`unopened`, and does anything count the delivered itemList against them?
|
|
|
|
ESTABLISHED IN RUN 1 (q_pack_content_1.py, d3_pack_elem_deser.asm), twice over --
|
|
once from the decompiler's frame locals and once from raw disassembly:
|
|
pack element deser 0x18013af30, record base = RSP+0x50 = RBP-0xB0, record size 0x158
|
|
itemQuantity 0x170 -> [RBP+0x94] -> rec +0x144
|
|
goldQuantity 0x149 -> [RBP+0x98] -> rec +0x148
|
|
silverQuantity 0x2c6 -> [RBP+0x9c] -> rec +0x14c
|
|
bronzeQuantity 0x63 -> [RBP+0xa0] -> rec +0x150
|
|
rareQuantity 0x273 -> [RBP+0xa4] -> rec +0x154
|
|
state 0x2eb -> [RBP+0x00] -> rec +0x0b0
|
|
start 0x2e3 -> [RBP+0x04] -> rec +0x0b4 (INT via 0x1800d7b30)
|
|
useDefaultImage0x36a -> [RBP+0x1c] -> rec +0x0cc (inverted)
|
|
unopened 0x35d -> [RBP+0x1d] -> rec +0x0cd (BOOL, stored raw)
|
|
`start` and `unopened` are TOP-LEVEL pack keys, NOT packContentInfo children.
|
|
|
|
HYPOTHESIS: nothing in CardsDLL reads +0x144..+0x154 back.
|
|
|
|
WHY A BYTE SCAN IS SOUND HERE: every offset of interest is >= 0x80, so x86 cannot
|
|
encode it as a signed disp8. Any instruction touching one of these slots must carry
|
|
the literal disp32 little-endian bytes. So a raw .text scan for those 4 bytes is an
|
|
EXHAUSTIVE upper bound on the set of candidate accesses; each hit is then confirmed
|
|
by asking Ghidra for the instruction containing it and checking the scalar.
|
|
|
|
POSITIVE CONTROL FOR THE SCAN: the record copy-assign 0x1801340e0 and the
|
|
push_back 0x180132180 must move all 0x158 bytes. If they copy field-by-field the
|
|
scan MUST list them; if the scan returns nothing at all for every offset including
|
|
theirs, the scan is broken, not the binary. Second control: the stride 0x158 must be
|
|
found in 0x18013af30 itself (the /0x158 count check) and in 0x180132180.
|
|
"""
|
|
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)
|
|
|
|
REC = {0x144: "itemQuantity", 0x148: "goldQuantity", 0x14c: "silverQuantity",
|
|
0x150: "bronzeQuantity", 0x154: "rareQuantity",
|
|
0x0b0: "state", 0x0b4: "start", 0x0cc: "useDefaultImage", 0x0cd: "unopened",
|
|
0x158: "STRIDE/record-size"}
|
|
|
|
|
|
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_disp(val):
|
|
"""every .text instruction carrying `val` as a literal 4-byte scalar"""
|
|
pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])
|
|
seen = {}
|
|
for h in find_all(pat, blocks=(".text",)):
|
|
ins = None
|
|
for back in range(0, 12):
|
|
try:
|
|
i2 = listing.getInstructionContaining(addr(h - back))
|
|
except Exception:
|
|
i2 = None
|
|
if i2 is not None:
|
|
ins = i2
|
|
break
|
|
if ins is None:
|
|
continue
|
|
ok = False
|
|
for i in range(ins.getNumOperands()):
|
|
for o in ins.getOpObjects(i):
|
|
try:
|
|
if (int(o.getValue()) & 0xFFFFFFFF) == val:
|
|
ok = True
|
|
except Exception:
|
|
pass
|
|
if not ok:
|
|
continue
|
|
a = int(ins.getAddress().getOffset())
|
|
seen[a] = (fname(a), str(ins))
|
|
return seen
|
|
|
|
|
|
try:
|
|
print("### CONTROL A: does the RS4 machinery work in THIS project copy?")
|
|
for nm in ("FutSquadSaveServerResponse", "FutStoreGetPackTypesServerResponse"):
|
|
hits = find_all(b"RS4:" + nm.encode())
|
|
print(" RS4:%-38s literal hits=%s" % (nm, [hex(x) for x in hits]))
|
|
for h in hits:
|
|
xs = xrefs_to(h)
|
|
print(" xrefs to literal %#x: %s" % (h, [(hex(f), t, n) for f, t, n, e in xs]))
|
|
for c, expect in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140),
|
|
("FutCreateMatch", 0x180120380)):
|
|
r = class_deser(c)
|
|
print(" class_deser(%-16s) -> %s expect %#x %s"
|
|
% (c, [hex(x[0]) for x in r], expect,
|
|
"PASS" if any(x[0] == expect for x in r) else "FAIL/UNKNOWN"))
|
|
|
|
print("\n### RECORD LIFECYCLE FUNCTIONS (full decompiles -> files)")
|
|
for va, tag in ((0x1801342d0, "record ctor"), (0x1801340e0, "record copy-assign"),
|
|
(0x180132180, "vector push_back/grow"), (0x1801232a0, "record dtor"),
|
|
(0x1800d7af0, "int conv A (quantities)"),
|
|
(0x1800d7b30, "int conv B (start,bonus)"),
|
|
(0x1800d7b10, "int conv C (id, 16-bit)")):
|
|
s = dump(tag, va, OUT + "d3_life_%x.txt" % va, echo=False)
|
|
print(" %#x %-26s len=%d -> d3_life_%x.txt" % (va, tag, len(s), va))
|
|
|
|
print("\n### EXHAUSTIVE disp32 SCAN OF .text")
|
|
allhits = {}
|
|
for off in sorted(REC):
|
|
s = scan_disp(off)
|
|
allhits[off] = s
|
|
print("\n --- offset %#05x (%s): %d confirmed instruction(s)"
|
|
% (off, REC[off], len(s)))
|
|
byfn = {}
|
|
for a, (fn, txt) in sorted(s.items()):
|
|
byfn.setdefault(fn, []).append((a, txt))
|
|
for fn in sorted(byfn):
|
|
print(" %s" % fn)
|
|
for a, txt in byfn[fn]:
|
|
print(" %#x %s" % (a, txt))
|
|
|
|
print("\n### VERDICT INPUT: functions touching ANY quantity slot")
|
|
q = set()
|
|
for off in (0x144, 0x148, 0x14c, 0x150, 0x154):
|
|
for a, (fn, txt) in allhits[off].items():
|
|
q.add(fn)
|
|
print(" ", sorted(q) if q else "NONE")
|
|
|
|
print("\n### STORE ROOT DESER 0x1801234e0 AND ITS CALLERS")
|
|
dump("store root deser", 0x1801234e0, OUT + "d3_store_root.txt", echo=True)
|
|
for a, n in callers(0x1801234e0):
|
|
print(" CALLER %#x %s" % (a, n))
|
|
dump("caller of store root", a, OUT + "d3_storeroot_caller_%x.txt" % a, echo=True)
|
|
|
|
print("\n### FutCreatePackServerResponse deser 0x180162880 (itemList / numberItems)")
|
|
dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=True)
|
|
for a, n in callers(0x180162880):
|
|
print(" CALLER %#x %s" % (a, n))
|
|
except Exception:
|
|
traceback.print_exc()
|
|
sys.stdout.flush()
|