407 lines
18 KiB
Markdown
407 lines
18 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.
|
|
|
|
## Contract effect — the `contract: 7` inference is REFUTED at the source
|
|
|
|
Do not implement a contract effect from the catalog's `contract: 7`.
|
|
|
|
`fifa17-recon/tools/fut_store.py:232` — the generic `_item()` factory that builds
|
|
EVERY item the oracle serves — hardcodes:
|
|
|
|
```python
|
|
"playStyle": 250,
|
|
"contract": 7,
|
|
"fitness": 99,
|
|
```
|
|
|
|
These are blanket placeholders on every item, players and consumables alike. The
|
|
staging squad's GK reads back `contract 7 / fitness 99 / playStyle 250`: the same
|
|
three constants. So the `contract: 7` carried in the production catalog for
|
|
resource 5001004 is **our own oracle placeholder round-tripped through an
|
|
observed profile**, not an EA value. Its evidence level is not INFERRED; it is
|
|
KNOWN-BOGUS as a source of the effect.
|
|
|
|
### What the client's own table does say
|
|
|
|
`fcc_contractcards` (13 rows) is NOT amount-less, contrary to an earlier note
|
|
here. Columns: `carddbid, cardsubtype, weightrare, cardassetid, gold, rating,
|
|
bronze, silver`.
|
|
|
|
| rating | player (201) | manager (202) | gold | silver | bronze |
|
|
|---|---|---|---|---|---|
|
|
| 50 | 5001001 | 5001007 | 1 | 2 | 8 |
|
|
| 65 | 5001002 | 5001008 | 8 | 10 | 10 / 8 |
|
|
| 80 | 5001003 | 5001009 | 13 | 11 | 15 / 11 |
|
|
| 60 | 5001004 | 5001010 | 3 | 6 | 15 |
|
|
| 70 | 5001005 | 5001011 | 18 | 24 | 20 / 18 |
|
|
| 90 | 5001006 | 5001012 | 28 | 24 | 28 / 24 |
|
|
| 90 | 5001013 | — | 99 | 99 | 99 |
|
|
|
|
Compare the sibling `fcc_healingcards`, which shares `carddbid, cardsubtype,
|
|
weightrare, cardassetid, rating` and differs only by carrying a single `amount`.
|
|
So `weightrare` is the drop weight and the differing column(s) are the effect
|
|
payload — which would make gold/silver/bronze a per-target-tier amount.
|
|
|
|
AGAINST that reading: the values are not monotonic across tiers (5001005 is gold
|
|
18, silver 24, bronze 20; 5001003 is gold 13, silver 11, bronze 15), which is
|
|
odd for an amount and unremarkable for a weight. Note also that **no column of
|
|
5001004 equals 7**, so nothing here explains the placeholder either way.
|
|
|
|
Unresolved, and NOT to be guessed: the fcc tables are loaded by `FIFA17.exe`, not
|
|
CardsDLL (the table-name and column literals are absent from the DLL), so the
|
|
reader that would settle amount-vs-weight lives in the EXE. Status stays
|
|
**EFFECT_UNKNOWN**.
|
|
|
|
## Post-ACK behaviour — OUTCOME B, LIVE_PROVEN (2026-08-22)
|
|
|
|
Captured with the staging probe answering `200 {"itemData":[]}` and mutating
|
|
nothing:
|
|
|
|
```
|
|
T0 POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]}
|
|
T1 200 {"itemData":[]}
|
|
T2 callback -> SUCCESS (no failure event; ZERO ut/delete/auth; session alive)
|
|
T4 GET club/consumables/contracts <- refresh of the SOURCE list
|
|
T5 GET club/consumables/development
|
|
T6 GET squad/active <- refresh of the TARGET
|
|
T7 no second mutation of any kind
|
|
```
|
|
|
|
So of the candidate protocols:
|
|
|
|
```
|
|
B) POST resource -> ACK -> client performs GET refresh
|
|
-> the SERVER is expected to have mutated state
|
|
```
|
|
|
|
Ruled out by observation: (A) the response carries the modified state — the body
|
|
was empty and the client was satisfied; (C) a follow-up generic PUT/item — none
|
|
was sent; (D) another route performs the mutation — nothing else was called.
|
|
|
|
Three consequences.
|
|
|
|
1. **The success verdict is transport-only, confirmed live.** The static read of
|
|
`0x180035520` said the body is never inspected; an empty `itemData` produced a
|
|
clean success and a surviving session, which is that prediction holding.
|
|
2. **The server owns the effect entirely.** The client does not compute one; it
|
|
re-reads. This is the good failure mode: a wrong server-side effect cannot be
|
|
masked by client-side optimism, and the refresh will always show server truth.
|
|
Here the refresh correctly showed `contracts copies=3` and an unchanged squad,
|
|
because the probe consumed nothing.
|
|
3. **There is no client-side amount to harvest.** Since the client never renders
|
|
an optimistic "+N games" of its own, the live path cannot reveal the grant
|
|
size. The number the client DISPLAYS on a contract card comes from the wire
|
|
`contract` atom (0xb8 -> record+0x8c; see `fut_consumables.py`, which notes
|
|
categories 2 and 3 ignore `amount` and read `contract`) — i.e. the server
|
|
tells the client what the card is worth.
|
|
|
|
That last point matters for honesty: our oracle has been sending the placeholder
|
|
`7` for that atom, so every contract card this project has ever shown a player
|
|
said "7" because WE said 7. Recovering EA's real value is not reachable from the
|
|
client's behaviour; it needs the `FIFA17.exe` reader of `fcc_contractcards`, or
|
|
it becomes an explicit design decision. Status: **EFFECT_UNKNOWN**.
|
|
|
|
### Boundary status
|
|
|
|
| aspect | status |
|
|
|---|---|
|
|
| route, method, source encoding, target encoding | LIVE_PROVEN |
|
|
| success condition (`[obj+0x1c] == 0`, body ignored) | STATIC_REVERSED + LIVE_CONFIRMED |
|
|
| response shape accepted by the client | LIVE_PROVEN (`{"itemData":[]}`, session survived) |
|
|
| post-ACK protocol | LIVE_PROVEN — outcome B |
|
|
| batching | UNPROVEN — refused, never guessed |
|
|
| contract effect / grant size | UNKNOWN (placeholder source refuted) |
|
|
| source instance selection with multiple copies | UNDETERMINED (only 1 copy owned) |
|
|
|
|
## Consumable QUICK-SELL is PUT item/resource — LIVE_PROVEN (2026-08-22)
|
|
|
|
Captured on staging when the operator quick-sold a Position Modifier from the
|
|
consumables screen:
|
|
|
|
```
|
|
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
|
|
```
|
|
|
|
So `ut/<sku>/item/resource/<resourceId>` carries THREE verbs, and this is the
|
|
third:
|
|
|
|
| verb | meaning |
|
|
|---|---|
|
|
| `GET` | item-definition lookup (`defs_route` parity) |
|
|
| `POST` | apply the consumable (`ApplyCardByRes`, body `{"apply":[{"id":N}]}`) |
|
|
| `PUT` | **quick-sell the consumable**, EMPTY body |
|
|
|
|
Note it is keyed by **resourceId**, i.e. the STACK, not by an owned instance
|
|
id — unlike the player quick-sell, which is `DELETE ut/<sku>/item/<instanceId>`
|
|
and is retail-proven in production. That asymmetry follows the consumables
|
|
screen's own model: the UI entity there is a stack, not a card.
|
|
|
|
Neither stack has ever served this route. The Python oracle maps
|
|
`item/resource` method-agnostically to `defs_route`, so a PUT would get a
|
|
definition list and HTTP 200 while nothing was sold — the client would believe
|
|
the sale succeeded. On staging the oracle is deliberately dead, so it 502'd and
|
|
Core was left untouched (coins 29843976, owned 1993, consumables 17).
|
|
|
|
### Consequence for production
|
|
|
|
Production's oracle IS alive, so today a consumable quick-sell there would reach
|
|
Python, return 200 from `defs_route`, and mutate nothing — the client would show
|
|
a successful sale that never happened. That is a second, independent reason not
|
|
to quick-sell consumables in production until this route is implemented in Rust.
|
|
|
|
### UNKNOWN, not to be guessed
|
|
|
|
* Does an empty-body PUT sell ONE copy or the WHOLE stack? The request carries no
|
|
quantity, and both readings fit. A stack of 2 at 38 is either +38 or +76.
|
|
* Which owned instance is consumed when several share the resourceId.
|
|
* What response the client requires (the player path's ack shape may not apply).
|