a2bd048ace
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:
'active' = 1 'inactive' = 2 'expired' = 3 'closed' = 4
The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.
PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.
Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.
Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.
340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
79 lines
2.4 KiB
Python
79 lines
2.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Dump CardsDLL's NULL-terminated {const char*, int} vocabulary tables from the
|
|
ON-DISK PE. READ-ONLY, static.
|
|
|
|
The transfer-market analysis records tradeState as decoding through a table walk at
|
|
0x180229e40 and lists sibling vocabularies (type/zone/lev/pos) as tables of the same
|
|
shape. This prints the exact token spellings and their integer codes, so the accepted
|
|
strings come from the client rather than from inference.
|
|
"""
|
|
import struct
|
|
|
|
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
|
|
TABLES = [
|
|
(0x180229E40, "tradeState (table walk)"),
|
|
(0x180229C30, "type"),
|
|
(0x1802296E0, "zone"),
|
|
(0x180229A60, "lev"),
|
|
(0x1802295C0, "pos"),
|
|
(0x180229AB0, "cat"),
|
|
(0x180229880, "form"),
|
|
]
|
|
MAX_ROWS = 64
|
|
|
|
pe = open(DLL, "rb").read()
|
|
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
|
|
coff = e_lfanew + 4
|
|
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
|
|
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
|
|
opt = coff + 20
|
|
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
|
|
sec_off = opt + opt_size
|
|
sections = []
|
|
for i in range(num_sections):
|
|
b = sec_off + i * 40
|
|
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
|
|
sections.append((vaddr, vsize, rawptr, rawsize))
|
|
|
|
|
|
def va2off(va):
|
|
rva = va - image_base
|
|
for vaddr, vsize, rawptr, rawsize in sections:
|
|
if vaddr <= rva < vaddr + max(vsize, rawsize):
|
|
off = rva - vaddr + rawptr
|
|
if 0 <= off < len(pe):
|
|
return off
|
|
return None
|
|
|
|
|
|
def cstr(va, limit=64):
|
|
off = va2off(va)
|
|
if off is None:
|
|
return None
|
|
end = pe.find(b"\0", off, off + limit)
|
|
if end < 0:
|
|
return None
|
|
try:
|
|
s = pe[off:end].decode("ascii")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
return s if s.isprintable() else None
|
|
|
|
|
|
for table_va, name in TABLES:
|
|
base = va2off(table_va)
|
|
print("\n=== %s VA %#x -> off %s ===" % (name, table_va, hex(base) if base else None))
|
|
if base is None:
|
|
print(" (VA did not resolve)")
|
|
continue
|
|
for i in range(MAX_ROWS):
|
|
ptr, code = struct.unpack_from("<Qi", pe, base + i * 16)
|
|
if ptr == 0:
|
|
print(" -- NULL terminator after %d rows --" % i)
|
|
break
|
|
s = cstr(ptr)
|
|
if s is None:
|
|
print(" row %d: ptr %#x does not resolve to a string; stopping" % (i, ptr))
|
|
break
|
|
print(" %-28s = %d" % (repr(s), code))
|