# The client's complete UTAS route surface Read out of the running client's own `.rdata` on 2026-08-21 (pid 6580) with `fifa17-recon/tools/url_template_probe.py`, then each route probed against staging. This bounds the server: FIFA 17 cannot ask for a route that is not in this list. Staging's Python upstream is deliberately dead, so a `502` there means the Rust host does not own the route — which makes the coverage column a measurement rather than an audit of the source. ## Route templates in CardsDLL `%s` is the sku segment, built from `game/%s` (`0x18021fac8`) → `game/fifa17`. ``` ut/auth ut/delete/auth ut/%s/user ut/delete/%s/user ut/%s/user/list ut/%s/club ut/%s/clubUser ut/%s/item ut/%s/item/resource ut/delete/%s/item ut/%s/defid ut/%s/squad ut/delete/%s/squad ut/%s/squad/mode ut/%s/purchased ut/%s/store ut/v2/%s/store ut/%s/trade ut/delete/%s/trade ut/%s/tradePile ut/%s/watchList ut/delete/%s/watchList ut/%s/auctionhouse ut/%s/marketdata ut/%s/match ut/%s/sbs ut/%s/season ut/%s/season/user ut/%s/season/%%s/user ut/%s/season/%%s/reset ut/%s/season/friendly ut/%s/tournament ut/%s/tournament/user ut/delete/%s/tournament/user ut/%s/champion ut/%s/draft/mode ut/%s/leaderboards ut/%s/leaderboards/options ut/%s/activeMessage ut/%s/livemessage ut/%s/clientdata ut/%s/phishing ut/%s/captcha ut/%s/tfa ``` Suffixes appended to the above, not standalone routes: `/consumables/%s`, `/items`, `/purchasegroup`, `/squadBuildingSets`, `/challenge/%d/squad`, `/choices/manager`, `/purchase/mode/%d/draft`, `/transfermarket?type=%s&start=%d&num=%d`. ## THE TRAP when reading this list A literal in `.rdata` is a **fragment**, not necessarily a callable path. Probing fragments bare manufactures fake gaps. Every one of these looked unserved and was not: | looked missing | actually | |---|---| | `clientdata` | real route is `clientdata/`; served (`clientdata/userHubData` → 200) | | `purchasegroup` | a suffix of `store`; `store/purchasegroup/all` is served | | `sbs/challenges` | not a route; the real ones are `sbs/sets`, `sbs/setId//challenges`, `sbs/challenge/` — all served | | `squadBuildingSets` | not a route in the oracle either | | `club/items` | `items/...` literals are ART ASSET paths, not UTAS | | `item` | only ever PUT (move/pile) and DELETE (quick-sell) | Check a candidate gap against `tools/utas_server.py`'s regex table before believing it. ## Genuinely unserved, and why that is correct * `squad/mode` — bare form is never used. The oracle only has Draft sub-paths (`squad/mode/draft/state`, `squad/mode//draft/choices/*`). Draft is out of scope, so this correctly stays on Python. ## Fixed by this measurement Four handlers existed and were unreachable because `classify` never produced their route, so every request fell through to Python. This is a **recurring defect class** in `openfut-utas-host` — `season/list` and `watchList` were the first two, and their fix comments are still in the file: | route | handler | was | |---|---|---| | `captcha` | `handle_static_ack`, returns the oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` | fell to Python | | `tfa` / `livemessage` / `activeMessage` | `handle_static_ack`, `{}` | fell to Python | | `tournament/user` | `FeatureOffEmpty`, `{}` — the oracle's answer with `FUT_MODES` off | fell to Python | `Route`'s own doc comment already claimed the first four as "Rust-owned UNCONDITIONAL", so the documentation had been wrong rather than the intent. All five are byte-identical to the oracle, so claiming them is parity, not new behaviour. Invisible in production (the upstream answers); a 502 on staging. Two regression tests now pin the vocabularies — `every_static_ack_tail_is_actually_routed` and `the_disabled_mode_reads_are_all_claimed` — so a handler cannot go unreachable a fifth time. ## No consumable apply endpoint exists Support level L5 for consumables was open, with an inherited note saying there is "no training/position/chemistry/manager-league endpoint at all". **The route table confirms it from the binary**: there is no apply/training/position/ chemistry route anywhere in CardsDLL. The only owned-item mutations the client can express are: ``` PUT ut/%s/item move / pile DELETE ut/%s/item/ quick sell POST ut/delete/%s/item bulk quick sell PUT ut/%s/squad squad write ``` So applying a consumable is **not** a dedicated server route. If it reaches the server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by capturing that PUT's payload while applying a card — not by looking for an endpoint that does not exist. ## FUT task vocabulary (2026-08-21, live) The client drives UTAS through named TASKS, not just URLs. The task-name table lives in CardsDLL `.rdata` as 0x20-byte inline slots holding MixedCase/UPPERCASE pairs (`tools/apply_route_search.py`, controls `tradePile`/`ut/%s/item`/`squad` all FOUND): ``` ViewCards AssingCard(sic) ApplyCard ApplyCardByRes ActivateCard ConsumeCard DiscardCard DiscardCardByRes DiscardACard MoveCard MoveCardByRes SwapCard CreateMatch MatchReady DestroyMatch PlayGame ResetMatch KeepAlive LoadCategoryDetails LoadSetChallenges StartChallenge LoadSquadChallenge SaveSquadChallenge SubmitChallenge TagSets SetSbcData TournamentList TournamentTeams SetUserInfo GetHistorical SetTutData ... ``` A descriptor table in `.data` pairs each name with a task id and a small setter thunk, e.g. `ApplyCard` id **0x0d** at `0x1802cb170`, `ApplyCardByRes` id **0x0e** at `0x1802cb1a0`. The thunks are `mov [rip+flag], cl; ret` (a per-task flag), NOT request builders, so the request is assembled elsewhere keyed by task id. **So consumable application IS a first-class client action (`ApplyCard` / `ApplyCardByRes` / `ConsumeCard`), even though no `/apply` URL exists.** It therefore rides an existing route. Which one is a one-capture question, and the host now names every unclaimed request: ``` utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N ``` ## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21) Captured end to end on staging, operator applying a bronze player contract: ``` POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]} ``` | element | value | where | |---|---|---| | source consumable | resource id `5001004` (player contract, subtype 201) | **path** | | target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` | | verb | `POST` | | **There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which we already serve for **GET** (item-definition lookup). The **POST** verb on that path is the mutation, and nothing claimed it, so it fell through to Python. This is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by res**ource" -- which is why the source is a definition id rather than an instance id. `apply` is an ARRAY, so one consumable resource can name several targets in a single request. Whether the client ever batches is unobserved. Corroborating UI evidence from the same session: applying to a PLAYER offered only the subtype-201 card and withheld both subtype-202 manager contracts, independently confirming the `201 = player_contract / 202 = manager_contract` split. Fail-closed confirmed: with the upstream dead the request 502s and Core is left EXACTLY unchanged (coins, owned count, and the source card all identical). ### Not yet known * the **response shape** the client expects on success; * the **effect** -- how many matches a contract grants. Our own catalog carries `contract: 7` for `5001004`, documented as "the number of matches the card grants", but that is observed profile data, i.e. INFERRED, not reversed. No effect is implemented on that basis. ## Consumables category `development` is unmapped (client really asks) The new passthrough/route logging caught the client requesting ``` GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0 ``` `consumable_families_for_category` has no `development` arm, so the screen is served empty. The client demonstrably asks for it, which is exactly the condition that function's own doc says should add an arm. Which families it should map to is NOT guessed here.