db5fb37980
Milestone 2 groundwork. The passthrough arm forwarded to Python without ever recording WHAT was asked for, so on staging -- where the upstream is deliberately dead -- an unhandled request produced an anonymous 502. It now logs method, path and body length before forwarding, which is how the next unclaimed route gets identified: utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=0 Also records the FUT TASK vocabulary read out of the live client. The client drives UTAS through named tasks held in a CardsDLL .rdata table of 0x20-byte MixedCase/UPPERCASE slots, with a .data descriptor table giving each a task id: ApplyCard 0x0d, ApplyCardByRes 0x0e, ConsumeCard, ActivateCard, AssingCard(sic), MoveCard, MoveCardByRes, SwapCard, DiscardCard, DiscardCardByRes, ViewCards, ... So consumable application IS a first-class client action even though the route table contains no /apply endpoint -- it must ride an existing route. The descriptor's function pointer is a `mov [rip+flag], cl; ret` setter, not a request builder, so the request is assembled elsewhere keyed by task id; that is cheaper to answer with one live capture than with more static tracing. Search tooling carries mandatory positive controls (tradePile, ut/%s/item, squad -- all FOUND), so the "no /apply route" result is a valid negative rather than a failed scan. Host 120 lib tests, fmt and clippy clean.
139 lines
6.3 KiB
Markdown
139 lines
6.3 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
|
|
```
|