diff --git a/fifa17-recon/docs/ENDPOINT_MAP.md b/fifa17-recon/docs/ENDPOINT_MAP.md index 733ca07..fb6bc04 100644 --- a/fifa17-recon/docs/ENDPOINT_MAP.md +++ b/fifa17-recon/docs/ENDPOINT_MAP.md @@ -1050,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`. | `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) | | `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` | | `displayGroupUseDefaultImage` | 0xdb | BOOL | | - | `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) | + | `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. | | `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) | | `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) | | `sortPriority` | 0x2cb | INT | | diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 9680ed9..b356b85 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -801,6 +801,68 @@ MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack") STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1" +# FUT_QUICKSELL: serve the SINGLE-CARD quick sell, which we have never served. +# +# CAPTURED LIVE 2026-08-05 19:39:52. The client sends: +# DELETE /ut/game/fifa17/item/100000240 (no body, id in the URL) +# ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, and ROUTES was built +# from the doc, so the regex at the bottom of this file has never matched a real quick +# sell. Every quick sell to date fell through to the catch-all. quick_sell_route() +# below, and STORE.quick_sell() behind it, have therefore never once been called. +# +# WHAT THE EMPTY RESPONSE DID. Answering {} does not merely skip the credit. The +# client takes its coin balance from this response, so with totalCredits absent it +# rendered an uninitialised value: a real session showed 1,133,686,384 coins against a +# true balance of 9,889,600. It is only a display artifact, corrected by the next +# GET /user/credits, and the save was never touched. But it means this response is +# BALANCE-BEARING and cannot be stubbed. +# +# WHAT IS STILL UNKNOWN, and what the next live run settles. We do not know whether +# the client ASSIGNS totalCredits as the new balance or ADDS it as a delta. One garbage +# sample cannot distinguish them. We send the NEW BALANCE, which is the natural reading +# of the field name, and the run is self-diagnosing: +# balance shows old + value -> assign. Correct, keep it. +# balance shows roughly double -> delta. Send (value) instead of (new balance). +# The coin figures are large enough that doubling is unmistakable. +# +# The credit itself is STORE.quick_sell()'s rating-based fallback, which is an invented +# number, not FUT's real discard table. That table is still UNKNOWN. Flagged here so +# nobody mistakes it for a reversed value. +# DEFAULT ON since 2026-08-05: live-proven. Two quick sells fired through this +# handler in one session, each credited 150 and removed the card, and the coin +# arithmetic reconciled exactly against the pack purchases either side of them. +# The previous behaviour (unmapped -> {}) is strictly worse: it credited nothing +# and left an uninitialised balance on screen. +QUICKSELL = os.environ.get("FUT_QUICKSELL", "1") == "1" + + +def quick_sell_url_route(h): + """DELETE ut/%s/item/ -- single-card Quick Sell, the real wire form. + + Default OFF returns exactly what the catch-all returned before, so the baseline + the client has always seen is unchanged until this has been in front of the game. + """ + m = re.search(r"/item/(\d+)", h.path) + if h.command != "DELETE" or not m: + return 200, {} + if not QUICKSELL: + log(" QUICKSELL: id=%s seen, handler DISABLED (FUT_QUICKSELL=0), " + "answering {} as before" % m.group(1)) + return 200, {} + iid = int(m.group(1)) + sold, coins = STORE.quick_sell([iid]) + if not sold: + # Do not claim to have sold a card we cannot account for. An invented verdict + # desyncs the client's model against ours, which is worse than an honest miss. + log(" QUICKSELL: id=%d NOT FOUND in either pile, no credit" % iid) + return 200, {"items": [], "totalCredits": STORE.coins()} + total = STORE.coins() + log(" QUICKSELL: sold id=%d for %d coins -> balance %d" % (iid, coins, total)) + # FutDiscardCardServerResponse: items is an array of OBJECTS and there is no + # top-level id. Bare ints here would be a type desync, i.e. a freeze. + return 200, {"items": [{"id": iid}], "totalCredits": total} + + def quick_sell_route(h): """POST ut/delete/%s/item -- Quick Sell (the reveal screen's 'Quick Sell All'). @@ -945,6 +1007,10 @@ ROUTES = [ # ---- FUT item-definition endpoints (must precede generic /item, /user) ---- (re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)), (re.compile(G + r"/defid"), lambda m, h: defs_route(h)), + # DELETE ut/%s/item/ -- single-card Quick Sell. Captured live 2026-08-05. + # Disjoint from the /item(\?|$) move route below (that one cannot match a path + # with a trailing /), but kept above it so the item routes read in one block. + (re.compile(G + r"/item/\d+"), lambda m, h: quick_sell_url_route(h)), (re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)), # ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ---- (re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)), @@ -2442,6 +2508,34 @@ def squad_route(h): # ---- STORE / PACKS (first-cut; iterate against the log) --------------------- +# FUT_PRICE_PROBE: step 1 of the pack live test, and the control the rest of it rests +# on. Sends `funds` and `finalFunds` as different numbers for the Gold Pack alone, so +# the tile reveals which key the client renders and, more importantly, whether the +# store body reaches the tile at all. +# +# OFF BY DEFAULT and it must go back off after the test: this puts a price on a real +# tile that the buy path does not charge. The pack still DEBITS p["price"] (5000), so +# a purchase made with this on leaves the tile and the wallet disagreeing by 679 coins. +# That is harmless for one run and confusing forever if it is left on. +# +# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart +_PRICE_PROBE = os.environ.get("FUT_PRICE_PROBE", "0") == "1" +# Which pack carries the probe. Env-driven because WHICH TILE IS REACHABLE is not +# something we control: observed live 2026-08-05, all three packs collapse into a single +# display group (we send displayGroup but never displayGroupAssetId 0xda, so they all +# share group 0), and drilling into any group renders one representative, Premium Gold. +# A probe on an unreachable tile answers nothing, so this has to be movable. +_PROBE_PACK_ID = int(os.environ.get("FUT_PRICE_PROBE_PACK", "5")) +_PROBE_FINAL_FUNDS = 4321 # not a round number FUT could plausibly have chosen itself + + +def _probe_final_funds(p): + """finalFunds for one pack tile. Identical to funds unless the probe is armed.""" + if _PRICE_PROBE and p.get("id") == _PROBE_PACK_ID: + return _PROBE_FINAL_FUNDS + return p["price"] + + def _pack_body(p, idx): """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30). @@ -2480,7 +2574,16 @@ def _pack_body(p, idx): "purchaseCount": 0, "isPremium": False, "sortPriority": idx, - "currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}], + # FUT_PRICE_PROBE: the step-1 CONTROL of the 2026-08-05 pack live test. + # `funds` and `finalFunds` are sent as DIFFERENT numbers for one pack only, so + # the tile tells us which of the two the client renders, and whether the store + # body reaches the tile at all. Without this a silent Quick Sell in step 4 is + # ambiguous between "Quick Sell sends nothing" and "our store response never + # arrived". Default OFF: it is a wrong price on a real tile, so it must not + # linger past the test. See docs/plan-2026-08-05-pack-opening.md section 7. + # finalFunds 4321 is deliberately not a round number FUT could have chosen. + "currencies": [{"name": "coins", "funds": p["price"], + "finalFunds": _probe_final_funds(p)}], "extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"}, "originalPrice": {"amount": mtx, "currency": "mtx"}}, "packContentInfo": {