diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 4943757..85c53a6 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -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": ""}. 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}