diff --git a/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md b/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md new file mode 100644 index 0000000..5cab575 --- /dev/null +++ b/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md @@ -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/`; 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//challenges`, `sbs/challenge/` — 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//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/ 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. diff --git a/fifa17-recon/tools/url_template_probe.py b/fifa17-recon/tools/url_template_probe.py new file mode 100755 index 0000000..37911f3 --- /dev/null +++ b/fifa17-recon/tools/url_template_probe.py @@ -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()) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 16ca681..f310572 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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);