Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.
That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.
Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.
Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.
Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.
New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
20 KiB
OpenFUT / FIFA 17 — live-test script for the CONSUMABLES round
Written 2026-08-05. Live server pid 91168 (started 10:16), FIFA 17 running (pid 91310). Nothing from this round has been landed yet: utas_server.py is clean in git, _club_stat_set() still emits 21 global rows and none of them is a consumables* name.
1. What shipped, per screen, with the exact env flag
Env actually set on the live process right now (/proc/91168/environ): FUT_CONSUMABLES=all, FUT_COACHES=all, FUT_MANAGERS=1. Everything else is at its code default.
Working and seen on screen (MEASURED)
| Screen | Exact flag | Default | Client KNOWN to request it? |
|---|---|---|---|
| Club player list, 17,563-player pool, real names/positions/nations | none, always on (club_route) |
on | YES. GET /club?type=player 61x this session, paged start=0..190&count=11 |
| Nation and league drill-downs (ENGLAND then Premier League = 17) | FUT_CLUBSTATS=1 |
on | YES. club/stats/year 45x, country/<id> 9x, league/<id> 6x |
| Managers, 34 staff cards, zero DB Error, flags + "LaLiga Santander" / "ENG 1" | FUT_MANAGERS=1 |
off | YES. type=manager 8x |
| Head coach family | FUT_COACHES=all |
off | YES, once. type=headcoach 1x, newly confirmed on the wire this morning |
| MY CLUB hub counter (clubPlayers 205) | FUT_HUBDATA=1 |
on | YES. /hub 32x |
| Squad, Send to Club, store packs | FUT_MASSINFO=full, FUT_USERINFO=roster, FUT_STORE_DISPLAYGROUP=1 |
on | YES |
Shipped but the client has never asked (INFERRED that it ever will)
| Arm | Exact flag | Status |
|---|---|---|
| GK coach / physio / fitness coach club arms | FUT_COACHES=1 or all |
Served. Never requested by the client. TODO/CONFIRM they are reachable |
| Consumable ITEM shelf, 126 items, 69 subtypes | FUT_CONSUMABLES=all |
Served on type=contract|training|healing|development, and on an untyped /club with no team=/league=. Measured: the game has never received a single consumable. All 9 of the client's untyped /club requests carry team=, so even the all arm has never fired for the game. The only +126 item(s) line in the whole log came from a Python-urllib probe |
| Draft state / purchase | FUT_DRAFT_STATE=1, FUT_DRAFT_PURCHASE=1 |
Never requested |
| Market | FUT_MARKET=sample |
Only /tradePile 4x, answered correct-and-empty |
Requested by the client and answered with a placeholder (MEASURED, this is the bug class)
| Request | Count this session | What we answer | Why |
|---|---|---|---|
GET club/stats/consumables |
41x from ProtoHttp | the 195-row PLAYER stat set (players 205, gold 189, ...) | _club_stat_set() sends none of the 14 consumables* names the panel reads. All 16 CARDS_NO_* rows must read 0 |
GET club/stats/staff |
9x | {} |
FutStaffBonus, different class. The five staff COUNTS come from the Stats2 store, which is why the staff screen worked anyway. What {} costs is only the percentages |
GET /settings |
11x | {"configs": []} |
40-flag feature gate, never populated |
GET leaderboards/options |
5x | {} |
FUT_MODES unset |
GET user/accountinfo |
4x | {} |
FUT_ACCOUNTINFO unset. First request of a cold boot, before POST /ut/auth |
To be landed before step 5 of the script (not yet in the tree)
- (A) the 14
consumables*rows plus 5 more vocabulary names (trophiesSeasonOnline0x38,badgeDBid0x2e,kitsHome0x29,kitsAway0x2a,leagueLogos0x2f) appended unconditionally to_club_stat_set(), with a_consumable_overlay_counts()hook that counts the SHELF, notSTORE.items(). Behind its own kill flag (suggestFUT_CONSUM_STATS, default on). - (B) two defensive routes above the generic
/clubatutas_server.py:958:/club/consumablesand/club/loan, both returning{}. - (C) optional:
club/stats/staffbody behindFUT_STAFF_BONUS=1.
2. The test script
Budget: one utas restart, one FIFA launch. Steps 1 to 3 cost nothing and use the client that is already running. Step 4 is a terminal gate. Steps 6 onward are the single in-game pass.
Step 1. Baseline the CONSUMABLES tab on the client that is running right now
Set: nothing. Do: MY CLUB, select CONSUMABLES. Write down two things: (i) was the tab present and selectable, or greyed/absent; (ii) the seven category numbers and the header count.
- Positive: tab opens, all seven read 0. That turns the diagnosis from "inferred from the binary" into "the screen says zero while we send no counters", and it is the whole justification for spending the restart.
- Negative: tab absent or greyed and no request fires. Then the counters are not the blocker, the gate is elsewhere (
/settingsis the standing suspect), and do not spend the restart on (A) this round. If instead the tab shows NON-ZERO numbers, the diagnosis is wrong outright: something other than the Stats2 store feeds it. Stop and re-plan either way.
Step 2. The tab sweep, same running client
Set: nothing. Do: from the FUT hub attempt each destination in turn and for each record BOTH facts, present/greyed/absent AND what appeared: Squad Building Challenges, Objectives / Manager Tasks, FUT Draft, Transfer Market (search, Transfer Targets, Transfer List), Seasons, Tournaments, Leaderboards, Team of the Week, Loans, MY CLUB > NEW CARDS, MY CLUB > CLUB summary, and Club Customisation (Badge, Kits, Stadium, Ball). Then:
grep -n 'ProtoHttp' -B3 /tmp/utas_server.log | tail -200
- Positive: any request path we have never seen (
/sbs*,/draft/mode*,/objective/*,/loan/*,?type=badge|kit|stadium|ball). That hands us the real base plus suffix off the wire, which the static census cannot give: we have the suffixes but not which base each composes onto. - Negative: interpretable ONLY because fact (i) was recorded. Absent or greyed and silent means a FRONT-END GATE, which promotes the
/settingswork to the top of next round and demotes all the sbs/draft route work. Present, clickable and still silent means the screen is entirely client-side and no server route will ever help it. Without fact (i) the silence is ambiguous and this step is worthless.
Step 3. Squad-side consumable path, same running client
Set: nothing. Do: open the squad, pick any player, look for "Apply Consumable" / the consumables context action. Try to reach a consumable list from there. Re-grep as in step 2.
- Positive: any
consumablesrequest line from ProtoHttp.FUN_180042c40(theFUT_SQUAD_CONSUMABLES_DPreader) is the second candidate consumer of the item container, and if the drill-down lives here rather than on MY CLUB it changes what step 7 means. - Negative: the action is absent or produces no request. Combined with step 7 that narrows the item-list trigger to neither known UI path, which is itself the answer we need before anyone builds a
/consumables/<segment>body.
Step 4. Terminal pre-flight, before the restart. This is a gate, not a test.
Run all four. Any failure means do not restart yet.
python3 tools/check_club_stat_vocab.pyagainst the OLD server. Expect the known baseline failure (the 14 missing rows on each mode). This proves the checker is wired to the right port.- Confirm the counts come from the shelf and not the store:
Must print
python3 -c "import fut_consumables as fc, fut_club_stats as cs; \ print({hex(k):v for k,v in cs.global_counts(fc.starter_consumables(fc.CONSUMABLE_ID_BASE)).items() if k>=0x3c})"0x3c 126, 0x41 21, 0x42 7, 0x43 21, 0x44 3, 0x45 20, 0x46 21, 0x47 6, 0x48 0, 0x49 0, 0x4a 3, 0x4b 19, 0x4c 5, 0x4d 0. If the integrator wiredSTORE.items()instead, you get fourteen zeros, which is byte-identical on screen to step 6's decisive negative, and the round draws the wrong conclusion from a wiring bug. The profile holds 205 items, every onecardsubtypeid 0. - Confirm the two defensive routes are ABOVE line 958 and that they do not shadow
/club. Curl the old server:GET /ut/game/fifa17/club?type=playermust still return 205 items. md5sum fifa17_profile.jsonand keep the value. The overlay is GET-time and must not touch the save.
Step 5. The restart and the launch
Stop pid 91168 and restart from fifa17-recon/tools with the family flags UNCHANGED so nothing that works today moves:
FUT_CONSUMABLES=all FUT_COACHES=all FUT_MANAGERS=1 [FUT_STAFF_BONUS=1] python3 -u utas_server.py
Then python3 tools/check_club_stat_vocab.py again: must print 0 failed. Then relaunch FIFA 17. The relaunch is mandatory, not hygiene: FUN_18012fa90 skips the HTTP fetch entirely when the store's mode/arg1/arg2 already match the pending request, so a warm client re-entering the tab replays the OLD numbers and you get a false negative.
Do not change any other flag in this restart. One variable per restart.
Step 6. THE HEADLINE. MY CLUB > CONSUMABLES
Do: open it, read the seven category rows and the header.
- Positive: TRAINING 42, CONTRACT 13, FITNESS 6, HEALING 21, PLAYSTYLE 24, MANAGER LEAGUE 0, TACTIC TRAINING 20, header 126. These are computed from the live shelf, not guessed, so a PARTIAL match is the valuable outcome: it localises the error to one kind-to-statId row instead of to the whole hypothesis. (MANAGER LEAGUE 0 and the formation/manager-training rows are structurally 0 because the shelf holds none of those families. That is correct, not a miss.)
- Negative: all seven still 0. Interpretable only because you ran the vocab checker first and because
tools/probe_club_stats.pycan read the store live. Run it against the running process with the tab open: if it shows0x42 = 7in bucket 0 with mode tag 6, the store was written correctly and the panel is not fed byFUN_180043b90case 6 /FUN_180095360/FUN_180096670case 0xb. All three candidate providers read the same ids from the same+0x800store, so seven zeros kills all three at once and redirects the hunt to the Scaleform side. Residual gap, state it honestly: the probe cannot see the Flash layer, so "published to the data provider but not rendered" stays indistinguishable from "rendered as zero".
Step 7. THE DRILL-DOWN GATE. Same screen, select Contracts then Healing
Do not change the server. Grep on the REQUEST line, not on UNMAPPED:
grep -n 'consumabl' /tmp/utas_server.log | grep -v 'club/stats\|CLUBSTATS\|(consumables='
- Positive: any
GET /ut/.../consumables/<segment>whose following lines carryUser-Agent: ProtoHttp. There are ZERO such lines in 9,900+ log lines, so one is decisive, and it hands us the base prefix thatFUN_1801308c0does not prove (it proves only the suffix/consumables/%s). - Negative: no such request. That proves the category strip is not what invokes
FUN_180048780, and the item list is reached from some other UI path, most likely the squad apply-consumable flow, which redirects next round to the squad screen. Useful either way. - Why the grep changed: the original spec keyed this test on the
!! UNMAPPED PATHlog line. Measured on the live server:/ut/game/fifa17/consumables/contractsdoes log UNMAPPED, but/ut/game/fifa17/club/consumables/contractdoes NOT, and today it returns 331 items and 140,662 bytes of player cards. If the client composes ontout/%s/club, which is exactly the base the reports call the strong inference, the UNMAPPED line never appears and the test returns a guaranteed false negative. Step (B)'s defensive route replaces those 140 KB with{}but still logs no UNMAPPED line. Key on the request line.
Step 8. STAFF percentages. Only if FUT_STAFF_BONUS=1 shipped
Server serves {"bonus":[{"type":"pace","value":7},{"type":"contract","value":3}]}. Do: MY CLUB > STAFF.
- Positive: head-coach group shows PACE 7% and manager group shows CONTRACTS 3%. Two names, two different groups, two different values cannot be coincidence; one number could be.
- Negative: both read 0%. That means either the 22-name table is wrong about its CONSUMER, or
club/stats/staffis not deserialized byFutStaffBonusServerResponseat all (the parser-to-class binding is confirmed at vtable 0x180221610 slot +0x08; the URL-to-class binding is TODO/CONFIRM). Both branches mean this route cannot set percentages, so the step stays interpretable, but do not write it up as "the consumer table is wrong". - Built-in control: the five staff counts on the same tab come from the Stats2 store, not from this body. They must still read manager 10, head coach 6, GK coach 6, physio 6, fitness coach 6.
Step 9. REGRESSION SWEEP. Mandatory. Do not close the session without it.
- MY CLUB players: 205, gold 189, silver 8, bronze 8, rare 205.
- ENGLAND then Premier League: still 17.
- Managers and coaches: still 34 staff cards, zero "DB Error", Luis Enrique 88 still draws the Spain flag and "LaLiga Santander", Conte / Wenger / Klopp / Koeman / Pardew still draw flags and "ENG 1".
- Store: packs still list and still deal named cards.
md5sum fifa17_profile.jsonagainst step 4's value if you opened nothing that writes.- Any change here is a regression, not a finding. Go straight to section 4.
Step 10. OPTIONAL, only if the integrator served non-zero kits/badges/stadia/balls
Repeat the four Club Customisation screens from step 2 and grep for type=badge|kit|stadium|ball|equippables or state=active.
- Positive: we get the exact query the client issues, including whether
state=rides along, and the club-item family becomes shippable against a real request. - Negative: only interpretable BECAUSE the counts were non-zero. With the zeros we serve today a count-gated screen that never asks looks identical to a screen that is not UTAS-fed, which is exactly the failure mode this whole round exists to fix. If the counts were not changed, skip this step entirely.
3. Still unsolved, ranked by value over cost
- Does a non-zero count actually gate the item fetch? The 1:1 taxonomy match (16
CARDS_NO_*rows partition into 8 URL segments) is the only reason to believe it.FUN_180048780reads no stat store at all, and the UI layer that invokes it is not incardsdll.dll. Cost: step 7, already in the script. - Which base does
/consumables/%scompose onto? Only the suffix is proven. Measured, the two candidates behave completely differently on our server, so this is not cosmetic. Cost: step 7's positive answers it for free. GET /settings, the 40-flag gate. Answered{"configs": []}on all 11 client requests, and the C++ constructor defaults were never read, so we do not know whether any flag is in a blocking state. Highest untouched lever, but it needs its own restart, a positive control (shipmaximumTradePileSize100 and read the transfer-list capacity, so a null result can be told apart from "the array never reached the consumer"), and it must NOT flipstoreEnabled/tradingEnabled/coinEnabledon the first pass:IS_STORE_ENABLEDandIS_TRADING_ENABLEDare literal UI state keys in .rdata and the store screen is live-proven working. Next round, gated on step 2's sweep.FutStaffBonusURL-to-class binding. Step 8 settles half of it.- Why the
FUT_CONSUMABLES=allarm has never fired for the game. The overlay serves only on an untyped/clubwith no drill-down, and all 9 of the client's untyped requests carryteam=. Whether the client ever issues an untyped, undrilled/clubis unknown. Cheap to settle from the log next session. - sbs / draft / objectives / loans. Six sbs suffixes, ten draft suffixes and two objective builders exist in the binary and none is routed;
docs/ENDPOINT_MAP.md's "objectives have no route" claim is refuted (FUN_180151610builds<base>/objective/%d/reward,FUN_180147780builds.../complete). The base template is unresolved for all of them. Step 2 converts the static census into a demand curve for free. Ship nothing here until a real request is observed. - Club items. Never once requested, renderer unlocated, and the subtype assignment for
{30, 31, 145, 146, 147, 148, 149, 150}is assigned nowhere in the 149 dumped tables. Identity path (teamid toFUN_180119bd0to localized name) is inferred, never traced. Budget: four menu clicks, nothing more. 0x49 consumablesTrainingManagerhas no family on the shelf, and0x48/0x4dare structurally 0. If a consumable family is missing frombuild_consumables.py, 0x49 is where it goes. Not worth chasing until step 6 proves the channel works.0x3d/0x3e/0x40(newcards CONTRACTS/TRAINING/FITNESS) can never be set from the wire: no atom produces them. Dead. Do not chase./hubanswered with two keys on 32 requests per session. Dispatches through C++ reflection with no atom ladder, so there is nothing to enumerate. Confidence anything is wrong: low.
4. What could break, and the instant fallback
The shared-route risk, named explicitly. _club_stat_set() and club_stats_route() are the SAME code path that serves the live-proven screens: the 205/189/8/8 player tiers, the 29 nation buckets, and the ENGLAND to Premier League 17. Change (A) adds rows to that shared function. club_route itself is NOT touched by (A), but change (B) inserts two routes directly above it.
| Failure | Symptom | Instant fallback |
|---|---|---|
| Exception inside the new overlay hook (bad import, KeyError in the kind-to-statId bucket) | club_stats_route 500s, so the player counts AND the nation/league drill-downs all die at once |
FUT_CONSUM_STATS=0 and restart. Blunt alternative FUT_CLUBSTATS=0 also works but reverts club/stats to {} and loses the 17 |
Defensive route regex too loose, shadows /club |
Player, manager and coach club lists all go empty in one stroke, and the hub counter drops to 0 | Delete the two route lines and restart. Caught earlier by pre-flight check 3 (?type=player must return 205) |
| Rows prepended instead of appended | CLUBSTATS: ... global players=<wrong> in the log, which is the human's fastest sanity read |
Append after the existing rows. fut_club_stats.stats_body sorts by stat id so id 1 stays row 0 |
A consumables* name misspelt |
Silent: unknown atom resolves to stat id 0 and lands in a bucket nothing reads, which looks exactly like "the hypothesis is refuted" | tools/check_club_stat_vocab.py before the launch. Also: never send consumablesContract (0xa6), consumablesTraining (0xa7), consumablesFitness (0xa8) or lowercase leaguelogos (0x18d). Real atoms, no arm in FUN_18012fd40 |
Counts wired to STORE.items() |
Fourteen zeros, indistinguishable from step 6's decisive negative | Pre-flight check 2. Do not launch until it prints 126/21/7/21/3/20/21/6/0/0/3/19/5/0 |
| Staff bonus body wrong or the URL is not that class | Staff percentages garbage, or the staff tab misbehaves | Unset FUT_STAFF_BONUS and restart. The five staff counts come from a different store; if they break too, that is a larger signal and worth recording |
| Client freezes at boot | Only new response content is 19 extra stat rows plus the optional staff body. All four stat keys are proven scalars, so a scalar-where-object busy-loop at 0x1801c7f1a is unlikely but not impossible |
Kill FIFA, unset FUT_STAFF_BONUS and FUT_CONSUM_STATS, restart, relaunch |
| Save mutation | fifa17_profile.json changes |
The overlay is GET-time and writes nothing. Verify with the md5 from pre-flight check 4. Do not restart the server mid-match or mid-pack-opening: those paths write the save |
Two things NOT to ship this round. The /consumables/<segment> item body: never requested, gate it on step 7 exactly as its own report instructs. And any club-item body: never requested, no renderer, unresolved subtypes.
One correction not to land: the proposed edit to the comment at utas_server.py:1060. It says 0x3d/0x3e/0x40 can never be SET from the wire, which is correct and is not a claim about 0x3c/0x41. Churning an accurate comment costs trust in the file.