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":