Files
OpenFUT/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md
T
funman300 97498c560e docs(re): refute the contract:7 effect source; record the competing development reading
Two corrections found while trying to close the effect boundary statically.

1. `contract: 7` IS OUR OWN PLACEHOLDER. fut_store.py:232's generic _item()
   factory -- which builds every item the oracle serves -- hardcodes
   playStyle 250 / contract 7 / fitness 99 on players and consumables alike. The
   staging GK reads back exactly those three constants. So the production
   catalog's contract:7 for resource 5001004 is an oracle placeholder
   round-tripped through an observed profile, not an EA value. Its status is not
   INFERRED, it is KNOWN-BOGUS as a source. Had the effect been implemented on
   it, it would have been a fabricated game rule wearing observed-data clothing.

2. fcc_contractcards is NOT amount-less. An earlier note here claimed it "has no
   amount column, so this value comes from observed data". It has 13 rows with
   gold/silver/bronze/rating, 6 player + 6 manager paired by rating plus a
   99/99/99 special. The sibling fcc_healingcards shares every column except
   that it carries a single `amount`, which argues the differing columns ARE the
   effect payload (per target tier). Against that: the values are non-monotonic
   across tiers, which suits weights better than amounts; and no column of
   5001004 is 7, so neither reading explains the placeholder.

   The reader that would settle amount-vs-weight is in FIFA17.exe, not CardsDLL
   (the table and column literals are absent from the DLL), so this stays
   EFFECT_UNKNOWN rather than being guessed.

Also records, in content_taxonomy.rs, the competing reading of `development`:
fut_consumables.py's TYPE_CATEGORIES groups it as card-categories {6,7,8,9,10}
(modifiers only), explicitly flagged there as inferred from UI-bucket names and
never observed on the wire. Different enum space from the CONSUMABLE_TYPE switch
that actually emits the segment, and the switch gives formation/position/
playStyle/managerLeagueModifier their own segments rather than folding them into
development -- so the unfiltered reading is better supported, but it is still a
reading and the doc now says so instead of sounding settled.

248 adapter tests, fmt clean. No behaviour change.
2026-08-22 01:01:47 +00:00

303 lines
13 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**.