Files
OpenFUT/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md
T
funman300 404e859cb6 docs(fifa17): the caption path is not in CardsDLL, and a 4th definitionId check
Chased the league-logo lead to a useful boundary and stopped there.
FUN_180119bd0 — the cardtype-7 caption resolver the whole club-item story rests
on — has ZERO references anywhere in CardsDLL: no call, no jmp, address never
taken in .text/.rdata/.data. It is nonetheless a real function. An unreferenced
real function in a DLL is almost certainly an export, which puts its caller in
FIFA17.exe. So the owned cardtype-9 caption path is not in CardsDLL and looking
for it there is wasted effort; the launch probe remains far cheaper than parsing
the export table and 79 MB of EXE.

Also verified definitionId a fourth way, by a different method than the existing
three: every real atom name appears exactly once in CardsDLL's .rdata
(resourceId, cardsubtypeid, itemState, assetId, cardassetid, rareflag, owners,
contract, discardValue, and localizedName), while definitionId is absent
entirely. Recorded but NOT applied — the path carrying it is live-proven and the
saving is payload only.

Method note added: CardsDLL is .text 0x180001000, .rdata 0x1801e5000, .data
0x18028a000. Confusing a live mapping offset with an image offset reads the wrong
section and returns false negatives — it made every atom lookup, controls
included, come back ABSENT until corrected. Validate scans against a known key.
2026-08-21 21:28:13 +00:00

69 KiB
Raw Blame History

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_180119bd0FUT_UC_KITS + TeamName_Abbr15_<teamid> teamid
stadium 10 7 FUN_180119bd0Stadium + StadiumName_<assetId> assetId
badge 11 7 FUN_180119bd0Badge + 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:

{"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.

A lead on league logos: a LeagueName_Abbr_15_%d path DOES exist

FUN_180098f20 (named above as the league-logo function, hedged "localizedName, probably") was read in full on 2026-08-21. It builds a real database query, and the literals settle what it does:

table     'fcc_leaguelogos'
where     'leagueid' '==' %d          ; the id arrives in r9d
columns   'carddbid'  'value'  'cardassetid'
caption   'LeagueName_Abbr_15_%d'     ; a localisation key built from the league id
domain    'FUT String'

So a database-backed league NAME demonstrably exists in the client, keyed on leagueid, in exactly the shape kits use (TeamName_Abbr15_<teamid>). That makes the blanket claim "cardtype 9 has no DB name resolver" too strong for league logos specifically.

WHAT THIS DOES NOT YET SHOW, stated plainly because the obvious next step is a trap. Its ONLY caller is 0x180098da3, and the [rbx+0x20] it passes as the league id is NOT the item record: rbx is reloaded from [rsp+0x48] and compared against an end pointer, i.e. it is a cursor over a list of small elements (int at +0x20, double at +0x24, int at +0x2c), not the 0x158-byte card record. So this is a CATALOG/BROWSE builder, and it is not established that the owned-item render path reaches it at all. Reading +0x20 as the record's assetId and concluding "send the leagueid as assetId" would be exactly the kind of inference this document exists to prevent.

The lead worth following: find whether the owned cardtype-9 render path reaches this resolver, and if so which field feeds the league id. If it does, league logos need no localizedName at all and separate from the ball/misc gap.

Where to look next, and where NOT to (2026-08-21)

The lead above was chased and stopped at a useful boundary. FUN_180119bd0 — the cardtype-7 caption resolver this whole section rests on — has zero references anywhere in CardsDLL: no call, no jmp, and its address is never taken in .text, .rdata or .data. It is nonetheless a genuine function (clean mov rax,rsp entry after int3 padding).

A real, unreferenced function in a DLL is almost certainly an export, which puts its caller in FIFA17.exe. That matches the shape of everything else here: CardsDLL owns the card model and the database, and the EXE owns the UI that asks for captions. FUN_180098f20's only caller likewise iterates a small list element, not a card record — a browse/catalog builder, not the owned-item path.

So the practical guidance is: stop looking for the owned cardtype-9 caption path inside CardsDLL. It is not there. Closing this by static reading means parsing CardsDLL's export table and following the callers in FIFA17.exe's 79 MB, which is a much larger job than the launch probe in "The one probe still outstanding" — one item, one family, and the answer is visible on screen.

Method note for whoever does dump memory here: CardsDLL's sections are .text at image 0x180001000, .rdata at 0x1801e5000, .data at 0x18028a000. Confusing a LIVE mapping offset with an IMAGE offset silently reads the wrong section and produces false negatives — every atom-name lookup came back ABSENT until the region was corrected, including controls like resourceId. Always validate a memory scan against a key known to be present.


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. CLOSED 2026-08-21. The one unopened link inside the eight-flag chain. It is eleven instructions, and it resolves TWO parallel arrays rather than the one the earlier claim described:

mov  rax, [rcx+0x10]              ; the ITEM record (same +0x10 hop the kit gate uses)
test r8b, r8b
jz   .low
mov  eax, [rax + rdx*4 + 0x124]   ; array B
ret
.low:
mov  eax, [rax + rcx*4 + 0x104]   ; array A  <- the claimed statsList
ret

So the signature is f(self, int idx, bool which): +0x104 + idx*4 when the flag is clear, +0x124 + idx*4 when it is set. The two arrays are 0x20 apart, i.e. eight ints each (+0x104..+0x123, +0x124..+0x143).

LIVE (pid 6580, production-served records): BOTH arrays read all zeros on every resident record, players included — e.g. resourceId 20801 rating 94 has A = [0]*8, B = [0]*8. That confirms "changes no action today because we send no statsList", and extends it: the sibling array at +0x124 is equally empty. Any action flag derived from either is reading 0 in production, so neither can be the reason an action is greyed.

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. PARTLY ANSWERED 2026-08-21 — the two databases are now definitively distinct; the load FLAG is still unlocated. The fcc_discardcoins result proves g_db is loaded and complete; it says nothing about the separate database behind LoadFUTDatabase / .dbFUTVer / DL_FUT_LIVEDB. Scanning FIFA17.exe's 79 MB of code+data in the live process (pid 6580) recovers the whole API name set, and it settles the distinction:

SetFUTDatabaseUnloaded   UpdateFUTDBVersion   StartFUTRosterDownload
LoadFUTDatabase          UnLoadFUTDatabase    GetFUTDBCRC
CancelRosterDownload     DL_FUT_LIVEDB        APPLY_FUT_LIVEDB
RosterXMLDownloadedFail  .dbFUTVer  .dbMajor  .dbMinor  .dbMajorCRC  .dbMinorCRC

Every one of those lives in FIFA17.exe; none is in CardsDLL. So the FUT roster DB is a DOWNLOADED, versioned, CRC-checked live database with its own download -> apply -> load/unload lifecycle (and its own failure state, RosterXMLDownloadedFail), which is a different kind of thing from the shipped card tables CardsDLL reads. They should stop being conflated, and this is the evidence for saying so.

What is NOT answered: whether it is loaded right now. The process holds no separate database file open — only Frostbite bundles (.sb / .cas) — which is consistent with the roster DB living inside a bundle or in memory, so absence of a file handle proves nothing either way. The SetFUTDatabaseUnloaded state implies a boolean somewhere; that global was not located, so "is it loaded" remains open and needs the flag found before it can be answered honestly.

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:

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

        "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():

    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:

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

    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.

# (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:

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.

# 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"):

    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.

Fourth verification, 2026-08-21 (independent method). Searched CardsDLL's own .rdata in the running client for the literal key names. Every real atom is present exactly once — resourceId 0x18022a3a8, cardsubtypeid 0x180230520, itemState 0x180231490, assetId 0x180230178, cardassetid 0x180204200, rareflag, untradeable, owners, contract, discardValue, and notably localizedName at 0x1802316d0 — while definitionId is ABSENT entirely. The client has no string for it, so no arm can exist. That is a different method from the three above (string table rather than key dictionary) and it agrees.

NOT applied all the same. The player path that carries definitionId is live-proven in production, the saving is payload only, and this project's house rule is that a flag defaults to the live-proven value. "Provably inert" is a good reason to stop documenting it as meaningful; it is not on its own a reason to change a working wire. Bundle it with the next change that needs a launch.


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.