e578443d73
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I committed earlier today. THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on Transfer Market" have been disabled in the reveal screen and nobody knew why. TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the tradingEnabled gate byte. Read live and reproduced independently: slot +0x2b0 friendlySeasons disp 0x1fd3a VALUE=1 slot +0x2c8 draftMode disp 0x1fd3d VALUE=1 slot +0x2e0 packOpeningAnim disp 0x1fd45 VALUE=1 slot +0x270 tradingEnabled disp 0x1fd2e VALUE=0 tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates the settings work from this morning: that plan died because every gate it targeted already read 1, and the conclusion drawn was that the settings array does not matter. It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in _SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off. So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping only the boolean would look like the finding failed. THE DISCARD "MISS" NEVER EXISTED, which correctse3092ca. fcc_discardcoins is resident and complete, the client lookup runs and is correct, and it lands at item+0x3c. The tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the client was not failing a lookup; it was faithfully displaying the 0 we sent. Same observable, completely different mechanism, and the version ine3092cais wrong. FUT_DISCARD_SEND remains exactly the right fix, now for the right reason. WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own +0x3c reads 36. That closes the gap I flagged ine3092caas not-guessed. CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9, stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination. All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent a launch and returned nothing for three of five families. THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it lands on and whether the client keeps it. Built by diffing what we serve against the parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer rather than by assuming the +0x38 offset). Corrections that change what we serve: +0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is itemState, definitionId is not an atom at all. A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at +0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the control was the wrong FORM. That is a new variant of the absence trap, which has now cost six wrong verdicts, and it is recorded in the doc. FIX TO MY OWN PATCH frome3092ca: purchased() and last_pack() lacked the _with_discard wrapper that items() had, so the pending pile, which is the one place a quick-sell value is actually read, served unstamped cards. Found by verification, not testing. All three read paths now stamp. Correcting an overstatement ine3092ca: "turning the flag off is a true revert" holds for the read paths, which copy, but NOT for cards minted while armed, because _item() stamps at creation and those persist (9 items currently). Kept deliberately: the pack reveal serves itemList straight from open_pack(), not through purchased(), so removing creation-stamping would leave the screen that matters unstamped. Persisted values are correct and self-heal, since every read recomputes and overwrites. Nothing here has been on screen. Six patches are proposed in the doc as pasteable text, env-flagged, defaulting off, none applied. Live: 439 contract checks, 414 card-family checks, market suite, all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1032 lines
60 KiB
Markdown
1032 lines
60 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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
**"List on Transfer Market" as a separate menu entry was not found.** The eight
|
||
flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes
|
||
`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but
|
||
whether it has its own enable predicate was not chased. The likely explanation is
|
||
that listing is only reachable from the trade pile, so both entries share one root
|
||
cause, but that is an inference and it is not established.
|
||
|
||
### 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`.** It gates the kit swap at value 4 and we can produce 1
|
||
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
|
||
instructions depending on method. The narrower anchor is the `/club` and
|
||
`/purchased` response handlers -- find the list-insert that assigns it, read the
|
||
constants. This is the single blocker between "we can mark a kit equipped" and "we
|
||
can equip a kit".
|
||
|
||
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
|
||
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
|
||
byte). Which wire atom sets it is unknown, so we cannot serve a specific kit
|
||
deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle.
|
||
|
||
**`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is
|
||
claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no
|
||
action today because we send no `statsList`, but it is two minutes of work and it
|
||
would close the chain.
|
||
|
||
**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved.
|
||
What remains is whether the field is visible anywhere worth populating.
|
||
|
||
**`FUN_1800fed90` has zero direct xrefs.** The trophy subtype mapping does not
|
||
depend on it (`FUN_180108c00` carries the same mapping independently), but the
|
||
dispatch table that reaches it was not identified, and trophies are a whole
|
||
unimplemented family.
|
||
|
||
**Case sensitivity of the `itemState` string match.** Almost certainly
|
||
unresolvable statically: `FUN_180008190` is a single indirect call through
|
||
`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact
|
||
casing from the table and do not experiment on the live save.
|
||
|
||
### Needs a live probe (read-only, no launch)
|
||
|
||
**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string
|
||
comparator. That answers the casing question without a launch.
|
||
|
||
**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and
|
||
"sequence counter". Low value; nothing we send reaches it.
|
||
|
||
**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result
|
||
proves `g_db` is loaded and complete; it says nothing about the separate database
|
||
behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in
|
||
FIFA17.exe and not in CardsDLL. These are different databases and they should stop
|
||
being conflated.
|
||
|
||
### Needs a launch the user must drive -- ranked, and short
|
||
|
||
1. **Transfer list.** `FUT_SETTINGS=keep` plus `FUT_TRADEABLE=1`. Read the card
|
||
action menu. This is first because it is the only item on the list that fixes a
|
||
thing the user can see is broken, both halves are one-line changes, and one
|
||
half is already measured. Falsifier if it fails: `+0x49` should read 1 and
|
||
`0x1fd2e` should read 1; if both are 1 and the entry is still greyed, the
|
||
remaining gate is `svc->0x308()` on `0xed80ed8`.
|
||
2. **Club items, one family at a time, kits first.** Kits have the loudest failure
|
||
mode: a wrong `teamid` produces a visibly wrong team abbreviation rather than
|
||
silence, which is the opposite of the cardtype-9 families and is why this
|
||
ordering is not arbitrary. Then badges, then stadia, then balls, then the
|
||
league-logo probe from section 3.
|
||
3. **The `+0x54` discriminator.** Serve one sub-65 player on the visible club page.
|
||
Predicts `+0x54 == 1` and a bronze face. Free if it rides along with anything
|
||
else; not worth a launch of its own, since the static case is already four-deep.
|
||
4. **`playStyle 251`.** Predicts `+0x88 == 1` and a visible chemistry badge, and
|
||
confirms the decoder bounds on real data. Also rides along.
|
||
|
||
Everything below rank 2 should ride along with something above it. The scarce
|
||
resource is menu trips, not tests.
|
||
|
||
---
|
||
|
||
## 6. Proposed patches
|
||
|
||
None of these are applied. All are env-flagged and default to the current
|
||
behaviour, per the house rule that a flag defaults to the live-proven value.
|
||
|
||
### P1 -- `FUT_TRADEABLE`: make cards listable
|
||
|
||
Two halves, and the first is already implemented. Half one is an environment
|
||
change only: run with `FUT_SETTINGS=keep`, which emits the existing
|
||
`_SETTINGS_KEEP` rows including `tradingEnabled`. Half two, in
|
||
`tools/fut_store.py`:
|
||
|
||
```python
|
||
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte is set.
|
||
#
|
||
# "Place on Transfer List" is greyed on every card and BOTH of its gates are ours.
|
||
# FUN_1801a7260, the TO_TRADE_PILE predicate published by FUN_18003e370, returns 1
|
||
# only if the service gate at vtable+0x270 of 0xed84b12 is non-zero AND item+0x49
|
||
# is non-zero. The deserializer stores untradeable INVERTED -- case 0x361 does
|
||
# `CONCAT11(cVar6 == '\0', ...)` -- so untradeable:true writes 0 and kills the flag.
|
||
#
|
||
# The service gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
|
||
# tradingEnabled gate byte (ENDPOINT_MAP, FutGetSettingsServerResponse). Measured
|
||
# live 2026-08-06 as 0, with three control gate bytes reading 1 in the same walk.
|
||
# So this flag alone is NOT sufficient: it needs FUT_SETTINGS=keep beside it.
|
||
#
|
||
# Freeze risk: NONE beyond what we already send. untradeable is atom 0x361, read by
|
||
# the BOOL primitive FUN_1801c7620, and we already send the key on every card -- only
|
||
# the value changes. Every freeze on this project has come from feeding a container
|
||
# where a scalar was expected; this does not change the shape of anything.
|
||
#
|
||
# Side effects, which are real but permissive. item+0x49 has a third consumer,
|
||
# FUN_1800bc580, which counts untradeable squad members; that count is published as
|
||
# UNTRADABLE_COUNT and gates squad submission in FUN_1800bba10, which today takes the
|
||
# couldNotSubmitSquad branch. Both move toward "more allowed", not less.
|
||
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
|
||
```
|
||
|
||
and in `_item()`, replacing the hard-coded `"untradeable": True`:
|
||
|
||
```python
|
||
"untradeable": not TRADEABLE,
|
||
```
|
||
|
||
Club items in `tools/fut_clubitems.py` already send `"untradeable": False` and
|
||
need no change.
|
||
|
||
**Type fidelity.** BOOL where a BOOL is read. The constructor default for `+0x49`
|
||
is 1, i.e. tradeable, so `false` moves the field *toward* the client's own default
|
||
rather than away from it.
|
||
|
||
### P2 -- stamp `discardValue` on the purchased pile
|
||
|
||
In `tools/fut_store.py`, `Profile.purchased()`:
|
||
|
||
```python
|
||
def purchased(self):
|
||
"""Items still held in the purchased/unassigned pile (returned by
|
||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item).
|
||
|
||
Stamped on the way out exactly as items() is. Without this the pending pile
|
||
renders 0 for anything whose discardValue is not already persisted in the
|
||
save: the quick-sell tile binds the Flash property DISCARD_CREDITS, which is
|
||
item+0x38, which is ONLY ever written from the wire. The client's own correct
|
||
answer lands at +0x3c under the name CALCULATED_DISCARD_CREDITS and no native
|
||
code in CardsDLL falls back from one to the other. Verified live 2026-08-06:
|
||
the pile's chemistry style 100000283 evaluates to 38 through discard_value()
|
||
and the client's own +0x3c holds 38, and the tile reads neither because the
|
||
key is absent from the wire."""
|
||
its = self.load().get("purchased", [])
|
||
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
|
||
```
|
||
|
||
**Freeze risk: none.** `discardValue` is atom `0xd7`, a plain INT already on the
|
||
wire for 20 of 22 resident items. Not stamped, not persisted, so turning
|
||
`FUT_DISCARD_SEND` off is still a true revert. No new flag: this is a bug in the
|
||
existing flag's coverage.
|
||
|
||
### P3 -- `FUT_DISCARD_STAFF`: price staff cards from the table `value` column
|
||
|
||
The unrated-card fallback in `discard_value()` documents the staff price as
|
||
unknown. It is not. The merge writes the staff table's `value` column into
|
||
`+0xb4`, and `round_half_up(value * price / 100)` reproduces the client's own
|
||
answer exactly. In `tools/fut_store.py`:
|
||
|
||
```python
|
||
# FUT_DISCARD_STAFF: price staff cards using the game table's `value` column as the
|
||
# rating input.
|
||
#
|
||
# discard_value() bails on a card with no `rating` key, which is every staff card we
|
||
# serve, so the pile's gkcoach 100000282 goes out unpriced and its tile shows 0. The
|
||
# rating a staff card USES is the `value` column of its own cards table -- the merge
|
||
# arms for families 2,3,4,5,10 all do `rec[0xb4] = row["value"]` -- so the number was
|
||
# always available server-side. Confirmed to the unit, live 2026-08-06: carddbid
|
||
# 9000081 has value 66 in gkcoachcards.json, cardtype 10 (subtype 6), rare 0, level 2
|
||
# -> price 55 -> round_half_up(66*55/100) = 36, and the client's own computation at
|
||
# record +0x3c reads 36.
|
||
#
|
||
# Freeze risk: NONE. This changes no wire shape at all. It only supplies a rating to
|
||
# a server-side arithmetic function; the `rating` key is not added to the JSON,
|
||
# because on a staff card the merge overwrites +0xb4 from the DB regardless and
|
||
# sending it would be inert.
|
||
DISCARD_STAFF = os.environ.get("FUT_DISCARD_STAFF", "0") == "1" and DISCARD_SEND
|
||
|
||
_STAFF_VALUE_TABLES = ("headcoachcards.json", "gkcoachcards.json",
|
||
"physiocards.json", "fitnesscoachcards.json",
|
||
"managercards.json")
|
||
_STAFF_VALUE = None
|
||
|
||
|
||
def _staff_value(resource_id):
|
||
"""carddbid -> the table's `value` column, or None. Built once, lazily."""
|
||
global _STAFF_VALUE
|
||
if _STAFF_VALUE is None:
|
||
_STAFF_VALUE = {}
|
||
base = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"..", "data", "tables")
|
||
for fn in _STAFF_VALUE_TABLES:
|
||
try:
|
||
with open(os.path.join(base, fn)) as f:
|
||
rows = json.load(f).get("rows") or []
|
||
except (IOError, ValueError):
|
||
continue
|
||
for r in rows:
|
||
cid, val = r.get("carddbid"), r.get("value")
|
||
if cid and val:
|
||
_STAFF_VALUE.setdefault(cid, val)
|
||
return _STAFF_VALUE.get(resource_id & 0xffffff)
|
||
```
|
||
|
||
and, inside `discard_value()`, replacing the early bail:
|
||
|
||
```python
|
||
r = item.get("rating")
|
||
if not r and DISCARD_STAFF:
|
||
r = _staff_value(int(item.get("resourceId") or 0))
|
||
if not r:
|
||
return None
|
||
```
|
||
|
||
**One caveat, stated rather than buried.** This is confirmed on one card. The
|
||
mechanism is read out of the merge, not curve-fitted, and the arithmetic matched
|
||
to the unit, but a second staff family would make it two.
|
||
|
||
### P4 -- `FUT_CLUBITEMS`: correct the five subtypes
|
||
|
||
In `tools/fut_clubitems.py`, the `FAMILIES` table and the probe set. This is a
|
||
data correction, not a new capability, and it should still be exercised one family
|
||
at a time through the existing `FUT_CLUBITEMS` machinery.
|
||
|
||
```python
|
||
# (table, art id, stat id, stat name, cardsubtypeid)
|
||
#
|
||
# CORRECTED 2026-08-06. Every previous value was inside the 0x91..0x96 block, which
|
||
# is TROPHIES (FUN_180108c00 computes subtype = tournamentType + 0x91; FUN_1800fed90
|
||
# is the only function in the binary whose case set is exactly {0x91..0x96}).
|
||
#
|
||
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
||
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver:
|
||
# manager vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when
|
||
# item+0x4c == 7, called with (subtype, teamid, assetId). So the docstring's
|
||
# "a wrong id cannot announce itself" is false for these three -- a wrong teamid
|
||
# produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
||
FAMILIES = [
|
||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||
]
|
||
|
||
# The candidate set for probe_shelf(). The old set {30,31,145..150} could not have
|
||
# answered the question for kits, stadia or badges, because 9, 10 and 11 were not
|
||
# in it -- it would have burned a launch and returned nothing for three of five.
|
||
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
||
```
|
||
|
||
and, in `_item()`, the per-family fields the cardtype-7 resolver requires:
|
||
|
||
```python
|
||
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
||
it = {
|
||
"id": item_id,
|
||
"resourceId": carddbid,
|
||
"assetId": carddbid,
|
||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||
"cardsubtypeid": subtype,
|
||
"itemState": "free",
|
||
"owners": 1,
|
||
"untradeable": False,
|
||
}
|
||
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
||
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
||
# StadiumName_<assetId>, which resourceId already supplies. teamid is atom 0x306,
|
||
# read with the INT primitive FUN_1801c79d0 and stored at record +0x94 -- an
|
||
# established scalar field, not a new shape.
|
||
#
|
||
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
||
# response that crashed the client. It was never bisected. `value` is the
|
||
# established suspect (it is an OBJECT member elsewhere, and a scalar where an
|
||
# object is expected is the 0x1801c7f1a busy loop), and that response also carried
|
||
# 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to ONE
|
||
# family, with the subtypes now correct. That is the narrow test the crash denied us.
|
||
if teamid is not None and subtype in (9, 11):
|
||
it["teamid"] = teamid
|
||
if extra:
|
||
it.update(extra)
|
||
return it
|
||
```
|
||
|
||
**`itemType` should be dropped from this builder** while it is being touched. It
|
||
currently sends `"itemType": "club"`, marked UNOBSERVED in the docstring, and the
|
||
deserializer parses `itemType` into a heap string below the record base and never
|
||
copies it in. It is inert, but it is also one of the few unobserved strings we
|
||
still emit.
|
||
|
||
**Freeze risk: low, and lower than the last attempt.** `teamid` is an INT read by
|
||
the scalar primitive; the subtype change alters an integer's value, not its type.
|
||
The blast radius is bounded by serving one family per test, which the existing
|
||
`FUT_CLUBITEMS` machinery already enforces and `equippables` still answers empty.
|
||
|
||
### P5 -- `FUT_CLUB_POSTAB`: answer the DEF/MID/ATT tabs
|
||
|
||
`club_route`'s filter keeps only `cardsubtypeid not in (0,1,2,3)` for any type
|
||
outside `player` and `custom`, so `type=playerdefender`, `playermidfielder`,
|
||
`playerforward` and `any` all return an empty list against a save whose items are
|
||
all subtype 0. The three position tabs are real traffic: `FUN_18012ddf0` remaps
|
||
request field `*(req+0x14)` values `0x1c/0x1d/0x1e` into type codes `0x1b/0x1c/0x1d`
|
||
and suppresses the `position=` parameter, so the MY CLUB position tabs arrive
|
||
exactly as those three strings.
|
||
|
||
```python
|
||
# The client's OWN position grouping, read out of FUN_180135890's recompute of
|
||
# record+0x14c from record+0x146: 0 -> GK, 1..8 -> DEF, 9..19 -> MID, 20..27 -> ATT.
|
||
# Not invented here; this is the ladder the client applies to its own records.
|
||
_POS_GROUP = {"playerdefender": (0, 8), "playermidfielder": (9, 19),
|
||
"playerforward": (20, 27)}
|
||
```
|
||
|
||
and in `club_route`, before the existing `if kind and kind not in ("player", "custom")`:
|
||
|
||
```python
|
||
if kind in _POS_GROUP:
|
||
lo, hi = _POS_GROUP[kind]
|
||
items = [i for i in items
|
||
if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)
|
||
and lo <= int(i.get("preferredPosition") or 0) <= hi]
|
||
log(" CLUB: type=%s -> %d item(s) (position group %d..%d)"
|
||
% (kind, len(items), lo, hi))
|
||
return 200, {"itemData": items}
|
||
if kind == "any":
|
||
log(" CLUB: type=any -> %d item(s) (unfiltered)" % len(items))
|
||
return 200, {"itemData": items}
|
||
```
|
||
|
||
**The one guess, named.** GK is folded into DEF because the observed tabs are
|
||
DEF/MID/ATT and there is no fourth. If the DEF tab comes back without goalkeepers
|
||
in it, the guess is wrong and the fix is to change `(0, 8)` to `(1, 8)`. Nothing
|
||
else in the patch is a guess.
|
||
|
||
**Freeze risk: none.** Server-side filtering only; the response shape is the
|
||
`{"itemData": [...]}` the route already returns everywhere.
|
||
|
||
### P6 -- drop `definitionId`
|
||
|
||
One line in `_item()`. `definitionId` is not in the 907-entry key dictionary, so
|
||
it hashes to an unregistered key and its value goes to the value-SKIP handler
|
||
`FUN_180135ff0`. Verified three ways: absent from `docs/fut_atoms.tsv`; absent
|
||
from the live key map walked in the running client with all 907 dictionary names
|
||
passing as a positive control; and colliding with no registered hash, so it cannot
|
||
be misrouted onto another field. **Freeze risk: none** -- removing a key the parser
|
||
skips strictly reduces executed code. Low value, zero cost, and it removes a field
|
||
that three documents describe as if it did something.
|
||
|
||
---
|
||
|
||
## 7. Proposed corrections to existing documents
|
||
|
||
### `docs/CARD_SYSTEM.md`
|
||
|
||
**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered.
|
||
Kit 9, stadium 10, badge 11, ball 30, league logo 31 (the last by elimination).
|
||
Route (a) of its own two proposals is what paid off: the consumer is the manager
|
||
vtable slot `+0x498` = `FUN_180119bd0`. Route (b), `FUT_CLUBITEMS=probe:<family>`,
|
||
would have failed for three of the five families because its candidate set did not
|
||
contain 9, 10 or 11. Keep the residual league-logo probe from section 3 above.
|
||
|
||
**Correct item 1 of "VERIFIED IN BINARY".** It says `FUN_1800d8330` returns
|
||
cardtype 9 for `0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, 0xec` and that this "leaves
|
||
0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls and league logos". The
|
||
first half is right; the inference is wrong. `0x91..0x96` are trophies, and three
|
||
of the five families are cardtype 7, not 9.
|
||
|
||
**Correct item 2.** The itemState enum table starts at `0x180229cc0`, not
|
||
`0x180229d20`. The full vocabulary is the twelve rows in section 4 above; the
|
||
recorded ten are missing `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`,
|
||
`forSale` and `offered`. Note that `WAITING_FOR_GAME` and `inGame` are genuine
|
||
aliases, both 2, and that omitting the key yields 0 = `invalid`, which is not
|
||
`free`.
|
||
|
||
**Update the card view-model paragraph.** It states that every rendered field is
|
||
read from a resolved definition record at `item+0x10` and "NEVER from our item
|
||
JSON". That was true of the generic-card era and is no longer the whole story:
|
||
rating, attributes, position, contract, fitness, rareflag and playStyle are read
|
||
by thin accessors straight off the item record for family 1, and `CARD_SYSTEM.md`'s
|
||
own later sections (real squads, managers painting `+0xde`/`+0xe0`, consumables
|
||
drawing `+5/+10/+15`) already contradict the earlier text. Add a pointer to the
|
||
field table in section 2 here rather than rewriting the history.
|
||
|
||
**Add the field-map corrections** as a dated block:
|
||
|
||
```
|
||
CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X):
|
||
+0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR
|
||
+0x48 owners (atom 0x207, u8; constructor default 0)
|
||
+0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1)
|
||
+0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum
|
||
+0x5c itemState (atom 0x172 via FUN_180166660, u32)
|
||
+0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17)
|
||
+0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY
|
||
+0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles)
|
||
+0xbf amount (atom 0x1b, u8) for the other consumable classes [already recorded]
|
||
+0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7
|
||
+0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7
|
||
+0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field
|
||
+0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm
|
||
itemType (atom 0x173) is parsed into a heap string and never stored
|
||
definitionId is NOT AN ATOM
|
||
```
|
||
|
||
**Correct the consumables section's route claim.** It says the observed route is
|
||
"not the `/consumables/%s` template in .rdata, which the client has still never
|
||
used". It is exactly that template: action row 9 `ConsumablesSearch` carries base
|
||
index 3 = `ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was
|
||
`ut/%s/club` all along. The same sentence appears in commit `ccb736f`.
|
||
|
||
**Correct the `discard_value()` docstring premise** in `tools/fut_store.py` while
|
||
you are at it: "WHY its lookup misses is still UNKNOWN" and "that lookup returns no
|
||
row for our cards" are both false. It does not miss. The tile reads a different
|
||
property.
|
||
|
||
### `docs/ENDPOINT_MAP.md`
|
||
|
||
**The Club section's URLs are wrong for four routes.** Rows 12, 13 and 16 give
|
||
`GET ut/game/fifa17/item?type=<consumable>`, `GET ut/game/fifa17/...` and `GET
|
||
ut/game/fifa17/...`. The client can emit exactly four request families on the
|
||
`ut/%s/club` base, and the binding is a table, not an inference: the 125-row action
|
||
table at `0x1802caa20` indexes the 48-entry URL-base table at `0x18021df80` through
|
||
column 1, and base index 3 = `ut/%s/club` is carried by exactly four rows.
|
||
|
||
```
|
||
| ClubSearch | FUN_18012ddf0 | GET ut/%s/club?<query> | FutStickerBookSearchServerResponse |
|
||
| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/<f>[/<id>] | FutStickerBookStats2ServerResponse |
|
||
| StaffStats | thunk 0x18012b080| GET ut/%s/club/stats/staff | FutStaffBonusServerResponse |
|
||
| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/<cat>| FutConsumablesSearchServerResponse |
|
||
```
|
||
|
||
**Add the club query grammar**, which is complete and ordered: `?year=2017`
|
||
(always, hardcoded), then `type`, `start` (omitted at 0), `count` (omitted at
|
||
100), `filter`, then either the filter block (`position, formation, state, level,
|
||
rare, nation, country, league, playStyle, team, sort`) or a comma-joined `defId=`
|
||
list, never both. Live control: the one ProtoHttp club search in the log is
|
||
`GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`,
|
||
which matches the predicted order and every suppression rule. Sub-vocabularies:
|
||
`filter` = available/base/exact/any; `level` = bronze/silver/gold/any; `sort` =
|
||
asc/desc; `rare` = the literal string `SP`, not a boolean; `state` = the itemState
|
||
names plus `any`, and note that the request spells it `onSale` where the response
|
||
value is `forSale`.
|
||
|
||
**Add the complete `?type=` vocabulary**, 30 values from `FUN_18012ec50` (29 cases
|
||
plus a default of `any`), and record that `/club/stats` has exactly seven forms:
|
||
`club`, `year`, `country/<id>`, `league/<id>`, `newcards`, `consumables`, and the
|
||
separately-dispatched `staff`. **There is no `/club/stats/team/<id>`**, verified
|
||
twice: the switch has six cases with no such arm, and an exhaustive string scan of
|
||
the PE finds no literal containing `stats/team`. `utas_server.py`'s handling of a
|
||
`team` stats mode is dead code.
|
||
|
||
**Two holes in the base table are worth recording** so nobody re-derives them as
|
||
findings. Base index 43 = `ut/v2/%s/store` is carried by no action row and has zero
|
||
references in `.text`, yet `ut/v2/store` is live-proven; base index 9 =
|
||
`ut/%s/activeMessage` is a second hole of the same kind. So at least one route is
|
||
composed outside CardsDLL, most likely inside the packed exe, and every "the table
|
||
bounds it" statement is bounded to CardsDLL.
|
||
|
||
**Under `FutGetSettingsServerResponse`**, add that `tradingEnabled` field `[10]`,
|
||
gate byte `0x1fd2e`, was measured **0** in the live client on 2026-08-06 while
|
||
`friendlySeasonsEnabled`, `enableDraftMode` and `packOpeningAnimationEnabled` all
|
||
read 1 in the same walk. The struct defaults are not uniform: some fields default
|
||
to 1 and trading defaults to 0. Gate byte `0x1fd2e` is read by vtable slot `+0x270`
|
||
= `FUN_18011c670`, and that slot is the service half of the `TO_TRADE_PILE`
|
||
predicate.
|
||
|
||
---
|
||
|
||
## Coverage, honestly
|
||
|
||
Five of the six dimensions came back deep and two adversarial rounds attacked the
|
||
claims that change what we serve. The live-heap dimension and the club-subtype
|
||
dimension are the strongest: both were re-derived from scratch by a verifier with
|
||
independent controls, and the disagreements between them were resolved by a third
|
||
method (the deserializer's frame arithmetic) rather than by preferring an author.
|
||
|
||
Two areas are thin and should be treated as single-source. The **auction-house
|
||
field routing** -- `tradeId`, `tradeState`, `expires`, `bidState` and friends
|
||
belonging to the auction element `FUN_18013e410` rather than to `itemData` -- rests
|
||
on one agent's census and was explicitly not re-attacked; the verifier confirmed
|
||
only the negative half, that those atoms have no arm in the item deserializer.
|
||
The **club query emission order** in section 7 likewise rests on one decompile
|
||
plus one live log line, though all 17 atom transcriptions were independently
|
||
re-resolved and none was wrong.
|
||
|
||
Nothing in this document was tested on screen. Every "the card will display X" is
|
||
a prediction from record contents and code, not an observation of pixels, with the
|
||
sole exception of the gate-byte measurement quoted in section 1, which is a
|
||
measurement of a byte and not of a menu. The client is bound to port 8099 and the
|
||
safety rules forbid reconfiguring it, so the live work here was read-only heap
|
||
reads, read-only GETs, and static analysis.
|
||
|
||
---
|
||
|
||
## The next action
|
||
|
||
Run the client with `FUT_SETTINGS=keep FUT_TRADEABLE=1` and open a card's action
|
||
menu. That single launch tests both halves of the transfer-list finding at once,
|
||
it is the only outstanding item that fixes something the user has already seen
|
||
broken, and it is unusually well instrumented: if the entry lights up, two gates
|
||
and an inverted boolean are confirmed together; if it stays grey, `gate_byte_probe.py`
|
||
extended to slot `+0x270` says immediately whether `0x1fd2e` flipped to 1, and
|
||
`+0x49` on any resident record says whether the boolean landed, so the failure
|
||
localises to one of three named places rather than to "it did not work". Bring the
|
||
kits along on the same trip if the club-item subtype patch is in -- one family,
|
||
`teamid` alone, subtype 9 -- because a wrong `teamid` announces itself in the
|
||
caption and that is the loudest failure mode any club family has.
|