Files
OpenFUT/fifa17-recon/tools/ghidra_queries/q_group_1.py
T
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
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>
2026-08-06 07:43:51 -07:00

101 lines
4.1 KiB
Python

"""Why do all three packs collapse into one store tile, and does displayGroupAssetId fix it?
OBSERVED LIVE 2026-08-05. With FUT_STORE_DISPLAYGROUP=1 the store shows three group
tiles named Bronze Pack / Gold Pack / Premium Gold, but drilling into ANY of them
renders the same single Premium Gold pack. Two of three packs are unbuyable. We send
displayGroup {"value": name} per pack and never displayGroupAssetId (0xda).
The risk was predicted in utas_server.py before it happened: sending displayGroup may
select a grouped RENDER PATH rather than merely filling a caption, and if so the packs
need something to group BY. The obvious candidate is displayGroupAssetId, which we omit,
so every pack presumably shares a default of 0 and lands in one group.
That is a hypothesis. Do not ship a fix on it. Establish:
Q1 where does displayGroupAssetId (0xda) store in the pack element deser 0x18013af30,
and what is its constructor default? If the default is not a constant, the
"everything shares group 0" story is wrong.
Q2 who READS that offset. The reader is the grouping code, and whether it lives in
CardsDLL or in the packed FIFA17.exe decides whether this is answerable statically
at all.
Q3 what does displayGroup (0xd9) store, and is there a second slot (the group's own
identity) distinct from the +0x00 caption slot that `value` writes?
Q4 does anything build a LIST of packs per group, e.g. a loop comparing one pack's
group id against another's? That is the function that decides tile membership.
CONTROLS
* `assetId` 0x23 is a known INT field of the same deser storing to [rbp-0x3c]. It must
resolve the same way, or the offset extraction is unreliable.
* the "unknown" literal at 0x180223108 is documented as the constructor default of the
caption slot written by FUN_180133f60. Reproducing that anchors Q3.
COVERAGE RULE: print every decompile in full with its length. No absence claim may be
made from a truncated print, and no claim of "X is the only reader" without showing the
xref list it came from.
"""
import traceback
PACK_DESER = 0x18013AF30
CTOR = 0x180133F60
UNKNOWN_LIT = 0x180223108
A = {"displayGroup": 0xD9, "displayGroupAssetId": 0xDA,
"displayGroupUseDefaultImage": 0xDB, "assetId": 0x23, "value": 0x377,
"priority": 0x250}
def dump(va, title):
try:
f = func(va)
src = dec(va)
print("\n" + "=" * 78)
print("%#x %s body %d bytes / decompile %d chars (IN FULL)"
% (va, title, f.getBody().getNumAddresses() if f else -1, len(src)))
print("=" * 78)
print(src)
return src
except Exception:
print("!! failed %#x" % va)
traceback.print_exc()
return ""
try:
src = dump(PACK_DESER, "pack element deserializer")
print("\n--- atom comparisons present, BOTH == and != forms ---")
import re as _re
for name, a in sorted(A.items(), key=lambda kv: kv[1]):
hits = _re.findall(r"[!=]= 0x%x\b" % a, src)
print(" %-30s %#-6x %s" % (name, a, hits or "ABSENT"))
print(" (the != form matters: q_hub_1 missed clubPlayers by grepping only for ==)")
dump(CTOR, "constructor that writes the caption default")
print("\n" + "=" * 78)
print("WHO REFERENCES THE 'unknown' LITERAL %#x" % UNKNOWN_LIT)
print("=" * 78)
for frm, typ, fn, ent in xrefs_to(UNKNOWN_LIT):
print(" %#x %s (entry %#x)" % (frm, fn, ent))
print("\n" + "=" * 78)
print("CALLERS OF THE PACK DESER (the store root and anything else)")
print("=" * 78)
for a, n in callers(PACK_DESER):
print(" %#x %s" % (a, n))
print("\n" + "=" * 78)
print("CALLEES OF THE PACK DESER (sub-object parsers, incl. the displayGroup body)")
print("=" * 78)
for a, n in callees(PACK_DESER):
print(" %#x %s" % (a, n))
# The store root: whatever assembles the tile list must walk the parsed vector.
print("\n" + "=" * 78)
print("STORE ROOT 0x1801234e0 IN FULL, and its callers")
print("=" * 78)
dump(0x1801234E0, "FutStoreGetPackTypes root")
for a, n in callers(0x1801234E0):
print(" caller %#x %s" % (a, n))
except Exception:
traceback.print_exc()