Commit Graph

112 Commits

Author SHA1 Message Date
funman300 e578443d73 fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
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 corrects e3092ca. 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 in e3092ca is 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 in e3092ca as 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 from e3092ca: 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 in e3092ca: "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>
2026-08-06 10:07:01 -07:00
funman300 e3092ca0f9 fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:55:05 -07:00
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    value = round_half_up(rating * price / 100)
    level    = 3 if rating >= 75, 2 if 65..74, else 1   (0x180141e8a..0x180141ea3,
               derived from rating, NOT a wire field)
    cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
               across every subtype 0..599 with zero disagreements

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:43:51 -07:00
funman300 3f3d5704a7 fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price
Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for
the whole life of the project.

QUICK SELL WAS NEVER SERVED. Captured on the wire:
    DELETE /ut/game/fifa17/item/100000240      (single card, id in the URL, no body)
ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the
doc, and the regex therefore never matched a real quick sell. Every quick sell fell
through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had
never once been called.

The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client
takes its coin total from it, so with totalCredits absent it rendered an uninitialised
value. A real session showed 1,133,686,384 coins against a true balance of 9,889,600.
Display artifact only, corrected by the next GET /user/credits, and the save was never
touched, but it is the reason a stub is not acceptable here.

quick_sell_url_route() serves the real form and returns the corrected shape,
{"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits:
two quick sells fired through it in one session, each credited 150 and removed the
card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases
either side of them. An unknown id returns {"items": []} rather than claiming a sale we
cannot account for.

Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as
the new balance or ADDS it as a delta. We send the new balance. The discriminating
window is about five seconds wide, because the client refetches GET /user/credits
straight afterwards, so either reading self-corrects and the practical impact is a brief
wrong number. It mattered only while we answered {}, because that garbage persisted.
The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard
table, which remains unknown.

finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack
and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE
with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off
and is disarmed: it puts a price on a tile that the buy path does not charge.

TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here:
  * The store grouping is broken and it is NOT cosmetic. All three packs collapse into
    one display group, and the Bronze, Gold and Premium group tiles all drill into the
    same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send
    displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything
    lands in group 0. The docs had this parked as a cosmetic "tiles read unknown"
    issue; it is an availability bug.
  * The FIFA Points price renders as the literal "or %1s", an unsubstituted printf
    placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and
    "mtx" is evidently not a currency token the client resolves. Cosmetic.

Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD /
11 RARES against exactly what we serve). An earlier screen showing zeros was the
display-GROUP level, which carries no content info. D3 was right.

Live: 439 contract checks pass, both probe flags off, store prices back to honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:03:41 -07:00
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00
funman300 1605e6effd fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.

TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):

  9  START_OBJECT      case 0x64, pushes state 2
  10 END_OBJECT        case 0x65, pops state 2
  11 FIELD_NAME        confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
                       atom-hashes the string at +0xf8
  12 START_ARRAY       case 0x66, pushes state 3
  13 END_ARRAY         case 0x67, pops state 3
  1  error             the caseD_78 sink

So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.

The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.

THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.

The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.

No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.

Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:25:01 -07:00
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00
funman300 d0dbfa99c0 fifa17-recon: the /settings gate bytes were never zero, and the plan built on that is dead
Yesterday's settings-gate plan asserted that IS_FRIENDLY_SEASON_ENABLED and
IS_DRAFT_MODE_ENABLED "have never been set to true by anything, on any run", and
proposed spending a launch on that premise. Measured against the running client,
both read 1, and so does packOpeningAnimationEnabled, while /settings has only ever
been answered {"configs": []}.

  disp 0x1fd3a (friendlySeasonsEnabled)      value = 1
  disp 0x1fd3d (enableDraftMode)             value = 1
  disp 0x1fd45 (packOpeningAnimationEnabled) value = 1

Reproduced on two separate launches and two different pids.

Where the reasoning went wrong: the finding that FUN_18011dc50 is the only writer of
those bytes, and that the FutDataManagerImpl constructor never touches them, was
correct. The inference was not. The applier runs whether or not the configs array has
content, and the struct it is handed defaults these fields to 1, so the bytes were
being written all along. "Nothing populates the array" was treated as "nothing writes
the byte". Only the first of those was ever established.

Seasons therefore does not refuse because its gate byte is false. Its gate byte is
true. That diagnosis restarts, and the live test in section 3 should not be run as
written. The doc keeps the wrong turn on the record rather than quietly deleting it.

tools/gate_byte_probe.py makes this repeatable instead of a one-off. It is read-only
(O_RDONLY + pread), resolves the pid by comm, re-derives the CardsDLL slide from
/proc/<pid>/maps rather than caching it across launches, proves the slide against the
FNV prologue at 0x180180d00 read from the on-disk PE before trusting any address, and
decodes each gate displacement out of its accessor stub (0f b6 81 <disp32>) rather
than reading it from a table. Needs the client at the FUT hub, since CardsDLL loads
only then.

Also carries the two /settings changes that were pending from before: the mode
defaults to `off` (the live-proven baseline, since nothing here has faced the game)
and the transfer-pile probe is 77 rather than 100, because 100 is a stock-looking
number that would prove nothing if it showed up in game.

Live: 439 contract checks pass. check_settings_flags.py passes in all four modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:02 -07:00
funman300 897259c8fb fifa17-recon: the /settings 42-flag gate, and why Seasons never asks
ENDPOINT_MAP said this class reads one key, `configs`, and that was true and
useless. What it missed is what happens after each element closes: the client
feeds the STRING VALUE of `type` back through the atom hasher and switches on
the result, 42 arms wide. A flag is a row, not a key, and the client hashes our
string itself.

Followed it to the end. FUN_18011dc50 is the only writer of the IS_* UI gate
bytes inside FutDataManagerImpl, every line is `byte = (field == 1)`, and the
constructor never touches those bytes. So a flag nobody sends is a gate nobody
opens. friendlySeasonsEnabled and enableDraftMode have never been sent by
anything, which is a mechanism for Seasons refusing while making zero requests
to any of the four servers.

The store is the control that makes this readable: IS_STORE_ENABLED is the same
kind of byte and its screen works, because storeEnabled and friends already
ship through the Blaze config store. That list has no seasons or draft flag.

Ship the gates behind FUT_SETTINGS (off/keep/gates, default gates), and
re-assert the working store flags in the same array on purpose: once a
populated array makes the applier run, it writes EVERY gate byte, so omitting
them could switch off a screen that works today.

maximumTradePileSize=100 rides along as a positive control, because a boolean
that changes nothing cannot distinguish "the flag did not help" from "the array
never reached the consumer".

check_settings_flags.py asserts each shipped name against the atom table AND
the recovered switch, since a misnamed flag is silently inert and looks exactly
like a failed fix. enableSquadBuildingSetsFeature is the reason both checks are
needed: a real atom with no arm here.

Live: 439 contract checks pass, market unit suite passes.
Not yet tested in game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:11:20 -07:00
funman300 9348b83374 fifa17-recon: club-item research -- cardtype map exact, itemState carries equipped state
Researched rather than guessed, after a guessed field crashed the client.

VERIFIED: FUN_1800d8330 returns cardtype 9 for exactly 0x1e, 0x1f, 0x91..0x96,
0xe7..0xe9, 0xec; fcc_misccards' cardsubtype 231 anchors the 0xe7 block to misc, so
badges/kits/stadia/balls/logos live in 0x1e, 0x1f and 0x91..0x96.

VERIFIED, and it answers a question nobody had asked: the itemState enum at
0x180229d20 is WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active. An EQUIPPED club item is the same
item with itemState set, not a different subtype. 'free' is right for owned-but-not-
equipped, which is what we already send.

VERIFIED: club items have no category group table (consumables and staff both do), and
the route is club?type= with SINGULAR names, observed live.

STILL UNKNOWN and labelled so: which subtype means which family. Not in any of the 149
dumped tables, no group table, and cardtype 9 has no merge arm so a wrong value cannot
announce itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 12:06:31 -07:00
funman300 ccf912c157 fifa17-recon: club items crashed the client -- unestablished fields, and too wide a blast radius
The game hung and then crashed on the first equippables fetch. My fault twice over.

CAUSE, primary. I copied teamid, leagueid and value straight out of the fcc row as
extras. `value` appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
and a scalar where an object is expected is the type-desync busy loop at 0x1801c7f1a
-- which presents exactly as "the game is taking its time" and then dies. Omission is
safe; an unestablished field is not. That is this project's own rule and I broke it
for three fields that were not needed to draw a card. All three are gone.

CAUSE, contributing. The last request before the crash was type=equippables&count=11
and we answered with 30 items spanning FIVE unverified cardsubtypeids at once: the
widest possible blast radius for a wrong shape, and it tells you nothing about which
subtype was wrong. equippables now answers [] until the subtypes are confirmed one
family at a time, and shelf() takes a families= filter so a test can serve exactly one.

Adds FUT_CLUBITEMS=probe:<family>, which serves one item per candidate subtype for a
single family, so the screen names the correct subtype instead of me guessing a third
time. Eight items, one family, one question.

The flag already defaulted off, so a plain restart cannot serve any of this.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:59:38 -07:00
funman300 f5002a0d4d fifa17-recon: serve club items -- the type names are SINGULAR and they were on the wire
Arming the counters made the client name the route within seconds, exactly as it did
for consumables:

    club?type=equippables&count=11
    club?type=stadium&count=200
    club?type=ball&count=200

So it IS club?type= for this family, with SINGULAR names -- stadium and ball, not
stadia and balls -- and equippables as the combined club-customisation view. Those
requests were being answered from STORE.items(), which holds no club items because
the shelf is synthetic, so they correctly returned empty and looked like nothing was
happening.

Now serves the shelf: stadium 4, ball 6, badge 8, kit 8, equippables 30 (all four
combined), with player untouched at 205.

The cardsubtypeid values remain UNVERIFIED. cardtype 9 has no arm in the merge, so a
wrong subtype cannot announce itself; whether these render is now a live question and
the next screenshot answers it.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:55:07 -07:00
funman300 3b58b29094 fifa17-recon: club-item counts, and packs that contain more than footballers
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.

CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).

Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.

What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.

PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.

Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.

Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:51:49 -07:00
funman300 cef1e8d1f4 fifa17-recon: consumable artwork CONFIRMED FIXED, and the two wrong guesses recorded
Real card art, tier colours and the GK glove icon all draw once cardassetid carries
the art id. Records both refuted hypotheses with the evidence that killed them, since
each was plausible and someone will reach for them again.

The generalisable trap: an fcc_ row has BOTH carddbid and cardassetid, they are not
interchangeable, and _item copies resourceId into cardassetid -- right for players,
wrong for every other family. Club items will hit it next: balls 37, kits 35,
stadium 36, badges 39, league logos 40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:47:05 -07:00
funman300 214be11202 fifa17-recon: the green NOT FOUND box was a wrong art id, cardassetid != carddbid
A player photographed a green tag under every consumable card. It is not a status
label at all: external/ion_fut/artAssets/.../notfound.swf is the client's placeholder
for an art asset it could not resolve, so the card was drawing 'no such artwork'.

The cause is two id columns that look interchangeable and are not. In the game's own
fcc_ tables a consumable row carries BOTH carddbid (5003001) and cardassetid (3), and
the art is keyed on the small one. fut_store._item copies resourceId into cardassetid,
which is correct for players and wrong for every other family, so the client looked up
art id 5003001, found nothing, and fell back.

Now mapped from data/tables/fcc_*.json, dumped read-only from the client's own
database, so these are the game's ids rather than a guess: training 3, contract 7,
healing 10, misc 45. The same table also gives balls 37, kits 35, stadium 36,
badges 39, league logos 40, which is what the club-item family will need.

Two earlier guesses at this badge were wrong and are recorded as such: it is not the
untradeable flag (changing it left the tag untouched, and the record showed the flag
had flipped) and not a loc-string failure.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:45:08 -07:00
funman300 c41b1514d3 fifa17-recon: consumables are served tradeable, so the untradeable badge clears
A player pointed at a green tag under every consumable card. It is ours, not the
client's: the deserializer sets a flag from (untradeableCount < count), we set
untradeableCount == count, so every stack read as fully untradeable and drew the
badge. UNTRADEABLE_COUNT and UNTRADABLE_COUNT are UI state keys in .rdata.

In FIFA 17 a pack-opened consumable is normally tradeable, so this was our own data
showing through rather than a rendering fault. Now untradeableCount is 0 and the
served copy carries untradeable false. FUT_CONSUM_UNTRADEABLE=1 restores the old
behaviour.

TODO/CONFIRM: the exact badge text was not read, only its source. If the tag survives
this change it is a different label and the untradeable theory is wrong.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:38:47 -07:00
funman300 550313362c fifa17-recon: consumables CONFIRMED LIVE -- artwork, stacks and correct amounts
Rendering with real artwork, quantity badges and +5/+10/+15 amounts, so atom 0x1b
reaches record+0xbf. The client's own dialog names the class we resolved: 'Search
Type: Consumables Search'.

Records the three things that each had to be right and each failed silently with a
200: the count gates the fetch, the route is club/consumables/<category> (a /club
PREFIX, so it was being answered with the player list), and the element is a five-atom
stack wrapper whose 0x16a member is the only thing that carries the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:36:47 -07:00
funman300 122b7b94d2 fifa17-recon: consumables are STACK records, not bare items
The route was right and the body was wrong. We served bare items, the client took the
response and inserted NOTHING (the live card map held only the 11 squad players), and
the screen stayed empty with no error logged anywhere. A 200 with a well-formed body
that the consumer silently discards is the worst failure shape there is, and it is the
third time this project has hit it.

FutConsumablesSearchServerResponse resolved from the RS4 literal 0x1802222f8:
factory 0x180130a10, vtable 0x180222200, deserializer +0x08 = 0x180130d10 (6873
chars). It takes itemData(0x16b) at the root like the club list, but its ELEMENT is
not an item. It is a five-atom wrapper and exactly one of the five carries the item:

    0xbc  count             int
    0xd7  discardValue      int
    0x16a item              -> FUN_18013fe00, the item parser itself
    0x287 resourceId        int
    0x362 untradeableCount  int

Everything else goes to the value-skip handler, which is precisely why a bare item was
accepted and did nothing. It also explains the UI: FUT draws consumables as one stack
with a quantity, not as N cards, and the wrapper is that quantity.

Identical consumables are now collapsed by resourceId and counted.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:34:01 -07:00
funman300 ccb736fa71 fifa17-recon: the consumables item route, found live -- GET club/consumables/<category>
The counter WAS the gate, and fixing it produced the request within seconds:

    11:23:36  GET /ut/game/fifa17/club/consumables/training
    11:23:40  GET /ut/game/fifa17/club/consumables/contracts

Neither is club?type=, and neither is the /consumables/%s template we had been
hunting in the binary. The client asks here, and it asks ONLY once
club/stats/consumables reports a non-zero count. Two rounds of item-shape work went
unrequested for want of a counter.

Worse, the path is a /club prefix, so it fell through to the generic route: the
consumables screen was answered with the 194-card PLAYER list. It asked for training
cards and got Cristiano Ronaldo.

Now routed above the generic /club, serving the shelf filtered by category. The
segment names come from the UI group table at 0x180203260; training and contracts are
CONFIRMED on the wire, the other five are from that table and matched case
insensitively, with singular "contract" accepted because the client has used both.
An unknown segment serves the whole shelf and logs loudly rather than showing an empty
screen, since a new spelling is a wire fact worth catching.

Per-category counts match the on-screen panel exactly: training 42, contracts 13,
fitness 6, healing 21, position 20, playStyle 24, managerLeague 0. That correspondence
is what makes an empty list distinguishable from a wrong mapping.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:26:53 -07:00
funman300 2f8a6512db fifa17-recon: answer the CONSUMABLES panel with consumable counts, not player counts
The tab was empty because we answered the wrong question. The client asks
GET club/stats/consumables 41 times a session; we replied with the PLAYER stat set
(players 205, playersGold 189 ...), which that panel does not read. Confirmed on
screen: seven categories present and selectable, every one reading 0.

Now appends 14 consumables* rows counted from the SHELF. The shelf, not STORE.items():
the consumables we serve are a synthetic overlay never granted into the save, so
counting the store gives fourteen zeros, which on screen is byte-identical to failure
and would have made the experiment unreadable. Counts match the independently derived
expectation exactly: 126 total, 21 healing, 7 player contracts, 21 player training,
3 player fitness, 20 position, 21 GK training, 6 manager contracts, 19 playstyle.

Safe by construction: the vocabulary is an ATOM switch (FUN_18012fd40, 40 arms,
default return 0), so an unrecognised name is inert rather than fatal, and the rows
are APPENDED -- the player, nation and league rows that drive the working screens are
untouched. Zero rows unless FUT_CONSUMABLES is armed.

Default ON because answering the consumables panel with player counts is wrong by
inspection rather than a judgement call. FUT_CONSUM_STATS=0 reverts.

439 + 414 checks green.

The open question this sets up: whether a non-zero count makes the client request an
item list at all. If it does, the log names the route and /consumables/%s is settled
for free. If the numbers move and no request follows, the panel renders from counts
alone and the 126-item shelf was never needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:19:29 -07:00
funman300 0f83d73364 fifa17-recon: the consumables panel asks 41 times a session and we answer with players
Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.

That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.

Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.

Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.

Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.

New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:13:54 -07:00
funman300 456ec24360 fifa17-recon: managers and coaches CONFIRMED LIVE, and the manager template paints our fields
34 staff cards, zero DB Error. Every coach id hit its table first time, which is the
payoff from enumerating the game's own database instead of sweeping for ids: the loud
miss-fill exists and never fired.

10 of 10 managers resolved with historically correct nations and leagues.

RESOLVED, previously TODO/CONFIRM: the manager card template paints record+0xde
(nation) and record+0xe0 (league). Luis Enrique draws the Spain flag and 'LaLiga
Santander'; the Premier League managers draw their flag and 'ENG 1'. The merge never
writes either field, so nothing but our own JSON could have supplied them.

Corrected: negotiation at +0xe3 is NOT on the card front, which shows CONTRACT 7
there. I had told the user to look for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:22:34 -07:00
funman300 22ba361578 fifa17-recon: repair_club keeps dead cards unless asked, plus the 2026-08-05 plan
Deleting cards from someone's club is their call, not the tool's. The nine
unrepairable blanks are now KEPT unless --delete-dead is passed. A blank card is ugly,
not harmful, and the 175 stale cards were never the deletion candidates anyway: they
are real players wearing old invented numbers and they get repaired in place.

Also records the build round's synthesis as docs/plan-2026-08-05-families.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:16:13 -07:00
funman300 21b2263e30 fifa17-recon: family flags -- FUT_CONSUMABLES=0 meant ON
os.environ.get returns the STRING "0", which is truthy, so anyone typing
FUT_CONSUMABLES=0 to turn the family off would have turned it on. "", "0", "off",
"no" and "false" now all mean off; any other value is the mode string ("1", "all").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:05:57 -07:00
funman300 0e4bc13db4 fifa17-recon: serve consumables, coaches and managers behind flags, default OFF
Wires the three families into club_route as a synthetic OVERLAY. Nothing changes by
default: with all three flags unset every route returns byte-identical bodies, checked
against the live 194-item save (club 194, type=player 194, league=53 drill-down 67,
hub clubPlayers 194, every staff stat 0).

  FUT_CONSUMABLES=1     serve on type=contract|training|healing|development
  FUT_CONSUMABLES=all   ... and on an untyped club fetch with no team=/league=
  FUT_COACHES=1         serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
  FUT_COACHES=all       ... and on type=manager, the one staff request ever observed
  FUT_MANAGERS=1        serve on type=manager|staff

WHY AN OVERLAY AND NOT A GRANT. Clearing the flag restores the real club exactly on
the next fetch, with no un-granting and no edit to a save a live client is holding
open. And Store.add_items() uses setdefault("id", ...), so items arriving with an id
already set do NOT advance nextItemId -- granting these would eventually collide two
id spaces. The four overlay bases (9.4e8 consumables, 9.5e8 coaches, 9.6e8 managers,
9.1e8 probe) are asserted disjoint from each other, from the save's 1e8 and from the
sweep's 9e8. The cost is that overlay cards cannot be quick-sold or moved.

WHY EACH FLAG IS TWO-VALUED. The tab-to-?type= binding is UNOBSERVED -- only
type=player, type=manager and type=custom have ever come from this client, and none of
the nine other arm names in FUN_18012ec50 has. So every club fetch now LOGS the arm it
was asked for while any flag is set. That is what makes an empty tab actionable: it
says whether the request reached us and under which name, instead of nothing.

THE MIRROR FILTER, and it is not optional. club_route applied its cardsubtypeid filter
only when `kind and kind not in ("player","custom")`. For type=player, for type=custom
(the by-league / by-team drill-downs) and for an untyped fetch it filtered NOTHING, so
the moment the club held a non-player item it would be served straight into the
players tab and the drill-downs -- and a manager carries nation, leagueId and teamid,
so he would have appeared as a footballer in exactly the MY CLUB rows that were only
just made non-zero. The player branch now filters cardsubtypeid in (0,1,2,3). Provable
no-op today: all 194 items in the save are cardsubtypeid 0.

Same hardening for the three counters that keyed on itemType == "player" (hub
clubPlayers, _club_stat_context, _club_stat_set) -> _is_player(), i.e. the CLIENT's own
definition from FUN_1800d8330. itemType is INERT on the wire (atom 0x173 is parsed into
a stack std::string in FUN_18013fe00 and freed; it never reaches the record), so keying
our own screens on it made their correctness depend on a field the client ignores.

FREE SECOND ORACLE: the five staff sub-type counters (0xb..0xf) are now computed from
the overlay. FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over those five, so
the parent id 0xa alone could never move it. That number moves without the merge being
involved at all, which keeps "our club reports N staff" separable from "the client
resolved the card".

item_def() also learns consumables (gated): answering a consumable resourceId with
cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no miss-fill, i.e. plausible
garbage -- and it carried the same hardcoded rareflag 1 as fut_store._item().

tools/test_card_families.py: 414 offline checks over the item BUILDERS. Deliberately a
separate suite -- test_fut_contract.py talks to a live server over HTTP and imports
nothing from the server's own code, which is what lets it certify a future non-Python
implementation; these must run against the working tree without restarting anything.
Each guard was mutation-checked: reverting the 219 fix, or letting coach_item accept a
miss-fill assetid, or letting consumable_item accept a dead zone, each fails the suite.

Suites after: test_fut_contract 439/0, test_match_rewards 61/0, test_card_families
414/0. utas_server was NOT restarted; this lands on disk only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:05:32 -07:00
funman300 9b22435421 fifa17-recon: manager cards -- 417 ids, real names, and a nation/league slot that is ours alone
managercards: 417 rows, carddbid 1000001..1001552, assetid == carddbid on all 417,
value 57..88 (120 rows at exactly 57), rare 282/135 and NOT a rating threshold.
talkrating and formationid are 0 on ALL 417 rows -- both columns are dead in FIFA 17.
MEASURED from data/tables/, rowcount == rows_emitted.

Names come out after all, and by the CLIENT's own rule rather than an inference:
FUN_1801bb060 (879 bytes, read in full) does SELECT firstname,surname,... FROM manager
WHERE managerid == (*(u32*)(rec+0x18) & 0xffffff) - 1000000. Joining data/tables/
manager.json on that rule gives 1000509 Luis Enrique 88, 1000089 Wenger 86, 1000417
Guardiola 87, 1000414 Klopp 84 -- the ratings match the men, which a shifted column
could not produce. This supersedes the note that we get ids and not names: true of
managercards, false once you join `manager`.

Fixed here: `manager` has 747 rows but 746 distinct managerids -- managerid 107 is
duplicated with an empty-name row, and a plain dict comprehension kept the wrong one,
silently giving carddbid 1000107 a blank name and teamid 1357 instead of Slutskiy and
315. 296 -> 297 usable names.

THE KEY FACT, verified at instruction level: the parser routes the JSON `nation` to a
DIFFERENT record offset for a manager. At 0x180140e0b FUN_1800d8330's result is DEC'd
twice -- cardtype 1 stores nation to rec+0x148, cardtype 2 to rec+0xde, everything
else DISCARDS it. leagueId (atom 0x18a) lands unconditionally at rec+0xe0. The manager
merge FUN_1801356c0 (452 bytes, 1,587 chars, read in full) writes only firstname,
lastname, assetid, rating, talkrating, negotiation and rare -- it never touches
rec+0xde/+0xe0. So for a manager, WE are the only source of nation and league, and
they are read: FUN_1801a8580/FUN_1801a8540 feed FUN_1800e5940 ("ManagerCardBio"),
which publishes NATIONALITY, NATIONALITY_ASSET_ID and LEAGUE_ID.

That merge has NO else-branch, so unlike a coach a wrong manager id is COMPLETELY
SILENT. resourceId must equal carddbid exactly -- the manager arm reads the key raw,
with no & 0xffffff.

Also corrected against the design round's own draft: talkrating and negotiation are
NOT unread. FUN_1800e5940 publishes them as ATTRIB_TEAM_TALKS (rec+0xe2) and
ATTRIB_CONTRACT_NEGOTIATION (rec+0xe3). They come from the DB, not from us, and since
talkrating is 0 on all 417 rows TEAM TALKS reads 0 on every manager card in the game.

Not wired into the server in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:05:05 -07:00
funman300 9feb577c1c fifa17-recon: the four coach families -- 411 real ids, and a miss that labels itself
headcoachcards 124 rows 2000004..2000328, gkcoachcards 121 rows 9000001..9000324,
physiocards 51 rows 4000002..4000259, fitnesscoachcards 115 rows 3000019..3000328.
All MEASURED from data/tables/, dumped read-only from the running client; rowcount ==
rows_emitted == len(rows) on all four, which is what makes "this id is absent" a claim
about a complete dump rather than about a truncated one. assetid == carddbid on every
row; every id fits in 24 bits.

WHY COACHES ARE THE CHEAPEST FAMILY TO TEST. Their four arms of FUN_180141660 (2,129
bytes, 214-line decompile read to its closing `return`) are the only merges in the
game that label their own failure: on rowcount < 1 each writes firstname = lastname =
"DB Error", rec+0xb4 = 0x32, rec+0x58 = 1 and a TABLE-UNIQUE assetid -- head 2000148,
fitness 3000259, physio 4000146, gkcoach 9000258. Two independent facts make that a
one-glance oracle, both verified by exhaustive scan of all 411 rows: no row in any of
the four tables has value == 50, and no fitnesscoach row is (fieldpos 1, posbonus 7,
amount 1).

CORRECTION to docs/plan-2026-08-04-card-families.md: the miss-fill is NOT uniform.
Only head coach and GK coach write 0xf into the attribute array at rec+0x98. Physio
writes 0xf into a BYTE at rec+0xdd; fitness coach writes no 0xf at all -- rec+0xde =
0x107 and rec+0xdd = 1. So card_identity_probe's attrs column means something
different per family, and its F_NAME_KNOWN=0xdd string read sits directly on top of
physio's, fitness coach's and the manager's raw stat bytes. Use coach_probe.py.

The key is RAW: all four staff branches pass *(u32*)(rec+0x18) unmasked into
`WHERE carddbid == ?`. Players are the only family that masks with & 0xffffff, so a
version byte in the top octet breaks every staff lookup -- silently on a manager,
loudly on a coach.

WHAT WE SEND: id, resourceId, cardsubtypeid, itemType, contract, itemState, owners,
untradeable. Nothing else. rating/rareflag/assetId are overwritten by the merge;
nation/leagueId/teamid would be INVENTED, because none of the four tables has such a
column; preferredPosition (rec+0x146) and attributeList (rec+0x98..) SURVIVE the merge
and are read by the generic view-model FUN_1800d7920, so sending them would hang a
position label and six attribute numbers on a coach. Omission is safe; a scalar where
an object is expected is not.

The starter shelf is one card per (tier, rare) combination per family -- 24 cards --
with two exclusions: the four miss-fill assetids (three of which are REAL rows, so a
hit and a miss would look identical on those cards), and any row whose own stat write
is byte-identical to its family's miss-fill (head/GK attribute 0 amount 15).

tier() is the binary's own tail, not our convention: the shared exit of FUN_180141660
writes rec+0x54 = 3 if rating >= 0x4b else 2 - (rating < 0x41), for every arm
including the miss arms.

Also lands the design round's read-only probe tooling: coach_probe.py (grades a live
record HIT/MISS/WRONG-BRANCH/NO-MERGE against the on-disk rows) and coach_window.py
(builds a mixed-control window; fires nothing).

Not wired into the server in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:04:44 -07:00
funman300 340c31f34e fifa17-recon: consumables -- the whole family, and it needs no id space at all
144 live cardtype-6 subtypes and 28 dead zones, all derived from the binary rather
than guessed, plus EA's own authored variants out of the dumped fcc_* tables.

MEASURED (decompiles read to their closing brace, lengths stated):
  FUN_1800d8330  (714 chars)   cardsubtypeid -> cardtype. The cardtype-6 space is
                               {51..136} u {201..220} u {250..273} u {300..341} = 172.
  FUN_18013f4d0  (8,354 chars) subtype -> category(rec+0xb8), sub-sel(rec+0xbc i16),
                               amount(rec+0xbf i8), single(rec+0xc0). Two callees, a
                               range clamp and an enum map; no DB handle is touched,
                               which is why a consumable has no identity to look up.
  FUN_1801bfac0  (42,813 chars) category -> FUT_CONSUMABLE_* string + a HARDCODED
                               5000xxx artwork constant. resourceId never reaches the
                               screen for a consumable.
  fcc_trainingcards 143 rows / fcc_healingcards 27 / fcc_contractcards 13, each with
  rowcount == rows_emitted == len(rows), so absences below are from a COMPLETE dump.

Two things the family will not forgive, both enforced in the builder rather than
documented and hoped for:
  * `amount` (atom 0x1b) is MANDATORY for categories 0, 4, 5, 9, 10. The parser
    initialises its temp to 0xffffffffffffffff, so omitting it stamps (byte)-1, and
    the accessors FUN_1801a8040/FUN_1801a8060 are `(int)*(char *)` -- SIGNED. The
    card reads "-1", not 0. consumable_item() raises instead.
  * A DEAD-ZONE subtype does not self-label. It falls to the bottom default of
    FUN_18013f4d0 and renders as an ordinary Squad Training (Pace) card with amount
    0. There is no "DB Error" analogue here, so every subtype we ship comes from
    data/consumables.json and the builder refuses the other 28.

Two corrections to the generated data, both from re-reading FUN_1801bfac0 case 5 and
case 0 rather than from the category table:
  * subtype 220 is named FUT_CONSUMABLE_NAME_SQUADTRAINING, not ..._PLAYERFITNESS.
    0xdc == 220 is the FIRST half of the squad-fitness test, so 220 always takes that
    branch, and there is no ..._SQUADFITNESS string in the binary at all.
  * all 28 dead zones are SQUADTRAINING, not PLAYERTRAINING: case 0 tests
    `subtype - 0x33 < 7` then `subtype - 0x3d < 7` and no dead zone satisfies either.
  Exactly 29 of 172 rows changed; nothing else moved.

INFERRED, and flagged as such in the module: the ?type= grouping. The vocabulary is
certain (FUN_18012ec50 arms healing=23, contract=24, training=25, development=6), but
the tab-to-arm binding has NEVER been observed -- only type=player, type=manager and
type=custom have ever come from this client.

Three families deliberately NOT shipped: manager_formation_mod (71-86) has zero rows
in the 143-row table AND FUN_1801bfac0 case 6 calls FUN_1801a0100 on the formations
result without the rowcount guard its twin case 7 has -- a crash candidate;
formation_mod (121-136) has artwork -1; manager_league (300-341) renders literally
"ML: %d" from a raw number and one shipped amount (2118) is in no league table.

Not wired into the server in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:04:21 -07:00
funman300 d8ef9d4c4f fifa17-recon: the rareflag trap -- a rare Player Fitness card is a SQUAD Fitness card
fut_store._item() hardcoded "rareflag": 1 on every item it builds. That is inert
for players and for staff, and CORRUPTING for exactly one consumable subtype.

MEASURED, in the binary: FUN_1801bfac0 case 5 (consumable category 5, fitness)
takes the squad-fitness branch when

    (cardsubtypeid == 0xdc) || FUN_1801a88c0(rec)

and FUN_1801a88c0 is exactly `*(int *)(rec + 0x58) == 1`. rec+0x58 is the rareflag
atom 0x271 (FUN_18013fe00 case 0x271 -> uStack_130; the frame arithmetic is
independently pinned by local_138 -> rec+0x50 and local_13c -> rec+0x4c, the two
offsets card_identity_probe has been reading live for days). FUN_180141660 does not
overwrite rec+0x58 for cardtype 6 -- cases 6/7/8/9 fall to the shared tail, which
writes only rec+0x54 -- so a rareflag we send survives all the way to the render.

Result: subtype 219 with rareflag 1 draws FUT_CONSUMABLE_NAME_SQUADTRAINING with
artwork 5000011 instead of Player Fitness with 5000010, and forces the
single-target count at param_5+0x1bc to 0. Silent. It would have corrupted the
first fitness card we ever served.

The guard is `0 if cardsubtypeid == 219 else rareflag`, added with two new KEYWORD
params. Every existing call site (fut_store.py:74/:357, utas_server.py:1404/:2136)
passes 8 positional args, so both take their defaults and the player dict is
byte-identical -- key order included, asserted in tools/test_card_families.py.

Scope correction to the round's own notes: rec+0x58 is read TWICE in that
42,813-char render function, not once. FUN_1801a88c0 is the category-5 read, but
line 108 reads it directly into param_5+0x1f0 (the rare/backing art) for EVERY
cardtype, before the `if (param_4 == 6)`. So the guard also stops a 219 being drawn
as rare -- intended, since rare IS the squad-fitness selector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:03:54 -07:00
funman300 9876a6c870 fifa17-recon: repair_club -- fix stale cards in place, remove only the dead ones
Audit of the live club: 194 items, 10 already correct, 175 stale, 9 dead.

STALE means a real FIFA 17 player carrying the old invented fields: attributes
computed from the rating, and in some cases a wrong club or nation (Kroos was stored
at Bayern and is at Real Madrid; Alaba was nation 40 and is Austria 4). Those are
REPAIRED in place from data/pool.json rather than deleted. Deleting them would throw
away 90 percent of the club for no reason: name, face and badge are already right and
only the numbers are wrong, and the item id does not change so squad slots survive.

DEAD means the playerid is not in the roster at all, so the client misses and stamps
its generic blank. Nothing to repair, so those are removed. A dead card referenced by
a saved squad is kept rather than breaking the slot.

The nation and team mappings were spot-checked against the game's own nations and
teams tables before trusting them across 175 cards: 4 Austria, 21 Germany, 38
Portugal, 60 Uruguay, 243 Real Madrid, 5 Chelsea.

MUST RUN WITH utas_server STOPPED. The server keeps the profile in memory and
rewrites it on its own schedule, so an edit underneath a running server is clobbered
by the next save. That is why the nine dead cards removed on 2026-08-04 were back a
few hours later: the removal was correct and the running server undid it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:03:10 -07:00
funman300 7b9a4fde15 fifa17-recon: /club honours ?team= and ?league= -- drill-downs showed the whole club
Reported live: a Cristiano Ronaldo card appearing under Chelsea and under Arsenal.

The data was right and so was the client. teamid 243 really is Real Madrid in the
game's own teams table, and all eight Ronaldo cards in the live CardsDb map read
teamid 243. The fault was ours: club_route parsed only ?type= and ignored ?team= and
?league=, so clicking a club or a league in the club panel was answered with the
ENTIRE 194-item club. Every drill-down therefore contained every player.

Note which half was already correct: the club/stats COUNTS were fixed yesterday and
were right (England 11, Premier League 17). It was only the item list behind them that
was unfiltered, which is why this looked like a data bug and was not one.

Now: team=5 gives 26 items, team=243 gives 20, league=13 gives 22, and the unfiltered
club list is untouched at 194. 439 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 09:44:56 -07:00
funman300 d17cf684ea fifa17-recon: keep raw memory captures out of git
data/memdump reached 2.3GB of raw /proc/PID/mem captures. Only its index.json is
worth tracking; the captures regenerate from tools/db_dump.py against a running game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 09:09:25 -07:00
funman300 9a0a76c9f4 fifa17-recon: real positions, clubs and the six card attributes for all 17,563 players
The pool now comes from the game's OWN resident database, not from a rating index
plus guesses. tools/db_dump.py walked the client's self-describing table catalog
read-only and wrote data/tables/ (149 tables, 55MB); tools/build_player_facts.py
turned it into data/player_facts.json; data/pool.json is the compact form fut_cards
loads.

MEASURED, per player: position (players.preferredposition1), nationality, teamid,
leagueid (via leagueteamlinks), and the six card attributes.

The six attributes are NOT columns -- they are a weighted sum of the 29 base
attributes, and the weights are read out of the game's own playerattributesmapping
table rather than from published formulas. Checked against real FIFA 17 cards:
Messi 89/90/86/96/26/61 and Ibrahimovic 72/90/81/85/31/86 are EXACT, Suarez is one
off on physical, Ronaldo within two on pace and shooting. Keepers come out directly
from the gk* columns.

What this fixes on screen: Kaka was a CDM, Bale a CM, Suarez a GK, and every
attribute was derived from the rating. Now Bale is RW, Boateng is a CB with 90
defending, De Gea is a GK, and a bronze pack deals real bronze players in real
positions.

REVERSAL, deliberate: nation/team/league were being sent as ZERO so the client would
fill its own values (the merge fills those three only when they arrive zero). Now
that we hold the game's own numbers there is nothing to gain from zeros, and they
actively hurt -- club-stats drill-downs bucket by the item's own nation and leagueId,
so a club full of zeros would have quietly emptied the per-nation and per-league
panels fixed the day before. Send the real values.

The old rating-index path is kept as a fallback so the pool still builds without
data/pool.json, and it now says out loud which of the two it used, because one is a
measurement and the other is a guess.

439 + 61 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 09:08:29 -07:00
funman300 49733f79d1 fifa17-recon: card-family enumeration round -- managers cracked, consumables need no ids
11-agent round, every investigation adversarially reviewed. The headline is that this
was never five id hunts: the client's database is resident in ordinary heap as a
self-describing catalog of bit-packed fixed-stride row arrays, walkable READ-ONLY, so
the id sets fall out at zero cost in human club visits.

MEASURED:
  managercards carddbid 1000001..1001455, assetid == carddbid; two agents using two
    different block locators agreed 417/417 (docs/managercards_ids.txt). This is why
    the earlier sweeps of 1..5000 and 6000..8000 were silent.
  staff bands: headcoach 2000004+, fitnesscoach 3000019+, physio 4000002+,
    gkcoach 9000001+, corroborated by the four miss-fallback assetids hard-coded in
    FUN_180141660 each landing inside its own table's decoded range.
  all four coach branches write a LOUD miss-fill: firstname/lastname "DB Error",
    rating 0x32, rare 1, attrs[0] 0xf, plus a table-unique assetid. Managers write
    none, so a wrong manager id is silent and a wrong coach id labels itself.
  consumables have NO table and NO id space: cardtype 6 has no arm in the merge,
    FUN_18013f4d0's only callees are a range clamp and an enum map, and every string
    is a hardcoded FUT_CONSUMABLE_* literal. A contract is three JSON keys.
  the ?type= taxonomy is 29 explicit arms plus a default: badge 11, kit 12, stadium
    13, ball 14, equippables 15, leaguelogos 16, misc 26. club/stats kits and
    badgeDBid are PLAIN COUNTS, not ids.
  fancards is a boolean column of the fixtures table; newcards is FUT atom 0x1d7.
    NEITHER is a card family, so two of the five hunts never existed.

REFUTED, and worth keeping: the live table-directory walk was off by one entry
(descriptor for table T is at entry-0x20, not entry+0x08), which had mislabelled
managercards as factory_teams and shifted every column count. managercards names are
32-bit string-pool offsets and the pool was never located -- we get ids, not names,
and we do not need names because the client supplies them.

TRAPS RECORDED: rareflag=1 silently converts a Player Fitness card (219) into Squad
Fitness, and fut_store._item() hardcodes rareflag 1 on every item.

Four proposed club-item sweep windows were killed by review as invariant by
construction: with no merge arm there is no miss-fill, so every id yields a
byte-identical record and the probe cannot discriminate. That is a wasted human action
correctly caught before it cost one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 22:40:24 -07:00
funman300 c76cf706ef fifa17-recon: CARD_SYSTEM -- record the solved identity mechanism and the oracle
Supersedes the parts of this document that were wrong: the CardsDb map is not empty
offline, and dbdata.dll is not the player database.

Records the merge dispatch table, the field-fill asymmetry that the pool design
depends on (send zero for what the client knows, send our own only where it knows
nothing), the three-state oracle with all three fingerprints, the 5000-item ingest
ceiling, the fact that the map is WIPED on every club fetch, and where the 17,547
player roster came from plus its independent cross-validation.

Also records the state of the other card families so the next session starts from
the manager branch writing no miss-fill, rather than rediscovering it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 22:00:18 -07:00
funman300 b996c0673d fifa17-recon: sweep every staff table at once (t*@)
The manager sweep (subtype 4, ids 1-5000) came back with 5000 records at
cardtype 2 -- confirming FUN_1800d8330(4)=2 live -- and NOTHING written: no name,
no nation, no teamid, and no miss-fill either. Our sentinel rating 7, position 25
and attributes all survived. So either manager ids are not in 1-5000 or that
branch keys off something the player branch does not.

Guessing the id space costs a club visit per guess, so 't*@lo-hi' now fans all
five non-player tables across one range in a single response: 4 managercards,
5 headcoachcards, 6 gkcoachcards, 7 physiocards, 8 fitnesscoachcards. One staff
tab load tests 1000 ids against all five.

The full table set, from the DLL's own strings: players, managercards,
headcoachcards, fitnesscoachcards, gkcoachcards, physiocards, fancards, newcards
-- plus a consumables family (contract, fitness, healing, position, training and
playstyle modifiers, formation and league mods) and club items (badges, balls,
kits) which are almost certainly NOT DB-resolved the way cards are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:25:43 -07:00
funman300 1b1c178c09 fifa17-recon: /club honours ?type= -- the STAFF tab was showing footballers
Observed live: the staff tab issues GET /club?year=2017&type=manager&count=200.
club_route ignored the parameter entirely and answered every type with the full
player list, so FUT displayed players as coaching staff.

The filter is deliberately narrow. 'player' and 'manager' are the only values the
client has ever been seen to send; 'custom' (the by-league and by-team drill-downs)
and a missing type keep exactly the behaviour that is already proven on screen,
because those drill-down counts were only just fixed and must not be disturbed. An
unrecognised type is filtered rather than answered with everything, since
answering an unknown question with the whole player list is the bug being fixed.

We own no staff cards, so type=manager is [] today -- an empty item list, the same
shape the itemData parser already accepts everywhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:20:06 -07:00
funman300 c28cad281b fifa17-recon: sweep can target the non-player card tables
The merge FUN_180141660 dispatches on record+0x4c, which FUN_1800d8330 derives
from cardsubtypeid alone, and each branch queries a different table by
carddbid = record+0x18 -- the same field players use for playerid:

    0..3 -> 1  players (live-proven)   5 -> 3  headcoachcards
    4    -> 2  manager                 8 -> 4  fitnesscoachcards
    6    -> 10 gkcoachcards            7 -> 5  physiocards
    9..b -> 7  unidentified            absent -> 0x156 -> 0, no merge at all

So a 't<subtype>@' prefix on the sweep window probes any of them the same way
players were probed: 't5@auto:1-20000:5000'.

Only cardsubtypeid changes. itemType stays 'player' because the merge dispatches
on the subtype alone and the wire shape of a real staff item has NEVER been
observed -- across every logged session the client has only ever asked for
type=player and type=custom. Inventing a shape for an unobserved request is the
change class behind every freeze this project has had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:18:01 -07:00
funman300 0a1c9dc2ce fifa17-recon: strip_dead_cards -- remove club cards whose playerid is not real
Leftovers from the invented-id pool that data/roster.json replaced. The client
misses on them and stamps its generic card (rating 50, teamid 1933, nation 14,
position 2, attributes 1, blank name), which is what a blank card on screen IS.

Removed 9 from the live club (5 distinct bad ids, 169193 four times over). 188 of
194 cards were already resolving; these were the whole remainder.

Refuses to remove anything a saved squad references, backs up first, and is a dry
run unless --fire. It touches only the club pile: a card present in both club and
purchased is the known fatal desync, and deleting from one pile cannot create that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:15:55 -07:00
funman300 e9e6f203c2 fifa17-recon: the card pool is now the REAL FIFA 17 roster, 17547 players
The old pool was 79 hand-written rows whose asset ids were mostly invented, on
the premise that the client's card map is empty offline so no id could render.
That premise was refuted by a live screenshot, and this replaces its consequence.

Source: tools/dbdata_extract.py reads FIFA's own rating-sorted index out of a
running process (0x40 stride, self-validating {begin,end,end+1} name-pointer
triple, anchored on 20801 = Ronaldo 94) -> data/roster.json. dbdata.dll was a
dead end and is documented as such: its single export getTableData is an
anti-tamper attestation routine, not a data accessor.

Cross-validated against a completely independent method. tools/sweep_collect.py
serves candidate ids as a synthetic club and reads back the identity the CLIENT
resolved through its own merge. 573 of 573 overlapping names agreed exactly, and
the single id present in one and not the other is 26501, the target of the
documented 22800..22879 Legends remap -- which is also what produced 'Alex Hunter
x80' in a sweep and had looked like a bug.

Field honesty, because half of these are real and half are not:
  playerid/rating/name  REAL   the roster
  club/nation/league    REAL   we send zeros and the CLIENT fills them (the merge
                               only fills those fields when they arrive as zero)
  position              PARTLY 59 from the game's own per-card cache, 17 curated
                               by hand, the rest synthetic but deterministic
  attributes            SYNTH  derived from rating and position

169193 is dropped from the curated set: it was in VERIFIED_ASSET_IDS and is not a
real player. The client resolves it to the database's empty placeholder row, which
renders as 'Jamal Blackman'. Two independent methods agreed.

NOTE BEFORE PUSHING ANYWHERE PUBLIC: data/roster.json is EA's player data,
extracted from your own installation. Fine locally; think twice about publishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 21:08:52 -07:00
funman300 b0bbc2a07f fifa17-recon: sweep auto-advance + the three-state oracle, live-proven
The oracle is three-valued, and all three fingerprints are now confirmed live
against a running client rather than read out of Ghidra:

  NAMED        our sentinel rating 7 survives and a real name appears. The id is
               real, and teamid/nation/leagueId come back FILLED by the game
               because we send them as zero.
  placeholder  rating 7 survives but the name is 'Jamal Blackman', team 0. The
               players-table row exists and is an empty slot. This is the trap:
               169193 does this and it was in VERIFIED_ASSET_IDS.
  MISS         rating 0x32, teamid 0x78d, nation 0xe, position 2, name ' '. That
               is the binary's miss-fill, byte for byte, and it is exactly the
               blank card photographed in a pack today.

Scale: 5000 candidates per response ingests cleanly; 20000 was served and then
silently not ingested (the map did not change at all), so the ceiling is between
them and auto chunks default to 5000.

Auto-advance: the client PAGES the club, so one visit yields several fetches.
'auto:lo-hi:step' hands out the next chunk per fetch. Item ids derive from the
candidate's offset in the WHOLE range, not its index in the chunk, so chunks never
collide and results accumulate across fetches for a single probe at the end.

sweep_collect.py accumulates into data/players.json and rejects the placeholder
name as a matter of course. Yield in 20000-24999 was 19 real ids per 5000, which
is why auto-advance matters: the real roster clusters in 150000-240000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 20:44:02 -07:00
funman300 87e5cd2e53 fifa17-recon: FUT_ID_SWEEP -- use the running game as the player-DB oracle
dbdata.dll is an anti-tamper decoy (getTableData returns a self-integrity blob),
so the players table only exists inside the running client. But we do not need to
unpack it: the client merges its own DB into every item we serve, keyed on
resourceId & 0xffffff, and leaves the result in a map we can already read.

So serve a RANGE of candidate playerids as a synthetic club, then read the map
back with card_identity_probe.py. One club fetch classifies the whole window.

Sends teamid/nation/leagueId as ZERO so the client fills the REAL values (the
merge only fills zeros -- confirmed live: one playerid appears twice with two
different nations, both ours). Sentinel rating 7, deliberately not 50, so the
miss-fill (rating 0x32) can never be mistaken for a surviving sentinel.

The window comes from a control FILE read per request, not just the env: a full
sweep is many windows and restarting mid-session is what produced 'error
connecting to FIFA 17 Ultimate Team' once already. Nothing is written to the save,
so clearing the file restores the real club on the next fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 20:35:51 -07:00
funman300 132a013b39 fifa17-recon: card identity is a DATA problem -- live probe proves the record model
Adds tools/card_identity_probe.py, a read-only /proc/PID/mem walk of the CardsDb
card map that reports the identity the CLIENT resolved for every card it holds.

Why it matters: identity never comes from us. The item-parser tail registers every
parsed item into the map, and immediately before that FUN_180141660 -> FUN_180135890
queries the client's own local players table by resourceId & 0xffffff. On a hit it
fills name/face and leaves our rating/position/attributes alone; on a miss it writes
a fixed generic card (rating 0x32, teamid 0x78d, nation 0xe, position 2, attrs 1,
name ' '). That miss fingerprint is exactly the blank card photographed in a pack
today, so the chain is confirmed by live evidence and not only in Ghidra.

First live run, 11 nodes, 0 failed reads, size counter agrees with the walk:
Ronaldo/Messi/Suarez/Kroos/Hazard all resolve with real names, so every record
offset derived statically (+0x18 resourceId, +0xb4 rating, +0x94 teamid, +0x148
nation, +0x146 position, names inline at +0xb8/+0xc8/+0xdd) is correct live.

This makes card identity a pure DATA problem: serve real playerids. The probe is
the bulk oracle for finding them -- N candidate ids served, one read classifies all N.

Also flips FUT_STORE_DISPLAYGROUP to default on; it shipped off pending proof that
the key does not switch FIFA17.exe to another tile render path, and it was then run
live and the store tiles showed their real names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 20:29:27 -07:00
funman300 a3fd9e870f fifa17-recon: REFUTED -- the CardsDb map is not empty offline
CARD_SYSTEM.md has claimed since it was written that offline the CardsDb map is EMPTY,
every lookup misses, and the view-model reads every rendered field from the resolved
record and NEVER from our item JSON. A live pack open falsifies both halves.

One bronze pack, five cards. Two rendered as real players with names, club badges and
national flags. Three rendered blank: rating 50, position RWB, every attribute 1. Our
pool contains no rating 50, no RWB and no all-ones attributes, so the blank is the
client default.

The two that resolved match our item JSON field for field:
  (232517, 62, RB, nation 36, league 19, team 175, [72,44,58,60,62,61])
    -> SILVA, 62 RB, Wolfsburg badge, Norway flag, 72 PAC 44 SHO 58 PAS 60 DRI 62 DEF
  (235066, 60, GK, nation 34, league 31, team 48, [62,63,33,61,17,62])
    -> NOWAK, 60 GK, 62/61 63/17 33/62

Those attribute numbers were invented by hand this afternoon. They cannot have come
from a database.

So: stats come from our JSON, name/badge/flag come from the client keyed by assetId,
and an assetId the client does not know collapses the WHOLE card to the blank, which
is why a bad id looks like a rendering failure rather than a lookup failure.

THE CONSEQUENCE IS THAT THE PLANNED WORK IS UNNECESSARY. This document recommended
populating the map by driving the insert, hand-building a red-black tree node, or
patching the resolve miss path, all of which write to a live process. None of it is
needed. The rule is: use asset ids that exist in the client database. fut_cards.py has
18 verified ids and 61 structural placeholders, and the placeholders are the blanks.
The remaining work is a DATA problem, not a code-injection problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 15:38:41 -07:00
funman300 7ad7aa0afa fifa17-recon: club stats -- per-NATION buckets, and the sub-type sums
LIVE 2026-08-04: the ENGLAND tile read 0 while drilling into it showed Premier League
17. Same bug as before, one level up: the LEAGUE buckets were keyed and the NATION
buckets were not.

The eight-row MY CLUB panel is FUN_180094ce0 (not FUN_180043b90, which is a different
provider using a different string family), and it computes:

  PLAYERS_EMPLOYED = +0x7f8(nationId, 4) + (nationId, 3) + (nationId, 2)
  STAFF_EMPLOYED   = +0x800 over 0xb, 0xc, 0xd, 0xe, 0xf
  TROPHIES_WON     = +0x800 over 0x33 .. 0x38
  STADIA_OWNED     = +0x800(0x14)      BALLS_EARNED = +0x800(0x1e)

Two consequences:

1. The no-id modes (year / consumables / club / newcards), which is what the client
   fires on entering MY CLUB, now carry PER-NATION buckets keyed by nation id. The
   three screens are consistent at last:
     no id        -> nation buckets   (the tab strip and the eight-row panel)
     country/<id> -> league buckets   (the leagues in that nation)
     league/<id>  -> team buckets     (the teams in that league)
2. STAFF_EMPLOYED and TROPHIES_WON are SUMS OF SUB-TYPES. Sending staff(0xa) or
   trophies(0x32) alone can never move those rows, whatever their value. The eleven
   sub-type rows are now emitted: staffManager/HeadCoach/GKCoach/Physio/FitnessCoach
   and trophiesOffline/Online/FeaturedOffline/FeaturedOnline/SeasonOffline. All zero
   today because the club owns no staff and has won nothing, but the mapping is what
   matters when it does.

All eleven new type strings verified against docs/fut_atoms.tsv, 0 mismatches.

Live: /club/stats/year now returns 117 rows across 16 nation buckets, England
(nation 14) summing to 11 players. 439 + 61 checks green, zero tracebacks.

This is the third correction to this one endpoint today. The pattern in all three is
identical and worth stating once more: the parser accepts anything, and only the
CONSUMER tells you which bucket and which type ids it reads. Every time I reasoned
about the body instead of reading the reader, I shipped a wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 15:35:17 -07:00
funman300 4c5cc3ab4b fifa17-recon: THE MY CLUB COUNTER -- it was clubPlayers in GET /hub all along
The tile's big number is `clubPlayers` (atom 0x90) in the body of GET ut/%s/hub, a
route we have answered with {} for the life of the project.

The chain, re-derived independently by two agents (one via Ghidra, one via raw PE plus
capstone with no decompiler) and checked by two reviewers:

  clubPlayers(0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 becomes 0)
    -> R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is literally
       `lea rax,[rcx+0x1fd70]; ret`)
    -> read by FUN_1800b0250, published as TEXT0 of TILE_ID 0x210
    -> captions FUT_GH_TOTAL_PLAYERS_0/_1 at 0x18020a0f8 / 0x18020a110
  auctionCount(0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile

FUN_180139610 (the hub body parser, 14855 chars, censused in full: 18 atoms, none
missed) is the ONLY writer of +0x3c anywhere in the image, one write, guarded by
`if (iVar6 != 0x90)`. This is not a candidate, it is the field.

I SPENT A DAY ON THE WRONG SURFACE AND WROTE THE WRONG CONCLUSION. REBUILD_RESEARCH
S19 declared the counter "not server-fixable" with a mechanism that was internally
correct and completely beside the point: the tile never read the club-stat store.
Two things reinforced the error and both are now fixed in the docs:

  * ENDPOINT_MAP said this response "uses C++ reflection / vtable dispatch, NOT an
    inline atom ladder -- no static field ladder to read" and marked it a GAP. False.
    There is an inline ladder, one indirection away.
  * The eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
    publishes six keys, and I treated the six-versus-eight mismatch as a puzzle rather
    than as evidence. It is a DIFFERENT provider, FUN_180094ce0, using a different
    string family (FUT_MYCLUB_*), reading neither the mode tag nor any type id we were
    sending. Two providers; we were reading the wrong one.

That is the third negative claim of this shape to fail today, after "this deserializer
has no skip handler" and "the factory does not wipe the stat map".

auctionCount is included as a FREE CONTROL: different field, different tile, so if MY
CLUB moves and TRANSFERS does not, delivery is fine and something is specific to +0x3c.

Default ON. Freeze risk is low by construction rather than by belief: a flat object of
two integers, both read with the INT getter, so there is no array, no nested object and
no type-desync surface. FUT_HUBDATA=0 restores {}.

Contract guard added, and verified to bite rather than merely pass:
  default        439 checks, 0 failed
  FUT_HUBDATA=0  435 checks, 3 FAILED  (clubPlayers missing / not a number)
A regression here would otherwise be silent: still 200, still valid JSON, tile quietly
back to 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 15:29:59 -07:00
funman300 ffb0e6033c fifa17-recon: real card pool -- 79 players, three tiers, and packs that differ
The pool was 18 players rated 85 to 94. open_pack() split it with `(rating >= 75) ==
gold`, so the bronze pack's filter matched NOTHING and fell back to the whole pool:
all three packs dealt gold rares and the bronze pack was a lie. The file's own TODO
asked for "a full dbdata.dll extract (~18k players)".

NEW fut_cards.py: 79 players, 39 gold / 20 silver / 20 bronze, 7 leagues, 20 nations,
18 teams, every outfield position plus GK, no duplicate asset ids. PACK_CATALOG now
carries a weighted `tiers` draw per pack. Simulated 40 opens of each:

  Bronze Pack   {'bronze': 159, 'silver': 41}    39 distinct cards, 12 positions
  Gold Pack     {'silver': 110, 'gold': 170}     59 distinct cards, 12 positions
  Premium Gold  {'gold': 392,  'silver': 48}     57 distinct cards, 12 positions

WHY THIS IS NOT THE dbdata EXTRACT, and why that does not matter yet. dbdata.dll is a
real PE with one export, getTableData, whose 2.5MB payload sits in a section named
.xdata that disassembles as obfuscated code rather than a table directory, so the base
DB is not statically extractable without running that export under Wine or defeating
the obfuscation.

More to the point it would change nothing on screen today. Per docs/CARD_SYSTEM.md the
card view-model 0x1800d7920 reads EVERY rendered field (rating +0xb4, position +0x146,
nation +0x148, teamid +0x94, six attrs +0x98..0xac, name +0xdd) from a definition
record resolved at item+0x10 out of the client's own CardsDb map, and NEVER from our
item JSON. Offline that map is EMPTY, so every lookup misses and a blank record is
emitted. No assetId we send, real or invented, can produce a named card until that map
is populated. That is a separate job (CARD_SYSTEM options A/B/C) about the CLIENT's
map, not about our pool.

What the pool DOES control is everything the server is source of truth for: the
gold/silver/bronze split, leagueId/nation/teamid which are exactly what the club-stats
drill-downs read (those now work, S20, and were being fed 5 leagues from 13 nations),
preferredPosition which decides whether a squad can be filled at all, and the six
attributes behind the market filters.

Asset ids: 18 are genuine FIFA 17 ids and are listed in VERIFIED_ASSET_IDS. The rest
are structural, and the docstring says so plainly rather than passing them off as real
players. Because the CardsDb map is empty offline an id being wrong has no visible
effect today; if the identity work lands, that set is the diff target.

Backwards compatible: open_pack() still honours the legacy `gold` boolean when no
tiers are given, and the old list survives as _LEGACY_POOL for the starter squad.

392 + 61 checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 15:23:23 -07:00
funman300 a7ac5a09fd fifa17-recon: club stats LIVE-PROVEN, default ON; S19's verdict was over-scoped
MY CLUB -> ENGLAND -> Premier League now reads 17. First non-zero number ever rendered
on that screen. Nothing froze, no other screen changed, 392 + 61 checks green with the
flag defaulted on and no env override.

S19 concluded "the MY CLUB counter is not server-fixable". That was too broadly scoped.
The nation and league drill-downs ARE server-fixable and are now fixed. S20 records the
corrected scope.

What made it work, from FUN_180043b90 case 3:

  uVar7  = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")   THE UI ROW'S OWN ID
  bronze/silver/gold = (+0x7f8)(store, uVar7, 2 / 3 / 4)
  publish("PLAYERS_EMPLOYED", gold + silver + bronze)         COMPUTED, never read
  rare/kits/badges   = (+0x7f8)(store, uVar7, 5 / 0x28 / 0x2d)

Still open and now correctly scoped: the hub tile's "0 TOTAL PLAYERS" and the MY CLUB
summary rows read the GLOBAL bucket via +0x800 in cases 1 and 5. We serve those rows.
The unchanged question is what SELECTS those cases, since the mode tag is copied from
the completed request and the client requests year, consumables, staff, country/<id>
and league/<id> but never club.

The method note, which is the durable part: two rounds of reasoning about this endpoint
produced two wrong bodies; twenty lines of the consumer produced the right one. Reading
the PARSER tells you what is accepted. Only reading the CONSUMER tells you what is used.
That question was answerable from the start and went unasked until live screenshots
forced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:50:06 -07:00
funman300 f65c197942 fifa17-recon: club stats -- key the buckets the way the READER looks them up
Second correction in an hour, and this one comes from reading the provider instead of
reasoning about it. The per-context getter is (+0x7f8)(store, contextValue, typeId),
and contextValue comes from THE UI ROW, not from the URL:

  case 3:  uVar7  = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
           bronze = (+0x7f8)(store, uVar7, 2)
           silver = (+0x7f8)(store, uVar7, 3)
           gold   = (+0x7f8)(store, uVar7, 4)
           publish "PLAYERS_EMPLOYED", gold + silver + bronze
           rare/kits/badges = (+0x7f8)(store, uVar7, 5 / 0x28 / 0x2d)
  case 4:  keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)

Three things my previous commit got wrong:

1. It keyed every row to the id in the URL. The reader iterates the SCREEN'S ROWS and
   looks up each row's own id, so one response must carry a bucket per row. Keying to
   the URL id fills exactly one bucket the screen never asks for, which is why the
   ENGLAND tab still showed zeros after the "fix".
2. PLAYERS_EMPLOYED is COMPUTED as gold + silver + bronze in the per-context cases and
   is never read from the store, so sending `players` (type id 1) does nothing there.
   The tier counts are mandatory.
3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, LEAGUE_ID)
   and league/<id> lists the TEAMS (case 4, TEAM_ID). That matches the live navigation
   exactly: selecting ENGLAND produced Premier League / Championship / League One /
   League Two.

Because every response wipes the whole map, each response only needs its own screen's
buckets, which also avoids a real collision: the storage key is contextValue alone, so
nation 14 and league 14 would otherwise share a bucket.

Live output now:

  country/14 -> 41 rows, 5 league buckets
                league 13 gold=17 -> PLAYERS_EMPLOYED=17   (Premier League)
                league 19 gold=26, league 53 gold=55, ...
  league/13  -> 6 team buckets {5:20, 21:15, 22:11, 240:21, 241:30, 243:17}

Recorded as a method note: two rounds of reasoning about this endpoint produced two
wrong bodies, and reading twenty lines of the provider produced the right one. The
question "what does the reader look up" is answerable and was not asked.

392 + 61 checks green, zero tracebacks. Still behind FUT_CLUBSTATS, default off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:45:40 -07:00
funman300 9ee21afb56 fifa17-recon: club stats -- populate the PER-CONTEXT buckets, not just the global one
LIVE 2026-08-04, and this corrects the body I shipped an hour ago. Selecting the
ENGLAND tab on the MY CLUB screen issues exactly one request:

  14:30:32  GET /ut/game/fifa17/club/stats/country/14      (14 = England)

and NO item-list request. So that tab is driven entirely by per-nation stats, and it
showed nothing while the club holds 8 England players.

THE GUARD WAS THE BUG. In deser 0x180130150, contextId == 1 or 5 <= contextId <= 9
FORCES contextValue to 0, which is the global bucket that the +0x800 getter reads. The
per-nation view reads the +0x7f8 getter keyed by the NATION ID instead. Every row I
sent carried contextId 1, so no matter what contextValue said, everything landed in
the global bucket and the per-context tabs could never see it. I had the guard written
down in my own comment and still sent a body that tripped it on every row.

Now, for country/<id>, league/<id> and team/<id>, the response carries the global rows
AND per-context rows keyed by that id, computed from the club's real nation/leagueId/
teamid fields:

  country/14 -> players 8, playersGold 8, rarePlayers 8, silver/bronze/kits/badges 0

Both sets ride in the SAME response because every response wipes the whole map first,
so anything left out is erased rather than merged.

contextId 3 is used purely because it is OUTSIDE the guard and therefore preserves
contextValue. TODO/CONFIRM what contextId means semantically; nothing read so far
gives it a meaning beyond that guard.

Also verified rather than assumed this round: all 11 type strings we emit resolve
correctly against docs/fut_atoms.tsv (players 0x238, rarePlayers 0x272, stadia 0x2d7,
balls 0x4f, kits 0x17c, badges 0x4b, trophies 0x340 ...), 0 mismatches. So the strings
were never the failure.

Does NOT claim to fix the MY CLUB hub counter, which remains the open question in S19.
This fixes the nation/league tabs, which is a different and now-understood symptom.

392 checks green. Still behind FUT_CLUBSTATS, default off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:33:01 -07:00