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.
69 KiB
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_18013fe00builds the record as a stack struct and hands&local_188to the merge, sorecord_offset = 0x188 - Xfor every Ghidra locallocal_X. This is structural, not heuristic, and it was validated against nine fields with independent corroboration before being used to settle anything. It is what decides the fields the live data cannot, because every resident record carries the same value.
Authority means: who wins when our JSON and the client's local database disagree. OURS = server-authoritative, the wire value reaches the screen. CLIENT = overwritten or ignored, sending it changes nothing. CONDITIONAL = depends on the card family or on whether we send zero.
Fields we send today
| field (atom) | parsed | lands at | authority | grade | address |
|---|---|---|---|---|---|
id (0x15c) |
yes | +0x08 u64, dup at -0x08 |
OURS | CONFIRMED | live diff, 22/22 |
resourceId (0x287) |
yes | +0x18 u32, version byte +0x24 |
OURS, and it is the only identity that matters | CONFIRMED | FUN_180166ca0 |
assetId (0x23) |
yes | +0x20 u32 |
CONDITIONAL: dead for families 1–5 and 10; live elsewhere; required for stadium (subtype 10), whose caption is StadiumName_<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 714–720 |
teamid (0x306) |
yes | +0x94 u32 |
OURS when non-zero; merge fills only if 0. Required for kit (9) and badge (11): the caption is TeamName_Abbr15_<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 250–273, +0xbf u8 otherwise, +0x98 for cardtype 9 |
OURS | CONFIRMED | FUN_18013f4d0; live +0xbe == 5; corroborated by the "+5/+10/+15" badges observed 2026-08-05 |
pile (0x226) |
no | n/a | ignored on itemData. +0x60 is set by the owning list, not the wire |
CONFIRMED | no 0x226 arm in FUN_18013fe00, checked in all four dispatch forms |
rating on staff |
see above | n/a | ignored on the wire, but load-bearing server-side as the discard input | CONFIRMED | merge staff arms; discard_value() |
Fields we do not send and could
| field (atom) | lands at | why it is worth knowing | grade |
|---|---|---|---|
lastSalePrice (0x185) |
+0x34 u32 |
published to Flash as BOUGHT_FOR. We leave it at 0 today. Upgraded from MEDIUM to HIGH by a verifier who resolved the arm |
CONFIRMED |
localizedName (0x19c) |
+0xd9 (0x38 bytes, cardtype 9); +0xbc (0x1f bytes, cardtype 7) |
cardtype 9 has no DB resolver, so a ball's displayed name can only come from the wire | CONFIRMED (offset), UNKNOWN (safe to send) |
description (0xd1) |
+0x111 (cardtype 9); +0x10f (cardtype 7) |
see the unsettled note below | CONFIRMED (offset), UNKNOWN (does anything read it) |
loans (0x19b) |
+0x90 |
do not send. loans > 0 with contract == 0 is the client's definition of an expired loan and it greys out MODIFY |
CONFIRMED |
injuryGames (0x167) |
+0x145 |
do not send non-zero. It kills TO_TRADE_PILE for players independently of everything in section 4 |
CONFIRMED |
value (0x377) |
n/a | never send. It is an object member elsewhere (displayGroup {"value": ...}) and is the prime suspect for the 2026-08-05 crash |
established prior work |
Record fields that are not wire fields
+0x25 is the item's index inside the itemData array that built it. +0x30 is
a client-generated timestamp written by FUN_1800d84e0() in the deser tail with
no wire input -- one agent reads it as a boot-relative millisecond tick, another as
rdtsc-derived, and the disagreement does not matter because nothing we send
reaches it. +0x54 is the discard level (3/2/1 by thresholding +0xb4 at
0x4b/0x41), written unconditionally at the merge tail; the previously recorded
"itemType enum 3=player 2=staff" is refuted, see below. +0x60 is the pile,
assigned by the owning list. +0x70 is a vtable pointer that differs between the
club and purchased lists. +0xb8/+0xc8/+0xdd are inline name buffers filled
from the local player DB. +0x14c is the position group, recomputed from
+0x146 by the client's own ladder (0 → GK, 1–8 → DEF, 9–19 → MID, 20–27 → ATT).
The +0x54 conflict, and why it is settled
Two passes graded contradictory claims about +0x54 at HIGH: one said the discard
level, one said an itemType enum with a "perfect bijection {player:3, staff:2}".
The bijection is real and it is degenerate. Over all 22 resident records the two
hypotheses predict the same value on 22 of 22, because every resident player is
rated 75 or above and the one staff card is rated 66. Worse, the tie is partly our
own doing: we serve "itemType": "player" on the chemistry-style consumable, so
even the one record that should have discriminated does not look like it does.
Static evidence settles it four ways. FUN_180141660's tail has exactly one write
to +0x54 and it is the rating ladder at 0x180141e8a..0x180141ea3. That tail is
reached on every path: the switch on +0x4c has arms for 1,2,3,4,5,10 and no
default, so every other family falls straight through, and the only escape is the
item+8 == 0 entry guard. No dispatch arm of FUN_18013fe00 writes the stack half
that maps to +0x54 -- all 52 case labels plus the three == arms and the three
range splits were enumerated. And the deser tail passes +0x54 to the
fcc_discardcoins query as the level column, whose semantics are already
independently established. +0x54 is the level. itemType is not stored at all.
I would bet on the level reading without reservation, and the falsifier is one
screen: serve a player rated 60 and +0x54 must read 1. The save already holds
twenty sub-75 players (100000161 at 53, 100000162 at 59, 100000159 at 65) that
would decide it if they were paged in.
3. Club item subtypes
The answer, for four of five families with certainty and the fifth by elimination:
| family | cardsubtypeid | cardtype | resolver | what it additionally needs |
|---|---|---|---|---|
| kit | 9 | 7 | FUN_180119bd0 → FUT_UC_KITS + TeamName_Abbr15_<teamid> |
teamid |
| stadium | 10 | 7 | FUN_180119bd0 → Stadium + StadiumName_<assetId> |
assetId |
| badge | 11 | 7 | FUN_180119bd0 → Badge + TeamName_Abbr15_<teamid> |
teamid |
| ball | 30 (0x1e) | 9 | none; FUT_UC_BALL caption only |
localizedName |
| league logo | 31 (0x1f) | 9 | FUN_180098f20 keyed on leagueid |
localizedName, probably |
The premise that all five live in cardtype 9 is wrong, and the root fact is not an
inference from a call site. FUN_1800d8330, read in full at 714 chars by two
agents, contains case 9: case 10: case 0xb: return 7;. Kits, stadia and badges
are cardtype 7, which does have a resolver, reached from FUN_1800f6c40 only
when item+0x4c == 7, called with (subtype, teamid, assetId). The manager vtable
slot was verified from both disk and live memory: qword at 0x18021c2a0 + 0x498
is 0x180119bd0 on disk, and the same value comes back from a read-only deref of
DAT_1802e6398 in the running client.
Four independent lines agree on kit = 9. The resolver's own arms. The card-detail
builder FUN_1801bfac0, which switches on a verified cardsubtypeid accessor
(FUN_1801a8640 is literally return *(u32*)(*(long*)(p+0x18) + 0x50)) and gives
subtype 9 the FUT_UC_KITS / FUT_ThirdKit / KitBioAwayDescription family, 10
the Stadium / StadiumDetailDesc family and 11 the Badge /
badgeBioDescription family with a leagueteamlinks lookup. An IS_KIT_%d flag in
FUN_1800f6c40 set when item+0x50 == 9. And the deserializer itself, which for
cardtype 7 with cardsubtypeid 9 defaults cardassetid to 0x23 = 35 -- which is
exactly the cardassetid carried by every one of the 1482 rows of
fcc_kitcards.
0x91..0x96 are trophies, not club customisation. FUN_180108c00 deserializes
tournamentType (0x32f) and computes subtype = value + 0x91, then picks
TOURNY_LOC_%d for 0x91 <= s < 0x95 and SEASON_LOC_%d for 0x95 <= s < 0x97.
FUN_1800fed90 is the only function in the binary whose switch case set is exactly
{0x91..0x96} -- established by enumerating all 8767 caseD_ symbols with
FUN_1800d8330's own jump-table labels present as the control -- and it maps them
to (tournament, 0..3) and (season, 0..1). FUN_1801017e0 builds
FUT::TournamentInfo objects and maps a 0..3 enum onto 0x91..0x94. So
tools/fut_clubitems.py currently assigns all five club families ids inside the
trophy block, and probe_shelf()'s candidate set {30,31,145..150} cannot find
the answer for kits, stadia or badges, because 9, 10 and 11 are not in it. That
probe would have burned a launch and returned nothing for three of five families.
The near-miss that would have produced a sixth wrong verdict. The enum table at
0x180229ab0 reads ... physio=9, badge=0xa, kit=0xb, leagueLogo=0xc, ..., stadium=0x15, ball=0x16. For players and staff it is exactly cardsubtypeid + 1, which makes
badge 9, kit 10 and leagueLogo 11 look like the answer. It is not: that table feeds
the transfermarket &cat=%s query parameter in FUN_180162c90, and reading it as
a subtype map swaps badge and kit and loses stadium entirely. Two agents found this
table; one of them nearly published it.
The one probe still outstanding
League logo = 31 is by elimination, and the elimination is airtight on its
premises but the premises are exhaustive-search results rather than a caption.
FUN_1800d8330's cardtype-9 set is exactly {0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, 0xec}; the 0xe7..0xec block is matched exactly by fcc_misccards' cardsubtype
column {231,232,233,236}; 0x91..0x96 are trophies; 0x1e has the FUT_UC_BALL
caption. That leaves one slot. There is no FUT_UC_LEAGUELOGO literal anywhere in
the DLL -- a verifier enumerated all 19 FUT_UC_* strings by regex over the whole
file -- so no caption can confirm it.
The minimal probe, specified to run without further thought. One item, one family, no unestablished extras:
{"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:
+0x49is 0 on every card, because_item()sends"untradeable": Trueand the deserializer stores the negation.- The service gate at slot
+0x270reads thetradingEnabledbyte0x1fd2e, measured 0 in the live client today, with three control gates reading 1 in the same walk.GET /settingsstill 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
- Transfer list.
FUT_SETTINGS=keepplusFUT_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:+0x49should read 1 and0x1fd2eshould read 1; if both are 1 and the entry is still greyed, the remaining gate issvc->0x308()on0xed80ed8. - Club items, one family at a time, kits first. Kits have the loudest failure
mode: a wrong
teamidproduces 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. - The
+0x54discriminator. Serve one sub-65 player on the visible club page. Predicts+0x54 == 1and 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. playStyle 251. Predicts+0x88 == 1and 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.