Files
OpenFUT/fifa17-recon/tools/vocab_dump.py
T
funman300 a2bd048ace feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
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.
2026-08-17 20:25:46 +00:00

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))