739228efdb
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.
Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.
With it, one operator apply captured the whole thing:
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
target item(s) : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
verb : POST
There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.
Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.
NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.
Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.
Host 120 lib tests, fmt clean.
192 lines
8.5 KiB
Markdown
192 lines
8.5 KiB
Markdown
# 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/<key>`; 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/<n>/challenges`, `sbs/challenge/<n>` — 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/<n>/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/<id> 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.
|