Commit Graph

342 Commits

Author SHA1 Message Date
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
funman300 d2bbb4d378 fifa17-recon: S19 -- the MY CLUB counter is NOT server-fixable, with the mechanism
Two experiments, both negative, and the negative has a mechanism behind it rather than
being another failed guess.

  FUT_CLUB_PAGE   served 114 items to /club?...count=11   tile still 0 TOTAL PLAYERS
  FUT_CLUBSTATS   full stat set, players=114, all modes   panel still Players 0

The bodies went out: four "CLUBSTATS: 11 stat rows (players=114)" responses in the log,
panel re-entered afterwards.

  FUN_18012fbe0   store+0x78 = request+0xc4      the mode tag is copied from the
                                                 REQUEST that just completed
  FUN_180043b90   switch (store+0x78)
    case 1  +0x800(1)->PLAYERS_EMPLOYED, (0x1e)->BALLS_EARNED, (0x28)->KITS_AVAILABLE,
            (0x14)->STADIA_OWNED, (10)->STAFF_EMPLOYED, (0x32)->TROPHIES_WON
    case 6  CARDS_NO_TRAINING_*, CARDS_NO_CONTRACT_*, CARDS_NO_FITNESS_*

The MY CLUB summary is case 1, which needs mode 1 (club). The client requests staff,
year and consumables and NEVER club, so the tag settles at 6 and case 1 is never
selected. Our values are stored correctly (contextId 1 forces contextValue 0, the
global bucket the +0x800 getter reads) and case 1 reads exactly the six ids we set.
Nothing ever asks for them.

THE MODE IS CHOSEN CLIENT-SIDE FROM THE REQUEST URL. No response body can change it,
so there is no body that fixes this and generating more of them is wasted work.

Two independent corroborations rather than one story that merely fits:
- case 6 reads 0x3d CONTRACTS, 0x3e TRAINING, 0x40 FITNESS, exactly the three ids
  FUN_18012fd40 cannot produce from any type string. The consumables view is
  unsettable from this endpoint by construction.
- FUT_CLUB_PAGE eliminated the only other candidate: the tile is not a count of the
  list we return.

Both flags stay implemented and default OFF. FUT_CLUBSTATS is correct against the
verified schema and would populate the moment a club-mode request occurred; deleting it
would throw away the schema work for no gain.

Recorded against myself: I argued from the matching labels (tile "TOTAL PLAYERS", panel
"Players", both zero while we served {}) that the two read the same store and one body
would fix both. The store IS shared. The SELECTION is not, and that is what decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:25:56 -07:00
funman300 1e2b073e04 fifa17-recon: route the draft entry purchase, and resolve the envelope ambiguity
LIVE 2026-08-04: the draft-state array fix WORKED. The screen rendered instead of
hanging and the client advanced to the entry-fee screen, then crashed on the next
call, which we had never implemented:

  GET  /squad/mode/draft/state?mode=ONLINE  -> our array body    screen RENDERED
  GET  /user/credits                        -> 7200
  GET  /store/purchasegroup/all             -> the entry-fee screen
  POST /purchase/mode/0/draft   {"currency":"COINS","usePreOrder":0}
                                            -> {}  UNMAPPED, then the crash

Advancing the failure to the next unimplemented call is what a correct fix looks like.

ENVELOPE AMBIGUITY RESOLVED, and ENDPOINT_MAP's note about it is wrong. Two structures
reference RS4:FutPurchaseDraftModeServerResponse:

  0x18014c260  vtable 0x180224ef8, factory 0x18014c090.  3188 chars, OBJECT root
               (prologue tests != 10 = END_OBJECT), 1 skip handler, exactly the seven
               scalar ints. THIS IS THE RESPONSE PARSER.
  0x180150310  vtable 0x1802262f0, factory 0x180150260.  1836 chars, ARRAY root
               (loops until 0xd), ZERO skip handlers -- and NOT a response root at
               all. It parses ENTRANCE CRITERIA: each element's name is strcmp'd
               against the literals "COINS", "POINTS", "DRAFT_TOKEN" and stored at
               +0x28/+0x2c/+0x30. It shares the class-name string because it is the
               fee sub-object, not an "alternate/summary envelope" as documented.

THE CRASH ITSELF DISCRIMINATED, which is worth keeping as a technique. An object-root
parser handed {} parses benignly and leaves defaults; an array-root parser handed {}
desyncs and HANGS, which is exactly what draft/state did before the fix. We observed a
CRASH, not a hang, so the object-root parser is what ran and the failure is downstream
of an empty-but-valid parse. Consistent with 0x18014c260, inconsistent with the other.

Coins are NOT deducted. The client posts the price in the URL and it sent 0, because we
omit entranceCriteria from draft/state so there is no fee to charge. Charging a guessed
amount would be inventing an economy rule.

ALSO FIXED, before it reached the game: the route table hands handlers the compiled
PATTERN, not a match object (the dispatcher calls fn(rx, self)), so calling .group() on
the first argument raised AttributeError and killed the connection outright. That is
strictly worse than the {} it was replacing. Caught by verifying the response actually
changed after the restart rather than assuming the route worked.

Default ON: the behaviour it replaces is a confirmed crash, so no working state is at
risk. FUT_DRAFT_PURCHASE=0 reverts.

392 + 61 checks green, zero tracebacks on a clean boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:17:45 -07:00
funman300 b434a3efdc fifa17-recon: FUT_CLUBSTATS -- serve the club-stat set (CLUB STATS panel, maybe the tile)
Live 2026-08-04: the CLUB STATS panel shows eight zeros (Rare Players, Players, Staff
Employed, Stadia Owned, Trophies Won, Kits, Badges, Balls Earned) while the client
fetches /club/stats/{staff,year,consumables} and we answer {} to all three. Those
zeros are ours. Every row name maps to a type string in the recovered map.

Wire schema, fully verified from deser 0x180130150 (7,870 chars, read end to end):
  {"stat":[{contextId:int, contextValue:int, type:string, typeValue:int}]}
Unknown keys route to FUN_180135ff0 at BOTH levels, so extras are inert.

FIVE THINGS THAT DECIDE WHETHER IT WORKS:

1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates, so a good body on
   one mode followed by a thin one on another ERASES the first and request ordering
   decides what survives. Handled by serving the SAME COMPLETE SET on every Stats2
   mode: whichever lands last leaves the map correct. (One investigator reported this
   factory does not wipe; a reviewer re-read it and refuted that. The wipe is real,
   and this is the second negative claim from that batch to fail.)
2. /club/stats/staff IS A DIFFERENT CLASS: FutStaffBonus, {"bonus":[{type,value}]},
   not Stats2. Its type strings are undecoded so it keeps {}, which is safe and also
   means it does not disturb the Stats2 map.
3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS -- the clears sit before
   the array loop, not inside it -- so omitting a key in element N inherits element
   N-1's value. All four keys are emitted in every element.
4. The storage key is contextValue ALONE; contextId is only a guard (1, or 5..9,
   forces contextValue to 0, the global bucket the +0x800 getter reads). contextId 1
   throughout.
5. 0x3d CONTRACTS, 0x3e TRAINING and 0x40 FITNESS are READ by the panel but cannot
   be SET from here. No type string produces them.

THIS IS ALSO NOW THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB tile
does not read this store, but flagged that negative as BOUNDED: the interface comes
through a QueryInterface adapter, so the vtable is assembled at runtime and cannot be
read statically. Live evidence points the other way. The tile reads "0 TOTAL PLAYERS"
and the panel reads "Players 0" -- same quantity, both zero, both while we answer {}.
And FUT_CLUB_PAGE ruled out the alternative: 114 items served to /club, tile still 0,
so it is not a count of the list. Strong inference, not proof; this flag is the test.

The test is unusually clean: the club holds 114 items and all of them are players, so
every other row is an honest zero. If it works, exactly two numbers move (Players and
Rare Players, 0 -> 114) and nothing else changes.

Gold/silver/bronze thresholds are FIFA's rating convention (75+/65-74/under), not
something read out of the binary, and the code says so.

Default OFF. 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 14:12:48 -07:00
funman300 285d4f6cb7 fifa17-recon: the blockers plan from the multi-agent pass
Five parallel Ghidra investigations, one adversarial reviewer each, one synthesis.
Kept in the repo because the reviewer corrections are load-bearing: they refuted claims
in four of the five reports, two of which would have shipped wrong behaviour (a
speculative /season body justified by our own curl traffic in the log, and a store
field block that was a freeze rather than a regression).

Carries the next live session (one launch, three flags, four menu actions, one
read-only memory probe), the implementation queue, what is genuinely blocked and why,
and a what-could-make-this-plan-wrong section that names the store change as the
concrete regression risk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:04:00 -07:00
funman300 1e9cfb6da9 fifa17-recon: ENDPOINT_MAP -- remove two documented hang recipes, fix five entries
This file has been handing out bodies that freeze the client, under the heading
"MINIMAL known-good".

1. FutGetDraftCurrentState. The root container is a JSON ARRAY. The documented body was
   object-root, used the spelling DRAFTSQUAD_ON which is NOT an accepted squadState
   value, and embedded a full squad object. Anyone serving it would have reproduced the
   exact hang the entry existed to prevent, which is what happened live on 2026-08-03
   when our generic /squad route answered this endpoint with a squad object.
   Path corrected too: it is ut/%s/squad/mode + /draft/state, not ut/%s/draft/state.
   The `squad/mode` segment was missing, which is why the URL is invisible to the
   request-template table. Established by live capture, not statically: FUN_180146ac0
   appends the suffix to a caller-supplied buffer and has no resolvable callers.

2. FutGetDraftAward (0x1801510c0) has the same array-root prologue, and its documented
   object-root body would hang identically. Corrected, and marked TODO/CONFIRM on the
   member list, which was not re-verified this pass.

   Both of these survived because a census claimed only three array-root readers
   existed in the DLL. It missed one. The census run to check it was wrong in the other
   direction. ~23 of 86 top-level readers are still unclassified, so the file now says:
   do not serve any endpoint here until its root container is classified by reading the
   actual prologue, not by regex.

3. roundsInfo element: `score` and `penaltyScore` offsets were swapped (+0x10 / +0x18).

4. FutSeasonList: deserializer is 0x1801683f0, not 0x180167740 (that is the ELEMENT
   parser), and the root is an OBJECT with one key `seasons`(0x2ad), not an array.
   Someone documented the element parser's key set at the document level, and
   utas_server.py served that shape for months on the strength of this row. Three of
   the listed element keys are inner members of elgReq and inert at element level.
   Added: element ordering (type before divisionId), stride 0x318, the (0xb-divisionId)
   short, and the three array-loop members that must stay omitted.

5. Recorded on the season entry that the client has NEVER requested /season across 486
   real requests, so no body there is observable yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:03:33 -07:00
funman300 9cf21202dc fifa17-recon: delete two loaded guns, fix the season root, add the tile-name fix
ZERO-LAUNCH FIXES from the multi-agent pass over 0x18013af30 and 0x1801683f0.

FUT_STORE_FIELDS IS DELETED, AND IT WAS A FREEZE, NOT A REGRESSION. It sent
"actionType": 0 and "firstPartyStoreId": 0 as JSON INTEGERS. Both atoms (0x8, 0x127)
are read with the STRING getter 0x1801c7aa0. That is the exact type-desync class this
project exists to avoid. So "the corrections stopped packs opening" was never bad luck
or an unrelated field: two of them were the documented freeze mechanism, shipped by a
change whose own comment claimed it was correct about what the parser reads. Knowing
WHICH atoms a parser reads tells you nothing about which TYPES it demands. Read the
getter, every time. (Two other fields in that block were no-ops anyway: useDefaultImage
0x36a stores inverted, and visible 0x37d never reads its value at all.)

FUT_STORE_GROUPS IS DELETED and its freeze is now traced end to end. It sent
displayGroup as an ARRAY of pack-shaped objects on the belief that the key was parsed
recursively by the same element parser. It is not recursive at all: case 0xd9 never
re-enters 0x18013af30. It is a FLAT OBJECT with exactly two members. An array desyncs
the reader, the parser runs off the end of the document, and the tokenizer returns the
same token forever with nothing consumed, spinning inside FUN_1801c7f10 whose body
contains the observed PC 0x1801c7f1a.

Both were being kept as togglable "maybe nearly right" experiments. Each is a loaded
gun; neither survives contact with the decompile. Deleted rather than left switched off.

NEW FUT_STORE_DISPLAYGROUP (default 0): send the one key that actually names a tile,
displayGroup = {"value": "<pack name>"}. `value` (0x377, STRING) writes record offset
+0x00, the same slot whose constructor default is the literal "unknown" (the only such
literal in the DLL, 0x180223108). The tiles say "unknown" because nobody ever sent the
field. Distinct values per pack so grouping stays 1:1. priority/displayGroupAssetId/
displayGroupUseDefaultImage all omitted as second variables.
Default OFF for a reason the token-balance proof does not cover: this may be the first
field we have sent that selects a RENDER PATH rather than a value, and that code is
packed.

SEASON ROOT SHAPE CORRECTED. season_list() and its docstring were both wrong in the
same way: the deserializer is 0x1801683f0 (object root, one key seasons=0x2ad, array
inside), not 0x180167740, which is the per-ELEMENT parser. Someone read the element
parser and served its key set at the document root, so a bare array populated nothing.
Three of the keys served (eligibilityKey/Slot/Value) are inner members of elgReq and
inert even at element level. Also recorded: `type` must precede `divisionId` because
the divisionId branch reads the parsed type at elem+0x1b4.
Still behind FUT_MODES and still pointless to serve: across 486 real client requests
the game has NEVER asked for /season. Every /season line in our log is our own curl.

NEW FUT_CLUB_PAGE (default 0): an experiment, not a fix. The MY CLUB counter's
renderer is not in cardsdll (no two-number formatter of any spelling exists) and
FutStickerBookSearch has no count atom, so there is no field we can send that IS the
number. What is still testable is whether the counter is a Flash-side count over the
returned list, which a pure length change discriminates. A NULL RESULT IS THE VALUABLE
ONE: if the counter does not move, there is no server-side lever for this symptom and
the right outcome is to prove that and stop.

392 + 61 checks green, defaults byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 14:02:09 -07:00
funman300 397d46f174 fifa17-recon: the -4 rule is literally the ASCII prefix "RS4:"
The "4-byte header" that precedes every response class name in .rdata, which cost six
failed class-to-deserializer resolutions before anyone noticed the offset, is not a
length prefix or a refcount. It is the string RS4:. The full literal is
RS4:FutXServerResponse, and searching for the bare class name lands four bytes in.

Verified directly on three classes:
  FutDestroyMatchServerResponse           name@0x18021d694  header = b'RS4:'
  FutGetDraftCurrentStateServerResponse   name@0x180224204  header = b'RS4:'
  FutStickerBookStats2ServerResponse      name@0x1802220cc  header = b'RS4:'

Found by a verification agent that had been instructed to distrust the rule. It did,
and came back with the reason rather than the offset. A magic constant you have to
remember is a rule you will eventually get wrong; a prefix you can read is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 13:48:14 -07:00
funman300 24bbc32da5 fifa17-recon: fix the match tail -- real URLs, endReason, and coins in the right place
The whole match family is one RPC descriptor block (rows 49-54, every row using URL
template index 16 = `ut/%s/match`) with a fixed suffix appended per call:

  CREATEMATCH  ut/game/fifa17/match          PLAYGAME    ut/game/fifa17/match
  MATCHREADY   ut/game/fifa17/match/ready    DESTROYMATCH ut/game/fifa17/match/end
  RESETMATCH   ut/game/fifa17/match/reset    KEEPALIVE   ut/game/fifa17/match/keepalive

THERE IS NO /match/{id} URL. The id travels in the body. Our reward path was gated on
`h.command == "DELETE" or "/ut/delete/" in h.path` and extracted the id with
re.search(r"/match/(\d+)"), so it was waiting for a request the client does not make.
The gate is now widened to include a /match/end path with ANY verb, because the verb
genuinely cannot be determined statically: the strings "PUT" and "DELETE" do not exist
anywhere in cardsdll.dll (0 hits each), so verb selection happens outside this DLL. A
reviewer flagged "the reward path can never fire" as overreach on exactly that point;
widening rather than replacing the gate is the response.

THE RESULT SIGNAL IS `endReason` (atom 260), a STRING enum with nine values: WIN DRAW
LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS. Not a score comparison. The score
lives in `myMatchStats.goals` / `opponentMatchStats.goals`, two literal-keyed objects
of 15 int fields each, and the client OMITS both when endReason is DNF or QUIT, so
nothing may require them. _match_result() now reads endReason first and keeps the old
spelling probe only as a fallback, because request-side static findings are a floor:
PUT /item's swap/tradeId appeared in no static listing either.

THREE CORRECTIONS TO THE RESPONSE, all of which were shipping wrong:

1. `coins` (atom 149) is NOT a top-level key. It is read only inside `gameModeAward`.
   The one field most obviously named "the reward" was being silently skipped.
2. `qualifiedChampionEventId` (0x269) has a SIDE EFFECT: its branch calls through a
   manager vtable after storing. Sending a habitual zero poked champion-event
   machinery for no benefit. Removed.
3. `bidTokens` (atom 89) inside gameModeAward is MATCHED and then handled by nothing,
   so its value token is left unconsumed. That is the precondition for the desync
   spin. A freeze trap dressed as an ordinary field; now guarded by a unit check.

test_match_rewards.py rewrote its expectations. The old version asserted a top-level
`coins` and passed happily while the server shipped a body whose reward field the
client never read. A test that encodes the wrong schema converts a bug into a
guarantee. New test_end_reason_is_authoritative covers all nine enum values, the
stats-less DNF case, and that endReason beats a contradictory score probe.

DEFAULT ON (FUT_MATCH_END=0 reverts), a reasoned exception to the flag convention:
nothing here is live-proven because no match has ever been played, and the old
behaviour is not a working screen but a path that provably could not fire.

61 unit checks (58 with the flag off), 392 contract checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 13:47:05 -07:00
funman300 0968cd351b fifa17-recon: route squad/mode/draft/state -- the root container is an ARRAY
Deser 0x180147070 (FutGetDraftCurrentStateServerResponse) discards tokens until it
sees START_ARRAY. Handed a top-level OBJECT it never reaches its exit condition and
spins in the inner `while (tok != END_OBJECT)` loop while the tokenizer returns EOF
forever. Process alive, no crash dump, no dialog: exactly the signature observed live
on 2026-08-03, when our generic /squad route answered this endpoint with a full
active-squad object.

The suffix composition `ut/%s/squad/mode` + `/draft/state?mode=...` makes the URL
invisible to the request-template table, which is why /squad swallowed it. Fourth time
a suffix endpoint has been invisible to that table, second time the generic /squad
route has eaten one (/squad/list was the first).

Verified at instruction level rather than by regex: 7 top-level atoms (4 int-getter
calls, 3 string-getter, 0 bool, 2 skip) across the whole body [0x180147070,
0x1801475a3]. FUN_180135ff0 IS present, twice, so unknown keys are inert. NO ATOM
COLLIDES with the squad object we were serving, which means the hang was purely the
container level and not a per-field type desync.

entranceCriteria(0x108) is now known to be an object of three int keys
COINS/DRAFT_TOKEN/POINTS. It is OMITTED anyway: knowing a shape is not a reason to
send it.

A second agent independently simulated this exact body through the deserializer line
by line and got a clean exit in 16 token reads, and separately refuted four claims in
the first agent's report (a census undercount, a wrong .rdata address where
0x18021e7f4 is 'TFA' not the squad template, an incorrect stateParam2 typing argument,
and a dangerous aside about a second array-root envelope). The body survived all of it.

DEFAULT ON, a deliberate exception to "default to the live-proven value": the
live-proven value here HANGS THE GAME, and there is no working screen to protect
because Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old
routing.

392 + 51 checks green; /squad/0 and /squad/list verified unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 13:43:34 -07:00
funman300 224874d1d3 fifa17-recon: close both standing items in the priority doc
User-Agent filter: implemented as the default in futlog.py.

The two wrong .rdata addresses: verified they never reached any document. They existed
only in an agent recon report, so there was nothing to correct. Kept the reviewer's
corrected values because they are verified and useful:
  RS4:FutGetClubInfoServerResponse         0x180221a38  (not 0x180220e38)
  RS4:FutStickerBookSearchServerResponse   0x180221e48  (not 0x180221248)

Closing an item by checking it was never a problem counts as closing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:35:23 -07:00
funman300 38b10e5ec5 fifa17-recon: S18d -- a testable hypothesis for the Seasons blocker
/leaderboards/options is the ONLY mode-related endpoint the real client has ever
requested, and we answer {} because FUT_MODES is off. Three of its seven occurrences
are followed within ~2 minutes by /user/accountinfo and a fresh /ut/auth, which is the
signature of hitting an error, returning to the main menu, and re-entering FUT.

Hypothesis: the client fetches mode options on entering the play area, caches them, and
later refuses Seasons from that cached EMPTY body without issuing another request. That
would explain the zero-requests-at-failure observation, which no response-shape theory
has been able to account for: the deciding fetch happened minutes earlier.

Stated as a hypothesis, not a finding. The correlation is real; the causation is not
established. Cheap to test: leaderboard_route already implements an options body behind
FUT_MODES=1.

Risk noted in advance: FUT_MODES=1 also enables /season, whose array-root shape is a
flagged freeze candidate. That risk cannot fire while the client never asks. If this
hypothesis is right, a populated options body is precisely what would make it ask for
the first time, so succeeding at step one arms step two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:33:56 -07:00
funman300 b7943aead3 fifa17-recon: contract test guarding the move-verdict shape (392 checks)
The bug we just closed was invisible to every existing check: PUT /item answered 200
with a well-formed JSON body, and the body told the client the move had FAILED. Seven
attempts, weeks of investigation, and nothing in the suite would have noticed a
regression back to {}.

test_move_verdict_shape asserts the record vector exists, has ONE RECORD PER REQUESTED
ITEM (an empty vector fails the client exactly as hard as a wrong field), and that
id/pile/success carry the number/string/bool types their getters require.

Non-mutating: it asks to move ids that cannot exist, so nothing changes pile. The
verdicts come back success=false, which is honest and is not what is asserted.

Verified it actually catches the regression rather than just passing:
  default (ack)        392 checks passed, 0 failed
  FUT_MOVE_BODY=empty  382 passed, 1 FAILED -- "returns itemData array"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:32:04 -07:00
funman300 dd8dddd7af fifa17-recon: log archaeology -- /club is only ever the SEARCH form
REBUILD_RESEARCH S18, from running futlog.py over the full 3044-request history.

The client has requested /club six times and EVERY ONE carried a query string
(year/type/count/position/level/nation/league/team/sort). The bare path has never
been requested. ENDPOINT_MAP says GET ut/%s/club is FutGetClubInfo, whose only
recognised member is user(0x36c), and concludes our itemData body is skipped and the
club list must therefore be empty. The club list is NOT empty; every card renders. So
either the query form dispatches elsewhere or the row is wrong. TODO/CONFIRM.

Relevant to the MY CLUB counter: we ignore the query string completely and return all
109 items to a request asking for count=11 with position and sort filters, and our
response carries no result total. A paged search response is exactly where a tab
counter would read its number from.

Also recorded: the unmapped view is a standing detector for suffix endpoints the URL
template table cannot show. It has caught four so far.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:30:33 -07:00
funman300 e5bfe58fc8 fifa17-recon: futlog.py -- client-filtered log reader, and a correction it caught
Implements the standing requirement recorded in priority-2026-08 S5: the User-Agent
filter is now the DEFAULT in the log tooling, not an option. The real client sends
ProtoHttp; our own probes send curl/* or Python-urllib/*. Reading the log unfiltered
gave this project a materially wrong picture of itself (/clubUser and /user/list had 93
and 180 hits, none from the game).

The old futlog.py was a one-off with a hardcoded path and no notion of who made the
request. Replaced with a real tool: timeline or summary, body/response display, path
regex, time window, status filter, and an --unmapped view that lists the endpoints the
client wants and we catch-all. --all and --probes exist for when you deliberately want
our own traffic.

Over the full 3044-request history: 486 requests came from the game.

IT IMMEDIATELY CAUGHT ME OVERSTATING SOMETHING. Yesterday's commit called the PUT /item
request shape "captured for the first time (the client had never successfully reached
this path)". False. There are NINE client PUT /item requests in the log, eight of them
during the failed attempts, every one carrying swap and tradeId:

  08:45:11 08:51:13 08:55:21 08:58:19 09:10:52 09:15:26 09:22:31 09:37:12 | 11:15:48

The request was on the wire and in the log the whole time. What was new was reading it.
Same class of error as the truncated decompile in S16: evidence already collected and
not looked at. REBUILD_RESEARCH S17 corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:28:42 -07:00
funman300 5b139864ee fifa17-recon: SOLVED "Send to Club" -- it was the response body all along
Live 2026-08-04 with FUT_MOVE_BODY=ack and FUT_PACK_AUTOCLUB=0. Bought a bronze pack,
opened it, chose Send to Club. The session SURVIVED, the five cards persisted into the
club pile, and there was no ut/delete/auth logout -- the logout that accompanied all
seven previous attempts.

  11:15:48 PUT /item
    req {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ... x5]}
    res {"itemData":[{"id":100000125,"pile":"club","success":true}, ... x5]}
  11:15:49 GET /user/credits      session alive
  11:15:51 GET /hub               no error dialog
  11:16:06 GET /club?year=2017... MY CLUB opened

PUT ut/%s/item never was an ack endpoint. It builds per-item VERDICT records, and the
completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the vector is empty or
success != 1. Every body this project ever returned, {} included, told the client the
move had FAILED, and the client ended the FUT session because that is what that event
does. We were failing our own move.

Defaults flipped: FUT_MOVE_BODY empty -> ack, FUT_PACK_AUTOCLUB 1 -> 0. The autoclub
workaround is retired.

New intel, captured for the first time because the client had never got this far: the
request carries `swap` and `tradeId` beside id/pile. We ignore both and the move
succeeded, so neither is load-bearing for a pending-to-club move.

PROCESS, and this is the part worth keeping. The FIRST attempt at this test produced
no PUT /item at all: FUT_PACK_AUTOCLUB=1 had already emptied the pending pile at
purchase time, so the reveal screen had nothing to assign and the client never issued
the request. The workaround for the bug was hiding the bug. Before testing a fix,
check the configuration still lets the client make the call the fix is for.

Two self-inflicted incidents, both recorded in REBUILD_RESEARCH S17:
- Restarting the server to inject a flag WHILE FIFA was running produced the exact
  "error connecting to FIFA 17 Ultimate Team" dialog this project spent weeks chasing,
  from a plain connection refusal during the ~30s window. Restart only at the main
  menu, and check the log for ProtoHttp requests before blaming a response.
- pgrep -f matched the invoking shell twice, killing it before the restart, because
  the same command contained the literal script name in a later clause.

Docs updated: REBUILD_RESEARCH S17 (the solve), priority-2026-08 S2/S3.1/S6 (next task
is now the MY CLUB counter), PROJECT_REPORT 6a, HANDOFF 5a plus the stale "FutMoveCard
has no skip handler" claim in S3 and a new 5d for Seasons/Draft.

380 + 51 checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:20:03 -07:00
funman300 18d864908e fifa17-recon: priority doc for August 2026
The deliverable owed from the assessment brief. Ordered by cost of the measurement
that would settle each item, not by how important the outcome feels.

Headline reordering: the observation session ran and did NOT produce the match
shape. Seasons refuses with zero requests to any layer and Draft hangs, so both
routes into a match are blocked and /match is now behind a fix rather than in front
of one. The next task is instead one launch with FUT_MOVE_BODY=ack, which is the
only open problem where the decompiler has produced a verified necessary condition
that has never been satisfied.

Also recorded:
- The User-Agent split. Real client (ProtoHttp) vs our own probes. /clubUser and
  /user/list have 93 and 180 recorded hits and not one came from the game. Every
  "the client asks for X" claim predating the split is unsupported until rechecked.
- The narrowed-core measurement. The proposed boundary ("ownership and economy keyed
  by opaque integer item ids") is correct and every per-item prediction held, but
  narrowing is not what guts core: FIFA 17 relevance is. Six services totalling 869
  lines model features with no FIFA 17 endpoint at all, survive narrowing perfectly,
  and are worth nothing here. Suite: 61 of 101, not the 81 previously claimed.
  Recommendation is to reuse the scaffolding and the 61 tests, not the service layer.
- Port timing stays "after", led by the match-result-shape argument: three of the
  four queued items will change a response schema, and porting a placeholder schema
  means porting the correction too.
- test_fut_contract.py is now implementation-independent (380 checks over HTTP), so
  it can certify a Rust port. That is the port's main de-risking asset and it exists.
- A "What could make this plan wrong" section, including that a negative ack result
  is not a refutation, and that every negative claim in ENDPOINT_MAP.md is weaker
  than the corresponding positive one after the FutMoveCard retraction.

Standing requirements recorded, including the User-Agent filter default (required,
not yet implemented) and two wrong .rdata addresses still to correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:09:14 -07:00
funman300 f488793b34 fifa17-recon: RETRACT the FutMoveCard "no skip handler" claim; stage the ack shape
RETRACTION. This repo claimed, in REBUILD_RESEARCH S14c and in utas_server's
item_route comment, that FutMoveCard 0x180128600 "HAS NO SKIP HANDLER
(FUN_180135ff0 appears zero times, unique among FUT deserializers)" and "parses
only itemData -> dreamSquads". Every part of that is false. Full decompile:

  FUN_180135ff0 call sites : 2   (offsets 5006, 6080)
  atoms parsed             : 7   active dreamSquads id itemData pile reason success

Cause: the decompile was written out as src[:4000] and then searched. The function
is 6193 chars, so BOTH skip-handler call sites and four of the seven atoms lay past
the cut. An absence was reported from a truncated listing -- the same failure mode
as the Memory.getBytes bytearray scan that silently returned zero hits. Never
conclude an absence without asserting the searched region covers the function.

Cost: the false claim implied "any extra key desyncs this parser", which sent the
investigation after client-side state for seven attempts, and the derived premise
"the deciding factor is client-side state, not the wire" was wrong too.

VERIFIED SHAPE. PUT ut/%s/item is not an ack endpoint; it returns per-item VERDICT
records:
  id(0x15c)      INT     0x1801c79d0  -> record+0x00
  pile(0x226)    STRING  0x1801c7aa0  -> enum 0x180142650 (club=7 purchased=6 trade=5)
  success(0x2fa) BOOL    0x1801c7620  -> record+0x0c
  reason(0x279)  STRING               -> "Destination Full" = 0xf
  dreamSquads(0xe9) INT array;  else  -> FUN_180135ff0 (skip)

The completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector
is EMPTY or record+0x0c != 1, and success is initialised to '\0' per element. So
every body ever returned reported the move as FAILED, {} included. Quick sell
survives an identical {} because its callbacks read only the transport code and
ignore the body -- that is the whole asymmetry, and it was on the wire after all.

STAGED, NOT DEFAULTED. FUT_MOVE_BODY=ack emits the correct shape; the default stays
`empty` because sufficiency is untested. One launch settles it.

The ack is answered BEFORE the `if moved:` gate: under FUT_PACK_AUTOCLUB=1 the
cards are already in the club when the reveal asks to move them, so move_items()
returns nothing and a moved-derived body would be zero-record -- failing in exactly
the configuration ack exists to fix. Caught in review before it ran. success is
asserted only for ids that were moved now or are already in the club; anything else
gets an honest success:false rather than an invented verdict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:05:38 -07:00
funman300 a93a8bcdd7 fifa17-recon: decouple contract suite from the Python implementation
test_fut_contract.py no longer imports ACCOUNT from fut_account. The expected
persona comes from FUT_TEST_PERSONA_ID and the target from FUT_TEST_BASE, so the
suite now imports nothing but stdlib and talks to a server at a URL.

That is what lets these 380 checks certify ANY implementation of the reversed
spec, a future Rust openfut-core included, without replaying the reverse
engineering. The original reason for reading ACCOUNT still holds and is preserved
in the comment: suite and server must not each hold a private copy of the
constant, or the identity-consistency checks would only prove two copies matched.

Also adds pileSizeClientData(0x227) behind FUT_PILESIZES (default off). A probe
run with 16 uniquely-valued entries did NOT move the MY CLUB counter, so that
member is eliminated as its source; the code is kept for the record and flagged
off.

Docs: OPENFUT_PROJECT_REPORT.md and OPENFUT_HANDOFF.md. The report now separates
"built but untested" from "never requested by the client" -- the server log records
User-Agent, and splitting real client traffic (ProtoHttp) from this project's own
probes shows /season, /tournament, /champion, /match, /clubUser and /user/list are
at ZERO client requests. /clubUser (0 client, 93 probe) and /user/list (0 client,
180 probe) are the starkest: work was done on both assuming the client wanted them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 10:31:37 -07:00
funman300 5d5198f5d1 fifa17-recon: match rewards, POW online layer, account backend, quick sell
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.

WORKING END TO END (live-verified this session):
  * match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
    (0x180121b60). Play a match, get coins, W/D/L updates.
  * packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
  * quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
    were destroyed for 0 coins. Now credits discardValue.
  * POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
    a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
    FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
    ROSTERUPDATE_URL. FUT_POW=1.
  * account backend -- fut_account.py replaces 7 hardcoded copies of the persona
    across 5 files; club/persona/online-profile editable via CLI.

CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
  * FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
    purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
    take externalPriceId(0x11a), not amount/currency.
  * FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
    unique among FUT deserializers) and parses only itemData -> dreamSquads.
  * class -> deserializer resolution: the name literal is preceded by a 4-BYTE
    HEADER and the factory LEA points at the header, so look up name_addr - 4.
    Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
    Draft schemas.
  * live-only endpoints the request table never lists: ut/%s/squad/list,
    ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
    table is a floor, not a ceiling -- the log is the only ground truth.
  * 163 RS4 call names exist; we served 17. All now served.

FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).

UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).

Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).

Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 09:42:59 -07:00
funman300 59934b4ef0 fifa17-recon: FUT squad blocker solved + userInfo delivered
The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.

Squad blocker (the long-standing "client never sends PUT /squad"):
  AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
  (pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
  only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
  populated ACTIVE squad model, which arrives via the massinfo `squad` member.
  No response of ours was ever being rejected.

userMassInfo is NOT required to be {}:
  0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
  "wrapper key is user" note was wrong, and the historical freeze was the
  malformed squad member, not the envelope.

clubNameChangeAllowed must be false:
  sending true advertises a club-rename flow whose UI model is never populated;
  the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
  reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
  in flight). Isolated by a single-variable run; guarded by a contract check.

Endpoint/schema corrections found in live traffic, invisible to static analysis:
  * GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
    not the active-squad object (the /list suffix is appended by the caller, so
    it never appeared in the request table)
  * PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
  * userInfo currencies are read as name/funds/finalFunds/active -- there is no
    "value" key, so coins always rendered 0
  * squad-list elements take STRING formation/squadType, not ints
  * the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
    is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)

FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-03 20:47:16 -07:00