fifa17-recon: delete two loaded guns, fix the season root, add the tile-name fix

ZERO-LAUNCH FIXES from the multi-agent pass over 0x18013af30 and 0x1801683f0.

FUT_STORE_FIELDS IS DELETED, AND IT WAS A FREEZE, NOT A REGRESSION. It sent
"actionType": 0 and "firstPartyStoreId": 0 as JSON INTEGERS. Both atoms (0x8, 0x127)
are read with the STRING getter 0x1801c7aa0. That is the exact type-desync class this
project exists to avoid. So "the corrections stopped packs opening" was never bad luck
or an unrelated field: two of them were the documented freeze mechanism, shipped by a
change whose own comment claimed it was correct about what the parser reads. Knowing
WHICH atoms a parser reads tells you nothing about which TYPES it demands. Read the
getter, every time. (Two other fields in that block were no-ops anyway: useDefaultImage
0x36a stores inverted, and visible 0x37d never reads its value at all.)

FUT_STORE_GROUPS IS DELETED and its freeze is now traced end to end. It sent
displayGroup as an ARRAY of pack-shaped objects on the belief that the key was parsed
recursively by the same element parser. It is not recursive at all: case 0xd9 never
re-enters 0x18013af30. It is a FLAT OBJECT with exactly two members. An array desyncs
the reader, the parser runs off the end of the document, and the tokenizer returns the
same token forever with nothing consumed, spinning inside FUN_1801c7f10 whose body
contains the observed PC 0x1801c7f1a.

Both were being kept as togglable "maybe nearly right" experiments. Each is a loaded
gun; neither survives contact with the decompile. Deleted rather than left switched off.

NEW FUT_STORE_DISPLAYGROUP (default 0): send the one key that actually names a tile,
displayGroup = {"value": "<pack name>"}. `value` (0x377, STRING) writes record offset
+0x00, the same slot whose constructor default is the literal "unknown" (the only such
literal in the DLL, 0x180223108). The tiles say "unknown" because nobody ever sent the
field. Distinct values per pack so grouping stays 1:1. priority/displayGroupAssetId/
displayGroupUseDefaultImage all omitted as second variables.
Default OFF for a reason the token-balance proof does not cover: this may be the first
field we have sent that selects a RENDER PATH rather than a value, and that code is
packed.

SEASON ROOT SHAPE CORRECTED. season_list() and its docstring were both wrong in the
same way: the deserializer is 0x1801683f0 (object root, one key seasons=0x2ad, array
inside), not 0x180167740, which is the per-ELEMENT parser. Someone read the element
parser and served its key set at the document root, so a bare array populated nothing.
Three of the keys served (eligibilityKey/Slot/Value) are inner members of elgReq and
inert even at element level. Also recorded: `type` must precede `divisionId` because
the divisionId branch reads the parsed type at elem+0x1b4.
Still behind FUT_MODES and still pointless to serve: across 486 real client requests
the game has NEVER asked for /season. Every /season line in our log is our own curl.

NEW FUT_CLUB_PAGE (default 0): an experiment, not a fix. The MY CLUB counter's
renderer is not in cardsdll (no two-number formatter of any spelling exists) and
FutStickerBookSearch has no count atom, so there is no field we can send that IS the
number. What is still testable is whether the counter is a Flash-side count over the
returned list, which a pure length change discriminates. A NULL RESULT IS THE VALUABLE
ONE: if the counter does not move, there is no server-side lever for this symptom and
the right outcome is to prove that and stop.

392 + 61 checks green, defaults byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 14:02:09 -07:00
parent 397d46f174
commit 9cf21202dc
+140 -46
View File
@@ -665,13 +665,21 @@ PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"
# FUT-session failure text and must never be read as a network event.
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")
# FUT_STORE_GROUPS: send displayGroup(0xd9) in the pack catalogue. DEFAULT OFF --
# it FROZE the store screen live on 2026-08-04 (recursive nested array through the
# same element parser 0x18013af30 -> busy-loop at 0x1801c7f1a).
STORE_GROUPS = os.environ.get("FUT_STORE_GROUPS") == "1"
# FUT_STORE_FIELDS: the re-extracted pack fields. DEFAULT OFF -- enabling them
# stopped packs opening live on 2026-08-04.
STORE_FIELDS = os.environ.get("FUT_STORE_FIELDS") == "1"
# FUT_STORE_GROUPS and FUT_STORE_FIELDS ARE GONE. Both were kept around as "maybe
# they were nearly right" experiments; the 2026-08-04 read of 0x18013af30 (19,279
# chars, read in full by two independent agents) showed both were wrong in ways that
# make them unsafe to keep even switched off:
# STORE_GROUPS sent displayGroup as an ARRAY. It is a flat OBJECT of two members.
# STORE_FIELDS sent actionType(0x8) and firstPartyStoreId(0x127) as INTEGERS. Both
# take the STRING getter 0x1801c7aa0.
# Each is the type-desync freeze, so leaving them in the file as togglable options was
# leaving two loaded guns on the table. The fix that replaces them is below.
#
# FUT_STORE_DISPLAYGROUP: send the ONE key that actually names a tile,
# displayGroup(0xd9) as {"value": "<pack name>"}. DEFAULT OFF: the parser-side proof
# is airtight but does not cover whether the presence of this key switches FIFA17.exe
# to a different (packed, unreadable) tile render path. See _pack_body.
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP") == "1"
def quick_sell_route(h):
@@ -938,13 +946,48 @@ ROUTES = [
]
# FUT_CLUB_PAGE -- an EXPERIMENT, not a fix, aimed at the MY CLUB hub counter.
#
# The counter's renderer is NOT in cardsdll.dll. There is no two-number formatter of
# any spelling in the binary, no tab-strip layout and no display label; the tab entry
# itself carries NAME/UUID/CARD_ID/CARD_TYPE/TAB_INDEX/LEVEL/IS_CLUB and panel-wide
# numTabs/numCards, and nothing else. The composition happens in FIFA17.exe or the
# Scaleform assets, neither of which is readable. Separately, FutStickerBookSearch
# (the GET /club?... parser) has no count or total atom at all: it reads itemData and
# nothing else. So there is no field we can send that IS the number.
#
# What remains testable: if the counter is a Flash-side count over whatever the club
# list returned, then changing the LENGTH of that array moves it. That is a pure
# volume change with no new key and no type change, which makes it the cheapest
# possible discriminator.
#
# The client asks with count=11 (and count=34 when filtering by position). We already
# ignore the query string entirely and return everything, so this flag deliberately
# does the opposite of paging: it is here to make the returned length UNAMBIGUOUS in
# the log so the on-screen number can be compared against it.
#
# A NULL RESULT IS THE VALUABLE ONE. If the counter does not move, the "counter is a
# count of what we sent" hypothesis is dead, and with the binary evidence above that
# leaves no server-side lever at all for this symptom -- which would mean the correct
# outcome is to prove it and stop, not to keep generating candidate bodies.
#
# Risk: paging behaviour on the client side is unknown, so an unexpected length could
# make the club screen misbehave, and then the counter reading is worthless rather
# than informative. Default OFF.
CLUB_PAGE = os.environ.get("FUT_CLUB_PAGE") == "1"
def club_route(h):
# PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other
# method keeps the exact body this route served before, so the rename support
# cannot change the behaviour of anything that already worked.
if h.command == "PUT":
return club_rename_route(h)
return 200, {"itemData": STORE.items()}
items = STORE.items()
if CLUB_PAGE:
log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare this "
"number against the MY CLUB counter on screen]" % len(items))
return 200, {"itemData": items}
def user_route(h):
@@ -1006,11 +1049,37 @@ _MODES = os.environ.get("FUT_MODES") == "1"
def season_list():
"""GET ut/%s/season -- FutSeasonList, deser 0x180167740 (HIGH).
ARRAY root of season descriptors. prizeSet(595)/elgReq(247) are nested and
FREEZE-RISK, so both are omitted (SKIP-safe)."""
return [{"id": 1, "divisionId": 10, "eligibilityKey": 0, "eligibilitySlot": 0,
"eligibilityValue": 0, "elgOperation": ""}]
"""GET ut/%s/season -- FutSeasonListServerResponse, deser 0x1801683f0.
CORRECTED 2026-08-04. This function and its docstring were BOTH wrong, in the
same way, and the error is instructive:
* The deserializer is 0x1801683f0, not 0x180167740. 0x180167740 is the
per-ELEMENT parser.
* The root is therefore an OBJECT, not an array. 0x1801683f0 runs a key loop
and matches exactly ONE atom, seasons(0x2ad); only inside that does an array
open. Someone read the element parser, saw its key set, and served those keys
at the document root. A bare array populates nothing at all.
Three of the keys previously served here (eligibilityKey, eligibilitySlot,
eligibilityValue) are inner members of elgReq and are completely inert at element
level, so even the element shape was wrong.
Ordering matters inside an element: `type` MUST precede `divisionId`, because the
divisionId branch reads the already-parsed type field at elem+0x1b4.
Still omitted, and now for a stated reason: prizeSet(0x253), elgReq(0xf7) and
matches(0x1b8) are all `while (tok != 0xd)` ARRAY loops. A scalar in any of them
is the 0x1801c7f1a spin.
NOT SERVED BY DEFAULT and there is no point serving it yet: across 486 real
client requests (User-Agent ProtoHttp, roughly 30 boots) the game has NEVER asked
for /season. Every /season line in our log is our own curl or urllib. A body here
changes nothing observable, so this is correctness-in-waiting, not a fix.
Semantic hazard for whenever it does ship: omitting untilEndSeconds makes the
season's end timestamp equal to now.
"""
return {"seasons": [{"type": "OFFLINE", "id": 1, "divisionId": 10}]}
def season_user():
@@ -1326,13 +1395,24 @@ def _pack_body(p, idx):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
corrections" broke pack BUYING live. It renders pack tiles as "unknown" (see
FUT_STORE_FIELDS below) but packs are purchasable, which matters more.
corrections" broke pack BUYING live, and now confirmed to be the right call for a
reason nobody had at the time.
The corrections were derived from re-reading the deserializer and are probably
right about what is PARSED -- but "parsed" is not "safe to change", and I
swapped a working body for an unverified one with no way to test it offline.
They now live behind FUT_STORE_FIELDS=1.
THE FUT_STORE_FIELDS BLOCK WAS A FREEZE, NOT A REGRESSION. It has been DELETED.
It sent `"actionType": 0` and `"firstPartyStoreId": 0` as JSON integers, and both
atoms (0x8 and 0x127) are read with the STRING getter 0x1801c7aa0. That is the
exact type-desync class this whole project exists to avoid: a scalar of the wrong
token type where the parser calls a typed getter, which is what spins the reader
at 0x1801c7f1a. So "the corrections stopped packs opening" was not bad luck or an
unrelated field; two of them were the documented freeze mechanism, shipped by a
change whose own comment said it was correct about what the parser reads.
"Parsed" is not "safe to change", and knowing WHICH atoms a parser reads tells you
nothing about which TYPES it demands. Read the getter, every time.
Two of the deleted block's other fields were no-ops anyway: `useDefaultImage`
(0x36a) stores inverted, so True set it false, and `visible` (0x37d) never reads
its value at all (`local_130 = 1` unconditionally).
"""
gold = p["gold"]
mtx = max(1, p["price"] // 100)
@@ -1361,40 +1441,54 @@ def _pack_body(p, idx):
"unopened": False,
},
}
if STORE_FIELDS:
# Re-extracted from 0x18013af30 (correct about what the parser READS, but
# live-untested and NOT proven safe -- the last attempt stopped packs from
# opening at all). extPrice inner objects take externalPriceId(0x11a)+active,
# not amount/currency.
body.update({
"dealType": "", "actionType": 0, "bonus": 0, "points": 0,
"value": p["price"], "priority": idx, "firstPartyStoreId": 0,
"useDefaultImage": True, "start": 0, "end": 0,
})
body["currencies"][0]["active"] = True
body["extPrice"] = {"finalPrice": {"externalPriceId": p["id"], "active": True},
"originalPrice": {"externalPriceId": p["id"], "active": True}}
if STORE_DISPLAYGROUP:
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
# recursion the old notes assumed does not exist and there was never anything
# to terminate. Exactly two members live inside it: value(0x377, STRING) and
# priority(0x250, INT).
#
# `value` writes record offset +0x00, which is the SAME slot whose constructor
# default is the literal "unknown" (the only such literal in cardsdll.dll,
# 0x180223108, written by FUN_180133f60). That is why the tiles read "unknown":
# not a missing translation, just a field nobody ever sent.
#
# ONE KEY, distinct per pack so grouping stays 1:1 and the current one-tile-
# per-pack layout is preserved. priority(0x250), displayGroupAssetId(0xda) and
# displayGroupUseDefaultImage(0xdb) are all OMITTED: each is a second variable,
# and 0xdb stores inverted so the old True was always a no-op.
#
# Parser-side this is provably balanced: the store root 0x1801234e0 consumes
# each element's START_OBJECT, so case 0xd9 dispatches with the value token
# current, and {"value": "..."} consumes FIELD_NAME, string scalar,
# END_OBJECT. Nothing left over, nothing over-consumed.
#
# WHAT THE PROOF DOES NOT COVER, and it is the real risk: this may be the first
# field we have ever sent that selects a RENDER PATH rather than a value.
# Going from no-displayGroup to a-displayGroup could switch FIFA17.exe from an
# ungrouped-tile layout to a group-tile layout, and that code is packed. If the
# tiles collapse into one, or render blank because `value` is a loc key rather
# than a caption, the store goes from ugly-but-working to unusable. Default OFF
# for exactly that reason, and the live test buys a pack to prove the buy path
# still works.
body["displayGroup"] = {"value": p["name"]}
return body
def store_catalog(h):
"""GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0).
The "unknown" tiles are STILL unfixed. displayGroup(0xd9) is the likely answer --
the store renders display GROUPS and that key is parsed recursively by the same
element parser -- but sending it FROZE the store screen (busy-loop 0x1801c7f1a),
so it is behind FUT_STORE_GROUPS=1, default OFF.
The "unknown" tiles are addressed by FUT_STORE_DISPLAYGROUP (see _pack_body).
THE FUT_STORE_GROUPS BLOCK IS DELETED, and its failure is now fully explained. It
sent displayGroup as an ARRAY of pack-shaped objects, on the belief that the key
was parsed recursively by this same element parser. It is not: it is a flat object
with two members. An array there desyncs the reader, the parser then runs off the
end of the document, and the tokenizer returns the same token forever with nothing
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
"""
packs = []
for idx, p in enumerate(PACK_CATALOG, start=1):
entry = _pack_body(p, idx)
if STORE_GROUPS:
group = _pack_body(p, idx)
group.pop("displayGroup", None)
entry["displayGroup"] = [group] # RECURSIVE -- froze the store
entry["displayGroupAssetId"] = p["id"]
entry["displayGroupUseDefaultImage"] = True
packs.append(entry)
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)]
return 200, {"purchase": packs, "timestamp": 1596326400}