# 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.
