21a81ad63c
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.
THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:
value = round_half_up(rating * price / 100)
level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3,
derived from rating, NOT a wire field)
cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
across every subtype 0..599 with zero disagreements
A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.
This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.
Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.
ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.
THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.
The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.
extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.
A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.
Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.
Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.
Live: 439 contract checks pass, market suite passes, both flags off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
105 lines
3.6 KiB
Python
105 lines
3.6 KiB
Python
"""D3 store-price q2.
|
|
|
|
HYPOTHESES
|
|
H5 Some function reads the 0x1a8 store-tile viewmodel's currency flags
|
|
vm+0xb5 (has mtx) / vm+0xb6 (has coins) / vm+0xb7 (has points) and the
|
|
formatted price string vm+0x168, and that function is the Scaleform push
|
|
that supplies the "or %1s" argument.
|
|
H6 DAT_1802de0d0 vtable slot +0x30 is the gate that disables the "points"
|
|
currency branch in FUN_18002c3c0.
|
|
|
|
METHOD / CONTROL. For H5 I scan EVERY function in .text and collect the set of
|
|
scalar operands, then report functions whose scalar set contains the distinctive
|
|
triple {0xb5,0xb6,0xb7}. The positive control is that FUN_18002c3c0 (known to
|
|
write all three) MUST appear in the result; if it does not, the scan is broken and
|
|
every negative is worthless.
|
|
"""
|
|
import traceback
|
|
|
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
|
|
|
|
try:
|
|
w = open(OUT + "/q2_raw.txt", "w")
|
|
|
|
def p(*a):
|
|
s = " ".join(str(x) for x in a)
|
|
print(s)
|
|
w.write(s + "\n")
|
|
|
|
# ---------- H5 whole-.text scalar-set scan ----------
|
|
want = {0xb5, 0xb6, 0xb7}
|
|
hits = []
|
|
nfun = 0
|
|
it = fm.getFunctions(True)
|
|
while it.hasNext():
|
|
f = it.next()
|
|
nfun += 1
|
|
sc = set()
|
|
ii = listing.getInstructions(f.getBody(), True)
|
|
while ii.hasNext():
|
|
ins = ii.next()
|
|
for i in range(ins.getNumOperands()):
|
|
for o in ins.getOpObjects(i):
|
|
try:
|
|
sc.add(int(o.getValue()) & 0xFFFFFFFFFFFFFFFF)
|
|
except Exception:
|
|
pass
|
|
if want <= sc:
|
|
hits.append((int(f.getEntryPoint().getOffset()), f.getName(),
|
|
0x168 in sc, 0xa4 in sc, 0xa8 in sc, 0xa0 in sc, 0x6c in sc))
|
|
p("=" * 70)
|
|
p("H5 scanned %d functions; %d contain the {0xb5,0xb6,0xb7} triple" % (nfun, len(hits)))
|
|
p("CONTROL: FUN_18002c3c0 present? %s" %
|
|
any(h[0] == 0x18002c3c0 for h in hits))
|
|
p("%-12s %-24s %-7s %-6s %-6s %-6s %-5s" %
|
|
("entry", "name", "has168", "hasa4", "hasa8", "hasa0", "has6c"))
|
|
for h in hits:
|
|
p("%-12x %-24s %-7s %-6s %-6s %-6s %-5s" % h)
|
|
|
|
# ---------- H6 the points gate ----------
|
|
p("")
|
|
p("=" * 70)
|
|
p("H6 writers of DAT_1802de0d0")
|
|
for a in (0x180013ed0, 0x180013f20):
|
|
src = dec(a)
|
|
p("")
|
|
p("--- FUN_%x len=%d FULL" % (a, len(src)))
|
|
p(src)
|
|
|
|
p("")
|
|
p("H6 the gate call site, FUN_18002c3c0 around 0x18002c77f")
|
|
ii = listing.getInstructions(addr(0x18002c750), True)
|
|
n = 0
|
|
while ii.hasNext() and n < 40:
|
|
ins = ii.next()
|
|
p(" %x %s" % (int(ins.getAddress().getOffset()), ins))
|
|
n += 1
|
|
|
|
# try to resolve the vtable of the gate object: find its ctor via the writer
|
|
p("")
|
|
p("H6 vtable candidates: qword at DAT_1802de0d0 in the static image = %#x" %
|
|
qword(0x1802de0d0))
|
|
|
|
# ---------- extra: literal 0x1801f04f0 / 0x1801f04f8 used by FUN_18002e680 ----------
|
|
p("")
|
|
p("=" * 70)
|
|
p("entitlement-category literals used by FUN_18002e680")
|
|
for a in (0x1801f04f0, 0x1801f04f8, 0x180228338, 0x1801eab80, 0x1802055ea):
|
|
p(" %#x = %r" % (a, rd_str(a, 40)))
|
|
|
|
# ---------- extra: CreatePack request serializer currency vocabulary ----------
|
|
p("")
|
|
p("=" * 70)
|
|
p("CreatePack request serializer FUN_180162530 (writes MTX / POINTS literals)")
|
|
src = dec(0x180162530)
|
|
p("len=%d FULL" % len(src))
|
|
p(src)
|
|
|
|
w.close()
|
|
except Exception:
|
|
traceback.print_exc()
|
|
try:
|
|
w.write(traceback.format_exc()); w.close()
|
|
except Exception:
|
|
pass
|