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.
Runs the REAL roster_server.py under `sudo unshare -n` (so port 8081 is free and
production is never touched) and validates its certificate BY THE DIALED IP, proving the
before/after the SAN fix (fbc0da2) targets:
* OLD cert (DNS-only, production's current shape) -> a by-IP-verifying client is
rejected with "IP address mismatch, certificate is not valid for '127.0.0.1'" — the
certificate_unknown class the FIFA client hit.
* NEW cert (fixed generator, DNS + IP SANs) -> verifies through the actual roster
server and returns the roster XML (200, application/xml).
roster-cert-verify.py is the client probe (trusts the served self-signed cert as CA,
checks it against the dialed IP, then GETs /fifa17/fut/rosterupdate.xml).
roster-cert-iso-test.sh drives the real server with each cert and asserts new=pass,
old=fail. Two harness bugs were found and fixed while writing it (a shared /tmp log the
production run owns, and a subshell pid that left the first server alive so the "old"
probe hit a stale server presenting the new cert — the tell was "self-signed" instead
of "IP mismatch"), so the final before/after is clean.
Complements the in-process check: this exercises the production server code path, not a
hand-rolled server. Live production confirmation still needs the container rebuilt with
OPENFUT_ADVERTISE set (operator-gated).
The entire docs/ tree (24 top-level notes, 68 evidence captures, 2 plans, research) has
been migrated into ~/OpenFUT-Vault, the curated Obsidian vault, which is now the sole
home for project documentation. Merges preserved all detail (obsolete material kept
under "Superseded" sections); evidence/plans were copied byte-identical; every migrated
note records its Source: docs/<original>.md provenance. Vault commit f55a5ba.
Documentation lives in the vault from here on. Code/script comments that still reference
docs/ paths are stale pointers only (no build dependency); they can be repointed at the
vault opportunistically. References to fifa17-recon/docs/ are a different tree and are
unaffected.
Updates status from root-caused to fixed, and records that option 1 (IP SAN) was
taken across the three cert generators, with the verification and the operator-gated
production rebuild that remains.
The FUT hub failed to load with "An error occurred downloading the FUT Squad
Update" because the client dials the roster (https://<advertise>:8081) and the
redirector BY IP, while the served certificate carried DNS SANs only
(winter15.gosredirector.ea.com + wildcards). The client aborts that handshake with
fatal certificate_unknown. Root cause and evidence in
docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md (commit 082246c): a wire capture shows the client
offering TLS1.2 with RSA suites, the server selecting them, then rejecting the cert —
and autopatch demonstrably patched both ProtoSSL gates in that process, so this
validation path is NOT one of the two the client-side patch covers. The SAN is the fix.
Three generators produced the cert and none put the advertised IP in the SAN:
* docker entrypoint.sh — the production path. The advertised IP is a RUNTIME value
(OPENFUT_ADVERTISE), unknown at image-build time, so the cert is now reconciled at
startup: reissued with IP:$ADV,IP:127.0.0.1 in the SAN only when the current cert
lacks it. That makes a restart reuse the same cert (no per-start fingerprint churn,
which would otherwise recreate the Aug-13 surprise) and self-heal if $ADV changes.
* Dockerfile — installs openssl unconditionally so the entrypoint can reissue at
runtime (previously it was dropped with the apt lists), and bakes a loopback-IP
baseline cert so a plain `docker build` still yields a usable image.
* openfut-fut.sh — the local orchestrator. ensure_cert now defaults the SAN IP to this
host's primary LAN IP (OPENFUT_ADVERTISE overrides) and reissues when the cert lacks
it, instead of only generating when the file is absent.
Verified without the client, which is the strongest evidence obtainable here: a
verifying TLS client checking the cert BY IP rejects the old DNS-only cert ("IP address
mismatch, certificate is not valid for '10.10.0.120'") and accepts the new
IP-bearing cert; and the entrypoint reconcile is idempotent end to end — an old cert is
reissued to carry IP:$ADV, a simulated restart leaves the fingerprint unchanged, and
the final SAN carries both the advertised and loopback IPs.
Live confirmation needs the production container rebuilt with OPENFUT_ADVERTISE set
(operator-gated); production is otherwise untouched.
entrypoint.sh carries unrelated pre-existing uncommitted work (env-based component
selection) that is not on any branch; only the cert-reconcile block is committed here,
and that work is left intact in the working tree.
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).
openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.
openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').
The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.
VERIFICATION — the ports are checked against the Python, not against themselves:
* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
constants, which proves consistency, not parity, and the Python cannot run here
(pycryptodome absent) with the client host unreachable. So the LCG and key derivation
were transcribed from the Python and run as plain arithmetic, and every AES value came
from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
netns, so the smoke test runs under `unshare -n` — the real binary on the port the
client actually dials, with no port-override hack and no risk to production. A
hand-written client read the unprompted <Challenge>, completed the handshake, and
decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
the exact Python message, dual stdout+logfile output, and a missing value rejected
with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
every constant by executing the Python module and drove the binary against a synthetic
client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
own parser fixture, so backend capability registration still works.
Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.