From f488793b345adbedbe702374cd2c456dba82a3a3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 4 Aug 2026 11:05:38 -0700 Subject: [PATCH] fifa17-recon: RETRACT the FutMoveCard "no skip handler" claim; stage the ack shape RETRACTION. This repo claimed, in REBUILD_RESEARCH S14c and in utas_server's item_route comment, that FutMoveCard 0x180128600 "HAS NO SKIP HANDLER (FUN_180135ff0 appears zero times, unique among FUT deserializers)" and "parses only itemData -> dreamSquads". Every part of that is false. Full decompile: FUN_180135ff0 call sites : 2 (offsets 5006, 6080) atoms parsed : 7 active dreamSquads id itemData pile reason success Cause: the decompile was written out as src[:4000] and then searched. The function is 6193 chars, so BOTH skip-handler call sites and four of the seven atoms lay past the cut. An absence was reported from a truncated listing -- the same failure mode as the Memory.getBytes bytearray scan that silently returned zero hits. Never conclude an absence without asserting the searched region covers the function. Cost: the false claim implied "any extra key desyncs this parser", which sent the investigation after client-side state for seven attempts, and the derived premise "the deciding factor is client-side state, not the wire" was wrong too. VERIFIED SHAPE. PUT ut/%s/item is not an ack endpoint; it returns per-item VERDICT records: id(0x15c) INT 0x1801c79d0 -> record+0x00 pile(0x226) STRING 0x1801c7aa0 -> enum 0x180142650 (club=7 purchased=6 trade=5) success(0x2fa) BOOL 0x1801c7620 -> record+0x0c reason(0x279) STRING -> "Destination Full" = 0xf dreamSquads(0xe9) INT array; else -> FUN_180135ff0 (skip) The completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or record+0x0c != 1, and success is initialised to '\0' per element. So every body ever returned reported the move as FAILED, {} included. Quick sell survives an identical {} because its callbacks read only the transport code and ignore the body -- that is the whole asymmetry, and it was on the wire after all. STAGED, NOT DEFAULTED. FUT_MOVE_BODY=ack emits the correct shape; the default stays `empty` because sufficiency is untested. One launch settles it. The ack is answered BEFORE the `if moved:` gate: under FUT_PACK_AUTOCLUB=1 the cards are already in the club when the reveal asks to move them, so move_items() returns nothing and a moved-derived body would be zero-record -- failing in exactly the configuration ack exists to fix. Caught in review before it ran. success is asserted only for ids that were moved now or are already in the club; anything else gets an honest success:false rather than an invented verdict. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW --- fifa17-recon/docs/REBUILD_RESEARCH.md | 114 +++++++++++++++++++++- fifa17-recon/tools/utas_server.py | 133 ++++++++++++++++++++------ 2 files changed, 217 insertions(+), 30 deletions(-) diff --git a/fifa17-recon/docs/REBUILD_RESEARCH.md b/fifa17-recon/docs/REBUILD_RESEARCH.md index fe3dbd4..5cb7029 100644 --- a/fifa17-recon/docs/REBUILD_RESEARCH.md +++ b/fifa17-recon/docs/REBUILD_RESEARCH.md @@ -507,7 +507,7 @@ POSTs `ut/delete/auth`. **Eliminated, each by live test:** | hypothesis | result | |---|---| | missing `chemistry`(0x81) | failed without it too | -| unknown keys + no skip handler in `0x180128600` | real finding (it genuinely has none), but `dreamSquads`-only still failed | +| unknown keys + no skip handler in `0x180128600` | **RETRACTED, FALSE** -- it has TWO skip handlers, see S16 | | POW saturating the HTTP layer (109k reqs) | same failure with POW off | | missing `FUT_RS4_URL_` keys | netwatch logged **ZERO** non-loopback dials; 146 were genuinely missing and are now served, but they were not the cause | | the response body at all | **`{}` fails too** | @@ -531,3 +531,115 @@ Four of six failed hypotheses were things testable before proposing them. The tw findings that actually moved this forward were the USER's: the screenshot with the error text, and the Quick Sell result. **Ask for the on-screen text and try the neighbouring action FIRST** — both were cheaper than any decompile done here. + +## 15. SURPRISE FOUR: `ut/%s/squad/mode/draft/state` (2026-08-04, observation session) + +Recorded immediately per the session checklist. **A live client request to a URL that is +not in the 45-template table.** + +``` +[10:35:28] GET /ut/game/fifa17/squad/mode/draft/state + User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows) <- the real client +[10:35:28] -> 200 {"id":0,"personaId":...,"squadName":"OpenFUT","formation":"f442",...} +``` + +The template table holds `ut/%s/squad/mode` (SQUADMODE) and `ut/%s/draft/mode` (DRAFT) +as separate entries. The real endpoint is a **suffixed composition of neither**: +`squad/mode` + `/draft/state`. This is the fourth time a suffix endpoint has been +invisible to the static table (`squad/list`, `user/club`, `club/stats/*`, now this). + +### What we answered, and why it is wrong + +Our generic `G + r"/squad"` route matched first and returned the **full active-squad +object** (23 slots, nested itemData, the 33-int `custom` string) to an endpoint asking for +DRAFT STATE. Identical bug class to `club/stats/*`, which was found the same way and +returned the entire club inventory to a stats endpoint. + +Per §9, `FutGetDraftCurrentState` (deser `0x180147070`) parses `roundsInfo`(0x293) plus a +state enum: `CAPTAIN_DRAFT`, `FORMATION_DRAFT`, `PLAYER_DRAFT`, `MANAGER_DRAFT`, +`COMPLETED_DRAFT`, `READY_FOR_MATCH`, `READY_FOR_REWARDS`, `PICK_DIFFICULTY`, `INVALID`. +Feeding that parser a squad object is a textbook type-mismatch freeze candidate. + +### Status + +**PROVEN:** the client requested this URL; we answered with a squad object; the game +became unresponsive immediately afterwards. `FIFA17.exe` was still running with **no +crash dump written**, so this was a hang, not an access violation. + +**NOT PROVEN:** that the wrong body caused the hang. It is the obvious candidate and the +timing is exact, but no measurement has isolated it. Do not record this as the cause until +something confirms it. + +**NOT FIXED.** Observed during an assessment-only session; recorded and left alone +deliberately. + +### Also observed this session + +`Single Player -> Seasons` raises *"There was a problem communicating with the FIFA +Ultimate Team servers"* **with ZERO requests to any layer** (UTAS, Blaze and POW logs all +show nothing but unrelated pings and one `CensusData::subscribeToCensusDataUpdates`). +Nothing failed because nothing was asked. This is the third time FIFA's error text has +described a network problem that did not happen; the message is a generic client-side +refusal. POW/EASFC was OFF this session and is the leading untested hypothesis for the +gate, disconfirmable in one launch with `FUT_POW=1`. + +## 16. RETRACTION: FutMoveCard's "no skip handler" claim was FALSE + +**Retracted 2026-08-04.** Sections 14c and the `utas_server.item_route` comment +claimed: + +> FutMoveCard `0x180128600` HAS NO SKIP HANDLER. Every other FUT deserializer routes +> an unrecognised key to `FUN_180135ff0`; this one calls it ZERO times. It parses +> exactly two atoms, `itemData` and `dreamSquads`. + +**Every part of that is wrong.** Verified on a full decompile: + +``` +FUN_180135ff0 call sites : 2 (offsets 5006 and 6080) +atoms parsed : 7 active, dreamSquads, id, itemData, pile, reason, success +``` + +**Cause of the error:** the decompile was written out as `src[:4000]` and then +searched. The function is **6193 characters**. Both skip-handler call sites and four +of the seven atoms lie beyond the cut. An absence was reported from a truncated +listing. + +**Lesson, and it has now cost twice:** this is the same failure mode as the +`Memory.getBytes` bytearray scan that silently returned zero hits. *Never conclude an +absence from a truncated or unverified-length extraction.* Print the length, or +assert the region searched covers the whole function. + +**Cost:** the false claim implied "any extra key desyncs this parser", which pointed +the whole investigation at client-side state for seven attempts. The runbook premise +"the deciding factor is client-side state, not the wire" derived from it and was +also wrong. + +### The verified schema + +`PUT ut/%s/item` is **not** an ack endpoint. It returns per-item VERDICT records: + +```json +{"itemData":[{"id": 100000123, "pile": "club", "success": true}]} +``` + +| atom | key | getter | destination | +|---|---|---|---| +| 0x15c | `id` | INT `0x1801c79d0` | record+0x00 | +| 0x226 | `pile` | STRING `0x1801c7aa0` -> enum `0x180142650` (club=7, purchased=6, trade=5) | record+0x08 | +| 0x2fa | `success` | BOOL `0x1801c7620` | record+0x0c | +| 0x279 | `reason` | STRING; `"Destination Full"` maps to 0xf | record+0x10 | +| 0xe9 | `dreamSquads` | INT array | | +| else | | `FUN_180135ff0` (skip) | | + +The completion handler raises `EVENT_CARDS_MOVE_CARD_FAILURE` when the record vector +is **empty** or when `record+0x0c != 1`, and `success` is initialised to `'\0'` at +the top of every element loop. So **every body ever returned reported failure**: +full cards, `+chemistry`, `dreamSquads`-only, and `{}` (zero records, fails the first +guard outright). + +Quick sell survives an identical `{}` because its callbacks check only the transport +error code and never read the body. That is the entire asymmetry, and it was on the +wire the whole time. + +**Status: necessary condition identified, sufficiency UNTESTED.** Staged behind +`FUT_MOVE_BODY=ack`, default still `empty`. One launch settles it. diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 6726743..88da4b1 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -640,20 +640,29 @@ PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "1") == "1" # FUT_MOVE_BODY -- what PUT ut/%s/item answers. Made switchable so the shape can be # bisected in one relaunch each instead of a code edit per attempt. -# empty (default) -> {} full -> echo the moved card objects -# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} +# ack -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]} +# the CORRECT shape per the deserializer. UNTESTED LIVE. +# empty (default) -> {} KNOWN-BROKEN, see below +# full -> echo the moved card objects (known-broken) +# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} (known-broken) # -# WHY `empty` IS NOW THE DEFAULT (live 2026-08-04, after six failed attempts): -# Quick Sell All hits the SIBLING endpoint POST ut/delete/%s/item, which was -# UNMAPPED and therefore answered with a bare {} -- and it WORKED: no error, session -# intact. Meanwhile every crafted body on PUT ut/%s/item was fatal. So a bare {} is -# demonstrably acceptable to this screen, and the inherited claim that "returning [] -# makes FIFA think the move failed -> kicks to main menu" is unproven and probably -# another misdiagnosis in the same lineage as the chemistry one. -# Also disproven this round: the netwatch recorded ZERO non-loopback connections, so -# "error connecting to FIFA 17 Ultimate Team" is FIFA's generic FUT-session failure -# text, not a real network failure -- and the 146 missing FUT_RS4_URL_ keys -# (now served, and genuinely missing) were not the cause either. +# `empty` REMAINS THE DEFAULT ONLY BECAUSE `ack` HAS NOT BEEN LIVE-TESTED. +# It is not a good default: 0x180128600 builds per-item VERDICT records and the +# completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is +# empty, so {} fails unconditionally. Every rung above except `ack` is now known to +# report the move as failed. Flip the default to `ack` the moment one launch +# confirms it. +# +# The earlier note here argued the opposite -- that a bare {} was "demonstrably +# acceptable" because Quick Sell survives one. That inference was wrong: quick +# sell's callbacks check only the transport code and never read the body, so its +# tolerance says nothing about this endpoint. Retained as a caution: a sibling +# endpoint accepting a body is not evidence that this one will. +# +# Still true and still useful from that round: the netwatch recorded ZERO +# non-loopback connections during a failure, so "error connecting to FIFA 17 +# Ultimate Team" is FIFA's generic FUT-session failure text and must not be read as +# a network event. MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "empty") # FUT_STORE_GROUPS: send displayGroup(0xd9) in the pack catalogue. DEFAULT OFF -- @@ -691,6 +700,44 @@ def quick_sell_route(h): return 200, {} +def _move_ack(req, moved): + """FutMoveCard per-item verdict records (deser 0x180128600). + + Built from the REQUEST, not from `moved`: under FUT_PACK_AUTOCLUB the pack's + cards are already in the club before the client asks to move them, so + STORE.move_items() legitimately returns nothing and a `moved`-derived body + would be empty -- which is the one shape guaranteed to raise + EVENT_CARDS_MOVE_CARD_FAILURE. + + success is asserted ONLY for ids we can actually account for: either this call + moved them, or they are already sitting in the club. Anything else gets + success:false, which is the truthful verdict -- claiming success for an id the + store has never seen would desync the client's model against ours, and that is + a worse failure than an honest per-item false. + + `reason`(0x279) is omitted deliberately. The only string known to map to a + specific code is "Destination Full" (0xf) and that is not what happened here; + the key is skip-safe, so sending nothing is better than sending a wrong reason. + """ + club_ids = {it.get("id") for it in STORE.items()} + moved_ids = {it.get("id") for it in moved} + out = [] + for r in req: + if not isinstance(r, dict): + continue + iid = r.get("id") + ok = iid in moved_ids or iid in club_ids + out.append({ + "id": iid, # INT getter -- send a number + "pile": r.get("pile", "club"), # STRING -> enum 0x180142650 + "success": bool(ok), # BOOL, record+0x0c, must be 1 + }) + if out and not all(x["success"] for x in out): + log(" ITEM: %d/%d verdicts are success=false (ids not accounted for)" + % (sum(1 for x in out if not x["success"]), len(out))) + return out + + def item_route(h): # PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal # screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0, @@ -706,27 +753,55 @@ def item_route(h): req = body.get("itemData") if isinstance(req, list): moved = STORE.move_items(req) + # ack is answered BEFORE the `if moved:` gate on purpose. Under + # FUT_PACK_AUTOCLUB=1 (the default) a pack's cards are already in the + # club by the time the reveal screen asks to move them, so move_items() + # legitimately returns nothing -- and gating the ack on `moved` would + # emit a ZERO-RECORD itemData, which is precisely the shape that raises + # EVENT_CARDS_MOVE_CARD_FAILURE. The verdict has to come from the + # REQUEST. (Caught in review before it ever ran; the earlier placement + # would have made ack mode fail in exactly the configuration it is meant + # to fix, and the bug would have looked like "ack does not work".) + if MOVE_BODY == "ack": + if moved: + log(" ITEM: moved %d item(s) to pile(s)" % len(moved)) + return 200, {"itemData": _move_ack(req, moved)} if moved: log(" ITEM: moved %d item(s) to pile(s)" % len(moved)) - # ROOT CAUSE of the "can't send cards to club -> kicked to the main - # menu" logout, found 2026-08-04 by decompiling the deserializer: + # RETRACTED 2026-08-04. This block previously claimed: + # "FutMoveCard 0x180128600 HAS NO SKIP HANDLER ... calls it ZERO + # times ... parses exactly two atoms, itemData and dreamSquads" + # That was WRONG and it sent the investigation after client-side + # state for seven attempts. Cause of the error: the decompile was + # truncated to 4000 chars before being searched, and the function is + # 6193 chars -- BOTH FUN_180135ff0 call sites (offsets 5006 and 6080) + # and four of the seven atoms lie past the cut. Never conclude an + # absence from a truncated decompile. # - # FutMoveCard 0x180128600 HAS NO SKIP HANDLER. Every other FUT - # deserializer routes an unrecognised key to FUN_180135ff0 (the - # value-SKIP handler); this one calls it ZERO times. It parses - # exactly two atoms -- itemData(0x16b) as an array, and inside each - # element dreamSquads(0xe9) as an int array -- and an unknown key - # leaves its VALUE unconsumed, so the next loop iteration reads that - # value as a key and the reader desyncs. + # VERIFIED SHAPE (full decompile, getter read from the first call + # after each atom compare): + # itemData(0x16b) array of PER-ITEM VERDICT RECORDS, 0x18 bytes each + # id(0x15c) INT 0x1801c79d0 -> record+0x00 + # pile(0x226) STRING 0x1801c7aa0 -> enum via 0x180142650 + # club=7 purchased=6 trade=5 + # success(0x2fa) BOOL 0x1801c7620 -> record+0x0c + # reason(0x279) STRING 0x1801c7aa0 -> "Destination Full" = 0xf + # dreamSquads(0xe9) INT array + # anything else -> FUN_180135ff0 (the skip handler, twice) # - # We were echoing the FULL card object: ~20 keys each, including a - # nested attributeList. Every one of them is unknown to this parser. - # That also explains the two earlier misdiagnoses -- ANY extra key - # breaks it, so `chemistry` looked causal when it was added, and - # removing it changed nothing because 20 other keys remained. + # This is NOT an ack endpoint. The completion handler raises + # EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or + # when record+0x0c != 1, and `success` is initialised to '\0' at the + # top of every element loop. So every body we have ever returned -- + # full cards, +chemistry, dreamsquads-only, and `empty` ({}, which + # produces zero records and fails the first guard outright) -- has + # reported the move as FAILED. Quick sell survives an identical {} + # because its callbacks check only the transport code and ignore the + # body: that is the whole asymmetry, and it was on the wire after all. # - # Shape selected by FUT_MOVE_BODY (see above) so it can be bisected - # live without a code edit. + # `ack` is the corrected shape. NOT the default yet: it is untested + # live, and the necessary condition being identified is not proof of + # sufficiency. One launch with FUT_MOVE_BODY=ack settles it. if MOVE_BODY == "full": return 200, {"itemData": moved} if MOVE_BODY == "dreamsquads":