Files
OpenFUT/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md
T
funman300 739228efdb feat(host): capture unclaimed request bodies; record the consumable-apply wire
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.

Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.

With it, one operator apply captured the whole thing:

    POST /ut/game/fifa17/item/resource/5001004
    {"apply":[{"id":100000003}]}

  source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
  target item(s)    : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
  verb              : POST

There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.

Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.

NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.

Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.

Host 120 lib tests, fmt clean.
2026-08-22 00:23:15 +00:00

8.5 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-hostseason/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: 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.