Files
OpenFUT/fifa17-recon/docs/plan-2026-08-05-consumables.md
T
funman300 0f83d73364 fifa17-recon: the consumables panel asks 41 times a session and we answer with players
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
2026-08-05 11:13:54 -07:00

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 (trophiesSeasonOnline 0x38, badgeDBid 0x2e, kitsHome 0x29, kitsAway 0x2a, leagueLogos 0x2f) appended unconditionally to _club_stat_set(), with a _consumable_overlay_counts() hook that counts the SHELF, not STORE.items(). Behind its own kill flag (suggest FUT_CONSUM_STATS, default on).
  • (B) two defensive routes above the generic /club at utas_server.py:958: /club/consumables and /club/loan, both returning {}.
  • (C) optional: club/stats/staff body behind FUT_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 (/settings is 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 /settings work 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 consumables request line from ProtoHttp. FUN_180042c40 (the FUT_SQUAD_CONSUMABLES_DP reader) 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.

  1. python3 tools/check_club_stat_vocab.py against 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.
  2. Confirm the counts come from the shelf and not the store:
    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})"
    
    Must print 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 wired STORE.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 one cardsubtypeid 0.
  3. 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=player must still return 205 items.
  4. md5sum fifa17_profile.json and 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.py can read the store live. Run it against the running process with the tab open: if it shows 0x42 = 7 in bucket 0 with mode tag 6, the store was written correctly and the panel is not fed by FUN_180043b90 case 6 / FUN_180095360 / FUN_180096670 case 0xb. All three candidate providers read the same ids from the same +0x800 store, 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 carry User-Agent: ProtoHttp. There are ZERO such lines in 9,900+ log lines, so one is decisive, and it hands us the base prefix that FUN_1801308c0 does 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 PATH log line. Measured on the live server: /ut/game/fifa17/consumables/contracts does log UNMAPPED, but /ut/game/fifa17/club/consumables/contract does NOT, and today it returns 331 items and 140,662 bytes of player cards. If the client composes onto ut/%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/staff is not deserialized by FutStaffBonusServerResponse at 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.json against 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

  1. 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_180048780 reads no stat store at all, and the UI layer that invokes it is not in cardsdll.dll. Cost: step 7, already in the script.
  2. Which base does /consumables/%s compose 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.
  3. 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 (ship maximumTradePileSize 100 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 flip storeEnabled / tradingEnabled / coinEnabled on the first pass: IS_STORE_ENABLED and IS_TRADING_ENABLED are literal UI state keys in .rdata and the store screen is live-proven working. Next round, gated on step 2's sweep.
  4. FutStaffBonus URL-to-class binding. Step 8 settles half of it.
  5. Why the FUT_CONSUMABLES=all arm has never fired for the game. The overlay serves only on an untyped /club with no drill-down, and all 9 of the client's untyped requests carry team=. Whether the client ever issues an untyped, undrilled /club is unknown. Cheap to settle from the log next session.
  6. 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_180151610 builds <base>/objective/%d/reward, FUN_180147780 builds .../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.
  7. 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 to FUN_180119bd0 to localized name) is inferred, never traced. Budget: four menu clicks, nothing more.
  8. 0x49 consumablesTrainingManager has no family on the shelf, and 0x48 / 0x4d are structurally 0. If a consumable family is missing from build_consumables.py, 0x49 is where it goes. Not worth chasing until step 6 proves the channel works.
  9. 0x3d / 0x3e / 0x40 (newcards CONTRACTS/TRAINING/FITNESS) can never be set from the wire: no atom produces them. Dead. Do not chase.
  10. /hub answered 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.