From 1e2b073e04af325a461eb7450a759aa2b075ed4d Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 4 Aug 2026 14:17:45 -0700 Subject: [PATCH] fifa17-recon: route the draft entry purchase, and resolve the envelope ambiguity LIVE 2026-08-04: the draft-state array fix WORKED. The screen rendered instead of hanging and the client advanced to the entry-fee screen, then crashed on the next call, which we had never implemented: GET /squad/mode/draft/state?mode=ONLINE -> our array body screen RENDERED GET /user/credits -> 7200 GET /store/purchasegroup/all -> the entry-fee screen POST /purchase/mode/0/draft {"currency":"COINS","usePreOrder":0} -> {} UNMAPPED, then the crash Advancing the failure to the next unimplemented call is what a correct fix looks like. ENVELOPE AMBIGUITY RESOLVED, and ENDPOINT_MAP's note about it is wrong. Two structures reference RS4:FutPurchaseDraftModeServerResponse: 0x18014c260 vtable 0x180224ef8, factory 0x18014c090. 3188 chars, OBJECT root (prologue tests != 10 = END_OBJECT), 1 skip handler, exactly the seven scalar ints. THIS IS THE RESPONSE PARSER. 0x180150310 vtable 0x1802262f0, factory 0x180150260. 1836 chars, ARRAY root (loops until 0xd), ZERO skip handlers -- and NOT a response root at all. It parses ENTRANCE CRITERIA: each element's name is strcmp'd against the literals "COINS", "POINTS", "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the class-name string because it is the fee sub-object, not an "alternate/summary envelope" as documented. THE CRASH ITSELF DISCRIMINATED, which is worth keeping as a technique. An object-root parser handed {} parses benignly and leaves defaults; an array-root parser handed {} desyncs and HANGS, which is exactly what draft/state did before the fix. We observed a CRASH, not a hang, so the object-root parser is what ran and the failure is downstream of an empty-but-valid parse. Consistent with 0x18014c260, inconsistent with the other. Coins are NOT deducted. The client posts the price in the URL and it sent 0, because we omit entranceCriteria from draft/state so there is no fee to charge. Charging a guessed amount would be inventing an economy rule. ALSO FIXED, before it reached the game: the route table hands handlers the compiled PATTERN, not a match object (the dispatcher calls fn(rx, self)), so calling .group() on the first argument raised AttributeError and killed the connection outright. That is strictly worse than the {} it was replacing. Caught by verifying the response actually changed after the restart rather than assuming the route worked. Default ON: the behaviour it replaces is a confirmed crash, so no working state is at risk. FUT_DRAFT_PURCHASE=0 reverts. 392 + 61 checks green, zero tracebacks on a clean boot. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW --- fifa17-recon/tools/utas_server.py | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index ea2c5d6..915c842 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -875,6 +875,7 @@ ROUTES = [ # is composed by appending a suffix, so it is invisible to the request-template # table, and the generic /squad route below was swallowing it. MUST precede it. (re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)), + (re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), # PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an @@ -1257,6 +1258,76 @@ def draft_state_route(h): }] +# ---- Draft entry purchase ---------------------------------------------------- +# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0} +# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns +# the fresh draft session summary. +# +# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the +# client CRASHED immediately after. Sequence, from the log: +# GET /squad/mode/draft/state?mode=ONLINE -> our array body, screen RENDERED +# GET /user/credits -> 7200 +# GET /store/purchasegroup/all -> the entry-fee screen +# POST /purchase/mode/0/draft -> {} then the crash +# So the draft-state fix worked and simply advanced the failure to the next +# unimplemented call, which is the outcome a correct fix is supposed to have. +# +# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two +# structures reference the class name. Resolved this session, and the doc's note about +# the second one is wrong: +# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root +# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly +# the seven scalar ints below. THIS IS THE RESPONSE PARSER. +# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root +# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a +# response root at all. It parses ENTRANCE CRITERIA: each element's +# name is strcmp'd against the literals "COINS", "POINTS" and +# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name +# string because it is the fee sub-object, not an alternate envelope. +# +# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An +# object-root parser handed {} parses benignly and leaves defaults; an array-root +# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it +# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran, +# and the failure is downstream of an empty-but-valid parse. That is consistent with +# 0x18014c260 and inconsistent with 0x180150310. +# +# All seven members are scalar ints and the skip handler is present, so unknown keys +# are inert and there is no freeze surface here. +# +# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the +# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state, +# so there is no fee to charge yet. Charging a guessed amount would be inventing an +# economy rule; when entranceCriteria is served the price becomes real and this is the +# place to take it. +# +# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a +# confirmed crash, so there is no working state being protected. +DRAFT_PURCHASE = os.environ.get("FUT_DRAFT_PURCHASE", "1") == "1" + + +def draft_purchase_route(h): + if not DRAFT_PURCHASE: + return 200, {} + # NB: the route table hands handlers the compiled PATTERN, not a match object + # (the dispatcher calls fn(rx, self)), so re-extract from the path rather than + # calling .group() on the first argument. Doing that raised AttributeError, + # which killed the connection outright -- strictly worse than the {} it replaced. + m = re.search(r"/purchase/mode/(\d+)/draft", h.path) + price = int(m.group(1)) if m else 0 + log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria " + "served yet, so the client posted its own price)" % price) + return 200, { + "championEventId": 0, + "expectedTierLevel": 1, + "gamesPlayed": 0, + "gamesRemaining": 4, # a draft run is 4 rounds + "rank": 0, + "score": 0, + "tierLevel": 1, + } + + def champion_route(h): """ut/%s/champion -- registration 0x18014980d (ack, no atoms), topX 0x18014a09d ({"entries":[]}), friends 0x18014b7ad ({} safe)."""