Files
OpenFUT/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md
T
funman300 a842c5ffb0 tools(fifa17): answer "who writes item +0x60" — nothing does
The plan called this "the single blocker between 'we can mark a kit equipped'
and 'we can equip a kit'", and recorded that two attempts to find the writer
drowned at 1688 and 4144 instructions.

They drowned because +0x60 is a common struct offset. Two filters make it
readable: only an IMMEDIATE store can introduce a constant (a register store
just propagates one), and item-record code is recognisable by touching +0x4c
(cardtype) or +0x5c (itemState) within a few instructions.

Measured read-only against pid 6580:
  - live +0x60 over all 27 resident records: {1: 23 players, 0: 4 staff}, never 4
  - CardsDLL has 4 comparisons of +0x60 (0, 0, 1, 4); the 4 is the kit gate and
    is the ONLY such comparison in the process
  - CardsDLL has 29 immediate stores to +0x60, constants {-2,0,1,908,0x3f800000}
  - FIFA17.exe, across 79 MB of code: ZERO stores of 4, zero comparisons with 4
  - the gate function has one xref (a jmp) and its address is never taken
  - every register store to +0x60 in CardsDLL is a struct copy or an init

So the gate is not a wire field we failed to send: the value it demands is never
produced by anything. Decoding it fully also shows every OTHER input is already
served — cardtype 7, itemState 101/102, teamid — leaving only the +0xba variant
selector beneath it, which makes a client-side patch the only remaining avenue.
2026-08-21 21:10:35 +00:00

1094 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 15 and 10; live elsewhere; **required** for stadium (subtype 10), whose caption is `StadiumName_<assetId>` | 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 714720 |
| `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_<teamid>` | 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 250273, `+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, 18 → DEF, 919 → MID, 2027 → 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>` | `teamid` |
| stadium | **10** | 7 | `FUN_180119bd0``Stadium` + `StadiumName_<assetId>` | `assetId` |
| badge | **11** | 7 | `FUN_180119bd0``Badge` + `TeamName_Abbr15_<teamid>` | `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 <caption> + TeamName_Abbr15_<teamid>, so
# without teamid the name comes out as the caption alone. STADIUM (10) reads
# StadiumName_<assetId>, 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:<family>`,
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=<consumable>`, `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?<query> | FutStickerBookSearchServerResponse |
| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/<f>[/<id>] | FutStickerBookStats2ServerResponse |
| StaffStats | thunk 0x18012b080| GET ut/%s/club/stats/staff | FutStaffBonusServerResponse |
| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/<cat>| 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/<id>`, `league/<id>`, `newcards`, `consumables`, and the
separately-dispatched `staff`. **There is no `/club/stats/team/<id>`**, 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.