Files
OpenFUT/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md
T
funman300 db5fb37980 feat(host): name unclaimed requests, and record the FUT task vocabulary
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.
2026-08-22 00:14:11 +00:00

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
```