# The card subsystem: the item record, club-item subtypes, and the card lifecycle Written 2026-08-06. Six parallel reversing passes over the shared item deserializer, the club-item families, the discard lookup, rating authority, the club route table and the live heap, plus three adversarial verification rounds that refuted four claims, corrected fourteen more and closed two gaps the original passes had declared unresolvable. FIFA 17 was running throughout as pid 183351, in Ultimate Team, and was read strictly read-only. No server was restarted, no server code was changed, and `tools/fifa17_profile.json` was never opened for write. Slide used for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, re-derived from `/proc/183351/maps` by five agents independently rather than asserted, and proved each time against bytes read from the on-disk PE in two different sections. Controls were the FNV hasher prologue at `0x180180d00` (`.text`) and a single-occurrence `.rdata` literal, each agent picking its own. One verifier added a third control that is worth stealing: the `.data` pointer at `0x1802d2760` deliberately MISMATCHES between file and memory, and the delta is exactly the slide, so the relocation itself confirms the arithmetic. The live addresses die with the process; the static ones do not. One methodological note before anything else, because it changed a verdict. The key hash is **FNV-1** (multiply, then xor), not FNV-1a. A verifier's first pass used FNV-1a against all 907 dictionary names and scored 0/907; the positive control caught it immediately. `docs/plan-2026-08-05-store-subsystem.md` calls `0x811c9dc5` "the FNV-1a offset basis". The basis is shared; the round order is not. --- ## 1. What we now know that we did not know this morning **The greyed-out transfer options are explained, both gates are identified, and one of them was measured at 0 in the live client today.** This has been an unexplained live observation for two days and it is now a two-row server fix. `FUN_18003e370` publishes eight per-card action booleans to Flash, and the fourth is `TO_TRADE_PILE`. Its predicate is `FUN_1801a7260`, which returns 1 only if **both** of two conditions hold: the item's tradeable byte at `+0x49` is non-zero, and slot `+0x270` of the `0xed84b12` service returns non-zero. We fail both. We fail the first because `_item()` hard-codes `"untradeable": True`, and the deserializer stores that field **inverted** (`local_140 = CONCAT11(cVar6 == '\0', ...)`), so our every card has `+0x49 == 0`. We fail the second because that slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and `0x1fd2e` is the `tradingEnabled` gate byte that `docs/ENDPOINT_MAP.md` already maps to settings field `[10]`. That second half was the open gap in the source material, and it is now measured rather than argued: ``` pid 183351 base 0x6ffffc140000 slide 0x6ffe7c140000 CONTROL A .text FNV prologue: MATCH DAT_1802e6398 -> 0xb7b4b920 vtable static 0x18021c2a0 slot +0x270 -> 0x18011c670 stub=0fb6812efd0100c3 disp=0x1fd2e VALUE=0 slot +0x2b0 -> 0x18011c500 stub=0fb6813afd0100c3 disp=0x1fd3a VALUE=1 slot +0x2c8 -> 0x18011c4b0 stub=0fb6813dfd0100c3 disp=0x1fd3d VALUE=1 slot +0x2e0 -> 0x18011c590 stub=0fb68145fd0100c3 disp=0x1fd45 VALUE=1 ``` The three controls are the gate bytes `tools/gate_byte_probe.py` already measured as 1 on two earlier launches. They come back 1 here, in the same walk, off the same object, with each displacement decoded from its own accessor stub rather than taken from a table. So the walk works and the zero is a fact about trading, not an artefact. `GET /settings` on the live server still answers `{"configs": []}`, which I fetched read-only to confirm. This also settles something the settings-gate document left ambiguous. That document's correction established that the applier runs even with an empty configs array and that the struct it is handed defaults several fields to 1. Trading is not one of them: its default is 0. So `tradingEnabled` is not a flag we have been overwriting, it is a flag nobody has ever sent, and `tools/utas_server.py` **already has the row plumbed** -- `tradingEnabled` is in `_SETTINGS_KEEP`, reachable today with `FUT_SETTINGS=keep`. The transfer-list fix is one existing env flag plus one boolean, and section 6 has both. Be precise about what this does and does not claim. It explains why the menu entry is greyed. It does not promise that the transfer market works behind it. **The card model now exists as a document.** Section 2 is the reference artifact this project has never had: every field the server sends, whether the client parses it, the byte it lands on, who wins when the client's own database disagrees, and a grade with an address. Twenty-eight rows, of which twenty-one are CONFIRMED with an address, four are INFERRED, and three are honestly UNKNOWN. It was built the only way it could have been: a live bijection over 22 resident records to find the offsets, and the deserializer's own stack-slot arithmetic to name the ones the live data could not separate. **Club-item subtypes are settled, and the starting premise was wrong for three of the five families.** Kits, stadia and badges are **cardtype 7**, subtypes 9, 10 and 11. Only balls (30) and league logos (31) are cardtype 9. The 0x91..0x96 block that `CARD_SYSTEM.md` and `tools/fut_clubitems.py` assign to club customisation is **trophies**. Every one of the five subtype constants currently in `FAMILIES` is wrong, and all five sit inside the trophy block. Section 3 has the evidence and the one probe still outstanding. **The discard "miss" was never a miss.** The premise that has been carried in `fut_store.py`'s own comments -- "that lookup returns no row for our cards" -- is false. `fcc_discardcoins` is resident in the running client with all 141 rows, dumped independently by two agents and byte-identical to the repo's reconstruction both times. Every one of the 22 resident records keys a real row. On the two records where our `discardValue` guard did not fire, the client's own stored answer at `+0x3c` reproduces the table evaluation **to the unit**: the gkcoach card at rating 66 computes 36, the chemistry style at rating 95 computes 38. The lookup runs, hits, and is right. The real cause is a consumer split. `FUN_1800eb850` pushes two separate named properties to Flash, `DISCARD_CREDITS` from `+0x38` (ours) and `CALCULATED_DISCARD_CREDITS` from `+0x3c` (the client's), and no native code anywhere in CardsDLL selects between them, sums them, or falls back from one to the other. Each getter has exactly one call site, confirmed by two agents using two different methods. The quick-sell tile binds `DISCARD_CREDITS`, so a card we do not price renders 0 while the correct number sits one dword away, unread. That is a negative result and it deletes work: there is no card field we get wrong, no table row to add, and nothing to fix in the formula. `FUT_DISCARD_SEND=1` is not a workaround for a data defect, it is the only wire input the tile reads, and it should stop being described as a workaround. **What FUT pays for staff is no longer unknown.** The brief listed this as an open question and the fallback in `discard_value()` documents it as a guess not worth shipping. It is the same formula, and the rating input is the staff table's own `value` column. The gkcoach card 9000081 has `value: 66` in `data/tables/gkcoachcards.json`; the merge writes that column into `+0xb4`; `round_half_up(66 * 55 / 100) = 36`; and the client's independently computed `+0x3c` reads exactly 36. One data point, but it is an exact one, and the mechanism is read out of the merge rather than curve-fitted. Section 6 has the patch, which is server-side arithmetic with no wire change at all. **Two of the eleven items in the pending pile are unpriced, and the cause is one missing wrapper rather than a missing formula.** `Profile.items()` stamps `discardValue` on the way out; `Profile.purchased()` returns the raw pile. I ran the live pile through the server's own `discard_value()` read-only: the chemistry style 100000283 comes back **38**, matching the client's own computation to the unit, and it is simply not on the wire. The staff card returns `None` because it carries no rating, which the previous finding fixes. **A confident absence claim was wrong, and the way it was wrong is worth more than the fact.** One pass established at HIGH that `playStyle` is stored nowhere in the item record, and did it properly: an exhaustive bijection over every offset at four widths across 22 records, with same-value constant controls (`contract == 7`, `fitness == 99`) that the same pass found. The controls passed. The claim is still false. `playStyle` lands at `+0x88`, written by deser case `0x23f`, and the search could never have found it because the value passes through `FUN_180136480`, a switch accepting only `0xfb..0x111` with `default: return 0`. We send 250, one below the first case, so the client stores 0 and no search for the literal 250 can succeed. The controls were **raw scalars written straight through**; the target was a **decoded scalar**. The control did not match the target's form. That is the absence trap in a costume nobody had seen: not a grep-versus-switch mismatch this time, but a same-form control that was the wrong form for a reason invisible in the live data. Add it to the list: when testing for a field's presence by value, first ask whether the value you sent is the value the client would store. **`amount` is also not dropped, and the correction is corroborated by a screenshot we already have.** Ground truth recorded `amount` as absent from the record. It lands at `+0xbe` for chemistry styles (subtypes 250..273) and `+0xbf` for other consumable classes, written by `FUN_18013f4d0` as a single **byte**. Live: item 100000283, we sent `amount: 5`, `+0xbe == 5`. The original sweep looked for a u32 and explicitly dismissed the stray single bytes. Independently, `CARD_SYSTEM.md` already records consumables drawing correct "+5 / +10 / +15" badges and attributes that to "atom 0x1b reaching record+0xbf" -- a live screen observation from 2026-08-05 that agrees with the static reading for the other consumable classes. Two routes, same answer. Three smaller things worth carrying forward. **`definitionId` is not an atom.** It is absent from the 907-row dictionary at `0x1802D2760`. I checked the repo's own `docs/fut_atoms.tsv` directly rather than taking it on report: `awk` for `definitionId` returns nothing while every neighbour resolves. A verifier went further and walked the **live** key map, recovering 908 nodes, scoring 907/907 on the dictionary as a positive control, and showing `definitionId`'s hash collides with nothing -- so it cannot even be silently misrouted onto another field. `_item()` sends it on every card. It is pure wire cost and dropping it is free. **`itemType` is parsed and thrown away.** Case `0x173` copies the string into a heap buffer that lives *below* the record base in the frame, so it is outside the 0x180-byte record by construction, and nothing copies it in. This matters mainly because we send `"itemType": "player"` on a chemistry-style consumable, which is wrong and harmless, but which also made the `+0x54` question look undecidable when it was not. **The client does not hold the club.** A 2.80 GiB sweep found only 31 of 249 served item ids resident: 22 as full records (the 11-item pending pile plus an 11-item club page) and 9 as bare u32. The nine were initially read as squad slot references; a base-rate test refutes it. Background density in that id range predicts 22.8 noise hits in our window and we observed 9, which is *below* noise, and eight of the nine appear exactly once in memory where every genuine record id appears three to ten times. So `/club` paging is real and any future probe that expects to find a 246-item array will fail for residency reasons, not structural ones. --- ## 2. The card model, field by field This is the reference the project has not had. "Where it lands" is an offset into the 0x180-byte parsed item record built by `FUN_18013fe00`. Two independent methods produced it and they agree everywhere they overlap: - **Live bijection.** 22 resident records at `0xb7b61a78..0xb7b639f8`, every offset tried at widths 1/2/4/8, each field correlated only over the records that carried it. Reproduced from scratch by three agents with the same result. Rows it produced alone are marked *(live diff)*. - **Frame arithmetic.** `FUN_18013fe00` builds the record as a stack struct and hands `&local_188` to the merge, so `record_offset = 0x188 - X` for every Ghidra local `local_X`. This is structural, not heuristic, and it was validated against nine fields with independent corroboration before being used to settle anything. It is what decides the fields the live data cannot, because every resident record carries the same value. Authority means: who wins when our JSON and the client's local database disagree. **OURS** = server-authoritative, the wire value reaches the screen. **CLIENT** = overwritten or ignored, sending it changes nothing. **CONDITIONAL** = depends on the card family or on whether we send zero. ### Fields we send today | field (atom) | parsed | lands at | authority | grade | address | |---|---|---|---|---|---| | `id` (0x15c) | yes | `+0x08` u64, dup at `-0x08` | OURS | CONFIRMED | live diff, 22/22 | | `resourceId` (0x287) | yes | `+0x18` u32, version byte `+0x24` | OURS, and it is the only identity that matters | CONFIRMED | `FUN_180166ca0` | | `assetId` (0x23) | yes | `+0x20` u32 | CONDITIONAL: dead for families 1–5 and 10; live elsewhere; **required** for stadium (subtype 10), whose caption is `StadiumName_` | CONFIRMED | `FUN_180119bd0`, `FUN_180135890` | | `cardassetid` (0x6b) | yes | `+0x1c` u32 | CONDITIONAL: clobbered for players by `rec[0x1c] = rec[0x18] & 0xffffff`; **live for every family the merge has no arm for**, which is 6,7,8,9 and the club families | CONFIRMED | `FUN_180135890` line 1; merge arms 1,2,3,4,5,10 only | | `definitionId` | **no** | nowhere | not an atom at all | CONFIRMED | absent from `fut_atoms.tsv` and from the live key map, 907/907 control | | `cardsubtypeid` (0x6c) | yes | `+0x50` u32; family `+0x4c` = `FUN_1800d8330(subtype)` | OURS, and it selects everything downstream | CONFIRMED | live diff 22/22; `FUN_1800d8330` read in full | | `itemType` (0x173) | yes, then discarded | nowhere in the record | ignored | CONFIRMED | case 0x173 writes a heap string below the record base | | `rareflag` (0x271) | yes | `+0x58` u32 | OURS | CONFIRMED | live diff 21/21; discard key `uStack_130 & 0xffffffff` | | `rating` (0x274) | yes | `+0xb4` u8 | CONDITIONAL: **OURS for family 1**; overwritten from the DB `value` column for families 2,3,4,5,10 | CONFIRMED | `FUN_1801a87f0`; merge staff arms | | `preferredPosition` (0x24a) | yes | `+0x146` u16 | OURS | CONFIRMED | live diff, exact 11-way bijection | | `nation` (0x1d1) | yes | `+0x148` (family 1), `+0xde` (family 2), dropped otherwise | OURS **when non-zero**; the merge fills it only `if (rec[0x148] == 0)` | CONFIRMED | deser tail lines 714–720 | | `teamid` (0x306) | yes | `+0x94` u32 | OURS **when non-zero**; merge fills only if 0. **Required** for kit (9) and badge (11): the caption is `TeamName_Abbr15_` | CONFIRMED | live diff 20/20; `FUN_180119bd0` | | `leagueId` (0x18a) | yes | `+0xe0` u16 | **Dead for players** -- the merge writes `+0x154` unconditionally on the DB-hit branch, and `+0xe0` is swallowed by the `+0xdd` commonname buffer. **OURS for managers** | CONFIRMED | `FUN_1801a8540`; manager merge `FUN_1801356c0` writes only `+0xb8`; live manager cards already draw "LaLiga Santander" from our JSON | | `playStyle` (0x23f) | yes, **decoded** | `+0x88` u32 | OURS, but only for values `251..273`; anything else stores 0 | CONFIRMED | case 0x23f → `FUN_180136480`; `FUN_1801a85c0` | | `attributeList` (0x31) | yes | `+0x98..+0xac`, 6 × u32, index order | OURS for family 1; the staff arms overwrite it from the DB | CONFIRMED | `FUN_1801a8450`, live diff 20/20 | | `itemState` (0x172) | yes, string → enum | `+0x5c` u32 | OURS | CONFIRMED | case 0x172 → `FUN_180166660`, table `0x180229cc0` | | `owners` (0x207) | yes | `+0x48` u8 | OURS. Constructor default is **0**, so omitting is not the same as sending 1 | CONFIRMED | case 0x207; `FUN_1801a89f0` | | `untradeable` (0x361) | yes, **inverted** | `+0x49` u8, holds *tradeable* | OURS. Constructor default is **1**, so omitting is equivalent to sending `false` | CONFIRMED | case 0x361 `CONCAT11(cVar6 == '\0', ...)`; `FUN_1801a7260` | | `contract` (0xb8) | yes | `+0x8c` u32 | OURS | CONFIRMED | live diff | | `fitness` (0x128) | yes | `+0xb0` u32, dup u8 at `+0x64` | OURS | CONFIRMED | live diff | | `discardValue` (0xd7) | yes | `+0x38` u32; client's own answer at `+0x3c` | OURS, and it is the **only** value the quick-sell tile reads | CONFIRMED | guard `0x180141025`; `FUN_1800eb850` | | `amount` (0x1b) | yes | `+0xbe` u8 for subtypes 250–273, `+0xbf` u8 otherwise, `+0x98` for cardtype 9 | OURS | CONFIRMED | `FUN_18013f4d0`; live `+0xbe == 5`; corroborated by the "+5/+10/+15" badges observed 2026-08-05 | | `pile` (0x226) | **no** | n/a | ignored on itemData. `+0x60` is set by the owning list, not the wire | CONFIRMED | no 0x226 arm in `FUN_18013fe00`, checked in all four dispatch forms | | `rating` on staff | see above | n/a | ignored on the wire, but **load-bearing server-side** as the discard input | CONFIRMED | merge staff arms; `discard_value()` | ### Fields we do not send and could | field (atom) | lands at | why it is worth knowing | grade | |---|---|---|---| | `lastSalePrice` (0x185) | `+0x34` u32 | published to Flash as `BOUGHT_FOR`. We leave it at 0 today. Upgraded from MEDIUM to HIGH by a verifier who resolved the arm | CONFIRMED | | `localizedName` (0x19c) | `+0xd9` (0x38 bytes, cardtype 9); `+0xbc` (0x1f bytes, cardtype 7) | cardtype 9 has no DB resolver, so a ball's displayed name can only come from the wire | CONFIRMED (offset), UNKNOWN (safe to send) | | `description` (0xd1) | `+0x111` (cardtype 9); `+0x10f` (cardtype 7) | see the unsettled note below | CONFIRMED (offset), UNKNOWN (does anything read it) | | `loans` (0x19b) | `+0x90` | **do not send.** `loans > 0` with `contract == 0` is the client's definition of an expired loan and it greys out `MODIFY` | CONFIRMED | | `injuryGames` (0x167) | `+0x145` | **do not send non-zero.** It kills `TO_TRADE_PILE` for players independently of everything in section 4 | CONFIRMED | | `value` (0x377) | n/a | **never send.** It is an object member elsewhere (`displayGroup {"value": ...}`) and is the prime suspect for the 2026-08-05 crash | established prior work | ### Record fields that are not wire fields `+0x25` is the item's index inside the `itemData` array that built it. `+0x30` is a client-generated timestamp written by `FUN_1800d84e0()` in the deser tail with no wire input -- one agent reads it as a boot-relative millisecond tick, another as rdtsc-derived, and the disagreement does not matter because nothing we send reaches it. `+0x54` is the discard **level** (3/2/1 by thresholding `+0xb4` at 0x4b/0x41), written unconditionally at the merge tail; the previously recorded "itemType enum 3=player 2=staff" is refuted, see below. `+0x60` is the pile, assigned by the owning list. `+0x70` is a vtable pointer that differs between the club and purchased lists. `+0xb8`/`+0xc8`/`+0xdd` are inline name buffers filled from the local player DB. `+0x14c` is the position group, recomputed from `+0x146` by the client's own ladder (0 → GK, 1–8 → DEF, 9–19 → MID, 20–27 → ATT). ### The +0x54 conflict, and why it is settled Two passes graded contradictory claims about `+0x54` at HIGH: one said the discard level, one said an itemType enum with a "perfect bijection {player:3, staff:2}". The bijection is real and it is degenerate. Over all 22 resident records the two hypotheses predict the same value on 22 of 22, because every resident player is rated 75 or above and the one staff card is rated 66. Worse, the tie is partly our own doing: we serve `"itemType": "player"` on the chemistry-style consumable, so even the one record that should have discriminated does not look like it does. Static evidence settles it four ways. `FUN_180141660`'s tail has exactly one write to `+0x54` and it is the rating ladder at `0x180141e8a..0x180141ea3`. That tail is reached on every path: the switch on `+0x4c` has arms for 1,2,3,4,5,10 and no default, so every other family falls straight through, and the only escape is the `item+8 == 0` entry guard. No dispatch arm of `FUN_18013fe00` writes the stack half that maps to `+0x54` -- all 52 case labels plus the three `==` arms and the three range splits were enumerated. And the deser tail passes `+0x54` to the `fcc_discardcoins` query as the **`level`** column, whose semantics are already independently established. `+0x54` is the level. `itemType` is not stored at all. I would bet on the level reading without reservation, and the falsifier is one screen: serve a player rated 60 and `+0x54` must read 1. The save already holds twenty sub-75 players (100000161 at 53, 100000162 at 59, 100000159 at 65) that would decide it if they were paged in. --- ## 3. Club item subtypes **The answer, for four of five families with certainty and the fifth by elimination:** | family | cardsubtypeid | cardtype | resolver | what it additionally needs | |---|---|---|---|---| | kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_` | `teamid` | | stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_` | `assetId` | | badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_` | `teamid` | | ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` | | league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably | The premise that all five live in cardtype 9 is wrong, and the root fact is not an inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two agents, contains `case 9: case 10: case 0xb: return 7;`. Kits, stadia and badges are cardtype 7, which **does** have a resolver, reached from `FUN_1800f6c40` only when `item+0x4c == 7`, called with `(subtype, teamid, assetId)`. The manager vtable slot was verified from both disk and live memory: qword at `0x18021c2a0 + 0x498` is `0x180119bd0` on disk, and the same value comes back from a read-only deref of `DAT_1802e6398` in the running client. Four independent lines agree on kit = 9. The resolver's own arms. The card-detail builder `FUN_1801bfac0`, which switches on a verified `cardsubtypeid` accessor (`FUN_1801a8640` is literally `return *(u32*)(*(long*)(p+0x18) + 0x50)`) and gives subtype 9 the `FUT_UC_KITS` / `FUT_ThirdKit` / `KitBioAwayDescription` family, 10 the `Stadium` / `StadiumDetailDesc` family and 11 the `Badge` / `badgeBioDescription` family with a leagueteamlinks lookup. An `IS_KIT_%d` flag in `FUN_1800f6c40` set when `item+0x50 == 9`. And the deserializer itself, which for cardtype 7 with cardsubtypeid 9 defaults `cardassetid` to **0x23 = 35** -- which is exactly the `cardassetid` carried by every one of the 1482 rows of `fcc_kitcards`. **0x91..0x96 are trophies, not club customisation.** `FUN_180108c00` deserializes `tournamentType` (0x32f) and computes `subtype = value + 0x91`, then picks `TOURNY_LOC_%d` for `0x91 <= s < 0x95` and `SEASON_LOC_%d` for `0x95 <= s < 0x97`. `FUN_1800fed90` is the only function in the binary whose switch case set is exactly `{0x91..0x96}` -- established by enumerating all 8767 `caseD_` symbols with `FUN_1800d8330`'s own jump-table labels present as the control -- and it maps them to (tournament, 0..3) and (season, 0..1). `FUN_1801017e0` builds `FUT::TournamentInfo` objects and maps a 0..3 enum onto `0x91..0x94`. So `tools/fut_clubitems.py` currently assigns all five club families ids inside the trophy block, and `probe_shelf()`'s candidate set `{30,31,145..150}` **cannot find the answer for kits, stadia or badges**, because 9, 10 and 11 are not in it. That probe would have burned a launch and returned nothing for three of five families. **The near-miss that would have produced a sixth wrong verdict.** The enum table at `0x180229ab0` reads `... physio=9, badge=0xa, kit=0xb, leagueLogo=0xc, ..., stadium=0x15, ball=0x16`. For players and staff it is exactly `cardsubtypeid + 1`, which makes badge 9, kit 10 and leagueLogo 11 look like the answer. It is not: that table feeds the transfermarket `&cat=%s` query parameter in `FUN_180162c90`, and reading it as a subtype map swaps badge and kit and loses stadium entirely. Two agents found this table; one of them nearly published it. ### The one probe still outstanding League logo = 31 is by **elimination**, and the elimination is airtight on its premises but the premises are exhaustive-search results rather than a caption. `FUN_1800d8330`'s cardtype-9 set is exactly `{0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, 0xec}`; the `0xe7..0xec` block is matched exactly by `fcc_misccards`' cardsubtype column `{231,232,233,236}`; `0x91..0x96` are trophies; `0x1e` has the `FUT_UC_BALL` caption. That leaves one slot. There is no `FUT_UC_LEAGUELOGO` literal anywhere in the DLL -- a verifier enumerated all 19 `FUT_UC_*` strings by regex over the whole file -- so no caption can confirm it. **The minimal probe, specified to run without further thought.** One item, one family, no unestablished extras: ```json {"itemData": [{ "id": 960000001, "resourceId": 8010001, "assetId": 8010001, "cardassetid": 40, "cardsubtypeid": 31, "itemState": "free", "owners": 1, "untradeable": false, "localizedName": "PROBE LEAGUE LOGO", "description": "PROBE" }]} ``` Served on `GET club?type=leaguelogos`, with `resourceId` taken from row 0 of `data/tables/fcc_leaguelogos.json` rather than the literal above. Expected: the league crest draws and the name is the string sent. If a ball caption appears, 30 and 31 are swapped. If nothing draws at all, league logos are not cardtype 9 and the elimination is unsound. Cost is one launch and it can ride along with any other club-item test. Note two things about that body. `untradeable: false` is deliberate and free -- the constructor default for `+0x49` is already 1, so sending `false` matches the default rather than departing from it. And `localizedName` and `description` are the one part of the probe that is *not* established as safe: the parser reads both as strings (deser cases 0x19c and 0xd1, both STR), but per this project's own rule, "the parser reads X" is not "sending X is safe". They are included because a cardtype-9 item has no other source of a display name, so a probe without them cannot tell "wrong subtype" from "right subtype, no name". ### An unsettled disagreement, recorded as unsettled One pass concluded that a ball needs both `localizedName` and `description`; a verifier showed that the ball's *subtitle* accessor `FUN_1801a8560` reads `+0xba`, while the `description` atom demonstrably lands at `+0x111` for cardtype 9. Either something else writes `+0xba`, or the ball subtitle is fed by a slot no wire atom reaches. The title half is agreed: `FUN_1801a8570` reads `+0xd9`, which is where `localizedName` lands. **I would bet on the verifier**, because its offsets come from the deserializer's own frame arithmetic rather than from inferring backwards from an accessor. So: send `localizedName` and expect it to show; send `description` and do not be surprised if nothing changes. The same `+0xba` also holds the unresolved kit-variant selector, so these two gaps may be one gap. ### The cardtype-9 name gap is ONE gap, not three (2026-08-21) Worth stating plainly, because it was being tracked as three separate holes. Everything OpenFUT still refuses to project is cardtype 9, and for exactly the same reason: | family | subtype(s) | definition table | why withheld | |---|---|---|---| | ball | 30 | `fcc_balls` (42) | no DB name resolver | | league logo | 31 | `fcc_leaguelogos` (44) | no DB name resolver | | misc | 231, 232, 233, 236 | `fcc_misccards` (42) | no DB name resolver | The cardtype-7 families (kit 9, badge 11, stadium 10) all resolve their caption from the client's own tables through `FUN_180119bd0`, so the server sends only identity and the name takes care of itself — which is why all three now project. Cardtype 9 has no such resolver, so the displayed name can ONLY come from `localizedName` on the wire, and that single unproven step gates all three families at once. Closing it closes the last of the ownable taxonomy. It needs the launch-driven probe in "The one probe still outstanding" above — one item, one family — and nothing else. Ownership, `content_kind`, club/stats counting and restart durability are already in place for all three, so the probe is the only remaining work: the projection arm is a two-line change once the name is proven. --- ## 4. The card lifecycle ### The complete itemState vocabulary Twelve entries in one NUL-terminated table at `0x180229cc0`, stride 0x10, `{const char* name, u32 value}`, walked in full by three agents from both disk and live memory. `FUN_180166660` is a linear walk over it and returns `0xffffffff` for anything not in the table. | string | value | what it permits | |---|---|---| | `invalid` | 0 | no consumer found. **This is the value an item gets when we omit `itemState`** | | `free` | 1 | the normal owned state; accepted by the squad builder; written back on unequip | | `WAITING_FOR_GAME` | 2 | alias of `inGame` | | `inGame` | 2 | accepted by the squad builder | | `forSale` | 5 | **never tested anywhere in CardsDLL** | | `offered` | 6 | **never tested anywhere in CardsDLL** | | `activeBadge` | 100 | equipped; drives the `IS_ACTIVE` tick | | `activeHomeKit` | 101 | equipped; additionally read by the kit swap, which needs more (below) | | `activeAwayKit` | 102 | equipped; ditto | | `activeBall` | 103 | equipped; the unequip path writes `free` back over it | | `activeStadium` | 104 | equipped | | `active` | 255 | no consumer found | The previous record in `CARD_SYSTEM.md` starts this table at `0x180229d20`, which is the middle of it, and therefore misses `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`. Immediately *before* the table, at `0x180229c30..0x180229cb0`, sits the itemType vocabulary (`any=-1, player=1, staff=2, clubInfo=3, training=4, development=5, stadium=6, ball=7`), which is exactly the table a reader arriving from the wrong direction would confuse with this one. **Omitting `itemState` is not the same as sending `free`.** The constructor initialises the 16 bytes covering `+0x50..+0x5f` from `_DAT_1801f66a0`, read as `56010000 00000000 00000000 00000000` from both disk and memory, so the default is 0 = `invalid`, and an item left at 0 fails the squad builder's `state == 1 || state == 2` acceptance test. Always send it. `forSale` and `offered` being untested survived a hard attack and it is worth recording how, because the original argument could not have established it. A scan that collects compare *immediates* cannot evaluate a compare against a *register*, and there are two such compares on `+0x5c`. A verifier resolved both: at `0x1800d7588` the register holds `(param_4 != 2) + 0x65`, and at `0x1801b3894` it holds a constant 1 loaded once and never reassigned. Neither can be 5 or 6. The same verifier found four *write* sites storing literal 5 and 6 into `[reg+0x5c]` in `FUN_180147070` and had to open it to establish that it is a different struct entirely. That is exactly the shape of hit that has produced wrong verdicts here before, and it was caught only by reading it. ### The eight action flags `FUN_18003e370` publishes eight per-card booleans to Flash. The link from those eight names to the eight bytes filled by `FUN_1800e2a40` was originally anchored only semantically; a verifier closed it by GUID. `FUN_180018bd0` requests service `0x10c80b95` and casts to interface `0x10c80b96`; the cast stub for that interface is at `0x1800e1660`; its only pointer sits at `0x180215b28`; the cast helper is vtable slot `+0x18`, so the vtable base is `0x180215b10`; and slot `+0x40` of that base is `FUN_1800e2a40`, which is the slot `FUN_18003e370` calls. The arithmetic was checked against a second interface as a control. | flag | rule | can the server move it | |---|---|---| | `DISCARD` | 0 only when `+0xb5` dream set and `+0xb6` clear | no (we send no dream) | | `MODIFY` | 0 on that dream condition, or `loans > 0 && contract == 0` | yes -- by not sending `loans` | | `TO_ACTIVE_SQUAD` | family in {1,2} and squad count < 0x17 and not already in the squad | indirectly | | `TO_TRADE_PILE` | service gate **and** `+0x49` tradeable **and** (players: `injuryGames == 0`, statsList[4] and [5] zero; others: subtype not in `{0xe7,0xe8,0xe9,0xec}`) | **yes, see below** | | `TO_STICKER_BOOK` | item valid, family not in {0,-1}, `+0x10 == 0` | no | | `MAY_BE_REMOVED` | constant 1 | no | | `QUICK_SEARCH` | dream card or expired loan | no | | `DREAM_REPLACE` | `loans > 0 && contract == 0` | yes, by omission | `itemState` is consulted by none of the eight. If you want the transfer menu back, `itemState` is the wrong lever. ### Why "Place on Transfer List" is greyed, completely Both conditions fail and both are ours to fix: 1. `+0x49` is 0 on every card, because `_item()` sends `"untradeable": True` and the deserializer stores the negation. 2. The service gate at slot `+0x270` reads the `tradingEnabled` byte `0x1fd2e`, measured **0** in the live client today, with three control gates reading 1 in the same walk. `GET /settings` still answers `{"configs": []}`. Doing only the first will look like the finding failed. Section 6 does both. Two side effects of flipping `untradeable`, neither a blocker but neither predicted by the original claim that `+0x49` has exactly two consumers. There is a third, `FUN_1800bc580`, which walks 11 squad slots and counts untradeable members; that count is published to Flash as `UNTRADABLE_COUNT` and gates squad submission in `FUN_1800bba10`, which currently takes the `couldNotSubmitSquad` branch. Both effects move in the permissive direction. There is also a second escape hatch in that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if squad submission behaves oddly afterwards, that is where to look. ### Equipping club items `itemState` really is the equip mechanism for the `IS_ACTIVE` tick: `FUN_180084720` and `FUN_180094220` each test `+0x5c` against `0x64..0x68` and publish the result, and the ball equip path `FUN_180113870` writes `0x67` on equip and `1` on unequip. But do not present it as a working kit swap. `FUN_1801c3480` gates the home/away kit read on `family == 7 && *(int*)(item+0x60) == 4`, and `+0x60` is not wire-derived: there is no `pile` arm in the item deserializer, and live it reads 1 for every `/club` item and 6 for every `/purchased` item. We cannot produce 4 on any route we know. So `activeHomeKit` will light the tick and will not change the kit. --- ## 5. Remaining unknowns, with the cheapest experiment for each ### Needs decompiling only **Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60` returns 1688 and 4144 instructions) because it is a common struct offset. Two filters cut it to a readable set: only an IMMEDIATE store can introduce a constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c` nearby. Measured with `fifa17-recon/tools/kit_gate_probe.py` against pid 6580: | evidence | result | |---|---| | live `+0x60`, all 27 resident records | `{1: 23 players, 0: 4 staff}` — never 4 | | `cmp dword [reg+0x60], imm8` in CardsDLL | 4 sites: `0`, `0`, `1`, `4`; the `4` is the gate and is UNIQUE in the process | | immediate stores to `[reg+0x60]`, CardsDLL | 29; constants `{-2, 0, 1, 908, 0x3f800000}` — no 4 | | immediate stores of 4, FIFA17.exe (79 MB) | 0; also 0 comparisons against 4 | | xrefs to the gate function | 1 (`jmp` from `0x1801a5329`); address never taken | | register stores to `+0x60`, CardsDLL | all struct copies or inits to 0/1/-2 | So the blocker is not a wire field we have not learned to send: the value the gate demands is never produced by anything. Every OTHER input to the gate is already served — `+0x4c == 7` (subtype 9), `+0x5c` 101/102 (`activeHomeKit`/`activeAwayKit`), `+0x94` teamid — leaving only the `+0xba` variant selector below it. A client-side patch is therefore the only remaining avenue, and a small one; it is not proposed here. **The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed byte). Which wire atom sets it is unknown, so we cannot serve a specific kit deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle. **`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no action today because we send no `statsList`, but it is two minutes of work and it would close the chain. **The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved. What remains is whether the field is visible anywhere worth populating. **`FUN_1800fed90` has zero direct xrefs.** The trophy subtype mapping does not depend on it (`FUN_180108c00` carries the same mapping independently), but the dispatch table that reaches it was not identified, and trophies are a whole unimplemented family. **Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 — CASE-SENSITIVE.** It was expected to be unresolvable statically, because `FUN_180008190` is nothing but a forwarding stub through a runtime-populated slot: ``` mov rax, [DAT_1802ddfd8] ; service object, handed to CardsDLL by the host mov r9, [rax + 0x248] jmp r9 ``` Resolved read-only against the running client (pid 6580) with `fifa17-recon/tools/service_ptr_probe.py`, which follows the chain and attributes each hop to a module (Wine maps PE sections anonymously, so the module comes from the nearest preceding named mapping): ``` *(service + 0x248) = 0x146d1c020 FIFA17.exe+0x20f9020 e9 … jmp rel32 → 0x145e27fe0 FIFA17.exe+0x1204fe0 ff 25 jmp [rip+…] → 0x6ffffd11c330 msvcr120.dll+0x3c330 function body ``` The body is `strncmp`: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` (NUL stop) / `cmp al,[rcx+rdx]`, then MSVC's 8-byte fast path with the `0x8080808080808080` and `0xfefefefefefefeff` NUL-detect constants. There is no `or ..,0x20` and no folding table anywhere in the body, so the compare is raw bytes. CONSEQUENCE: a mis-cased token does not degrade, it matches nothing — `FUN_180166660` returns `0xffffffff`, the record keeps `0` = `invalid`, and the item fails the squad builder's `state == 1 || state == 2` test. The casing in the table at `0x180229cc0` is a contract. Send it verbatim; do not experiment on the live save. ### Needs a live probe (read-only, no launch) **Re-read `+0x30` after a refetch** to decide between "monotonic clock" and "sequence counter". Low value; nothing we send reaches it. **Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result proves `g_db` is loaded and complete; it says nothing about the separate database behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in FIFA17.exe and not in CardsDLL. These are different databases and they should stop being conflated. ### Needs a launch the user must drive -- ranked, and short 1. **Transfer list.** `FUT_SETTINGS=keep` plus `FUT_TRADEABLE=1`. Read the card action menu. This is first because it is the only item on the list that fixes a thing the user can see is broken, both halves are one-line changes, and one half is already measured. Falsifier if it fails: `+0x49` should read 1 and `0x1fd2e` should read 1; if both are 1 and the entry is still greyed, the remaining gate is `svc->0x308()` on `0xed80ed8`. 2. **Club items, one family at a time, kits first.** Kits have the loudest failure mode: a wrong `teamid` produces a visibly wrong team abbreviation rather than silence, which is the opposite of the cardtype-9 families and is why this ordering is not arbitrary. Then badges, then stadia, then balls, then the league-logo probe from section 3. 3. **The `+0x54` discriminator.** Serve one sub-65 player on the visible club page. Predicts `+0x54 == 1` and a bronze face. Free if it rides along with anything else; not worth a launch of its own, since the static case is already four-deep. 4. **`playStyle 251`.** Predicts `+0x88 == 1` and a visible chemistry badge, and confirms the decoder bounds on real data. Also rides along. Everything below rank 2 should ride along with something above it. The scarce resource is menu trips, not tests. --- ## 6. Proposed patches None of these are applied. All are env-flagged and default to the current behaviour, per the house rule that a flag defaults to the live-proven value. ### P1 -- `FUT_TRADEABLE`: make cards listable Two halves, and the first is already implemented. Half one is an environment change only: run with `FUT_SETTINGS=keep`, which emits the existing `_SETTINGS_KEEP` rows including `tradingEnabled`. Half two, in `tools/fut_store.py`: ```python # FUT_TRADEABLE: send untradeable=false so the client's tradeable byte is set. # # "Place on Transfer List" is greyed on every card and BOTH of its gates are ours. # FUN_1801a7260, the TO_TRADE_PILE predicate published by FUN_18003e370, returns 1 # only if the service gate at vtable+0x270 of 0xed84b12 is non-zero AND item+0x49 # is non-zero. The deserializer stores untradeable INVERTED -- case 0x361 does # `CONCAT11(cVar6 == '\0', ...)` -- so untradeable:true writes 0 and kills the flag. # # The service gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the # tradingEnabled gate byte (ENDPOINT_MAP, FutGetSettingsServerResponse). Measured # live 2026-08-06 as 0, with three control gate bytes reading 1 in the same walk. # So this flag alone is NOT sufficient: it needs FUT_SETTINGS=keep beside it. # # Freeze risk: NONE beyond what we already send. untradeable is atom 0x361, read by # the BOOL primitive FUN_1801c7620, and we already send the key on every card -- only # the value changes. Every freeze on this project has come from feeding a container # where a scalar was expected; this does not change the shape of anything. # # Side effects, which are real but permissive. item+0x49 has a third consumer, # FUN_1800bc580, which counts untradeable squad members; that count is published as # UNTRADABLE_COUNT and gates squad submission in FUN_1800bba10, which today takes the # couldNotSubmitSquad branch. Both move toward "more allowed", not less. TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1" ``` and in `_item()`, replacing the hard-coded `"untradeable": True`: ```python "untradeable": not TRADEABLE, ``` Club items in `tools/fut_clubitems.py` already send `"untradeable": False` and need no change. **Type fidelity.** BOOL where a BOOL is read. The constructor default for `+0x49` is 1, i.e. tradeable, so `false` moves the field *toward* the client's own default rather than away from it. ### P2 -- stamp `discardValue` on the purchased pile In `tools/fut_store.py`, `Profile.purchased()`: ```python def purchased(self): """Items still held in the purchased/unassigned pile (returned by GET /purchased/items); they move to the club via FutMoveCard (PUT /item). Stamped on the way out exactly as items() is. Without this the pending pile renders 0 for anything whose discardValue is not already persisted in the save: the quick-sell tile binds the Flash property DISCARD_CREDITS, which is item+0x38, which is ONLY ever written from the wire. The client's own correct answer lands at +0x3c under the name CALCULATED_DISCARD_CREDITS and no native code in CardsDLL falls back from one to the other. Verified live 2026-08-06: the pile's chemistry style 100000283 evaluates to 38 through discard_value() and the client's own +0x3c holds 38, and the tile reads neither because the key is absent from the wire.""" its = self.load().get("purchased", []) return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its ``` **Freeze risk: none.** `discardValue` is atom `0xd7`, a plain INT already on the wire for 20 of 22 resident items. Not stamped, not persisted, so turning `FUT_DISCARD_SEND` off is still a true revert. No new flag: this is a bug in the existing flag's coverage. ### P3 -- `FUT_DISCARD_STAFF`: price staff cards from the table `value` column The unrated-card fallback in `discard_value()` documents the staff price as unknown. It is not. The merge writes the staff table's `value` column into `+0xb4`, and `round_half_up(value * price / 100)` reproduces the client's own answer exactly. In `tools/fut_store.py`: ```python # FUT_DISCARD_STAFF: price staff cards using the game table's `value` column as the # rating input. # # discard_value() bails on a card with no `rating` key, which is every staff card we # serve, so the pile's gkcoach 100000282 goes out unpriced and its tile shows 0. The # rating a staff card USES is the `value` column of its own cards table -- the merge # arms for families 2,3,4,5,10 all do `rec[0xb4] = row["value"]` -- so the number was # always available server-side. Confirmed to the unit, live 2026-08-06: carddbid # 9000081 has value 66 in gkcoachcards.json, cardtype 10 (subtype 6), rare 0, level 2 # -> price 55 -> round_half_up(66*55/100) = 36, and the client's own computation at # record +0x3c reads 36. # # Freeze risk: NONE. This changes no wire shape at all. It only supplies a rating to # a server-side arithmetic function; the `rating` key is not added to the JSON, # because on a staff card the merge overwrites +0xb4 from the DB regardless and # sending it would be inert. DISCARD_STAFF = os.environ.get("FUT_DISCARD_STAFF", "0") == "1" and DISCARD_SEND _STAFF_VALUE_TABLES = ("headcoachcards.json", "gkcoachcards.json", "physiocards.json", "fitnesscoachcards.json", "managercards.json") _STAFF_VALUE = None def _staff_value(resource_id): """carddbid -> the table's `value` column, or None. Built once, lazily.""" global _STAFF_VALUE if _STAFF_VALUE is None: _STAFF_VALUE = {} base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables") for fn in _STAFF_VALUE_TABLES: try: with open(os.path.join(base, fn)) as f: rows = json.load(f).get("rows") or [] except (IOError, ValueError): continue for r in rows: cid, val = r.get("carddbid"), r.get("value") if cid and val: _STAFF_VALUE.setdefault(cid, val) return _STAFF_VALUE.get(resource_id & 0xffffff) ``` and, inside `discard_value()`, replacing the early bail: ```python r = item.get("rating") if not r and DISCARD_STAFF: r = _staff_value(int(item.get("resourceId") or 0)) if not r: return None ``` **One caveat, stated rather than buried.** This is confirmed on one card. The mechanism is read out of the merge, not curve-fitted, and the arithmetic matched to the unit, but a second staff family would make it two. ### P4 -- `FUT_CLUBITEMS`: correct the five subtypes In `tools/fut_clubitems.py`, the `FAMILIES` table and the probe set. This is a data correction, not a new capability, and it should still be exercised one family at a time through the existing `FUT_CLUBITEMS` machinery. ```python # (table, art id, stat id, stat name, cardsubtypeid) # # CORRECTED 2026-08-06. Every previous value was inside the 0x91..0x96 block, which # is TROPHIES (FUN_180108c00 computes subtype = tournamentType + 0x91; FUN_1800fed90 # is the only function in the binary whose case set is exactly {0x91..0x96}). # # Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has # `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: # manager vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when # item+0x4c == 7, called with (subtype, teamid, assetId). So the docstring's # "a wrong id cannot announce itself" is false for these three -- a wrong teamid # produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first. FAMILIES = [ ("balls", "fcc_balls.json", 37, 0x1E, "balls", 30), ("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10), ("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11), ("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9), ("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31), ] # The candidate set for probe_shelf(). The old set {30,31,145..150} could not have # answered the question for kits, stadia or badges, because 9, 10 and 11 were not # in it -- it would have burned a launch and returned nothing for three of five. CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31) ``` and, in `_item()`, the per-family fields the cardtype-7 resolver requires: ```python def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None): it = { "id": item_id, "resourceId": carddbid, "assetId": carddbid, "cardassetid": cardassetid, # THE ART ID, never a copy of resourceId "cardsubtypeid": subtype, "itemState": "free", "owners": 1, "untradeable": False, } # KIT (9) and BADGE (11) display as + TeamName_Abbr15_, so # without teamid the name comes out as the caption alone. STADIUM (10) reads # StadiumName_, which resourceId already supplies. teamid is atom 0x306, # read with the INT primitive FUN_1801c79d0 and stored at record +0x94 -- an # established scalar field, not a new shape. # # BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the # response that crashed the client. It was never bisected. `value` is the # established suspect (it is an OBJECT member elsewhere, and a scalar where an # object is expected is the 0x1801c7f1a busy loop), and that response also carried # 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to ONE # family, with the subtypes now correct. That is the narrow test the crash denied us. if teamid is not None and subtype in (9, 11): it["teamid"] = teamid if extra: it.update(extra) return it ``` **`itemType` should be dropped from this builder** while it is being touched. It currently sends `"itemType": "club"`, marked UNOBSERVED in the docstring, and the deserializer parses `itemType` into a heap string below the record base and never copies it in. It is inert, but it is also one of the few unobserved strings we still emit. **Freeze risk: low, and lower than the last attempt.** `teamid` is an INT read by the scalar primitive; the subtype change alters an integer's value, not its type. The blast radius is bounded by serving one family per test, which the existing `FUT_CLUBITEMS` machinery already enforces and `equippables` still answers empty. ### P5 -- `FUT_CLUB_POSTAB`: answer the DEF/MID/ATT tabs `club_route`'s filter keeps only `cardsubtypeid not in (0,1,2,3)` for any type outside `player` and `custom`, so `type=playerdefender`, `playermidfielder`, `playerforward` and `any` all return an empty list against a save whose items are all subtype 0. The three position tabs are real traffic: `FUN_18012ddf0` remaps request field `*(req+0x14)` values `0x1c/0x1d/0x1e` into type codes `0x1b/0x1c/0x1d` and suppresses the `position=` parameter, so the MY CLUB position tabs arrive exactly as those three strings. ```python # The client's OWN position grouping, read out of FUN_180135890's recompute of # record+0x14c from record+0x146: 0 -> GK, 1..8 -> DEF, 9..19 -> MID, 20..27 -> ATT. # Not invented here; this is the ladder the client applies to its own records. _POS_GROUP = {"playerdefender": (0, 8), "playermidfielder": (9, 19), "playerforward": (20, 27)} ``` and in `club_route`, before the existing `if kind and kind not in ("player", "custom")`: ```python if kind in _POS_GROUP: lo, hi = _POS_GROUP[kind] items = [i for i in items if i.get("cardsubtypeid", 0) in (0, 1, 2, 3) and lo <= int(i.get("preferredPosition") or 0) <= hi] log(" CLUB: type=%s -> %d item(s) (position group %d..%d)" % (kind, len(items), lo, hi)) return 200, {"itemData": items} if kind == "any": log(" CLUB: type=any -> %d item(s) (unfiltered)" % len(items)) return 200, {"itemData": items} ``` **The one guess, named.** GK is folded into DEF because the observed tabs are DEF/MID/ATT and there is no fourth. If the DEF tab comes back without goalkeepers in it, the guess is wrong and the fix is to change `(0, 8)` to `(1, 8)`. Nothing else in the patch is a guess. **Freeze risk: none.** Server-side filtering only; the response shape is the `{"itemData": [...]}` the route already returns everywhere. ### P6 -- drop `definitionId` One line in `_item()`. `definitionId` is not in the 907-entry key dictionary, so it hashes to an unregistered key and its value goes to the value-SKIP handler `FUN_180135ff0`. Verified three ways: absent from `docs/fut_atoms.tsv`; absent from the live key map walked in the running client with all 907 dictionary names passing as a positive control; and colliding with no registered hash, so it cannot be misrouted onto another field. **Freeze risk: none** -- removing a key the parser skips strictly reduces executed code. Low value, zero cost, and it removes a field that three documents describe as if it did something. --- ## 7. Proposed corrections to existing documents ### `docs/CARD_SYSTEM.md` **Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered. Kit 9, stadium 10, badge 11, ball 30, league logo 31 (the last by elimination). Route (a) of its own two proposals is what paid off: the consumer is the manager vtable slot `+0x498` = `FUN_180119bd0`. Route (b), `FUT_CLUBITEMS=probe:`, would have failed for three of the five families because its candidate set did not contain 9, 10 or 11. Keep the residual league-logo probe from section 3 above. **Correct item 1 of "VERIFIED IN BINARY".** It says `FUN_1800d8330` returns cardtype 9 for `0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, 0xec` and that this "leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls and league logos". The first half is right; the inference is wrong. `0x91..0x96` are trophies, and three of the five families are cardtype 7, not 9. **Correct item 2.** The itemState enum table starts at `0x180229cc0`, not `0x180229d20`. The full vocabulary is the twelve rows in section 4 above; the recorded ten are missing `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`. Note that `WAITING_FOR_GAME` and `inGame` are genuine aliases, both 2, and that omitting the key yields 0 = `invalid`, which is not `free`. **Update the card view-model paragraph.** It states that every rendered field is read from a resolved definition record at `item+0x10` and "NEVER from our item JSON". That was true of the generic-card era and is no longer the whole story: rating, attributes, position, contract, fitness, rareflag and playStyle are read by thin accessors straight off the item record for family 1, and `CARD_SYSTEM.md`'s own later sections (real squads, managers painting `+0xde`/`+0xe0`, consumables drawing `+5/+10/+15`) already contradict the earlier text. Add a pointer to the field table in section 2 here rather than rewriting the history. **Add the field-map corrections** as a dated block: ``` CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X): +0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR +0x48 owners (atom 0x207, u8; constructor default 0) +0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1) +0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum +0x5c itemState (atom 0x172 via FUN_180166660, u32) +0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17) +0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY +0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles) +0xbf amount (atom 0x1b, u8) for the other consumable classes [already recorded] +0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7 +0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7 +0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field +0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm itemType (atom 0x173) is parsed into a heap string and never stored definitionId is NOT AN ATOM ``` **Correct the consumables section's route claim.** It says the observed route is "not the `/consumables/%s` template in .rdata, which the client has still never used". It is exactly that template: action row 9 `ConsumablesSearch` carries base index 3 = `ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was `ut/%s/club` all along. The same sentence appears in commit `ccb736f`. **Correct the `discard_value()` docstring premise** in `tools/fut_store.py` while you are at it: "WHY its lookup misses is still UNKNOWN" and "that lookup returns no row for our cards" are both false. It does not miss. The tile reads a different property. ### `docs/ENDPOINT_MAP.md` **The Club section's URLs are wrong for four routes.** Rows 12, 13 and 16 give `GET ut/game/fifa17/item?type=`, `GET ut/game/fifa17/...` and `GET ut/game/fifa17/...`. The client can emit exactly four request families on the `ut/%s/club` base, and the binding is a table, not an inference: the 125-row action table at `0x1802caa20` indexes the 48-entry URL-base table at `0x18021df80` through column 1, and base index 3 = `ut/%s/club` is carried by exactly four rows. ``` | ClubSearch | FUN_18012ddf0 | GET ut/%s/club? | FutStickerBookSearchServerResponse | | ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/[/] | FutStickerBookStats2ServerResponse | | StaffStats | thunk 0x18012b080| GET ut/%s/club/stats/staff | FutStaffBonusServerResponse | | ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/| FutConsumablesSearchServerResponse | ``` **Add the club query grammar**, which is complete and ordered: `?year=2017` (always, hardcoded), then `type`, `start` (omitted at 0), `count` (omitted at 100), `filter`, then either the filter block (`position, formation, state, level, rare, nation, country, league, playStyle, team, sort`) or a comma-joined `defId=` list, never both. Live control: the one ProtoHttp club search in the log is `GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`, which matches the predicted order and every suppression rule. Sub-vocabularies: `filter` = available/base/exact/any; `level` = bronze/silver/gold/any; `sort` = asc/desc; `rare` = the literal string `SP`, not a boolean; `state` = the itemState names plus `any`, and note that the request spells it `onSale` where the response value is `forSale`. **Add the complete `?type=` vocabulary**, 30 values from `FUN_18012ec50` (29 cases plus a default of `any`), and record that `/club/stats` has exactly seven forms: `club`, `year`, `country/`, `league/`, `newcards`, `consumables`, and the separately-dispatched `staff`. **There is no `/club/stats/team/`**, verified twice: the switch has six cases with no such arm, and an exhaustive string scan of the PE finds no literal containing `stats/team`. `utas_server.py`'s handling of a `team` stats mode is dead code. **Two holes in the base table are worth recording** so nobody re-derives them as findings. Base index 43 = `ut/v2/%s/store` is carried by no action row and has zero references in `.text`, yet `ut/v2/store` is live-proven; base index 9 = `ut/%s/activeMessage` is a second hole of the same kind. So at least one route is composed outside CardsDLL, most likely inside the packed exe, and every "the table bounds it" statement is bounded to CardsDLL. **Under `FutGetSettingsServerResponse`**, add that `tradingEnabled` field `[10]`, gate byte `0x1fd2e`, was measured **0** in the live client on 2026-08-06 while `friendlySeasonsEnabled`, `enableDraftMode` and `packOpeningAnimationEnabled` all read 1 in the same walk. The struct defaults are not uniform: some fields default to 1 and trading defaults to 0. Gate byte `0x1fd2e` is read by vtable slot `+0x270` = `FUN_18011c670`, and that slot is the service half of the `TO_TRADE_PILE` predicate. --- ## Coverage, honestly Five of the six dimensions came back deep and two adversarial rounds attacked the claims that change what we serve. The live-heap dimension and the club-subtype dimension are the strongest: both were re-derived from scratch by a verifier with independent controls, and the disagreements between them were resolved by a third method (the deserializer's frame arithmetic) rather than by preferring an author. Two areas are thin and should be treated as single-source. The **auction-house field routing** -- `tradeId`, `tradeState`, `expires`, `bidState` and friends belonging to the auction element `FUN_18013e410` rather than to `itemData` -- rests on one agent's census and was explicitly not re-attacked; the verifier confirmed only the negative half, that those atoms have no arm in the item deserializer. The **club query emission order** in section 7 likewise rests on one decompile plus one live log line, though all 17 atom transcriptions were independently re-resolved and none was wrong. Nothing in this document was tested on screen. Every "the card will display X" is a prediction from record contents and code, not an observation of pixels, with the sole exception of the gate-byte measurement quoted in section 1, which is a measurement of a byte and not of a menu. The client is bound to port 8099 and the safety rules forbid reconfiguring it, so the live work here was read-only heap reads, read-only GETs, and static analysis. --- ## The next action Run the client with `FUT_SETTINGS=keep FUT_TRADEABLE=1` and open a card's action menu. That single launch tests both halves of the transfer-list finding at once, it is the only outstanding item that fixes something the user has already seen broken, and it is unusually well instrumented: if the entry lights up, two gates and an inverted boolean are confirmed together; if it stays grey, `gate_byte_probe.py` extended to slot `+0x270` says immediately whether `0x1fd2e` flipped to 1, and `+0x49` on any resident record says whether the boolean landed, so the failure localises to one of three named places rather than to "it did not work". Bring the kits along on the same trip if the club-item subtype patch is in -- one family, `teamid` alone, subtype 9 -- because a wrong `teamid` announces itself in the caption and that is the loudest failure mode any club family has.