Two things this project kept carrying as INFERRED are directly observable in the
card record, so this reads them instead of trusting the decompile:
rec+0x18 resourceId, rec+0x4c cardtype (derived by FUN_1800d8330),
rec+0x50 cardsubtypeid (as sent), rec+0x5c itemState (decoded enum value).
Measured against the live client (pid 6580, 27 records):
subtype 0 -> cardtype 1 (23 records) agrees with FUN_1800d8330
subtype 4 -> cardtype 2 (1) agrees
subtype 6 -> cardtype 10 (1) agrees
subtype 8 -> cardtype 4 (2) agrees
itemState runtime value 1 on all 27, and every one of those was served as "free"
So the cardtype map is now runtime-confirmed for every subtype we actually serve,
and `free == 1` is an empirical anchor for the itemState enum rather than a
reading of the table at 0x180229cc0. The probe prints the Ghidra prediction
beside each measurement and says DISAGREES rather than quietly matching, so it
stays useful as new families are served.
It also states the obvious limit in its own output: a runtime value only appears
if the client was actually served an item in that state, so absence is not
evidence of absence. The equipped states (activeBadge 100, activeHomeKit 101,
activeAwayKit 102, activeBall 103, activeStadium 104) remain table-recovered and
un-measured until a kit is fetched by the client.
Read-only: /proc/PID/mem is opened 'rb' and there is no write path.
`card_identity_probe` reads the PLAYER slots (F_NATION 0x148, F_LEAGUE 0x154). A
manager does not use those, so grading a manager with it reports nation=0 /
leagueId=0 and reads like a server bug when it is only the wrong offsets.
The manager layout, from the Ghidra reversal already recorded in fut_staff.py, is
teamid rec+0x94, nation rec+0xde, leagueId rec+0xe0, talkrating rec+0xe2,
negotiation rec+0xe3. The managercards merge (FUN_1801356c0) NEVER writes +0xde
or +0xe0, which is exactly what makes them a clean test: whatever sits there came
from our JSON and nowhere else.
Read against the live client (pid 6580, 27 records in the CardsDb map):
resource teamid nation league talkrating negot verdict
1000509 241 45 53 0 3 SERVER FIELDS LANDED
So the manager's chemistry fields DO reach the record, and `negotiation=3` agrees
with managercards row 1000509, i.e. the merge ran as well. That moves manager
nation/league from INFERRED to PROVEN without a screenshot.
Read-only: /proc/PID/mem is opened 'rb' and there is no write path.
FIFA refuses to kick off with "your squad must have at least 11 players and 7
subs … currently below the minimum number of players (18)". The imported club's
squad carries ONLY its starting XI, so a freshly installed real club is
unplayable until someone fills the bench by hand in the hub.
`fill_bench_to_minimum` tops the squad up with the club's best spare players,
writing EMPTY bench slots from index 11 upward. The 23 slots are 0..10 pitch and
11..22 bench/reserves, derived from the index alone, so the starting XI — and
any bench the operator has already chosen — is never touched and a re-run is a
no-op. Only real PLAYER definitions are eligible: a kit, a manager or a
consumable in a squad slot is nonsense the client would drop anyway. Selection
is best-rating-first with a stable id tiebreak, so the same bench comes back on
a re-run rather than shuffling.
Scoped to the REAL club on purpose. The 14-item fixture is a market test bed
with a single spare player; demanding 18 there would abort a bring-up that never
needed to kick off. Fixture mode therefore does not call this at all.
Verified against a copy of the club snapshot (the live staging database was left
alone, since it currently holds a squad the operator saved by hand): 11 -> 18
players, slots 0..17, no duplicate instance in two slots, every filled pick a
real player definition, and a second run filling nothing.
This is NOT a diagnosis of the failure the operator just hit — that squad had
already been filled to 23 valid players before the match was created, and the
host log shows the client issued no request at all after `match-create` beyond
account-sync, so that refusal is decided entirely client-side. It removes the
variable: after a clean bring-up the club is now playable without hand-editing.
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:
utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502
Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.
The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.
`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.
MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.
ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.
THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.
One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.
Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.
Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.
`squad.manager[]` was emitted as a bare `[{id, dream}]`. That looked
retail-faithful, and the previous commit defended it on the grounds that no
capture had ever shown otherwise. Re-reading the captures with a populated
manager in hand shows why that was the wrong conclusion: every retail capture
carrying the bare form has `id: 0` — an EMPTY manager. None of them ever
demonstrated that a POPULATED ref renders without its item, because none of them
had one. `plan-2026-08-05-families.md` says as much outright: "FUN_18013d1f0 was
never read for a staff member".
The squad object is self-contained everywhere else: `players[].itemData` carries
the whole card rather than an id resolved out of band. The manager is the same
kind of slot in the same object, and the one implementation that ever drove a
working manager — the Python oracle's squad — emits `id` BESIDE `itemData`.
The element shapes differ and both are now pinned by tests: a player slot is
`{index, itemData, kitNumber}`, the manager is `{id, itemData, dream}`.
So the manager is projected through `resolve_staff` and its item embedded with
`shape_staff_item`, the same 11-key record `/club` serves. An assignment with no
resolvable staff identity still yields `[]` rather than a fabricated ref.
`STAFF_CONTRACT` moves next to `shape_staff_item` in `fut::item` (re-exported
from `club_response`) so `/club` and the squad cannot disagree about the
contract the client checks before kickoff.
Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.
The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.
Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:
* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
client's merge never writes, so the server is their only source — they are the
flag, the league badge and both halves of manager chemistry. Coaches get
neither, because the four coach tables have no nation/league/team column and
emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
mask (players are the only masked family), so `version` must stay 0 or the
lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
omitted because the merge overwrites them from the client's own tables. A
staff card is therefore never routed through `shape_item`.
Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.
`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.
TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:
1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
indistinguishable from "no manager". Now reads `id`, and a present-but-
unreadable manager is an error rather than a silent absence. The projection
also now warns when an assignment cannot be resolved to an owned instance,
which is the documented "Core drops an owned card with no CardDefinition from
/collection without erroring" trap.
2. `Route::WatchList` was produced by NO classifier arm, so its handler was
unreachable and every `watchList` request fell through to Passthrough — the
same defect class as `season/list`. Against a stack whose Python upstream is
deliberately dead this 502'd. This was failing
`sbc_survives_complete_core_and_host_restart` at HEAD before this change.
The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.
Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
The operator could not field a starting XI because staging has only ever held the
14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The
real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it
sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts
list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays.
Worth recording while looking for the club: the LIVE production Core container
serves an EMPTY database (0 owned cards, schema predating even the game_id column).
The real club is not being served anywhere right now; it exists as state on disk.
So restoring it into staging is not a convenience, it is the only way to play it.
Two pieces:
`scripts/club-snapshot.py` is the ONE place allowed to read production state, and
it is read-only by construction: the Core database is opened `mode=ro` and copied
with sqlite's online backup API (a plain file copy can tear a database with a hot
WAL), every destination is asserted to be outside the production directory before
anything is opened for writing, and the sha256 of every source is compared before
and after -- a mismatch aborts, because that would mean the snapshot modified
production. It then proves the copy is faithful (same counts, coins, squad) and
that the identity store maps EVERY owned card to a wire id, since an unmapped card
would reappear under a freshly minted id and break the client's cached squad.
`sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE
Core first starts, so Core migrates the copy forward from schema v19 through
match_completions, squad managers and kit assignments. Seller A is then already
present -- it IS the imported persona -- so only Buyer B is seeded, the kit
fixtures are attached to the real club so the kit work stays exercisable, and the
real squad is left alone. The up script still never reads production state: the
snapshot lives outside it, which is precisely what makes `--club real` compatible
with the `safe_path()` refusal.
The resolvability preflight now covers whichever club will actually be served. This
is the check that matters most for the real one: Core does not fail on an owned card
whose definition is missing, it silently filter_map-drops it, so a gap shows up as
an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct
card ids resolve in both the content pack and the identity catalog, 0 missing.
`sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club
is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have
overwritten the operator's own lineup with an auto-picked XI -- destructive and not
recoverable in place. `--show` still works in every mode; `--force` overrides.
Verified end to end against the restored club: /club 29,843,976 coins, /collection
1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated
90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from
the snapshot identity store. The fixture path was re-run afterwards and still seeds
exactly 14 items, so the SOLD experiment is unaffected.
Single-player Seasons failed with "There was a problem communicating with the
FIFA Ultimate Team Servers". Two independent faults, both fixed:
1. The client never reached a season endpoint at all. It aborts on a
prerequisite web file, captured live by the deployed trace:
SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
That is the hook's side (launcher 5294f58): the CDN base is empty in the
emulator, so the url stays relative and never reaches the POW content server
on 8085 that actually serves it.
2. `season/list` and `season/user` were not served. Only the EXACT tail
"season" was classified (as FeatureOffEmpty); every sub-path fell through to
Passthrough — the deliberately-dead Python upstream — so the mode could not
have worked even once the web file resolved.
Adds `fut::season_wire` with the reversed element schema (parser FUN_180167740,
stride 0x318; matches elements via FUN_180167fb0) and a `Route::Season` owning
`season…` for GET plus the state-storing PUT. Anything else still proxies rather
than being claimed without evidence.
Two things the types encode because getting them wrong is fatal:
* `matches` is NEVER empty. StartSeason (FUN_1800fc500) indexes
`matches[*(x+0x70)].teamId` off `elem+0x2e8`; an empty vector makes that a
NULL dereference and the client dies at CardsDLL+0xfc5b5. A full ten-round
schedule is emitted, with opponents drawn from team ids observed in this
client's own database.
* `type` MUST serialise before `divisionId`. `serde_json::Value` is a BTreeMap
here (no preserve_order), so `json!` sorts keys ALPHABETICALLY and emitted
divisionId first — caught by a test written for exactly this. The wire shapes
are therefore `#[derive(Serialize)]` structs (declaration order) rendered
straight to text via a new `json_text_status`, never round-tripped through
Value.
Verified on staging: season/list returns the ten-round OFFLINE season with
type before divisionId, season/user the round-1 position, history an empty
list, and the bare tail still {}.
Season progress is not yet persisted: the state-storing PUT is acknowledged
with {} (what the retail wire answers) rather than pretending a round advanced.
Core bae0a2b: `POST /matches/result` fails closed (it was a second economy
authority with no transaction and no idempotency key); loan expiry and Core
season progression move into `complete_match`'s transaction behind opt-in flags
that default OFF, so the FIFA 17 retail path is unchanged; notifications emit
post-commit and only when applied; `/auth/reset` now clears `match_completions`,
which previously made any profile that completed a match unresettable.
Bridge 07e83fe: the two EA result routes and the dashboard submit to
`/matches/complete` with a per-submission `match_identity`. Pushed to
`fix/matches-complete-migration` — the bridge pin is deliberately behind its
origin/main, so this does not touch that branch.
Every real squad save was failing with 400 unresolved_wire_ids. Reproduced on
staging with the repo's own seeder, which sends the captured retail body:
route=squad-replace status=400 outcome=unresolved_wire_ids detail=[[100000427]]
FIFA 17 always sends a manager ref, and on a real profile it does not resolve to
an owned instance. scripts/sold-staging-seed-squad.py already recorded why:
production's own squad points at instance 100000427, which is absent from
production's /club/staff (1975 items spanning 100000001..100004826), and the
client accepts that squad back unchanged -- so the client never validates the
manager against the club, and the pre-0023 server accepted it.
Making the manager ownership-backed (d37a9d5 / 25f4ad1) turned that ref into a
hard refusal, which took out the primary FUT write path: no squad save means no
squad, which means the client will not enter the FUT hub at all.
A manager ref is not a squad slot. An unresolvable PLAYER slot must still refuse
the save -- committing it would silently drop an owned card from the club. An
unresolvable MANAGER ref just means there is no ownership-backed manager, which
is exactly the state before migration 0023: the save commits, the assignment is
cleared as a full replacement should, and the id is reported on
ProposedSquad::unresolved_manager_wire_id so the host can log what it could not
map instead of letting it vanish. A ref that DOES resolve is still assigned and
still authorized against the club.
Verified end to end on staging: the seeder now answers {"id": 0} with 11
occupied slots, the host logs
`manager_ref_unresolved=100000427 (saved with no manager assignment)`, and the
projected squad carries `manager: []`.
Staging served `kits=2 kitsHome=0 kitsAway=0`: the split compared the catalog
`asset_id` against `fcc_kitcards.assetid` (14/15), but a kit's catalog
`asset_id` IS its carddbid (6300006), not that column, so neither family ever
matched.
The carddbid range is the same fact in the form we actually carry: across all
1482 kit rows, assetid 14 covers precisely the 828 `63xxxxx` ids and assetid 15
precisely the 654 `64xxxxx` ids, with no exceptions either way. Keying on the id
we already have avoids carrying `assetid` as a second source of truth for the
same split. Tests now use the real team-21 pair (6300006 home / 6400003 away).
Verified against the staging stack: `kits=2 kitsHome=1 kitsAway=1`, team-21
bucket 2, `?type=kit` still activeHomeKit/activeAwayKit, `?type=player` 12.
Four defects found by running the suites and the staging lifecycle end to end
after the kit milestone.
1. club-stats kits were half-implemented. The global `kits` counter was real
but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded
0, so the same screen reported two owned kits and zero home/away kits.
`kits` is a total with a family split, exactly like players/playersGold and
staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home
family and 15 the away family, verified across all 1482 rows of the kit
table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15
exactly the 64xxxxx ones, 654 rows; no exceptions either way).
ClubStatInput now carries `asset_id`, and a kit buckets onto the team that
wears it -- including a team the club owns no player from, the normal case
for a kit won from a pack. The host reads both from the catalog through new
NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a
read-only stats query must never do as a side effect.
2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1 /
d37a9d5); 56bd9dd updated the squad_projection integration test and stopped
there. `put_body` hardcoded the captured manager ref 100000427 into EVERY
save, including tests with no manager fixture, so each one was refused with
`unresolved_wire_ids` -- the tests were reporting a real invariant against a
fixture that could not satisfy it. The manager is now an explicit
`Option<i64>` per test, and FakeCore models Core's manager persistence
instead of inheriting the "not implemented" default that 502'd every save.
Added the coverage whose absence let this rot: a manager assignment
round-trips as a Core owned id, a later save without one CLEARS it, and an
unowned manager ref refuses the whole save with nothing committed.
3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit`
forwarding to Core, which the kit commit deliberately replaced with
host-side pagination. It only ever passed because FakeCore ignored the
window -- against a real Core, `start=10` over a one-item club was always an
empty page. Retargeted to the real contract (Core gets semantic filters and
NO window) plus a new test that the window is applied locally after
filtering, which the old fake made vacuous.
4. The staging lifecycle scripts identified production by hardcoded pids, so a
correct teardown FATAL'd: production moved into containers and pids
3631953/3374264 died with a container restart days ago. A pinned pid rots
into the worst of both worlds -- a kill-refusal gate that no longer names
any real production process, and a liveness gate that fails a healthy
teardown. New shared `scripts/openfut_production.py` resolves production
pids AND published ports from the container runtime at the moment they are
needed, refuses to signal anything it cannot see, and proves production is
the same processes serving the same ports before and after. Both lifecycle
scripts use it, which also closed a real gap: port 8085 is published by
openfut-fut-backend but was missing from the up script's forbidden list, so
staging could have bound a production port.
Also fixes the economy differential, red because `complete_match` unlocks
achievements in the same transaction that pays the match reward -- a deliberate
Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted
that progression did not exist; it now asserts the delta is the 400 match reward
plus exactly the achievements the match unlocked, read from Core's own report.
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.
adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
(id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
the 101/102 integers are the client's post-deserialisation runtime enum
(item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.
host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
defaulting to no active kits so a Core without the endpoint degrades
instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
empty page and outcome=unsupported_type, and now always fetches
unpaginated from Core: kind and transfer-pile membership are host-side
concepts Core cannot express, so filtering and pagination must both
happen after shaping or pages come back short.
importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
kit counts/balances, and card_asset_id/team_id carried into the emitted
catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
(missing_kit_render_metadata) rather than defaulted; conflicting render
metadata across instances defers as render_metadata_conflict.
staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
Points at launcher 00ad631: retire FIFA 23 as a build target/template while
keeping the hook game-generic by per-game feature, plus the earlier config-driven
FIFA17 socket redirect (16f3452). Build invariant --features fifa17 unchanged.
Add read-only preflight verifier and Windows-client documentation for the
reimaged native-Windows FIFA17 client host (10.10.0.105). No launcher script:
the native model is _fifa17.exe run as admin (RUNASADMIN + shortcut). Covers
routing (openfut.cfg -> 10.10.0.120), rollback (version.dll swap), and the
x64dbg RVA<->VA (ASLR) attach workflow (ImageBase 0x180000000 CardsDLL/powdll).
The manager moved from an opaque extension field to an ownership-backed
canonical assignment: SquadProjectionInput.manager (owned item) replaces
Fifa17SquadExtensionV1.manager.
- project_put stand-in passes manager: None; baseline asserts the manager
projects empty when none is owned.
- persisted_read registers the manager's owned instance + identity and
passes it as the assignment, asserting the resolved [{id,dream}] ref
round-trips to the read oracle's manager.
Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason
enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end
payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the
reward-response projection (only reversed fields; never bidTokens/
qualifiedChampionEventId). Match logic removed from economy_policy.rs
(kept pack/fee); registered match_wire in fut/mod.rs.
Host: handle_match_end now applies the match to Core's authoritative
complete_match (POST /matches/complete) fail-closed — any Core error is a
503, never a Python fallback — and renders Core's authoritative coins.
Per-match identity from matchReportId or a body fingerprint keys Core's
durable idempotency. New CoreEconomy::complete_match transport +
CoreMatchCompletion/CoreMatchReceipt.
Tests: adapter endReason/parse/projection; host shaping, fail-closed,
malformed, identity dedupe; integration replay + rebased balance chains
(match now also grants XP/level-up/achievement coins).
Thread the manager assignment between the FIFA squad path and Core:
- CoreAccess gains get_squad_manager/set_squad_manager (GET/PUT
/club/manager); HttpCoreClient implements both.
- project_active_squad fetches the assigned manager owned item and passes
it to the projector (non-fatal on error/absence).
- handle_put_squad authorizes the resolved manager against the active
club (like a slot) and persists it via set_squad_manager after the
atomic squad replace; fails loudly, never silently drops it.
Move the squad manager from an opaque, unvalidated wire ref in the squad
extension to a resolved, ownership-backed assignment (Core migration 0023
squad_managers).
- squad::to_proposed reverse-resolves the manager wire ref to a Core
owned_card_id on ProposedSquad; an unresolvable manager is reported as
an unresolved wire id (a live save is refused rather than assigning a
manager the club does not own).
- squad_ext: drop the opaque manager field from Fifa17SquadExtensionV1
(clean cutover) and the now-unused SquadEntityRef->WireItemRef From.
- squad_projection: project the manager as the STATIC_REVERSED [{id,dream}]
wire ref resolved from the owned assignment; absent -> [] (never faked).
Richer manager itemData (contract/league/nation) is INFERRED-only and
left out pending wire reversal.
- import(apply): drop a historical dangling manager ref rather than
failing the whole import (live PUTs still refuse an unresolved manager).
AGENTS.md is the new canonical AI-agent entry point (FIFA17 active target,
repo map, FIFA23->FIFA17 pivot history). README/CLAUDE/setup.sh get stale
banners pointing to it. Retained pre-existing WIP brought forward.
Reads openfut-core's authoritative API directly (balance, entitlements, profile,
club, collection, squad/ext) with the fifa17 game header and emits a
machine-readable JSON snapshot plus integrity checks: coin cross-check, duplicate
owned_card_id, squad-references-non-owned, owned-set + card-multiset hashes for
drift/resurrection detection across operations and restarts. Read-only oracle
tooling; used to verify Phases 1-9 of the Core-backed FUT loop audit.
openfut-hook builds two mutually exclusive injection paths from one crate. The
default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin_spy transport
hooks; `--features fifa17` installs only the FIFA-17-safe logic (module map,
FIFA 17 cert-verify, SBC dispatch, store tab bind).
Deploying a default-feature build into FIFA 17 hijacks the login transport: the
client reports "Unable to connect to the EA servers at this time" and none of the
FIFA 17 repairs are present in the binary at all.
That happened today: artifact 1c71a17a was built by hand without the feature and
deployed, costing two failed launches. It was diagnosed only by comparing embedded
strings between the deployed DLL and the last known-good one (the deployed DLL had
0 occurrences of CardsDLL_Win64_retail.dll and SBC_DISPATCH, and 6 of cert-verify
plus 1 of "connect: inline-hooked" -- the inverse of a fifa17 build).
`build` already passes --features fifa17, but OPENFUT_FIFA17_HOOK_DLL lets a
hand-built DLL reach stage/deploy, so verify_fifa17_profile asserts the profile on
the bytes: CardsDLL_Win64_retail.dll and SBC_DISPATCH must be present, and the
FIFA-23-only markers must be absent. Wired into verify_inputs (stage/inspect) and
into deploy's staged-artifact checks.
Verified: the gate rejects 1c71a17a, accepts 3641d581 (last known good) and
f0ef528f (the corrected fifa17 build now deployed).
Records the store-entry category-clamp hook (launcher 9aecc65) as the
launcher tip on main. Only the launcher gitlink is bumped; core stays
271c363 and bridge stays 0f581eb (both as already recorded), and the
in-tree formatting/doc churn is left untouched.
Reversed from the pinned CardsDLL (4706a881): eligibilityKey and
eligibilityOperation are localization ordinals (LOC_SBC_ELG_KEY_%d),
not the atom hex ids. The client's only consumer is the requirement-
display string builder at ~0x1800ef900 (formats via indexed locale
keys, no comparison/gate). The ordinal->string map lives only in the
packed locale (absent from all assets we hold), so any emitted value
would render the WRONG requirement text. Submission stays fully
validated server-side by Core; the empty elgReq is display-only.
Correct ENDPOINT_MAP.md's implied atom-id==ordinal assumption and
pin the exact remaining blocker at the emit site.
Picks up openfut-launcher 94feaec, which makes the guarded native SBC dispatch
repair a build-armed promoted feature instead of an OPENFUT_SBC_DISPATCH env gate.
Any launch path (Steam, the launcher Launch button, bare umu-run) now gets the
repair, so the SBC screen no longer depends on a harness script exporting a
variable. Every runtime guard is unchanged; rollback is a version.dll file swap.
A reward pack advertised its own id (70-75) as assetId, which is not a client art
asset, so its My Packs tile rendered blank. Reward tiles now carry their tier
store pack as the cover asset (bronze->1, silver->3, gold->5) while `id` stays the
pack own id (the open packId / SERVER_ID); the sentinel keeps its own id so it
stays an inert placeholder.
LIVE-MAPPED on the retail client 2026-08-18 across all three store tabs and two
reward tiles, which corrected an earlier wrong assumption:
* assetId only gates whether art renders AT ALL (unknown id -> blank tile).
* WHICH art is drawn comes from packType + packContentInfo.rareQuantity, NOT
assetId: BRONZE+1rare -> bronze card, BRONZE+3 -> silver, SILVER+1 -> gold,
SILVER+3 -> silver trio, GOLD+1 -> blue special, GOLD+3 -> red inform.
Remapping assetId 5->3 and 3->2 left both frames unchanged and only rotated
the featured player, which proves art is content-driven.
So a reward tile now shows the same cover as the equivalent purchasable pack (a
gold reward shows the blue-special art the 5000-coin Gold Pack shows - EA art
advertises an aspirational card rather than the tier colour). Regression test
reward_tiles_carry_a_renderable_cover_asset added; pack70 golden regenerated.
Reward/My-Packs tiles were emitted with no currencies row (dropped to avoid an
"undefined" payment label). But FIFA 17 opens My Packs through the store PURCHASE
flow (My Packs is a client-side filter over the store catalogue; the open-vs-buy
fork is client-side and no response selects it), so a tile with no purchase path
is not actionable — clicking it navigates instead of opening, sending no request.
Fix: keep the coins currency at the pack price (0 for reward packs; no extPrice,
so no `or %1s` mtx bug). The client then treats the tile as free and clicking
sends POST /purchased, which the server opens for free (owned-only redeem, no
debit). The empty-My-Packs sentinel keeps no currency row so it stays
non-openable. pack70 golden regenerated.
LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards on the
retail client (host log: POST /purchased/items 200 -> GET /purchased/items).
SBC completion and the other Core reward services grant packs with symbolic
definition_ids ("silver_pack", "gold_pack", ...). The FIFA 17 pack system keys
entirely on numeric catalogue ids, and entitlement_pack_ids / handle_pack_open
resolved definition_ids with definition_id.parse::<u64>(), so every symbolic
reward pack was silently dropped from the openable My Packs list. The unopened
count (recoveredPacks, = entitlement count) still counted them, so the client
showed "you have N packs" but had no tile to open -> "no pack available".
Fix (adapter-layer, Core stays game-neutral): add owned-only reward pack
catalogue entries 71-75 (bronze/silver/gold/rare_gold/icon) and a resolver
owned_pack_id_for_definition() that maps both numeric owned ids and the symbolic
reward names to their numeric owned-only pack. entitlement_pack_ids and the
pack-open entitlement selection now use it, so reward packs render as openable
My Packs tiles and redeem their entitlement for free (consume-once, no debit).
Server-verified on staging: 3 Core reward entitlements now render 3 openable
mypacks tiles matching recoveredPacks=3. Tests: adapter resolver + rendering,
host symbolic-reward open flow; full adapter + host suites green.
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the
repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry
even when repeatable=true. So a repeatable challenge now always projects
timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0
(sets_body); a non-repeatable challenge keeps its true count and stays locked
once completed. Core keeps the authoritative completion record — economy is
unaffected; this is presentation only.
Live-proven on the retail client 2026-08-18: a completed repeatable Bronze
Upgrade now re-opens for a fresh submit instead of blocking. Regression test
repeatable_completed_challenge_stays_enterable added; adapter + full host suite
(incl. differential and the concurrency race) green.
The all-groups landing (shown on first store entry) renders one tile per group
whose background is the client-bundled packs_backgrounds_%d.dds, selected by
displayGroupAssetId. Live-probed on the retail client 2026-08-18: index 0 is
blank; indices 1/2/3 render real pack art. Assign non-zero per category
(bronze=1, silver=2, gold/mypacks=3) so that landing shows native pack art
instead of blank shields. The persistent tabbed store (MY PACKS/BRONZE/SILVER/
GOLD PACKS) draws pack art from the packs themselves and is unaffected.
LIVE-CONFIRMED end-to-end: native tabbed store renders the real 6-pack catalogue
with correct prices (Gold 5000 / Premium Gold 7500), counts (12 items, 10 gold,
1/3 rares), pack art, and no "or %1s" line.
- store_catalog: replace the invented catalogue with the real always-available
FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.
Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
Retail Gate C captured PUT /sbs/challenge/101/squad: 23-slot players[] of
{index,itemData:{id,dream}} plus manager/chemistry/rating/formation siblings.
parse_wire_item_ids already handles it (players[].itemData.id, non-zero only);
update the stale "captures unavailable" note and add a verbatim regression test.