From 3f3d5704a79c1e0c68cfb049a371a0e936798957 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 5 Aug 2026 20:03:41 -0700 Subject: [PATCH] fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for the whole life of the project. QUICK SELL WAS NEVER SERVED. Captured on the wire: DELETE /ut/game/fifa17/item/100000240 (single card, id in the URL, no body) ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the doc, and the regex therefore never matched a real quick sell. Every quick sell fell through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had never once been called. The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client takes its coin total from it, 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. Display artifact only, corrected by the next GET /user/credits, and the save was never touched, but it is the reason a stub is not acceptable here. quick_sell_url_route() serves the real form and returns the corrected shape, {"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits: two quick sells fired through it in one session, each credited 150 and removed the card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases either side of them. An unknown id returns {"items": []} rather than claiming a sale we cannot account for. Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as the new balance or ADDS it as a delta. We send the new balance. The discriminating window is about five seconds wide, because the client refetches GET /user/credits straight afterwards, so either reading self-corrects and the practical impact is a brief wrong number. It mattered only while we answered {}, because that garbage persisted. The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard table, which remains unknown. finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off and is disarmed: it puts a price on a tile that the buy path does not charge. TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here: * The store grouping is broken and it is NOT cosmetic. All three packs collapse into one display group, and the Bronze, Gold and Premium group tiles all drill into the same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything lands in group 0. The docs had this parked as a cosmetic "tiles read unknown" issue; it is an availability bug. * The FIFA Points price renders as the literal "or %1s", an unsubstituted printf placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and "mtx" is evidently not a currency token the client resolves. Cosmetic. Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD / 11 RARES against exactly what we serve). An earlier screen showing zeros was the display-GROUP level, which carries no content info. D3 was right. Live: 439 contract checks pass, both probe flags off, store prices back to honest. Co-Authored-By: Claude Opus 5 (1M context) --- fifa17-recon/docs/ENDPOINT_MAP.md | 2 +- fifa17-recon/tools/utas_server.py | 105 +++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) 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": {