404e859cb6
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.
1212 lines
69 KiB
Markdown
1212 lines
69 KiB
Markdown
# The card subsystem: the item record, club-item subtypes, and the card lifecycle
|
||
|
||
Written 2026-08-06. Six parallel reversing passes over the shared item
|
||
deserializer, the club-item families, the discard lookup, rating authority, the
|
||
club route table and the live heap, plus three adversarial verification rounds
|
||
that refuted four claims, corrected fourteen more and closed two gaps the
|
||
original passes had declared unresolvable. FIFA 17 was running throughout as pid
|
||
183351, in Ultimate Team, and was read strictly read-only. No server was
|
||
restarted, no server code was changed, and `tools/fifa17_profile.json` was never
|
||
opened for write.
|
||
|
||
Slide used for every live read: `live = static - 0x180000000 + 0x6ffffc140000`,
|
||
re-derived from `/proc/183351/maps` by five agents independently rather than
|
||
asserted, and proved each time against bytes read from the on-disk PE in two
|
||
different sections. Controls were the FNV hasher prologue at `0x180180d00`
|
||
(`.text`) and a single-occurrence `.rdata` literal, each agent picking its own.
|
||
One verifier added a third control that is worth stealing: the `.data` pointer at
|
||
`0x1802d2760` deliberately MISMATCHES between file and memory, and the delta is
|
||
exactly the slide, so the relocation itself confirms the arithmetic. The live
|
||
addresses die with the process; the static ones do not.
|
||
|
||
One methodological note before anything else, because it changed a verdict. The
|
||
key hash is **FNV-1** (multiply, then xor), not FNV-1a. A verifier's first pass
|
||
used FNV-1a against all 907 dictionary names and scored 0/907; the positive
|
||
control caught it immediately. `docs/plan-2026-08-05-store-subsystem.md` calls
|
||
`0x811c9dc5` "the FNV-1a offset basis". The basis is shared; the round order is
|
||
not.
|
||
|
||
---
|
||
|
||
## 1. What we now know that we did not know this morning
|
||
|
||
**The greyed-out transfer options are explained, both gates are identified, and
|
||
one of them was measured at 0 in the live client today.** This has been an
|
||
unexplained live observation for two days and it is now a two-row server fix.
|
||
`FUN_18003e370` publishes eight per-card action booleans to Flash, and the fourth
|
||
is `TO_TRADE_PILE`. Its predicate is `FUN_1801a7260`, which returns 1 only if
|
||
**both** of two conditions hold: the item's tradeable byte at `+0x49` is
|
||
non-zero, and slot `+0x270` of the `0xed84b12` service returns non-zero. We fail
|
||
both. We fail the first because `_item()` hard-codes `"untradeable": True`, and
|
||
the deserializer stores that field **inverted** (`local_140 = CONCAT11(cVar6 ==
|
||
'\0', ...)`), so our every card has `+0x49 == 0`. We fail the second because that
|
||
slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and `0x1fd2e` is the
|
||
`tradingEnabled` gate byte that `docs/ENDPOINT_MAP.md` already maps to settings
|
||
field `[10]`.
|
||
|
||
That second half was the open gap in the source material, and it is now measured
|
||
rather than argued:
|
||
|
||
```
|
||
pid 183351 base 0x6ffffc140000 slide 0x6ffe7c140000
|
||
CONTROL A .text FNV prologue: MATCH
|
||
DAT_1802e6398 -> 0xb7b4b920 vtable static 0x18021c2a0
|
||
slot +0x270 -> 0x18011c670 stub=0fb6812efd0100c3 disp=0x1fd2e VALUE=0
|
||
slot +0x2b0 -> 0x18011c500 stub=0fb6813afd0100c3 disp=0x1fd3a VALUE=1
|
||
slot +0x2c8 -> 0x18011c4b0 stub=0fb6813dfd0100c3 disp=0x1fd3d VALUE=1
|
||
slot +0x2e0 -> 0x18011c590 stub=0fb68145fd0100c3 disp=0x1fd45 VALUE=1
|
||
```
|
||
|
||
The three controls are the gate bytes `tools/gate_byte_probe.py` already measured
|
||
as 1 on two earlier launches. They come back 1 here, in the same walk, off the
|
||
same object, with each displacement decoded from its own accessor stub rather
|
||
than taken from a table. So the walk works and the zero is a fact about trading,
|
||
not an artefact. `GET /settings` on the live server still answers `{"configs":
|
||
[]}`, which I fetched read-only to confirm.
|
||
|
||
This also settles something the settings-gate document left ambiguous. That
|
||
document's correction established that the applier runs even with an empty
|
||
configs array and that the struct it is handed defaults several fields to 1.
|
||
Trading is not one of them: its default is 0. So `tradingEnabled` is not a flag
|
||
we have been overwriting, it is a flag nobody has ever sent, and
|
||
`tools/utas_server.py` **already has the row plumbed** -- `tradingEnabled` is in
|
||
`_SETTINGS_KEEP`, reachable today with `FUT_SETTINGS=keep`. The transfer-list fix
|
||
is one existing env flag plus one boolean, and section 6 has both.
|
||
|
||
Be precise about what this does and does not claim. It explains why the menu
|
||
entry is greyed. It does not promise that the transfer market works behind it.
|
||
|
||
**The card model now exists as a document.** Section 2 is the reference artifact
|
||
this project has never had: every field the server sends, whether the client
|
||
parses it, the byte it lands on, who wins when the client's own database
|
||
disagrees, and a grade with an address. Twenty-eight rows, of which twenty-one
|
||
are CONFIRMED with an address, four are INFERRED, and three are honestly UNKNOWN.
|
||
It was built the only way it could have been: a live bijection over 22 resident
|
||
records to find the offsets, and the deserializer's own stack-slot arithmetic to
|
||
name the ones the live data could not separate.
|
||
|
||
**Club-item subtypes are settled, and the starting premise was wrong for three of
|
||
the five families.** Kits, stadia and badges are **cardtype 7**, subtypes 9, 10
|
||
and 11. Only balls (30) and league logos (31) are cardtype 9. The 0x91..0x96
|
||
block that `CARD_SYSTEM.md` and `tools/fut_clubitems.py` assign to club
|
||
customisation is **trophies**. Every one of the five subtype constants currently
|
||
in `FAMILIES` is wrong, and all five sit inside the trophy block. Section 3 has
|
||
the evidence and the one probe still outstanding.
|
||
|
||
**The discard "miss" was never a miss.** The premise that has been carried in
|
||
`fut_store.py`'s own comments -- "that lookup returns no row for our cards" -- is
|
||
false. `fcc_discardcoins` is resident in the running client with all 141 rows,
|
||
dumped independently by two agents and byte-identical to the repo's
|
||
reconstruction both times. Every one of the 22 resident records keys a real row.
|
||
On the two records where our `discardValue` guard did not fire, the client's own
|
||
stored answer at `+0x3c` reproduces the table evaluation **to the unit**: the
|
||
gkcoach card at rating 66 computes 36, the chemistry style at rating 95 computes
|
||
38. The lookup runs, hits, and is right.
|
||
|
||
The real cause is a consumer split. `FUN_1800eb850` pushes two separate named
|
||
properties to Flash, `DISCARD_CREDITS` from `+0x38` (ours) and
|
||
`CALCULATED_DISCARD_CREDITS` from `+0x3c` (the client's), and no native code
|
||
anywhere in CardsDLL selects between them, sums them, or falls back from one to
|
||
the other. Each getter has exactly one call site, confirmed by two agents using
|
||
two different methods. The quick-sell tile binds `DISCARD_CREDITS`, so a card we
|
||
do not price renders 0 while the correct number sits one dword away, unread. That
|
||
is a negative result and it deletes work: there is no card field we get wrong, no
|
||
table row to add, and nothing to fix in the formula. `FUT_DISCARD_SEND=1` is not
|
||
a workaround for a data defect, it is the only wire input the tile reads, and it
|
||
should stop being described as a workaround.
|
||
|
||
**What FUT pays for staff is no longer unknown.** The brief listed this as an
|
||
open question and the fallback in `discard_value()` documents it as a guess not
|
||
worth shipping. It is the same formula, and the rating input is the staff table's
|
||
own `value` column. The gkcoach card 9000081 has `value: 66` in
|
||
`data/tables/gkcoachcards.json`; the merge writes that column into `+0xb4`;
|
||
`round_half_up(66 * 55 / 100) = 36`; and the client's independently computed
|
||
`+0x3c` reads exactly 36. One data point, but it is an exact one, and the
|
||
mechanism is read out of the merge rather than curve-fitted. Section 6 has the
|
||
patch, which is server-side arithmetic with no wire change at all.
|
||
|
||
**Two of the eleven items in the pending pile are unpriced, and the cause is one
|
||
missing wrapper rather than a missing formula.** `Profile.items()` stamps
|
||
`discardValue` on the way out; `Profile.purchased()` returns the raw pile. I ran
|
||
the live pile through the server's own `discard_value()` read-only: the chemistry
|
||
style 100000283 comes back **38**, matching the client's own computation to the
|
||
unit, and it is simply not on the wire. The staff card returns `None` because it
|
||
carries no rating, which the previous finding fixes.
|
||
|
||
**A confident absence claim was wrong, and the way it was wrong is worth more
|
||
than the fact.** One pass established at HIGH that `playStyle` is stored nowhere
|
||
in the item record, and did it properly: an exhaustive bijection over every
|
||
offset at four widths across 22 records, with same-value constant controls
|
||
(`contract == 7`, `fitness == 99`) that the same pass found. The controls passed.
|
||
The claim is still false. `playStyle` lands at `+0x88`, written by deser case
|
||
`0x23f`, and the search could never have found it because the value passes
|
||
through `FUN_180136480`, a switch accepting only `0xfb..0x111` with `default:
|
||
return 0`. We send 250, one below the first case, so the client stores 0 and no
|
||
search for the literal 250 can succeed. The controls were **raw scalars written
|
||
straight through**; the target was a **decoded scalar**. The control did not match
|
||
the target's form. That is the absence trap in a costume nobody had seen: not a
|
||
grep-versus-switch mismatch this time, but a same-form control that was the wrong
|
||
form for a reason invisible in the live data. Add it to the list: when testing for
|
||
a field's presence by value, first ask whether the value you sent is the value the
|
||
client would store.
|
||
|
||
**`amount` is also not dropped, and the correction is corroborated by a screenshot
|
||
we already have.** Ground truth recorded `amount` as absent from the record. It
|
||
lands at `+0xbe` for chemistry styles (subtypes 250..273) and `+0xbf` for other
|
||
consumable classes, written by `FUN_18013f4d0` as a single **byte**. Live: item
|
||
100000283, we sent `amount: 5`, `+0xbe == 5`. The original sweep looked for a u32
|
||
and explicitly dismissed the stray single bytes. Independently, `CARD_SYSTEM.md`
|
||
already records consumables drawing correct "+5 / +10 / +15" badges and attributes
|
||
that to "atom 0x1b reaching record+0xbf" -- a live screen observation from
|
||
2026-08-05 that agrees with the static reading for the other consumable classes.
|
||
Two routes, same answer.
|
||
|
||
Three smaller things worth carrying forward.
|
||
|
||
**`definitionId` is not an atom.** It is absent from the 907-row dictionary at
|
||
`0x1802D2760`. I checked the repo's own `docs/fut_atoms.tsv` directly rather than
|
||
taking it on report: `awk` for `definitionId` returns nothing while every
|
||
neighbour resolves. A verifier went further and walked the **live** key map,
|
||
recovering 908 nodes, scoring 907/907 on the dictionary as a positive control, and
|
||
showing `definitionId`'s hash collides with nothing -- so it cannot even be
|
||
silently misrouted onto another field. `_item()` sends it on every card. It is
|
||
pure wire cost and dropping it is free.
|
||
|
||
**`itemType` is parsed and thrown away.** Case `0x173` copies the string into a
|
||
heap buffer that lives *below* the record base in the frame, so it is outside the
|
||
0x180-byte record by construction, and nothing copies it in. This matters mainly
|
||
because we send `"itemType": "player"` on a chemistry-style consumable, which is
|
||
wrong and harmless, but which also made the `+0x54` question look undecidable
|
||
when it was not.
|
||
|
||
**The client does not hold the club.** A 2.80 GiB sweep found only 31 of 249
|
||
served item ids resident: 22 as full records (the 11-item pending pile plus an
|
||
11-item club page) and 9 as bare u32. The nine were initially read as squad slot
|
||
references; a base-rate test refutes it. Background density in that id range
|
||
predicts 22.8 noise hits in our window and we observed 9, which is *below* noise,
|
||
and eight of the nine appear exactly once in memory where every genuine record id
|
||
appears three to ten times. So `/club` paging is real and any future probe that
|
||
expects to find a 246-item array will fail for residency reasons, not structural
|
||
ones.
|
||
|
||
---
|
||
|
||
## 2. The card model, field by field
|
||
|
||
This is the reference the project has not had. "Where it lands" is an offset into
|
||
the 0x180-byte parsed item record built by `FUN_18013fe00`. Two independent
|
||
methods produced it and they agree everywhere they overlap:
|
||
|
||
- **Live bijection.** 22 resident records at `0xb7b61a78..0xb7b639f8`, every
|
||
offset tried at widths 1/2/4/8, each field correlated only over the records that
|
||
carried it. Reproduced from scratch by three agents with the same result. Rows
|
||
it produced alone are marked *(live diff)*.
|
||
- **Frame arithmetic.** `FUN_18013fe00` builds the record as a stack struct and
|
||
hands `&local_188` to the merge, so `record_offset = 0x188 - X` for every
|
||
Ghidra local `local_X`. This is structural, not heuristic, and it was validated
|
||
against nine fields with independent corroboration before being used to settle
|
||
anything. It is what decides the fields the live data cannot, because every
|
||
resident record carries the same value.
|
||
|
||
Authority means: who wins when our JSON and the client's local database disagree.
|
||
**OURS** = server-authoritative, the wire value reaches the screen. **CLIENT** =
|
||
overwritten or ignored, sending it changes nothing. **CONDITIONAL** = depends on
|
||
the card family or on whether we send zero.
|
||
|
||
### Fields we send today
|
||
|
||
| field (atom) | parsed | lands at | authority | grade | address |
|
||
|---|---|---|---|---|---|
|
||
| `id` (0x15c) | yes | `+0x08` u64, dup at `-0x08` | OURS | CONFIRMED | live diff, 22/22 |
|
||
| `resourceId` (0x287) | yes | `+0x18` u32, version byte `+0x24` | OURS, and it is the only identity that matters | CONFIRMED | `FUN_180166ca0` |
|
||
| `assetId` (0x23) | yes | `+0x20` u32 | CONDITIONAL: dead for families 1–5 and 10; live elsewhere; **required** for stadium (subtype 10), whose caption is `StadiumName_<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:
|
||
|
||
```json
|
||
{"itemData": [{
|
||
"id": 960000001,
|
||
"resourceId": 8010001,
|
||
"assetId": 8010001,
|
||
"cardassetid": 40,
|
||
"cardsubtypeid": 31,
|
||
"itemState": "free",
|
||
"owners": 1,
|
||
"untradeable": false,
|
||
"localizedName": "PROBE LEAGUE LOGO",
|
||
"description": "PROBE"
|
||
}]}
|
||
```
|
||
|
||
Served on `GET club?type=leaguelogos`, with `resourceId` taken from row 0 of
|
||
`data/tables/fcc_leaguelogos.json` rather than the literal above. Expected: the
|
||
league crest draws and the name is the string sent. If a ball caption appears, 30
|
||
and 31 are swapped. If nothing draws at all, league logos are not cardtype 9 and
|
||
the elimination is unsound. Cost is one launch and it can ride along with any
|
||
other club-item test.
|
||
|
||
Note two things about that body. `untradeable: false` is deliberate and free -- the
|
||
constructor default for `+0x49` is already 1, so sending `false` matches the
|
||
default rather than departing from it. And `localizedName` and `description` are
|
||
the one part of the probe that is *not* established as safe: the parser reads both
|
||
as strings (deser cases 0x19c and 0xd1, both STR), but per this project's own rule,
|
||
"the parser reads X" is not "sending X is safe". They are included because a
|
||
cardtype-9 item has no other source of a display name, so a probe without them
|
||
cannot tell "wrong subtype" from "right subtype, no name".
|
||
|
||
### An unsettled disagreement, recorded as unsettled
|
||
|
||
One pass concluded that a ball needs both `localizedName` and `description`; a
|
||
verifier showed that the ball's *subtitle* accessor `FUN_1801a8560` reads `+0xba`,
|
||
while the `description` atom demonstrably lands at `+0x111` for cardtype 9. Either
|
||
something else writes `+0xba`, or the ball subtitle is fed by a slot no wire atom
|
||
reaches. The title half is agreed: `FUN_1801a8570` reads `+0xd9`, which is where
|
||
`localizedName` lands. **I would bet on the verifier**, because its offsets come
|
||
from the deserializer's own frame arithmetic rather than from inferring backwards
|
||
from an accessor. So: send `localizedName` and expect it to show; send
|
||
`description` and do not be surprised if nothing changes. The same `+0xba` also
|
||
holds the unresolved kit-variant selector, so these two gaps may be one gap.
|
||
|
||
### The cardtype-9 name gap is ONE gap, not three (2026-08-21)
|
||
|
||
Worth stating plainly, because it was being tracked as three separate holes.
|
||
Everything OpenFUT still refuses to project is cardtype 9, and for exactly the
|
||
same reason:
|
||
|
||
| family | subtype(s) | definition table | why withheld |
|
||
|---|---|---|---|
|
||
| ball | 30 | `fcc_balls` (42) | no DB name resolver |
|
||
| league logo | 31 | `fcc_leaguelogos` (44) | no DB name resolver |
|
||
| misc | 231, 232, 233, 236 | `fcc_misccards` (42) | no DB name resolver |
|
||
|
||
The cardtype-7 families (kit 9, badge 11, stadium 10) all resolve their caption
|
||
from the client's own tables through `FUN_180119bd0`, so the server sends only
|
||
identity and the name takes care of itself — which is why all three now project.
|
||
Cardtype 9 has no such resolver, so the displayed name can ONLY come from
|
||
`localizedName` on the wire, and that single unproven step gates all three
|
||
families at once.
|
||
|
||
Closing it closes the last of the ownable taxonomy. It needs the launch-driven
|
||
probe in "The one probe still outstanding" above — one item, one family — and
|
||
nothing else. Ownership, `content_kind`, club/stats counting and restart
|
||
durability are already in place for all three, so the probe is the only
|
||
remaining work: the projection arm is a two-line change once the name is proven.
|
||
|
||
#### 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`:
|
||
|
||
```python
|
||
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte is set.
|
||
#
|
||
# "Place on Transfer List" is greyed on every card and BOTH of its gates are ours.
|
||
# FUN_1801a7260, the TO_TRADE_PILE predicate published by FUN_18003e370, returns 1
|
||
# only if the service gate at vtable+0x270 of 0xed84b12 is non-zero AND item+0x49
|
||
# is non-zero. The deserializer stores untradeable INVERTED -- case 0x361 does
|
||
# `CONCAT11(cVar6 == '\0', ...)` -- so untradeable:true writes 0 and kills the flag.
|
||
#
|
||
# The service gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
|
||
# tradingEnabled gate byte (ENDPOINT_MAP, FutGetSettingsServerResponse). Measured
|
||
# live 2026-08-06 as 0, with three control gate bytes reading 1 in the same walk.
|
||
# So this flag alone is NOT sufficient: it needs FUT_SETTINGS=keep beside it.
|
||
#
|
||
# Freeze risk: NONE beyond what we already send. untradeable is atom 0x361, read by
|
||
# the BOOL primitive FUN_1801c7620, and we already send the key on every card -- only
|
||
# the value changes. Every freeze on this project has come from feeding a container
|
||
# where a scalar was expected; this does not change the shape of anything.
|
||
#
|
||
# Side effects, which are real but permissive. item+0x49 has a third consumer,
|
||
# FUN_1800bc580, which counts untradeable squad members; that count is published as
|
||
# UNTRADABLE_COUNT and gates squad submission in FUN_1800bba10, which today takes the
|
||
# couldNotSubmitSquad branch. Both move toward "more allowed", not less.
|
||
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
|
||
```
|
||
|
||
and in `_item()`, replacing the hard-coded `"untradeable": True`:
|
||
|
||
```python
|
||
"untradeable": not TRADEABLE,
|
||
```
|
||
|
||
Club items in `tools/fut_clubitems.py` already send `"untradeable": False` and
|
||
need no change.
|
||
|
||
**Type fidelity.** BOOL where a BOOL is read. The constructor default for `+0x49`
|
||
is 1, i.e. tradeable, so `false` moves the field *toward* the client's own default
|
||
rather than away from it.
|
||
|
||
### P2 -- stamp `discardValue` on the purchased pile
|
||
|
||
In `tools/fut_store.py`, `Profile.purchased()`:
|
||
|
||
```python
|
||
def purchased(self):
|
||
"""Items still held in the purchased/unassigned pile (returned by
|
||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item).
|
||
|
||
Stamped on the way out exactly as items() is. Without this the pending pile
|
||
renders 0 for anything whose discardValue is not already persisted in the
|
||
save: the quick-sell tile binds the Flash property DISCARD_CREDITS, which is
|
||
item+0x38, which is ONLY ever written from the wire. The client's own correct
|
||
answer lands at +0x3c under the name CALCULATED_DISCARD_CREDITS and no native
|
||
code in CardsDLL falls back from one to the other. Verified live 2026-08-06:
|
||
the pile's chemistry style 100000283 evaluates to 38 through discard_value()
|
||
and the client's own +0x3c holds 38, and the tile reads neither because the
|
||
key is absent from the wire."""
|
||
its = self.load().get("purchased", [])
|
||
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
|
||
```
|
||
|
||
**Freeze risk: none.** `discardValue` is atom `0xd7`, a plain INT already on the
|
||
wire for 20 of 22 resident items. Not stamped, not persisted, so turning
|
||
`FUT_DISCARD_SEND` off is still a true revert. No new flag: this is a bug in the
|
||
existing flag's coverage.
|
||
|
||
### P3 -- `FUT_DISCARD_STAFF`: price staff cards from the table `value` column
|
||
|
||
The unrated-card fallback in `discard_value()` documents the staff price as
|
||
unknown. It is not. The merge writes the staff table's `value` column into
|
||
`+0xb4`, and `round_half_up(value * price / 100)` reproduces the client's own
|
||
answer exactly. In `tools/fut_store.py`:
|
||
|
||
```python
|
||
# FUT_DISCARD_STAFF: price staff cards using the game table's `value` column as the
|
||
# rating input.
|
||
#
|
||
# discard_value() bails on a card with no `rating` key, which is every staff card we
|
||
# serve, so the pile's gkcoach 100000282 goes out unpriced and its tile shows 0. The
|
||
# rating a staff card USES is the `value` column of its own cards table -- the merge
|
||
# arms for families 2,3,4,5,10 all do `rec[0xb4] = row["value"]` -- so the number was
|
||
# always available server-side. Confirmed to the unit, live 2026-08-06: carddbid
|
||
# 9000081 has value 66 in gkcoachcards.json, cardtype 10 (subtype 6), rare 0, level 2
|
||
# -> price 55 -> round_half_up(66*55/100) = 36, and the client's own computation at
|
||
# record +0x3c reads 36.
|
||
#
|
||
# Freeze risk: NONE. This changes no wire shape at all. It only supplies a rating to
|
||
# a server-side arithmetic function; the `rating` key is not added to the JSON,
|
||
# because on a staff card the merge overwrites +0xb4 from the DB regardless and
|
||
# sending it would be inert.
|
||
DISCARD_STAFF = os.environ.get("FUT_DISCARD_STAFF", "0") == "1" and DISCARD_SEND
|
||
|
||
_STAFF_VALUE_TABLES = ("headcoachcards.json", "gkcoachcards.json",
|
||
"physiocards.json", "fitnesscoachcards.json",
|
||
"managercards.json")
|
||
_STAFF_VALUE = None
|
||
|
||
|
||
def _staff_value(resource_id):
|
||
"""carddbid -> the table's `value` column, or None. Built once, lazily."""
|
||
global _STAFF_VALUE
|
||
if _STAFF_VALUE is None:
|
||
_STAFF_VALUE = {}
|
||
base = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"..", "data", "tables")
|
||
for fn in _STAFF_VALUE_TABLES:
|
||
try:
|
||
with open(os.path.join(base, fn)) as f:
|
||
rows = json.load(f).get("rows") or []
|
||
except (IOError, ValueError):
|
||
continue
|
||
for r in rows:
|
||
cid, val = r.get("carddbid"), r.get("value")
|
||
if cid and val:
|
||
_STAFF_VALUE.setdefault(cid, val)
|
||
return _STAFF_VALUE.get(resource_id & 0xffffff)
|
||
```
|
||
|
||
and, inside `discard_value()`, replacing the early bail:
|
||
|
||
```python
|
||
r = item.get("rating")
|
||
if not r and DISCARD_STAFF:
|
||
r = _staff_value(int(item.get("resourceId") or 0))
|
||
if not r:
|
||
return None
|
||
```
|
||
|
||
**One caveat, stated rather than buried.** This is confirmed on one card. The
|
||
mechanism is read out of the merge, not curve-fitted, and the arithmetic matched
|
||
to the unit, but a second staff family would make it two.
|
||
|
||
### P4 -- `FUT_CLUBITEMS`: correct the five subtypes
|
||
|
||
In `tools/fut_clubitems.py`, the `FAMILIES` table and the probe set. This is a
|
||
data correction, not a new capability, and it should still be exercised one family
|
||
at a time through the existing `FUT_CLUBITEMS` machinery.
|
||
|
||
```python
|
||
# (table, art id, stat id, stat name, cardsubtypeid)
|
||
#
|
||
# CORRECTED 2026-08-06. Every previous value was inside the 0x91..0x96 block, which
|
||
# is TROPHIES (FUN_180108c00 computes subtype = tournamentType + 0x91; FUN_1800fed90
|
||
# is the only function in the binary whose case set is exactly {0x91..0x96}).
|
||
#
|
||
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
||
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver:
|
||
# manager vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when
|
||
# item+0x4c == 7, called with (subtype, teamid, assetId). So the docstring's
|
||
# "a wrong id cannot announce itself" is false for these three -- a wrong teamid
|
||
# produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
||
FAMILIES = [
|
||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||
]
|
||
|
||
# The candidate set for probe_shelf(). The old set {30,31,145..150} could not have
|
||
# answered the question for kits, stadia or badges, because 9, 10 and 11 were not
|
||
# in it -- it would have burned a launch and returned nothing for three of five.
|
||
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
||
```
|
||
|
||
and, in `_item()`, the per-family fields the cardtype-7 resolver requires:
|
||
|
||
```python
|
||
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
||
it = {
|
||
"id": item_id,
|
||
"resourceId": carddbid,
|
||
"assetId": carddbid,
|
||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||
"cardsubtypeid": subtype,
|
||
"itemState": "free",
|
||
"owners": 1,
|
||
"untradeable": False,
|
||
}
|
||
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
||
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
||
# StadiumName_<assetId>, which resourceId already supplies. teamid is atom 0x306,
|
||
# read with the INT primitive FUN_1801c79d0 and stored at record +0x94 -- an
|
||
# established scalar field, not a new shape.
|
||
#
|
||
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
||
# response that crashed the client. It was never bisected. `value` is the
|
||
# established suspect (it is an OBJECT member elsewhere, and a scalar where an
|
||
# object is expected is the 0x1801c7f1a busy loop), and that response also carried
|
||
# 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to ONE
|
||
# family, with the subtypes now correct. That is the narrow test the crash denied us.
|
||
if teamid is not None and subtype in (9, 11):
|
||
it["teamid"] = teamid
|
||
if extra:
|
||
it.update(extra)
|
||
return it
|
||
```
|
||
|
||
**`itemType` should be dropped from this builder** while it is being touched. It
|
||
currently sends `"itemType": "club"`, marked UNOBSERVED in the docstring, and the
|
||
deserializer parses `itemType` into a heap string below the record base and never
|
||
copies it in. It is inert, but it is also one of the few unobserved strings we
|
||
still emit.
|
||
|
||
**Freeze risk: low, and lower than the last attempt.** `teamid` is an INT read by
|
||
the scalar primitive; the subtype change alters an integer's value, not its type.
|
||
The blast radius is bounded by serving one family per test, which the existing
|
||
`FUT_CLUBITEMS` machinery already enforces and `equippables` still answers empty.
|
||
|
||
### P5 -- `FUT_CLUB_POSTAB`: answer the DEF/MID/ATT tabs
|
||
|
||
`club_route`'s filter keeps only `cardsubtypeid not in (0,1,2,3)` for any type
|
||
outside `player` and `custom`, so `type=playerdefender`, `playermidfielder`,
|
||
`playerforward` and `any` all return an empty list against a save whose items are
|
||
all subtype 0. The three position tabs are real traffic: `FUN_18012ddf0` remaps
|
||
request field `*(req+0x14)` values `0x1c/0x1d/0x1e` into type codes `0x1b/0x1c/0x1d`
|
||
and suppresses the `position=` parameter, so the MY CLUB position tabs arrive
|
||
exactly as those three strings.
|
||
|
||
```python
|
||
# The client's OWN position grouping, read out of FUN_180135890's recompute of
|
||
# record+0x14c from record+0x146: 0 -> GK, 1..8 -> DEF, 9..19 -> MID, 20..27 -> ATT.
|
||
# Not invented here; this is the ladder the client applies to its own records.
|
||
_POS_GROUP = {"playerdefender": (0, 8), "playermidfielder": (9, 19),
|
||
"playerforward": (20, 27)}
|
||
```
|
||
|
||
and in `club_route`, before the existing `if kind and kind not in ("player", "custom")`:
|
||
|
||
```python
|
||
if kind in _POS_GROUP:
|
||
lo, hi = _POS_GROUP[kind]
|
||
items = [i for i in items
|
||
if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)
|
||
and lo <= int(i.get("preferredPosition") or 0) <= hi]
|
||
log(" CLUB: type=%s -> %d item(s) (position group %d..%d)"
|
||
% (kind, len(items), lo, hi))
|
||
return 200, {"itemData": items}
|
||
if kind == "any":
|
||
log(" CLUB: type=any -> %d item(s) (unfiltered)" % len(items))
|
||
return 200, {"itemData": items}
|
||
```
|
||
|
||
**The one guess, named.** GK is folded into DEF because the observed tabs are
|
||
DEF/MID/ATT and there is no fourth. If the DEF tab comes back without goalkeepers
|
||
in it, the guess is wrong and the fix is to change `(0, 8)` to `(1, 8)`. Nothing
|
||
else in the patch is a guess.
|
||
|
||
**Freeze risk: none.** Server-side filtering only; the response shape is the
|
||
`{"itemData": [...]}` the route already returns everywhere.
|
||
|
||
### P6 -- drop `definitionId`
|
||
|
||
One line in `_item()`. `definitionId` is not in the 907-entry key dictionary, so
|
||
it hashes to an unregistered key and its value goes to the value-SKIP handler
|
||
`FUN_180135ff0`. Verified three ways: absent from `docs/fut_atoms.tsv`; absent
|
||
from the live key map walked in the running client with all 907 dictionary names
|
||
passing as a positive control; and colliding with no registered hash, so it cannot
|
||
be misrouted onto another field. **Freeze risk: none** -- removing a key the parser
|
||
skips strictly reduces executed code. Low value, zero cost, and it removes a field
|
||
that three documents describe as if it did something.
|
||
|
||
**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.
|