18 KiB
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
resource" -- 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: 7for5001004, 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:
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:
"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.
- The success verdict is transport-only, confirmed live. The static read of
0x180035520said the body is never inspected; an emptyitemDataproduced a clean success and a surviving session, which is that prediction holding. - 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=3and an unchanged squad, because the probe consumed nothing. - 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
contractatom (0xb8 -> record+0x8c; seefut_consumables.py, which notes categories 2 and 3 ignoreamountand readcontract) — 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).