252 lines
11 KiB
Markdown
252 lines
11 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.
|
|
|
|
### Success contract — STATIC_REVERSED (2026-08-22)
|
|
|
|
The apply completion handler is `0x180035520`:
|
|
|
|
```asm
|
|
0x180035529 mov ecx,DWORD PTR [rdx+0x1c] ; the ONLY field tested
|
|
0x18003552c test ecx,ecx
|
|
0x18003552e jne 0x18003555c ; nonzero -> FAILURE
|
|
0x18003553c lea rdx,[EVENT_CARDS_APPLY_CARD_SUCCESS] ; 0x1801f37f0
|
|
0x180035569 lea rdx,[EVENT_CARDS_APPLY_CARD_FAILURE] ; 0x1801f3810
|
|
```
|
|
|
|
It tests exactly one 32-bit field — the transport code — and **never inspects
|
|
the body**. `EVENT_CARDS_APPLY_CARD_SUCCESS` has precisely one reference in the
|
|
module, so this is the whole verdict path.
|
|
|
|
This does NOT resemble the move ack (`0x180128600`), which builds per-item
|
|
verdict records and reports FAILURE on an EMPTY vector. The "`{}` is
|
|
known-broken" precedent is specific to that route and does not transfer here.
|
|
|
|
Supporting structure: the response object's constructor `0x1800a4ce0` installs
|
|
vtable `0x1801fb5b0` and initialises its record vector at `+0x50`/`+0x58`/`+0x60`
|
|
EMPTY (0x20-byte elements); `0x1800682b0` is the matching destructor, freeing
|
|
that range with a 0x20 stride. An empty result is therefore a legal parsed state
|
|
for this response, unlike the move.
|
|
|
|
Registration site: `0x1800357da` installs the completion handler and
|
|
`0x1800357e5` the response factory, back to back.
|
|
|
|
**Probe response**: `{"itemData":[]}` — an object root (matching how the oracle's
|
|
method-agnostic `item/resource` route answers this path) containing an empty
|
|
vector (legal per the constructor). Labelled a PROBE. The client's SUCCESS only
|
|
requires transport code 0.
|
|
|
|
## Consumables categories — nine, not seven (2026-08-22)
|
|
|
|
Correcting the earlier claim that the two formation-modifier families "have no
|
|
group code, so no segment can reach them — the client's own gap". The client's
|
|
own switch says otherwise. Literal table at `0x1801f5a38` (under
|
|
`MyClubAdapterClass` / `CONSUMABLE_TYPE`); switch at `0x180048820` indexing by
|
|
`enum + 1` through the byte table at `0x180048a90` into the case table at
|
|
`0x180048a6c`:
|
|
|
|
| CONSUMABLE_TYPE | segment |
|
|
|---|---|
|
|
| **-1 (unset)** | `development` |
|
|
| 1, 2 | `contracts` |
|
|
| 3 | `healing` |
|
|
| 4 | `fitness` |
|
|
| **16** | `formation` |
|
|
| 17 | `position` |
|
|
| 23 | `playStyle` |
|
|
| 24 | `managerLeagueModifier` |
|
|
| 0, 5..15, 18..22 | `training` (switch default) |
|
|
|
|
`formation` was a SERVER gap, not a client one. `development` is the type-unset
|
|
bucket — index 0 of an `enum + 1` table — i.e. the unfiltered view; the eight
|
|
typed segments already reach all thirteen families exactly once, so it owns no
|
|
family privately and maps to their union.
|