Files
OpenFUT/fifa17-recon/docs/CARD_SYSTEM.md
T
funman300 22cfae830f docs(fifa17): apply the plan's corrections to CARD_SYSTEM.md and fut_store.py
Section 7 of plan-2026-08-06-card-subsystem.md listed these and they were never
applied, so the stale text kept misleading readers — it already cost this project
a ten-row itemState table.

CARD_SYSTEM.md:
  - "STILL UNKNOWN, AND NOT GUESSED" is ANSWERED. Its candidate set was wrong:
    it asked which of 0x1e/0x1f/0x91..0x96 meant kit/badge/stadium, but three of
    the five families are cardtype 7 (kit 9, stadium 10, badge 11) and are not in
    that set at all, and 0x91..0x96 are trophies. Replaced with the settled map
    and how each family's caption resolves.
  - itemState table starts at 0x180229cc0, not 0x180229d20 — the recorded address
    points MID-table, which is why six rows were missing. Added that
    WAITING_FOR_GAME/inGame are aliases, that omitting the key yields invalid and
    not free, and that the match is case-sensitive (measured).
  - the consumables route claim "the /consumables/%s template ... the client has
    still never used" is false; it IS that template, with base index 3 = ut/%s/club.
  - added the dated field-map correction block, extended with the +0x60 and
    definitionId findings measured on 2026-08-21.

tools/fut_store.py: the discard_value premise "that lookup returns no row for our
cards" / "WHY its lookup misses is still UNKNOWN" is false — it does not miss, the
tile reads a different property. That story sent one round of work chasing a table
defect that never existed.
2026-08-21 21:31:59 +00:00

535 lines
29 KiB
Markdown

# FIFA 17 FUT — Card System RE + Next-Steps Plan
Status as of 2026-08-01. All clean-room (our own binaries + running client only).
## ★ SOLVED: real player cards render offline
A full 88-rated real squad (Ronaldo/Messi/Suárez/Ramos/Kroos/…) renders 100%
offline. The definition FETCH turned out to be unnecessary — FIFA has all player
identity data locally in `dbdata.dll`. **The CLUB-SEARCH / ADD-PLAYER flow is the
trigger:** when FIFA displays club-search results (`GET /club?year=..&type=player&
count=..`, served by our `/club` route), it resolves each item's assetId against
its own local DB (real name/photo/club/nation) and merges the rating/attributes
our `/club` item carries, then caches a real record in the CardsDb store
(`obj+0x160c0`). No `idList` fetch, no leaked data.
**Working recipe:**
- `utas_server` on `FUT_SQUAD_STEP=s3v0``/club` serves the full XI (real asset
IDs + our attributes).
- In FUT: Squads editor → Add Player / player-name **search** → results render as
real cards → **add them to slots** → full real squad.
- Distinction: the squad-EMBED path (`GET /squad/0` itemData) does NOT resolve
(generic); only the CLUB-SEARCH/ADD path resolves. So: serve-club + search/add.
Cosmetic-only nit: card short-name label shows a default "BAS" (real full names
are in the record + Player Details panel) — likely the FUT commonName/knownAs
field; low priority.
Everything below is the reverse-engineering that led here.
## Where we are
Offline FIFA 17 Ultimate Team runs end-to-end on our backend:
- Every EA online gate cracked (Origin/LSX, Blaze, UTAS/RS4) → **FUT hub**.
- **Squads editor renders**: correct 4-4-2 formation, 11 labelled slots, 5-star
squad rating, manager slot, benches — **no freezes**.
- The one missing piece: player cards render as the generic "FUT 17" back
(rating 0) — no real player *identity/face* resolves.
## The squad-shell (what makes it work — don't regress these)
- ~~`userMassInfo` MUST return `{}`~~ — **superseded 2026-08-03.** `0x180174630` is fully
decompiled: a FLAT `{userInfo, squad, settings, userData}` body. The old desync is
attributed to the `squad` member, which was built before `0x18013d1f0`'s schema was
known. `utas_server.py` now defaults to `FUT_MASSINFO=full`; fall back through
`squad`/`userinfo`/`settings`/`empty` to bisect if the hub freezes at 0x1801c7f1a.
- Deliver the squad via **`GET /squad/0`** (LoadActiveSquad, deser 0x18013d1f0),
which FIFA fetches on **Squads-tab entry** (re-fetches on tab-switch, not on
editor re-open). `FUT_SQUAD_STEP` selects the squad (s2v0 = 1 real item).
- `GET /user` is NEVER called at boot — userInfo can only reach FIFA via
userMassInfo, which is now populated (above), so the hub's coins/record and the
squad roster (`userInfo.squadList`) are delivered at boot. Pending live verify.
## Why cards render generic (definitively reversed — 3 workflows)
The card view-model (0x1800d7920) reads EVERY rendered field
(rating@+0xb4, position@+0x146, nation@+0x148, teamid@+0x94, 6 attrs@+0x98..0xac,
name@+0xdd) from a **resolved player-definition record at `item+0x10`** — NEVER
from our item JSON. That's why item-format/version/field changes had zero effect.
`item+0x10` is filled by the resolve at 0x180141160-76:
`getter 0x18011a830 → CardsDb singleton [0x1802e6398] → call [vtable+0xa08]`
(= lookup **0x18011cca0**), output buffer `[rbp+0x160]`. The lookup searches a
std::map at `CardsDb_obj+0x160c0` keyed by resourceId. **Offline that map is
EMPTY**, so every lookup misses and a **default blank record** is emitted →
generic card.
Dead ends (proven, do not retry):
- **Version advertising is inert.** `itemDbVersion` (atom 0x16c) and
`checkServerDbVersion` (atom 0x80) are JSON field names routed to the value-SKIP
handler 0x180135ff0 in every parser — parsed and discarded, never compared.
Roster-version bump and Blaze `itemDbVersion=999999999` both did nothing.
- **Serving owned items does NOT auto-trigger a definition fetch.** The
itemData-array parser (0x1801293d0→0x18013fe00) does no membership check and no
enqueue; the render-miss is terminal. Our squad already has an owned item
rendering generic with 0 `idList` fetches — empirical proof.
- **In-place map overwrite is dead** — the map at `+0x160c0` stays empty even with
all 11 cards rendering (probed live). The resolve emits a transient default to
the caller stack each frame; nothing persistent to edit.
The definition FETCH (`ut/17/item?idList=`, `/item/resource`, `/defid`; URL builder
0x180129200) is issued by FUT-controller vtable method 0x180119010 / requestDefinitions
0x180036e20 — **both have zero call-sites inside CardsDLL**. The decision to fetch
lives in the **packed FIFA17.exe** (decrypted in live memory only).
Our definition-serving endpoints (`item_def`/`defs_route` in `utas_server.py`,
routes `item/resource`/`defid`/`item?idList=`) are **built and ready** for if/when
the fetch is ever driven.
## Next-steps plan (real player faces) — ordered by tractability
### Option A — Drive the fetch from FIFA17.exe (most "correct", hardest to find)
FIFA17.exe is packed on disk but decrypted in live memory (Wine flat-maps at
0x140000000). Find the call-site of the idList issuer (CardsDLL vtable method
0x180119010, .rdata slot 0x18021cb78) inside the live FIFA17.exe image: set a
hardware breakpoint / rwatch on that slot's invocation, or scan decrypted .text
for the call. Identify what condition it gates on and satisfy it. If FIFA then
requests `item?idList=<ids>`, our endpoints already answer → cards resolve. This
is the clean win: no ongoing memory writes, works via normal data flow.
### Option B — Populate the CardsDb store so lookups HIT (live-memory injection)
Pre-insert real records into the std::map at `CardsDb_obj+0x160c0`
(node: Left+0x0/Right+0x8/Parent+0x10/color+0x18/key(resourceId)+0x20/record+0x28).
Two sub-approaches:
- **B1 (call the game's own insert):** drive the map's find-or-insert
(0x180115c30, reached from lookup 0x18011cca0) with a resourceId + a record we
fill. Requires a small code-injection harness (set up registers + call) since
the insert isn't reachable from our side otherwise.
- **B2 (hand-build a node):** allocate a node in FIFA's heap, write
left/right/parent/color/key/record, splice into the tree + rebalance. Fiddliest;
RB-tree invariants must hold or later lookups corrupt.
Record fields to fill (from base `dbdata.dll`): rating@+0xb4, position@+0x146,
nation@+0x148, teamid@+0x94, 6 attrs@+0x98..0xac, name@+0xdd.
### Option C — Patch the resolve miss-path (proof-of-concept, then key it)
Patch lookup 0x18011cca0's miss branch to write real fields into its output record
`[rbp+0x160]`. Quickest to see *a* real card, but naively makes ALL cards show one
player; must be keyed by resourceId to be useful. Good first experiment to confirm
the field offsets end-to-end before investing in A or B.
### Prerequisite for all: a dbdata.dll extractor
Build a tool to read the base player DB (`/mnt/games/FIFA 17/dbdata.dll`,
single export `getTableData`; tables players/playernames/teams/nations/
teamplayerlinks) → a `resourceId → {name,rating,pos,nation,team,attrs}` table
(resourceId = playerId | version<<24; Ronaldo playerId 20801). This feeds B and C
and validates A. No such tool exists yet.
## Recommended order
1. **C** as a 30-minute proof: patch the miss-path to emit a fixed real record →
confirm a real card face appears (validates the whole record-offset model live).
2. Build the **dbdata extractor** (needed by everything).
3. Attempt **A** (find the FIFA17.exe fetch trigger) — the clean, durable win.
4. Fall back to **B1** (drive the game's insert) if A's trigger proves unreachable.
## Reusable tooling
- `/proc/PID/mem` read/write pattern: `autopatch.py`, the poke/force tools
(ptrace_scope=0 armed by `root_arm.sh`).
- Live vtable/struct probing: the python snippets used this session (singleton
`[0x1802e6398]`, static↔live base map).
- `fifadrive.sh` (screen capture) + `vgamepad.py` (virtual pad) for headless
drive/observe — note keyboard XTEST does NOT reach FIFA; the gamepad is unverified
in-game.
---
## REFUTED 2026-08-04: the CardsDb map is NOT empty offline
This document has claimed since it was written that "**Offline that map is EMPTY**, so
every lookup misses and a default blank record is emitted -> generic card", and that
the view-model reads every rendered field from the resolved record and "NEVER from our
item JSON". A live pack open falsifies both halves.
### The observation
One bronze pack, five cards. Two rendered as real players with names, club badges and
national flags. Three rendered as blanks: rating 50, position RWB, every attribute 1,
no name. Nothing in our pool has rating 50, position RWB or all-ones attributes, so the
blank is the client's default record, exactly as this document describes.
The two that resolved match OUR ITEM JSON field for field:
| we sent | screen showed |
|---|---|
| `(232517, 62, RB, nation 36, league 19, team 175, [72,44,58,60,62,61])` | SILVA, 62 RB, Wolfsburg badge, Norway flag, 72 PAC / 44 SHO / 58 PAS / 60 DRI / 62 DEF / 61 PHY |
| `(235066, 60, GK, nation 34, league 31, team 48, [62,63,33,61,17,62])` | NOWAK, 60 GK, 62/61, 63/17, 33/62 |
Those attribute numbers were invented by hand. They cannot have come from a database.
### What this means
- The map HAS entries offline. Some asset ids resolve.
- Rating, position, nation, league, team and the six attributes come from **our item
JSON** for a card that resolves.
- Name, club badge and national flag come from the **client's own data**, keyed by
assetId.
- When the assetId is NOT in the client's data, the whole card collapses to the blank
default, which is why a bad id looks like a rendering failure rather than a lookup
failure.
### The consequence, which is much cheaper than what this document proposed
The recommended plan here was to populate the client's map by driving its insert, hand
building a red-black tree node, or patching the resolve miss-path, all of which write
to a live process. **None of that is needed to get real cards.** The rule is simply:
> Use asset ids that exist in the client's database. Valid id gives a real card.
> Invalid id gives the blank.
`fut_cards.py` currently carries 18 verified ids and 61 structural placeholders, and
the placeholders are what produce the blanks. Two of them (232517, 235066) happen to be
real, which is why the pack was a mix. The remaining work is therefore a DATA problem,
getting the real id list out of `dbdata.dll`, and not a code-injection problem.
`TODO/CONFIRM`: whether a resolved card's rating truly comes from our JSON or whether
the client's record happens to agree. The attributes settle it (they were invented) but
rating specifically has not been isolated. Send a deliberately wrong rating for a known
good id and look.
---
# SOLVED 2026-08-04 (evening). Card identity, end to end.
Everything above this line is superseded where it disagrees. Two of its premises were
wrong: the CardsDb map is NOT empty offline, and `dbdata.dll` is NOT the player
database.
## The mechanism
Every item object in every response is inserted into the CardsDb map by the item
parser tail (`0x18014115b` -> registrar vtable `+0xa08` = `0x18011cca0`, a
find-or-insert). There is no fetch to trigger; `item?idList` was a red herring.
IMMEDIATELY BEFORE registering, `FUN_180141660` merges in the client's OWN local
database. It switches on `record+0x4c`, which `FUN_1800d8330` derives from the JSON
atom `0x6c cardsubtypeid` alone:
0..3 -> 1 players 4 -> 2 managercards
5 -> 3 headcoachcards 6 -> 10 gkcoachcards
7 -> 5 physiocards 8 -> 4 fitnesscoachcards
9..b -> 7 UNIDENTIFIED absent -> 0x156 -> 0, NO merge at all
For players `FUN_180135890` queries `players` by `playerid = record+0x18 & 0xffffff`,
where `record+0x18` is atom `0x287 resourceId`. Atom `0x23 assetId` lands at `+0x20`
and is NEVER read by the merge: sending assetId alone does nothing.
On a HIT it fills the name (`+0xb8` first, `+0xc8` last, `+0xdd` knownAs, all inline
char arrays), fills `nation +0x148` and `teamid +0x94` ONLY IF THEY ARRIVED AS ZERO,
and always recomputes `leagueid +0x154`. It never touches `rating +0xb4`,
`position +0x146` or `attributes +0x98..+0xac`.
So: send zero for everything the client knows better than us, and send our own value
only where the client has nothing. That asymmetry is the whole design of `fut_cards`.
## The three-state oracle (all three confirmed live)
NAMED sentinel rating survives, real name -> the id is REAL
placeholder sentinel survives, name is "Jamal Blackman", team 0 -> the row
exists but is an EMPTY SLOT. This is the trap: 169193 does this and
it was in VERIFIED_ASSET_IDS.
MISS rating 0x32, teamid 0x78d, nation 0xe, position 2, attributes 1,
name " " -> the id does NOT exist. This fingerprint matched a
user's blank pack cards field for field.
`FUT_ID_SWEEP` (utas_server) serves a window of candidate ids as a synthetic club;
`tools/card_identity_probe.py` reads back what the client resolved. 5000 items per
response ingests cleanly; 20000 was served and silently NOT ingested. THE MAP IS
WIPED ON EVERY CLUB FETCH, so an auto sweep must be collected continuously
(`sweep_collect.py --watch`) and not once at the end.
## Where the roster came from
`tools/dbdata_extract.py` reads FIFA's own rating-sorted index out of a running
process (0x40 stride, self-validating {begin,end,end+1} name-pointer triple, anchored
on 20801 = Ronaldo 94) -> `data/roster.json`, 17,547 players. Cross-validated against
the sweep oracle, a completely independent method: 573 of 573 overlapping names
agreed. The one id in the sweep and not the index is 26501, the target of the
documented 22800..22879 Legends remap, which is also what produced "Alex Hunter x80"
in a sweep and had looked like a bug.
`dbdata.dll` is an anti-tamper decoy. Its single export `getTableData` returns a fixed
759-byte self-integrity blob and the file contains no tables. Do not re-attempt it.
## Still missing for players
`position`, `nationality`, `teamId` and the six attributes are NOT in the rating
index. Two live sources were found and BOTH are per-materialised-card caches, not
tables: the 0x180-stride resolved card records, and a 32-byte keyed container
(entries `{playerId, position | hash<<32, rating, ?}`). 59 positions came from the
second. A full-roster position source has NOT been found.
## The other card families, as of 2026-08-04
Mechanism known, id spaces NOT. `FUN_1801356c0` queries `managercards` by column
`carddbid` taken RAW from `record+0x18` (no mask), selecting firstname, lastname,
assetid, value, talkrating, negotiation, rare; it writes firstname to `+0xb8`,
assetid to `+0x20`, a byte to `+0xb4`, bytes to `+0xe2/+0xe3`, and `+0x58 = (rare==1)`.
Sweeps of carddbid 1..5000 and 6000..8000 both produced 2000+ manager records with
cardtype 2 and NOTHING written. THE MANAGER BRANCH WRITES NO MISS-FILL, so a wrong id
is SILENT and a negative result does not distinguish a wrong id from a wrong
mechanism. Manager name strings are in memory near 0x42800000 but are inline with NO
inbound pointers, so the players trick (find the index that points into the name pool)
does not transfer.
Tables named in the DLL and not yet probed: `headcoachcards`, `fitnesscoachcards`,
`gkcoachcards`, `physiocards`, `fancards`, `newcards`. Consumables are a separate
family (consumablesContract/Fitness/Healing/Position/Training/Formation plus
`FUT_CONSUMABLE_NAME_*` loc keys) and may be enum-driven rather than DB-driven.
`GET /club?type=` has only ever been observed with two values: `player` (paged,
start=N&count=11) and `manager` (count=200, from the STAFF tab).
---
# CONFIRMED LIVE 2026-08-05: managers and the four coach families
All five non-player card tables render correctly in game. Nothing here is inferred.
## Coaches: 34 staff cards, ZERO DB Error
Served subtypes 5/6/7/8 (headcoach, gkcoach, physio, fitnesscoach) from ids read out
of the game's own dumped tables. Every id hit its table on the first attempt:
A Shaikh 80, J Kim 77, M Kuhn 80, P Trenouth 80, C Duke 74 and the rest, ratings
55..80, with real face photos and their bonus percentages (+15% SHO and so on) drawn
by the client. The whistle / gloves / heart icons distinguish the families.
That is the payoff from enumeration over probing. The miss-fill exists and is loud
("DB Error", rating 0x32), and it never fired once, because the ids came from reading
the database rather than sweeping for it.
## Managers: 10 of 10 resolved, and the template paints OUR fields
Luis Enrique 88, Conte 87, Wenger 86, Klopp 84, Koeman 82, Pardew 80, Di Francesco
78, Maes 75, Wdowczyk 70, Canning 65. Every nation and league is historically right:
Conte/Wenger/Klopp/Koeman/Pardew all Premier League, Di Francesco Serie A, Maes
Jupiler, Canning Scottish.
RESOLVED, previously TODO/CONFIRM: the manager card template DOES paint record+0xde
(nation) and record+0xe0 (league). The Luis Enrique card draws the Spain flag and the
words "LaLiga Santander"; the Premier League managers draw their flag and "ENG 1".
Neither field is written by the merge (FUN_1801356c0 does not touch them), so nothing
but our own JSON could have supplied them. That is the cleanest possible proof that
those two offsets are ours.
NOT confirmed: negotiation at record+0xe3 is NOT on the card front. The card shows
"CONTRACT 7" there instead. FUN_1800e5940 does read +0xe3 and publish it as
ATTRIB_CONTRACT_NEGOTIATION, so it surfaces somewhere else or not at all.
## Why managers needed the coaches beside them
A wrong manager carddbid is completely SILENT: FUN_1801356c0 has no else-branch and
writes no miss-fill, so a bad id and a bad wire shape look identical. The coaches were
put on the same screen precisely because their miss IS loud. Nothing failed, but the
test was designed so that a failure would have been diagnosable rather than mute.
---
# CONFIRMED LIVE 2026-08-05: consumables
Rendering with real artwork, stack quantity badges and correct amounts (+5 / +10 /
+15, so atom 0x1b reaches record+0xbf and FUN_1801a8040's sign-extension never fires).
The client's own dialog names the class: "Search Type: Consumables Search".
## Three things had to be right, and each failed SILENTLY with a 200
1. THE COUNT IS THE GATE. The client will not ask for consumable items until
GET club/stats/consumables reports a non-zero count. We answered that route 41
times a session with the PLAYER stat set, so the panel read seven zeros and never
proceeded. Two rounds of item-shape work sat unrequested for want of a counter.
2. THE ROUTE IS GET club/consumables/<category>. Not club?type=, which a previous
round shipped four arms for. That path is a /club PREFIX, so a naive router
falls it through to the generic route and answers the consumables screen with
the 194-card player list.
**CORRECTED 2026-08-21.** This item used to add "and not the
`/consumables/%s` template in .rdata, which the client has still never used".
That is false, and the same sentence is in commit `ccb736f`. 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, which is why the observed URL and the template look
like different things and are not.
3. THE ELEMENT IS A STACK WRAPPER, NOT AN ITEM. FutConsumablesSearchServerResponse
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
0x180130d10, 6873 chars) reads itemData(0x16b) at the root like the club list, but
its element is five atoms:
0xbc count 0xd7 discardValue
0x16a item -> FUN_18013fe00, the item parser itself
0x287 resourceId 0x362 untradeableCount
Everything else goes to the value-skip handler, so a bare item was ACCEPTED and did
nothing: the live card map held only the 11 squad players afterwards. That is also
why FUT draws consumables as one stack with a quantity rather than N cards.
## The lesson, since it has now cost three rounds
A 200 with a well-formed body that the consumer silently discards is the worst failure
shape in this project. Nothing errors, nothing logs as unmapped, the screen is just
empty. The diagnostic that worked every time was reading the CardsDb map: if the
client ingested nothing, the shape is wrong; if it ingested records that do not draw,
the failure is downstream. Guessing between those two costs a human a menu trip each
time; the probe costs 0.03 seconds.
## The green box, and why two guesses missed it
CONFIRMED FIXED 2026-08-05. Consumable cards draw real artwork once cardassetid
carries the ART id from the fcc_ tables (training 3, contract 7, healing 10, misc 45)
instead of a copy of the resourceId. The placeholder was
external/ion_fut/artAssets/.../notfound.swf, the client's stand-in for art it cannot
resolve.
The two wrong guesses are recorded because the reasoning behind each was plausible:
1. "It is the untradeable badge." The deserializer really does set a UI flag from
(untradeableCount < count) and UNTRADEABLE_COUNT really is a state key. Changing
it flipped the flag in the live record (+0x49 went to 1) and the tag did not
move. Refuted by measurement, not by argument.
2. "It is an untranslated loc key." Also wrong: the string NOT FOUND does not exist
anywhere in cardsdll.dll, in any casing.
What settled it was reading the badge TEXT off an unobstructed screenshot. "NOT FOUND"
is not a status, it is a missing-asset placeholder, and that turned a UI question into
an id question. The lesson is the cheap one: get the exact on-screen string before
theorising about what produces it.
THE TRAP GENERALISES. An fcc_ row carries BOTH carddbid and cardassetid and they are
not interchangeable. fut_store._item copies resourceId into cardassetid, which is
correct for players and wrong for every other family. The club-item family will need
the same mapping: balls 37, kits 35, stadium 36, badges 39, league logos 40.
---
# Club items: what the research established, 2026-08-05
> **SUPERSEDED 2026-08-21 in part.** `docs/plan-2026-08-06-card-subsystem.md` is
> the authority for club items and for the `itemState` vocabulary; where this
> file and that one disagree, that one wins. The corrections are applied inline
> below and marked. The subtype question this section calls UNKNOWN is ANSWERED.
Researched after a guessed field crashed the client. Facts first.
## VERIFIED IN BINARY
1. THE CARDTYPE MAP IS EXACT. FUN_1800d8330 (714 chars, read in full) returns cardtype
9 for cardsubtypeid 0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9 and 0xec, and nothing else.
fcc_misccards carries cardsubtype 231 = 0xe7, which anchors the 0xe7..0xe9 block to
misc cards.
**CORRECTED 2026-08-21.** The first half is right; the inference that followed
it was wrong. It read "that leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits,
stadia, balls and league logos". In fact `0x91..0x96` are TROPHIES, and three
of the five club families are **cardtype 7, not 9**`FUN_1800d8330` contains
`case 9: case 10: case 0xb: return 7;`. Only ball (0x1e) and league logo
(0x1f) are cardtype 9.
2. ITEMSTATE CARRIES THE EQUIPPED STATE. So an EQUIPPED club item is not a
different subtype, it is the same item with itemState set to one of those five.
**CORRECTED 2026-08-21.** The table starts at **`0x180229cc0`**, not
`0x180229d20` — the recorded address points into the MIDDLE of it, which is why
only ten rows were seen. The full vocabulary is TWELVE rows; the six missing
from the reading below are `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`,
`forSale` and `offered`. Two further consequences the ten-row reading hid:
`WAITING_FOR_GAME` and `inGame` are genuine ALIASES (both decode to 2), and
OMITTING the key yields `0` = `invalid`, which is NOT the same as `free` — an
item left at 0 fails the squad builder's `state == 1 || state == 2` test. The
match is also CASE-SENSITIVE (measured 2026-08-21: the comparator is
`msvcr120.dll+0x3c330`, a plain `strncmp` with no case folding), so the casing
in the table is a contract. See `openfut-adapter-fifa17/src/fut/item_state.rs`.
3. CLUB ITEMS HAVE NO CATEGORY GROUP TABLE. Consumables have one at 0x180203260 (seven
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
position) and staff have one at 0x180203310 (five codes). There is no equivalent
for club items; the only club-item strings are the COUNT labels
FUT_MYCLUB_KITS_AVAILABLE / BADGES_AVAILABLE / STADIA_OWNED / BALLS_EARNED, which
we already serve correctly.
4. THE ROUTE IS club?type= WITH SINGULAR NAMES. Observed live: type=stadium,
type=ball, type=equippables (the combined customisation view). Not the plural stat
names, and not a club/<family> path.
## ANSWERED 2026-08-06 (was "STILL UNKNOWN, AND NOT GUESSED")
The question was "which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus
badge versus kit". It was the wrong candidate set — three of the families are not
in it at all. The settled map:
| family | cardsubtypeid | cardtype | how the caption resolves |
|---|---|---|---|
| kit | **9** | 7 | `TeamName_Abbr15_<teamid>` |
| stadium | **10** | 7 | `StadiumName_<assetId>` |
| badge | **11** | 7 | `TeamName_Abbr15_<teamid>` |
| ball | **30** (0x1e) | 9 | no DB resolver; `FUT_UC_BALL` caption only |
| league logo | **31** (0x1f) | 9 | by elimination |
`0x91..0x96` are TROPHIES, not club items. Route (a) of the two proposals above is
what paid off — the consumer is the manager vtable slot `+0x498` =
`FUN_180119bd0`, dispatched when `item+0x4c == 7`. Route (b),
`FUT_CLUBITEMS=probe:<family>`, would have FAILED for three of the five families,
because its candidate set never contained 9, 10 or 11.
Kit, badge and stadium are served by OpenFUT today. Ball and league logo are
withheld: cardtype 9 has no database name resolver, so their name could only come
from `localizedName` on the wire, and that is not established as safe to send.
One residual probe remains, specified in `plan-2026-08-06-card-subsystem.md` §3.
## WHY THE CRASH HAPPENED, recorded so it is not repeated
teamid, leagueid and value were copied out of the fcc row as "extras". `value` appears
elsewhere as an OBJECT member (displayGroup {"value": ...}); a scalar where an object
is expected is the type-desync busy loop at 0x1801c7f1a, which presents as the game
taking its time and then dies. None of the three was needed to draw a card. Compounding
it, the response that crashed was type=equippables carrying 30 items across FIVE
unverified subtypes at once, so even the crash taught us nothing about which subtype
was wrong. Both are fixed: no extras, equippables withheld, one family per test.
---
# Field-map corrections (dated)
This file's earlier field notes predate the deserializer frame arithmetic. Where
they disagree with the table in `plan-2026-08-06-card-subsystem.md` §2, that
table wins — it is derived structurally (`FUN_18013fe00` builds the record as a
stack struct and hands `&local_188` to the merge, so `record_offset = 0x188 - X`)
rather than inferred backwards from an accessor.
```
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
+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
```
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
right, and the stronger statement is now measured: the pre-match kit selector
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
(live: `{1: players, 0: staff}`). Every OTHER input to that gate is already
served. So the empty kit-selection screen is a client dead end, not a missing
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
atom name appears exactly once in CardsDLL's `.rdata``resourceId`,
`cardsubtypeid`, `itemState`, `assetId`, `cardassetid`, `rareflag`, `owners`,
`contract`, `discardValue`, `localizedName` — while `definitionId` is absent
entirely. It is still sent on the live-proven player path; it is inert, not
harmful, and has not been removed.