fifa17: claim five routes whose handlers were already unreachable

Read the client's COMPLETE UTAS route surface out of CardsDLL's .rdata in the
running process (new tools/url_template_probe.py) and probed every one against
staging, where the Python upstream is deliberately dead so anything the Rust host
does not own answers 502 instead of being silently proxied.

That found five routes whose handlers already existed and were dead code because
`classify` never produced their Route -- the same defect as `season/list` and
`watchList`, whose fix comments are still in the file. This is the third and
fourth time:

  captcha        -> handle_static_ack, which already returns the oracle's exact
                    {encodedImg,sequence,sizeBeforeEncode}
  tfa            -> handle_static_ack, {}
  livemessage    -> handle_static_ack, {}
  activeMessage  -> handle_static_ack, {}
  tournament/user-> FeatureOffEmpty, {} == the oracle with FUT_MODES off
                    (tools/utas_server.py:1504); the client builds this literal
                    at CardsDLL 0x18021e540 and the bare `tournament` arm never
                    matched it

Route's own doc comment already claimed the first four as "Rust-owned
UNCONDITIONAL", so the documentation was wrong rather than the intent. All five
are byte-identical to the oracle, so claiming them is parity, not new behaviour.
Invisible in production because the upstream answers there.

Two tests pin the vocabularies so a handler cannot go unreachable a fifth time;
both are mutation-checked (removing the captcha arm fails the first).

Also documents the surface in docs/CLIENT_ROUTE_SURFACE.md, including the trap
that bit me repeatedly: an .rdata literal is a FRAGMENT, not a callable path.
`clientdata`, `purchasegroup`, `sbs/challenges`, `squadBuildingSets`, `club/items`
and `item` all looked unserved and are not. Only `squad/mode` is genuinely
unserved, and correctly so -- it is Draft-only, which is out of scope.

L5 finding: there is NO consumable-apply route anywhere in the binary. The only
owned-item mutations the client can express are PUT item (move/pile), DELETE
item/<id> and POST delete/item (quick sell), and PUT squad. So applying a
consumable is not a dedicated endpoint; L5/L6 must be pursued by capturing the
PUT item payload, not by implementing a route that does not exist.

Host 123 lib + 45 host_test, fmt and clippy clean. tournament/user, livemessage
and activeMessage verified 200 on staging (were 502).
This commit is contained in:
funman300
2026-08-21 23:28:29 +00:00
parent cd5983ecdd
commit fb38ee6087
3 changed files with 272 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# 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.
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Enumerate every UTAS URL template CardsDLL can build, from live memory.
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
WHY
---
Support level L5 ("apply endpoint") for consumables was recorded as unreversed,
with an earlier note claiming there is "no training/position/chemistry/
manager-league endpoint at all" and that the only owned-item mutations upstream
are quick sell and move/pile. That claim is load-bearing -- if true, applying a
consumable is not a server route at all and L5/L6 cannot be implemented as one --
so it deserves to be checked against the binary rather than inherited.
This scans CardsDLL's .rdata for route-shaped strings and prints them, so the
full reachable surface can be read at once.
Positive control: known-live routes MUST appear (e.g. a 'item' path and a
'club' path). If the control is empty the region is wrong, not the game.
Usage:
python3 url_template_probe.py # route-shaped strings
python3 url_template_probe.py --all # every printable string >= 6 chars
python3 url_template_probe.py --grep pat # substring filter (case-insensitive)
"""
import argparse
import re
import sys
import watch_club_model as W
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
DATA_LO, DATA_HI = 0x18028A000, 0x1802F0000
# Route-ish: contains a slash and no spaces, or looks like a UTAS path fragment.
ROUTE_HINTS = ("ut/", "game/", "item", "club", "squad", "purchase", "consumable",
"apply", "training", "position", "chemistry", "contract",
"fitness", "healing", "playstyle", "manager", "pile", "delete",
"transfer", "market", "auction", "sbs", "pack", "store")
PRINTABLE = re.compile(rb"[\x20-\x7e]{6,}")
def strings(mem, lo, hi):
buf, bad = mem.read_pages(W_live(lo), hi - lo)
if not buf:
return [], bad
out = []
for m in PRINTABLE.finditer(bytes(buf)):
out.append((lo + m.start(), m.group().decode("ascii")))
return out, bad
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true")
ap.add_argument("--grep")
a = ap.parse_args()
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
global W_live
W_live = lambda i: base + (i - W.IMG_BASE)
print("pid=%d CardsDLL live base %#x" % (pid, base))
found = []
for lo, hi, name in ((RDATA_LO, RDATA_HI, ".rdata"), (DATA_LO, DATA_HI, ".data")):
ss, bad = strings(mem, lo, hi)
print(" %s: %d strings (%d bad pages)" % (name, len(ss), len(bad)))
found.extend(ss)
if a.grep:
pat = a.grep.lower()
sel = [(va, s) for va, s in found if pat in s.lower()]
elif a.all:
sel = found
else:
sel = [(va, s) for va, s in found
if "/" in s and " " not in s
and any(h in s.lower() for h in ROUTE_HINTS)]
print("\n%d matching string(s):" % len(sel))
for va, s in sel:
print(" %#x %s" % (va, s))
ctrl = [s for _, s in found if "ut/game" in s.lower()]
print("\nCONTROL ('ut/game' present): %s (%d)"
% ("OK" if ctrl else "EMPTY -> wrong region", len(ctrl)))
return 0
if __name__ == "__main__":
sys.exit(main())
+63
View File
@@ -245,6 +245,18 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("hub") if get => Route::Hub,
Some("store") => Route::StaticAck,
Some("match/keepalive") => Route::StaticAck,
// THIRD instance of the `watchList` defect below: `handle_static_ack`
// has answered these four since it was written — `captcha` with the
// oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` and the other
// three with `{}` (`tools/utas_server.py:1525-1529`) — and `Route`'s own
// doc comment claims them as "Rust-owned UNCONDITIONAL". But no arm ever
// produced the route, so every one fell through to the Python upstream.
// Invisible in production, where that upstream answers; on staging, where
// it is deliberately dead, all four are a 502.
Some("captcha") => Route::StaticAck,
Some("tfa") => Route::StaticAck,
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
// `watchList` has had a Rust handler all along, but nothing ever produced
// this route, so `Route::WatchList` was unreachable and every request fell
// through to Passthrough — the same defect class as `season/list`. The
@@ -264,6 +276,13 @@ pub fn classify(method: &str, path: &str) -> Route {
Route::Season
}
Some("tournament") if get => Route::FeatureOffEmpty,
// FOURTH instance of the same defect: the client builds
// `ut/%s/tournament/user` (literal at CardsDLL 0x18021e540) and the bare
// `tournament` arm does not match it, so it fell through to Python. The
// oracle answers it with `{}` whenever FUT_MODES is off
// (`tools/utas_server.py:1504`), which is exactly what FeatureOffEmpty
// returns — so claiming it is byte-identical parity, not new behaviour.
Some("tournament/user") if get => Route::FeatureOffEmpty,
Some("champion") if get => Route::FeatureOffEmpty,
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
@@ -5759,6 +5778,50 @@ mod tests {
}
// ── Session/capability vertical (this slice) ────────────────────────────
/// Every tail `handle_static_ack` can answer MUST also be produced by
/// `classify`, or the handler is dead code and the request silently falls
/// through to the Python upstream. That has now happened three times in this
/// file (`season/list`, `watchList`, and these four), so it gets a test.
#[test]
fn every_static_ack_tail_is_actually_routed() {
for tail in [
"store",
"match/keepalive",
"captcha",
"tfa",
"livemessage",
"activeMessage",
] {
assert_eq!(
classify("GET", &format!("/ut/game/fifa17/{tail}")),
Route::StaticAck,
"{tail} must reach handle_static_ack, not Python"
);
}
}
/// The mode reads the client can actually build MUST all be claimed. The
/// tails come from CardsDLL's own route literals (`ut/%s/tournament/user` at
/// 0x18021e540), read out of the live binary with
/// `fifa17-recon/tools/url_template_probe.py` — not from guesswork about
/// what the client might ask for.
#[test]
fn the_disabled_mode_reads_are_all_claimed() {
for tail in [
"tournament",
"tournament/user",
"champion",
"clubUser",
"user/list",
] {
assert_eq!(
classify("GET", &format!("/ut/game/fifa17/{tail}")),
Route::FeatureOffEmpty,
"{tail} must be Rust-owned, not proxied"
);
}
}
#[test]
fn classify_routes_session_vertical() {
assert_eq!(classify("POST", "/ut/auth"), Route::Auth);