Files
OpenFUT/docs/evidence/market-sold-re-2026-08-17/q_sold_5.py
T
funman300 571c5f9261 docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.

RECOVERED FROM THE BINARY

1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
   rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
   (invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
   activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
   MUST therefore be a combination of existing atoms.

2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
     IS_GLOW = (tradeState==closed) ? bidState != none
                                    : bidState in {outbid, buyNow}
     INBOX   = bidState in {highest, buyNow}

3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
   partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
   coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
   its consumer was never found; it is now traced. DURATION also renders the
   localised FUT_AUCTION_EXPIRED when expires underflows.

4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
   yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
   as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
   values - the discrimination exists and lives entirely in unread ActionScript.
   This retires the question as a static target, and it contradicts the
   third-party lore that a seller's sold row is closed+buyNow (the corpus's own
   lifecycle table says closed+highest and assigns buyNow to the buyer).

5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
   field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
   class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
   request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
   makes sense if sold rows PERSIST in the seller's pile until cleared, which is
   incompatible with our Fix A invariant - so the sold path will require revisiting
   it under live validation.

6. The seller's SOLD counter is real, proven end to end with no inference: the hub
   tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
   tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
   caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
   count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
   Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
   bucket can never fill.

7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
   table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
   and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
   resolve a name by the pointer slot INSIDE the table, never by the first matching
   string in the binary, or you get confident nonsense.

8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
   0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
   candidate function was checked for the value-SKIP/atom-loop signature and none
   qualifies. Server-side or telemetry only; it does not carry sold state here.

TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.

Settlement NOT promoted. No production process, port or database was touched.
2026-08-18 01:32:02 +00:00

84 lines
2.8 KiB
Python

"""Q5 — fix the atom lookup, then find the CONSUMER of atoms 0x36..0x3c
(auctionLost*/auctionSold*/auctionWon*) and read the sold-related string
neighbourhoods.
"""
import struct
BASE = 0x1802D2760
TABLE_LO, TABLE_HI = 0x1802D2760, 0x1802D4800
def atom_id(name):
"""Every string with this exact spelling, then the pointer that lies INSIDE
the atom table. Taking the first string match is wrong: common words appear
in several unrelated tables."""
out = []
for sa in find_all(name.encode() + b"\x00", blocks=(".rdata", ".data")):
for pa in find_all(struct.pack("<Q", sa), blocks=(".rdata", ".data")):
if TABLE_LO <= pa < TABLE_HI and (pa - BASE) % 8 == 0:
out.append((sa, pa, (pa - BASE) // 8))
return out
print("=" * 78)
print("== atom ids, resolved against the table range only")
print("=" * 78)
for name in ("sold", "count", "offered", "selling", "maxAuctionsAllowed",
"credits", "auctionInfo", "itemData", "bidState", "tradeState",
"coinsProcessed", "offers", "duplicateItemIdList", "total"):
res = atom_id(name)
if not res:
print(f" {name:22s} ABSENT from the atom table")
for sa, pa, aid in res:
print(f" {name:22s} str=0x{sa:x} ptr=0x{pa:x} ATOM ID = 0x{aid:x} ({aid})")
print()
print("=" * 78)
print("== functions that compare against >=3 of the auction-outcome atoms 0x36..0x3c")
print("=" * 78)
WANT = set(range(0x36, 0x3D))
hits = {}
fi = fm.getFunctions(True)
n = 0
for f in fi:
n += 1
ent = int(f.getEntryPoint().getOffset())
body = f.getBody()
seen = set()
it = listing.getInstructions(body, True)
while it.hasNext():
ins = it.next()
m = ins.getMnemonicString()
if m not in ("CMP", "SUB", "MOV", "LEA"):
continue
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in WANT:
seen.add(v)
if len(seen) >= 3:
hits[ent] = (f.getName(), sorted(hex(v) for v in seen))
print(f" scanned {n} functions")
for ent, (nm, vals) in sorted(hits.items()):
print(f" 0x{ent:x} {nm:28s} sees {vals}")
print()
print("=" * 78)
print("== string neighbourhoods: SoldFromTradePile / SOLD / sold")
print("=" * 78)
for label, lo, hi in (("SoldFromTradePile", 0x1801EFA40, 0x1801EFB80),
("SOLD", 0x18020A040, 0x18020A140),
("sold@228bed", 0x180228B60, 0x180228C60)):
print(f"-- {label}")
a = lo
while a < hi:
s = rd_str(a, 70)
if s and len(s) > 1 and s.isprintable():
print(f" 0x{a:x} {s!r}")
a += len(s.encode()) + 1
else:
a += 1