d146f9c3fc4d4dca5e4cc5eaaaa95ec17270e8c8
51 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4156dc5810 |
ops(systemd): record unattended proof of post-boot recovery
The reboot-survival gate is a test no operator can stand inside: the machine under test is the machine running the session. So the machine records its own recovery. openfut-boot-evidence.service polls until the anchor, Core and host agree on a namespace (or a 180s deadline expires), then writes a JSON file with the boot id, anchor/Core/host pids and netns inodes, unit states, restart counts, whether the reconciler had to act this boot, mount count, four non-mutating probes, route ownership, and the full economy snapshot — plus the journal for the boot so ordering is read from real timestamps rather than inferred from unit dependencies. It observes only; it never starts, stops or repairs anything, and carries no Requires= or ordering that anything else waits on, so it cannot affect the boot it is measuring. If the chain is broken the file says so, which is the point. Polling rather than a fixed sleep means a boot-time reconcile retry is recorded as "settled late" rather than as a failure. |
||
|
|
fc1fdcc5ab |
ops(systemd): exact mount match in status, add detached rollback script
Two things surfaced by the production promotion. `status` counted namespace mounts with an unanchored grep, so on production "run/netns/openfut" also matched "openfut-staging" and reported a phantom "2 = leaked stack" against a perfectly healthy host. A status command that invents a fault is the same class of bug as a unit that reports active while serving nobody, so it is fixed with an exact mount-point match. The bind and reconcile logic is untouched; it always umounted an exact path. openfut-rollback-detached.sh makes the documented rollback executable rather than a paragraph in a runbook: it removes supervision, resolves the anchor's CURRENT pid from Docker, and relaunches the incumbent detached pair with the environment replayed from the captured env.json. --dry-run prints the exact commands and touches nothing, which is how it was validated while production was still being served by the processes it would restore. |
||
|
|
1e8d46b258 |
ops(systemd): follow the anchor container's netns across recreation
Recreating the Docker anchor left the supervised Core and host stranded in the dead namespace while systemd still reported them active — serving nobody, invisible to any monitoring that trusts unit state. Reproduced on staging: netns 4026539938 -> 4026540033, both pids unchanged in the old one, both units "active", traffic ConnectionResetError. There is no systemd-native edge signal to bind to. Containers do appear as units, but the scope name embeds the container ID (docker-<id>.scope), which changes on every recreate, so BindsTo= has no stable target; NetworkNamespacePath= resolves once at start; a .path unit on /run/netns would watch the file this tooling maintains. So: a level-triggered reconcile on a 10s timer, comparing the namespace the services are ACTUALLY in against the anchor's CURRENT one, acting only on a real difference. That cannot miss an event while the watcher restarts or dockerd is down, and needs no debounce — a burst of three recreations produced exactly one rebind. The trigger stays separable: a docker-events unit could invoke the same script. Anchor absent stops the dependants rather than falling back to host networking; docker unavailable logs once and retries on the next tick. Two defects found while testing and fixed here: - mount --bind STACKS when the old mount is busy, silently leaking nsfs entries; the bind helper now drains stale mounts in a loop. - reconcile must stop -> rebind -> start, not rebind -> restart: a running service holds the old namespace open and makes the umount fail busy. Staging also gained a faithful anchor container so the reproduction is structural rather than mocked. Economy state was byte-identical across every lifecycle test. Production units are templates only and remain uninstalled. |
||
|
|
8ae432223a |
ops: systemd supervision for Core and the FIFA17 host (staging-proven)
Replaces the detached `setsid nohup … nsenter …` launch, which had no restart policy, no boot persistence and no supervisor-visible logs. Staging units are installed and proven; production units are TEMPLATES and are not installed. Three decisions, each measured rather than assumed: * `Wants=`, not `Requires=`, from host to Core. With `Requires`, stopping Core stopped the host AND a later Core start did not bring it back -- a routine Core restart would leave the client with no server. With `Wants` the host survives a Core outage, answers 503 core_unavailable, never falls back to Python, and resumes the moment Core returns with no intervention. Both halves tested. * Readiness is a bounded ExecStartPre TCP gate, because ordering proves nothing about readiness and Type=exec only proves the binary exec'd. Core binds its listener after migrations and content load, so "port open" is a real signal. The gate FAILS rather than blocking: a host that waits forever looks healthy while serving nobody. * The netns is resolved by container NAME every start. The container is restart=unless-stopped and its netns inode CHANGES on restart (measured: 4026539938 -> 4026540033), so a hardcoded pid is wrong by construction and anything left in the old namespace serves nobody. Proven equivalent to today's nsenter against a scratch container, never production's namespace. `systemd-analyze verify` caught two real defects before deployment: StartLimitIntervalSec/StartLimitBurst sat in [Service], where systemd 252 silently ignores them, so the crash-loop ceiling was not taking effect; and a Documentation URL containing %20 parsed as a specifier. Both fixed and the effective properties re-confirmed from the running units. Staging evidence: Core-first ordering, host refused when Core is absent or merely not listening, outage survival, automatic recovery, restart, graceful stop with no strays, boot simulated via multi-user.target, 3x SIGKILL contained at ~5s spacing, journald logs, and economy state byte-identical throughout (integrity ok, fk 0). |
||
|
|
9026220533 |
feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every non-player Core definition while `d.rating` already held EA's authoritative `value` -- and the very next block wrote that same number correctly to the adapter catalog. So the tier existed host-side but never reached Core: Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 -> tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is exactly what the 409 was protecting against, so the refusal was correct. The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating the production pack changes exactly 18 entries and exactly one field each (source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a. WHY value IS the tier source, and why the thresholds are the player ladder: LIVE_PROVEN, not inferred. The client re-rates staff from its own managercards/*coachcards/physiocards by carddbid and applies discard_level's 65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3, coaches 66 -> level 2). The shipped coach tables corroborate: each family has exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2. Manager contracts stop refusing and now resolve the TARGET's tier from Core-owned state. Still fail-closed everywhere it matters: a coach or physio is `contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a manager Core carries no source_rating for is `manager_tier_unknown` rather than a guessed tier. Core's own content_kind token is sent as target_kind, because Core calls the squad manager `manager` while the catalog classifies it `staff`+subtype 4. NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT. |
||
|
|
f0c6dcf238 |
tooling: verified backup, state snapshot, retargetable apply validator
Promotion prep for the contract-apply cutover, which unlike the quick-sell promotion moves BOTH binaries and applies a schema migration. fifa17-promotion-backup.py uses SQLite's online backup API, not cp. Production runs WAL with a routinely uncheckpointed WAL (515 KB at capture time); copying the main file alone is not atomic against a live writer and carries no guarantee the WAL holds no newer committed state. Emits a checksummed backup, a metadata record and a RESTORE-*.sh that removes the stale -wal/-shm BEFORE restoring -- omit that and SQLite replays the old journal over the file you just put back, resurrecting the state you were abandoning. fifa17-promotion-snapshot.py is read-only (mode=ro) and counts EVERY table rather than a hand-picked list, so a delta cannot hide in a table nobody thought to name. It also fingerprints the ownership rows, catching a row silently rewritten when counts alone would match. fifa17-contract-apply-validate.py gains --host/--db so one tool serves staging, the migration rehearsal and the production acceptance run. Defaults stay staging: there is deliberately no production default, so a bare invocation cannot touch production. |
||
|
|
6c97bc4e2b |
feat(fifa17): real player-contract consumable apply, replacing the probe
POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.
THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.
No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.
The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".
FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.
`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.
CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.
Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
|
||
|
|
5b8bee286c |
feat(ops): read-only interception preflight for OpenFUT endpoints
During the Rust production cutover four stale openfut-switch nft rules were
still redirecting production-facing traffic to staging (42127->42227,
8081->8281, 8094->18094, 8099->18106). They matched `ip daddr 10.10.0.120`, so
every server-side probe via 127.0.0.1 or the container IP passed while the
CLIENT was refused. That cost a full false-negative acceptance round: a retail
quick-sell landed on staging while production sat untouched, and the launcher
reported the server "not answering".
The failure mode is mechanical, so the check is:
* openfut-switch.sh status
* nft rules on OpenFUT ports, split into REDIRECT (interception) and DNAT
(docker publishing, expected -- reporting those as problems would train the
reader to ignore the tool)
* the actual point: loopback vs the ADVERTISED address per port. A redirect
keyed on the LAN IP is invisible to loopback, which is exactly why the
cutover probes all passed.
Verdict is CLEAN / INTERCEPTION_PRESENT with exit 0/1/2. Both branches
observed: it reports CLEAN now, and reported INTERCEPTION_PRESENT on a
loopback/advertised disagreement before :4216 was excluded.
:4216 is excluded from the verdict because LSX runs on the game machine --
compose publishes the port but OPENFUT_SERVERS omits lsx, so "published but not
served" is its normal state. It is still printed, marked as expected.
READ-ONLY by design: it never deletes a rule. Clearing interception stays a
deliberate operator act via `openfut-switch.sh off --name <id>`.
Run before production acceptance, client repoints, migrations and retail
protocol tests.
|
||
|
|
88b4cad780 |
test(fifa17): migration invariant capture and rehearsal harness
fifa17-migration-invariants.py Pre/post invariants across every domain the
migration authorization names: coins, ownership (+kind histogram, distinct
definitions, chemistry styles, loans, position overrides), squads,
managers, staff, consumables, club items, transfer state (market_listings),
packs, SBC, match history, plus integrity_check and foreign_key_check.
Table names are the REAL schema, not guessed: transfer state lives in
market_listings (29 rows in production), the FIFA17 opaque squad blob in
game_entity_ext.
fifa17-migration-rehearse.py Serves a migrated COPY with the candidate Rust
stack on isolated ports and validates the wire surface: club discardValue
is table-derived, squad projects, consumable categories populate, and the
apply probe is OFF (502 upstream-unavailable rather than a diagnostic ack).
Both are read-only against production: the rehearsal operates on a copy under
/home/alex/openfut-migration/, and nothing under openfut-promotion/state is
opened.
Evidence from the 2026-08-22 rehearsal is written up in the Vault runbook
"FIFA17 Rust Production Migration (rehearsed)".
|
||
|
|
8cb2a0f9c6 |
test(fifa17): smoke-test the apply probe and prove the gate fails closed
Unit tests cover classification and body parsing; they do not prove the running
host behaves. These three scripts exercise the real service, and they found
nothing broken but make the two load-bearing claims checkable:
fifa17-apply-snapshot.py Core truth around an apply: coins, owned rows, kind
histogram, the source stack's copy count, and the
target's mutable fields (contract/fitness/playStyle/
training/injury). Coins and ownership come from the
staging DB, not the wire, so the check cannot be
satisfied by a projection bug.
fifa17-apply-probe-smoke.py Replays the EXACT captured request plus the edges,
against the live host, no client needed:
1. {"apply":[{"id":100000003}]} -> 200 {"itemData":[]}
source=Consumable subtype=201 copies=1,
target=fifa17_200389 rating=87
2. two targets -> 400 apply_batch_unsupported
3. unknown target wire id -> 200 UNRESOLVED_WIRE_ID
4. unowned source -> 200 NOT_OWNED
then re-snapshots: Core identical after all four.
fifa17-apply-gate-off.py The production-safety claim. With APPLY_PROBE unset
the same request must produce the pre-probe
behaviour, and does: no apply-probe line, three
passthrough lines, 502 into the dead upstream, Core
unchanged. Verified by restarting staging without the
flag -- an assertion about failing closed is worth
nothing unless the closed path is executed.
Nothing here writes to production; snapshot reads the staging DB read-only.
|
||
|
|
0a7c4e129c |
docs(fifa17): record discard economy validation evidence and impact
Adds scripts/fifa17-discard-impact.py (owned-instance economic impact, computed from the shipped implementation's matrix -- informational, never a reason to alter a value) and records the measured results. Owned club, 1993 instances: legacy 1,820,700 -> recovered 19,128,031 = 10.51x. Players 10.53x, manager 1.88x, consumables 0.19x (the ladder overpaid them ~5x), staff 0.24x, club items 900 -> 0. |
||
|
|
a96d06dbc0 |
test(fifa17): validate discard payouts, replay, concurrency and persistence
Adds two staging-only harnesses and records the results. scripts/fifa17-discard-validate.py drives the REAL Rust/Core quick-sell path for a fixture spanning every quick-sell-relevant category, and checks each against the authoritative table value emitted by the discard_matrix example (i.e. the shipped implementation, not a reimplementation). Per item it asserts the payout is exact, the instance is removed exactly once, and a REPLAY of the same request grants nothing and resurrects nothing. Results with OPENFUT_FIFA17_DISCARD_TABLE=1 on the real 1993-item club: players 6/6 exact 752 .. 74,400 (rareflag 1,3,4,5,6,11,21,22,23,24) staff 2/2 exact 36 (gk coach, fitness coach) consumables 4/4 exact 3, 3, 32, 38 club item 1/1 exact 0 (kit -- and 0 is what the client displays) TOTAL 12/12 exact, 0 replay grants wire discardValue == expected == actual payout for every player, so what the client is shown and what Core credits are the same number by construction. Concurrency: 4 simultaneous DELETEs on one wire id -> removed exactly 1, paid exactly once (23,280). scripts/fifa17-restart-persistence.py restarts Core and host IN PLACE with their own environment rather than via the bring-up script, because `up` re-seeds the club and would mask a persistence failure. It refuses to signal any process outside the staging root -- production runs as another user and is skipped explicitly. Result across SIGTERM + respawn of both: coins 29,967,428, owned 1978, players 1958 -> PERSISTED EXACTLY. Production untouched; staging only, flag set only in staging. |
||
|
|
f97654af86 |
fifa17: give the staging manager the rating the client re-rates it to
The bring-up mints a manager (neither club owns one, and FIFA refuses to start a match without one). Its catalog entry carried nation/league/team read out of managercards but not `value` or `rare`, so discard pricing declined for it and fell back to the placeholder ladder: 150 paid against the 282 the client computes for itself. Carry managercards.value 88 and managercards.rare 1, from the same table and with the same provenance as the fields already there. `rare` is not cosmetic -- it selects the discard price column, which is the whole difference between 282 and 97. Both are live-confirmed on the running client: coach_probe grades the manager record HIT (so +0xb4 == value and +0x58 == rare) and discard_probe reads the value it computed for itself at +0x3c as 282. Verified on staging: the manager now quick-sells for exactly 282. With this, every card the client prices for itself -- manager, GK coach, both fitness coaches -- is paid the number it displays. |
||
|
|
4b66906adc |
feat(fifa17): definition-coverage guard, and a complete-club staging fixture
The guard classifies every definition id the game ships into exactly one of
KNOWN_OWNABLE / KNOWN_PRESENTATION_ONLY / KNOWN_UNSUPPORTED / UNKNOWN, and fails
when anything lands in UNKNOWN, so a table we cannot place is loud instead of
quietly assumed cosmetic. All 149 tables place: UNKNOWN = 0, 20,876 ownable ids.
Two tables are KNOWN_UNSUPPORTED with stated reasons rather than guessed at:
`fut_storymodehero` (80 ids; the shipped row is only {carddbid, teamid}, so no
item can be shaped without inventing one) and `fcc_misccards` (42 ids; these ARE
owned items, but their per-subtype semantics are not reverse-engineered).
It also counts by SHARED ID SPACE: `fcc_leaguelogos` and
`fcc_leaguelogostickers` both start at carddbid 8010000 and all 39 sticker ids
collide, so the union is 44 and never 83. The collision is printed so summing
cannot regress silently.
Staging now seeds the remaining club families (badge, ball, stadium, league
logo) from the client's own tables, so the rig exercises EVERY ownable class
instead of only the two the real club happens to hold.
|
||
|
|
23f120f889 |
fix(staging): reclassify the restored club, and assert Core's ownership truth
The snapshot predates the content taxonomy, so every fresh bring-up restored a club whose coaches, kits and consumables were recorded as players. The wire still looked right (the adapter classifies from its own catalog), which is exactly the kind of divergence that hides until something keys off ownership. Bring-up now runs Core's reclassify and asserts the club really owns something of each kind. A Core binary predating the subcommand ignores it and boots the server instead, so the step is bounded and says so rather than hanging. |
||
|
|
1a6355cad5 |
fix(staging): re-stamp the squad extension after seeding, and prove it
Filling the bench writes squad rows behind Core's back, which invalidates the stored FIFA17 opaque extension: Core saw a canonical squad that no longer matched the extension's fingerprint, the host refused to apply it (`stale_integrity`), and the client got a squad with zero players and no manager. Nothing failed loudly — the rig just came up empty. The seeder now recomputes the fingerprint exactly as `services::squad::squad_fingerprint` does, and bring-up asserts the squad really projects (>= 18 occupied, a manager present) instead of trusting that it did. Seeded owned rows also state their content_kind, so Core does not record a kit or a manager as a player. |
||
|
|
106cb83988 |
fix(staging): fill the real club's bench to the client's 18-player minimum
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. |
||
|
|
9c2edc4eee |
feat(fifa17): serve staff, so the club has a manager and matches can start
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.
|
||
|
|
de747b79e6 |
feat(staging): let staging serve the operator's real club, not just the fixture
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. |
||
|
|
3442eac6f0 |
fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate
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 ( |
||
|
|
db743ffd1f |
feat(fifa17): project owned kits with active home/away designation
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.
|
||
|
|
ab62440dbf | Make FIFA17 roster hostname configurable | ||
|
|
0b189b36c5 |
chore(tools): add utas-filter-diff.py diagnostic
Read-only UTAS capture diff helper. Retained pre-existing WIP verified. |
||
|
|
8e1fb640b6 |
tools: authoritative Core-state snapshot for FUT loop verification
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. |
||
|
|
e8d1c1ddac | test(fifa17): harden SBC retail acceptance | ||
|
|
fc55de19fa |
test(fifa17-tls): reproducible isolated confirmation of the roster-cert fix
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 (
|
||
|
|
16771b0b33 |
test(scripts): recover the client's error text, and diff hub shapes against production
Three diagnostics from chasing a FUT error that four server-side fixes failed to resolve, kept because the technique generalises. client-error-string.py recovers FIFA's on-screen message from /proc/<pid>/mem, read-only, scanning ASCII and UTF-16LE (FIFA UI strings are wide). This ended the guessing: the dialog reads "An error occurred downloading the FUT Squad Update. Please try again." -- a CONTENT DOWNLOAD failure, not the player's lineup. Every squad fix before it was aimed at the wrong subsystem, because "squad update" in FIFA means the roster update, and the server-side symptom (a squad the client would not accept) was consistent with both readings. When the server says 200 and the client says no, the client's own words are the cheapest evidence available and should have been the FIRST thing recovered, not the fifth. hub-dump.py + hub-diff-prod-staging.py diff every hub route between production (known-good, same client accepts it) and staging, comparing key presence and JSON types rather than values, since values legitimately differ. Result: 0 structural differences across 14 routes, which retired the whole "a missing field breaks bootstrap" line of investigation in one run instead of one restart at a time. Also ruled out with evidence: cert gates ARE patched (autopatch logs "pid 56298: PATCHED cert gates", and the gate bytes read back as the patched patterns); the roster server serves the FUT Squad Update fine (TLS1.2 AES256-GCM-SHA384, HTTP/1.0 200, application/xml) once probed with ALL:@SECLEVEL=0 -- a default modern context gets SSLV3_ALERT_HANDSHAKE_FAILURE and would have been a false alarm; production and staging Blaze advertise identical roster/POW hosts; the Blaze session is healthy and answering PINGs; and the squad round-trips exactly through PUT/GET. |
||
|
|
022634704a |
fix(scripts): staging squad now matches production's known-good shape exactly
Adds the manager reference, the last remaining difference from the squad the same client demonstrably accepts. Staging's squad shape is now identical to production's: zero missing keys, zero type differences, zero empty-vs-populated mismatches. The manager looked unfixable. Production points at instance 100000427 while the staging club holds 11 players and zero staff, so there was apparently nothing to reference, and inventing an id would have pointed at a non-existent item. Checking production properly dissolved the problem: 100000427 is absent from production's OWN club listing too. /club/staff returns 1975 items spanning ids 100000001..100004826 and 100000427 is not among them, and the type=staff/type=manager filters are ignored (200 players either way). Production's manager reference is dangling and the client accepts that squad anyway, which proves the client does not validate the manager id against the club -- only a populated array matters. So the reference is mirrored verbatim, dangling id included. That replicates the known-good state exactly and is better than pointing the manager slot at a player, which would have been a guess dressed up as a fix. Method note: every step here came from diffing against production rather than reading the client. The host reported squad-active 200 outcome=ok throughout, and 200 with the right players was never evidence the client accepted the body. |
||
|
|
96ca7c0484 |
fix(scripts): the seeded squad was structurally valid but the client still refused it
First seed sent only squadName/formation/captain/players. The host logged squad-active 200 outcome=ok and /squad/0 showed 11 occupied slots, yet the client still threw a FUT squad update error -- a 200 with the right players is not proof the client accepts the body. Diffed against production's known-good squad, which the same client accepts, comparing key presence and JSON TYPES rather than values. Staging returned null for exactly the five fields the PUT never carried, because the extension stored nothing for them: squadType (a string enum), chemistry, rating, starRating (ints) and custom (the opaque 33-int tactics array the client definitely parses). kicktakers was empty where production carries five. Now sends all of them: squadType REGULAR_SQUAD, chemistry, rating/starRating derived from the XI's mean rating, production's custom array verbatim (opaque server-side, only its shape matters), and five kicktakers. Re-diff leaves exactly one difference -- manager, which production points at owned staff instance 100000427 while the staging club holds 11 players and zero staff. Left empty rather than inventing an id that references a non-existent item; recorded in the code as the one known remaining gap. Also fixes a KeyError from the rewrite dropping the players key. |
||
|
|
202366611e |
test(scripts): front-load the hub preconditions instead of finding them one restart at a time
The sold A/B stalled twice on preconditions no headless check exercised: the staging identity had no squad (the hub refuses to open, showing a squad update error), and any route without a Rust owner falls through to a deliberately dead Python upstream and answers 502. Each cost a full operator cycle. Sweeps the routes the client is observed to request and separates three failure classes that need different fixes: 502/PYTHON_FALLBACK (no Rust owner), missing_integrity (200 but the underlying state is absent -- exactly 'no extension stored' before the squad was seeded), and 200-but-unusable (a squad with zero occupied slots). A 200 is not proof the client is satisfied, so squad responses are judged on occupied slots. Also reads the host's own classification for the requests just made, since the host is the authority on ownership and integrity rather than the response body. Every path is verified against what the client actually sends. A first pass flagged five 'fatal' routes that were my own guesses -- /accountinfo (client uses /user/accountinfo), bare /squad (uses /squad/active), and /watchlist (camelCase watchList). Crying wolf about the stack is worse than not checking, so the list now carries only observed paths and that trap is written down in the comment. Current result: 14 ok, 0 integrity warnings, 0 fatal. |
||
|
|
ea92057e53 |
test(scripts): seed the staging seller an XI so the FUT hub will open
The A/B identity had owned items but no squad, because the sold-row work only ever
needed the tradePile wire. Every headless check passed -- none of them asks for a
squad -- but the real client refuses to enter the FUT hub with an empty one and shows
a squad update error. A squad is a hub precondition, not a Transfer-List detail.
Seeds via the real PUT /ut/game/fifa17/squad/0, the same request the client sends, so
parse_squad_put/build_squad_write produce exactly what a genuine save would. Writing
Core rows by hand could yield a shape the live path never emits, which is the kind of
divergence that quietly invalidates an experiment.
Picks one owned player per 4-3-3 slot, best rating first, without reusing an instance;
fills the fixed 23-slot array with 0..=10 as the pitch and empty slots as
itemData.id == 0; refuses to write a partial XI and reports any out-of-position
substitution loudly rather than silently reproducing the broken state. Verified
0 -> 11 occupied with no substitutions and a {"id":0} ack.
|
||
|
|
c71593b286 |
test(scripts): enable the hook on the deployed launcher without a rebuild
Bridges openfut-launcher c542415 onto the already-built launcher on .105 by writing WINEDLLOVERRIDES=version=n,b into game_profile.env, which that build does apply. Forward-compatible: the fixed launcher defers to a profile that already pins version=, so this value simply wins. Records the prior value -- including its absence, as the literal <absent> -- to a sidecar before mutating, so revert restores the real previous state instead of assuming the key was missing. Refuses to edit while the launcher runs, since it holds its config in memory and would write the stale value back. |
||
|
|
e0e46d8a57 |
fix(scripts): the port switcher was editing a derived file, so the A/B ran on production
openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher
is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json,
fail-closed, immediately before every launch. So `staging` set the ports, verified
them, and the next launch silently reverted them.
Caught only because the capture harness cross-checks instead of trusting the screen.
The operator reported "the Transfers tile does not show Sold" -- which looked like a
clean negative result about the sold counter, and was in fact a reading of their
PRODUCTION club, where sold:0 is correct. Evidence chain:
* route-log delta contained 5 lines, all of them the harness's own GETs; the
client issued nothing to staging at all;
* `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130
(production Blaze) plus TIME-WAIT to :8099 (production UTAS);
* the hook logged `blaze_redir=42127 blaze_main=42130`;
* openfut.cfg mtime was 2s before process start, sha back to the production value.
Had the harness reported the tile at face value, the sold counter recovered from
CardsDLL would now be recorded as refuted by a run that never reached the code.
Fixes: own the launcher config (source) before openfut.cfg (derived), with the same
record-before-mutate sidecar discipline on both; refuse to edit while the launcher is
running, since it holds config in memory and would write the stale values back;
report both files and both guards in `show`. launcher_running() matches the
kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has
bitten this project before.
No production change; staging stack and its variant-A sold row untouched.
|
||
|
|
fbe29da05b |
fix(scripts): sold-client-ports guard self-matched its own shell, wedging it ON
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line contains the literal pattern -- so fifa_running() always returned True and the port switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a FIFA client is running" moments after FIFA had actually exited. Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and blocked the A/B entirely. Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking shell reads as zsh and cannot self-match, while a genuine FIFA process still does. Validated both directions with the same loop -- it found pid 36958 while FIFA was up, and reports gone once it exited. Still fail-closed on read errors. The lesson generalises: a pattern-matching process guard checked over a transport that carries the pattern in its own argv is self-satisfying, and a guard that can only ever say "yes" is not a guard. |
||
|
|
9ffbd651b1 |
test(market): one-command live capture for the sold A/B, with in-run validation
Turns the operator's job into "navigate, say go" and removes any chance of a half-recorded variant. One command captures and labels: the staging wire surfaces, the client's OWN auction record decoded read-only from /proc/<pid>/mem (STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE), and the staging host route-log DELTA since the last capture -- which is how a client-issued DELETE .../trade/sold gets OBSERVED rather than assumed. The part that matters is the wire-vs-memory cross-check. It validates the observation mechanism against a known-positive in the SAME run: if the wire says bidState "highest" and the client's memory decodes 2(highest), the probe is demonstrably reading the right struct this time. It also recomputes the native IS_GLOW/INBOX formulas from the wire and compares them to what the client stored. Proven honest on first run: with the client attached to PRODUCTION and not on the Transfer List, it reported the staging sold row on the wire, 0 client records, and INSTRUMENTATION NOT VALIDATED -- refusing to draw a conclusion from an empty read. Two earlier sessions were misled by exactly that (a sampler bug printing "countdown NO", and auction containers read while the screen was unbound), so an empty container is explicitly not treated as an empty pile. Probe base-address discovery was separately confirmed against the live client (pid 36958, FNV control=MATCH, model resolved, containers read cleanly), and production's wire independently agreed at total=0. No production change. Client config untouched (still production Blaze ports). |
||
|
|
aa5fb2cc40 |
test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the client's reaction is attributable to neither. The env knobs were already orthogonal (--variant and --coins-processed are independent, cp defaults to 0), but sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly the contaminated A/B the brief forbids. Fixed: the primary pair now holds coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState']. New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic sale, then re-reads every seller-facing surface under each variant by restarting only the host (same Core, same DBs, same sale), and diffs with explicit classification -- MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?". Two orthogonal pairs: PRIMARY bidState highest vs buyNow, coinsProcessed held at 0 ORTHOGONAL coinsProcessed 0 vs 1, bidState held at highest Result, 36/36: the ONLY finding on /tradePile is VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs in exactly the same one path; counts are byte-identical. The orthogonal pair's only finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the capture is reproducible rather than merely consistent. Counts states the live run has to interpret, measured not guessed: S1 0 active + 1 sold -> count 0, selling 0, sold 1 S2 1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode) That divergence IS the open question for the client; production is unchanged. scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is built so restoration cannot depend on memory: it records the production values to a sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing if it is absent. It rewrites only known keys (a missing key is an error, never a silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA client is running because the hook reads the file at connect time. Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface, one file per variant so A can never overwrite B. Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105 (pid 32188), and live-session mutual exclusion applies. The client config was NOT touched -- the switcher's guard refused, as designed. Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0, counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened. |
||
|
|
468bc0fba9 |
feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.
PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.
OPENFUT_FIFA17_SOLD_EXPERIMENT = highest | buyNow (else OFF)
OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1 (else 0)
OPENFUT_FIFA17_SOLD_COUNT_MODE = active_plus_sold (else active)
WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.
CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.
FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.
STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
* scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
responder and asserts every substitution applied, so a silent no-op cannot leave
it pointing at production. Kills only recorded pids whose cmdline contains the
staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
* docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
* src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
(CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
leaves the listing live with nothing moved. Refuses any path containing
openfut-promotion or the production ports.
* scripts/sold-wire-check.py -- proves the whole flow headless before any operator
time is spent.
WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.
Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.
Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.
The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
|
||
|
|
f6606accb3 |
feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live here. FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and match_reward_total because 5% is a game policy constant and Core must stay game-neutral — Core only validates 0 <= fee <= gross and never computes a rate: TRANSFER_MARKET_FEE_PERCENT = 5 transfer_market_fee(gross) = floor(gross * 5 / 100), i128 intermediate seller_proceeds(gross) = gross - fee Integer only. Floating point is never used for coin settlement: 0.05 is not representable in binary and a f64 round trip can create or destroy a coin at large prices. Widening to i128 makes overflow unreachable for any i64 price, so no price ceiling has to be assumed. ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller keeps the fractional coin, chosen because it makes fee + proceeds == gross hold exactly at every input — the property the accounting invariant rests on. The discriminating case against flooring the seller's 95% instead is a gross of 150: this rule pays 143, the alternative 142. Nothing in the corpus or the client binary settles which the real server did (the client is only ever told the gross; no tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/ 150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep. HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on HttpCoreClient as POST /economy/settle-sale. Request field names were checked against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club ids are OMITTED from the body (not null), which is what Core's Outside/active-club defaults depend on, so a unit test pins that body shape. handle_market_buy is deliberately untouched: the synthetic buy path has no counterparty, so minting there is correct. HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a hard deny-list checked in three places), seeds the canonical two-party fixture, prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31 pass. It found the rejection-precedence bug fixed in Core, and that Core's content preflight aborts startup on an owned card whose CardDefinitionId no pack defines. Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code fmt-clean. Nothing deployed; no production process, port or database was touched. |
||
|
|
71fcf5e251 |
feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)
Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing Core-accurate counts: player tiers + rare from the collection, staff/consumable families from catalog kind+subtype, per-nation buckets via the reverse entity resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts + context_rows). Unlike the oracle (stale profile + synthetic consumable shelf), this reflects the real imported content (incl. the content-gap consumables/staff). Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain Python (documented). Adds adapter club_stats module (5 tests), host handler + classify arm + resolver subtype_of/rareflag_of, ownership + integration tests; reachability tool splits club/stats global(migrated) vs context(residual). |
||
|
|
70eb3fc13f |
feat(fifa17): own FUT hub tile counts in Rust
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff excluded via catalog kind; may be lower than Python's profile count by the deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from the durable market store via the async bridge. Fail-closed 503 on Core error; market read failure degrades cosmetic counts to 0. Adds classify arm, handler, ownership + integration tests; reachability tool marks hub migrated. |
||
|
|
6eec3b9ec7 |
test(fifa17): add UTAS route-reachability reporter (Python-hit gate)
Parses the host owner= dispatch log into per-owner + per-domain counts and gates on the post-P1 invariants: economy Python hits == 0 (P1 regression), migrated non-economy routes (accountinfo/settings/leaderboards/match-reset/phishing) == 0, and residual Python domains == documented set. Read-only; staging-preflight and Phase 40 live-ownership use. |
||
|
|
88da16a11e | feat(fifa17): curated dev content pack generator + Core dev seed (submodule 36abd4b) | ||
|
|
36fe1caa3f |
feat(fifa17): deterministic base-card seed generator + full identity catalog
scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data (pool.json + roster.json + leagues/nations/teams tables) -> the card-definition identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>), version 0 (base cards only; resource_id == asset_id). --check mode diffs against committed output (drift-detection mutation-proven). Provenance embedded. Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563 base assets. Semantic definition coverage (to /tmp, not committed here): 17,547 resolvable; 16 skipped for missing roster name (reported, never fabricated). Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801 -> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards exist', not 'which the user owns'. Core content seeding + dev-owned set next. |
||
|
|
04c5043aba |
utas: My Squad filter corpus — every filter identified, root cause measured
Controlled retail capture, one criterion at a time, cleared between each. 47 transactions. Every filter the My Squad picker sends is now known from the wire rather than guessed. Route: GET /ut/game/fifa17/club -- the picker hits UTAS and reuses the general club-inventory route. level=any|gold quality lowercase, ALWAYS present rare=SP "Special" uppercase, OMITTED when off position=ST position uppercase, omitted when off nation=52 entity id numeric league=13 entity id numeric team=5 entity id numeric, NESTED under league sort=desc client constant; the UI has no sort control start=/count=11 pagination Two encoding families: short string enums, and numeric FIFA ids. The ids must never reach Core. Filters compose as plain ANDs in one query -- string and id filters alike -- so each maps independently. THE ROOT CAUSE IS SELF-AMPLIFYING. club_route honours type, team and league; it never reads start, count, level, sort or year. Because start is ignored, every page returns the same full set, so the client concludes the page was full and asks for the next one. One scroll produced 22 requests and 6.2 MB, stopping at start=200 only because the client gave up -- against a filtered set of 32 items that should have been three pages. That also explains why the bug reads as erratic rather than broken: league=13&position=ST returns every Premier League player instead of Premier League strikers. Plausible, wrongly sized, hard to notice. Measured filtered sets, from the real cluttered club -- these are the acceptance test for the fix: unfiltered 1962 league=13 350 league=13&team=5 32 Two client behaviours worth carrying forward: the picker fires a query per highlighted entry, not per selection (two requests for one club pick), and parameter ORDER is not stable, so parsing must be key-value. FIXTURE SIZE: bodies over 4 KB are truncated in the committed fixture, with body_full_len and body_full_sha256 retained, because the same 1.1 MB club response repeats ~25 times and its hash already proves identity. 13.3 MB -> 247 KB. The raw .ofcap keeps every byte, privately and gitignored. Truncation is recorded per transaction so a trimmed fixture is never mistaken for a whole response. Audited across all three identifier surfaces -- headers, JSON bodies, query strings -- before and after the size change: no leaks. 6/6 sanitiser mutations still killed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0b66662525 |
utas: first real corpus, and two sanitiser gaps the audit caught
24 transactions across 11 connections from a retail session: login, hub, one pack open, two squad saves, a quick-sell, with before/after state manifests. Raw .ofcap stays gitignored at 0600; the sanitized corpus is committed as adapter fixtures. TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER. 1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens were being redacted correctly and these were not. A committed fixture is a published fixture. 2. Then, with those fixed, the audit fired AGAIN on the file about to be committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in the QUERY STRING. Three input surfaces carry identifiers -- headers, JSON bodies, and query strings -- and the sanitiser knew about two. Both fixed in the tool rather than by editing the file, with a regression test and a mutation for the query path. AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported the query-redaction mutation as SURVIVED while a hand-run of the same mutation killed it. Cause: the harness pointed at a stale scratchpad copy of the test that pre-dated the query assertion, so it was faithfully testing the mutated tool against a test that could not detect the mutation. That is the same class as the build guard checking the wrong binary and cargo reusing a binary compiled from mutated source -- the third instance today of measuring the wrong artifact. The harness now resolves ROOT from its own location and runs the COMMITTED test; the stale copy is deleted. Harness committed as scripts/mutate-utas-observe.py so this is repeatable rather than a thing that happened once in a scratch directory. 6/6 killed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cdea85e214 |
utas: standalone recording proxy that tees rather than rebuilds
UTAS needs a real request/response corpus before any Rust is written: it is where protocol shape and FUT state start being coupled, so guessing is worse here than it was for Blaze. The oracle truncates logged bodies at ~200 chars, and raising that cap would mean editing the behavioural specification to make it easier to copy -- backwards. A proxy gets the same evidence and leaves the oracle untouched. THE DESIGN RULE: TEE, DO NOT REBUILD. UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive, pipelining and chunked transfer are all live. A proxy that parses a request and re-emits it can corrupt the traffic it exists to observe -- and that corruption would present as a UTAS bug, pointing the investigation in exactly the wrong direction. So bytes are copied verbatim in both directions and a second copy goes to disk; transactions are reconstructed later, offline, from that copy. A parser bug therefore spoils the record and never the session. Standalone, NOT in the container, so the same tool can later sit in front of a Rust UTAS host and replay an identical captured request against both. Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode 0600 and gitignored; sanitized/transactions.jsonl is the committed artefact. Bodies are preserved EXACTLY and sanitised second -- only known-secret headers and JSON keys are replaced, structure is never reshaped, and every redaction is recorded in the transaction so a reader knows what was touched. Captured per transaction: connection id, sequence, relative and wall time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED ORDER as pairs (a dict would drop duplicates and ordering), raw body and length for both directions, status, and observed keep-alive. Verified as two independent properties, because they fail differently: transparency (bytes through the proxy identical to bytes direct, Date masked, with the mask asserted to have fired) and fidelity (parsed transactions match what was sent, including a dechunked response and a 300-byte POST body). 5/5 mutations killed, including "record but do not forward", "drop the last byte of every chunk" and "stop redacting". scripts/test-utas-observe.py is committed alongside it: a capture tool nobody can re-verify is not evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f3b659c33 |
lifecycle: one host-lifecycle helper; roster.sh; ban pkill -f
redirector.sh and the coming roster.sh needed the same five rules, each
of which cost something to learn:
* resolve /proc/PID/exe; never match a command line. `pkill -f` /
`pgrep -f` match any shell whose ARGUMENTS mention the name, including
the shell running the command. That has killed this session's own
shell twice, and is now banned in migration tooling -- the helper
contains no `-f` matching and the header says why.
* `readlink`, not `readlink -f`. After a rebuild the link reads
"<path> (deleted)" and -f resolves it to nothing, so the orphan check
goes blind to exactly the long-lived processes it exists to find. Two
orphans hid there, one serving the wrong certificate.
* stop PROVES the process is gone and the port free.
* an ambiguous binary is an error for start/verify but NOT for
stop/status: rollback must never be blocked by a question about the
build tree.
* verify the RUNNING process's commit, not the artifact on disk, which
a rebuild can silently advance past.
Copying those into a second script would have been the same mistake as
copying the TLS setup. Instead scripts/host-lifecycle.sh owns them and a
service supplies four facts: name, crate, executable, port variable.
redirector.sh goes from 178 lines to 26 and roster.sh is 24, with no
behaviour change -- the refactored redirector.sh still sees the live
armed process (pid 830736, port 42227) and still refuses correctly
because HEAD has moved past it.
Paths are unchanged (rundir, pidfile, portfile, commit stamp, log), so
the currently running redirector stays manageable across this refactor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fc411bb6f1 |
scripts: require one certificate across the whole FIFA-facing TLS stack
Two live gates were lost to a second variable I had been asked to eliminate. The Rust redirector was pointed at the repo's fifa17-recon/tools/redir_cert.pem (fingerprint F9:16:1A...), while the running container serves a different cert baked into its image (E7:F9:46...) which the Python redirector, roster and the rest of the stack all share. So the A/B compared TLS implementation AND certificate identity at once. FIFA 17's ProtoSSL caches the server certificate for a backend. The redirector is the first TLS connection of a session, so its cert becomes the one the client expects; the next service presenting a different cert fails its handshake. That is why the redirect itself always succeeded and the failure surfaced later, on the roster fetch -- "An error occurred downloading the FUT Squad Update". It stayed invisible because Python's socketserver swallows it: a handshake failure at accept() raises ssl.SSLError, which subclasses OSError and is discarded by _handle_request_noblock. No request log, no stderr. Every server looked healthy while the client could not talk to any of them. Confirmed on the wire: tls-observe in front of the roster server captured four ClientHellos from the client, correct SNI and the same 8 static-RSA suites it offers the redirector, none of which produced a request. The check is mutation-tested against the real bug: with a redirector started on the stale repo cert it exits 1 and names the mismatch. |
||
|
|
5bc39e902d |
tooling: observe client connection ATTEMPTS; make the build guard reject bad args
openfut-observe.sh answers the one question no server log can: when a gate fails and a service logged nothing, did the client try and fail, or never try? Both look like silence. Two redirector gates were lost to that ambiguity -- "roster server logged nothing" was equally consistent with a broken roster service, a wrong roster URL, and a client that never asked. Built on iptables packet counters because this box has no tcpdump, no conntrack, and no readable kernel log. That last one is verified rather than assumed: an initial LOG-based version installed correctly and its rules matched (counters proved it), but the output went nowhere -- journalctl -k has no entries and dmesg is empty. Counters are also lower volume and record only SYNs, so no payload can be captured even in principle. Validated against the live client, not a loopback stand-in: an initial self-test using this host's own address counted almost nothing, because locally-generated packets never traverse PREROUTING. Against the real remote client it counts 8081 at ~4/min, matching the roster server's own log. Known gap, recorded rather than hidden: the catch-all TOTAL runs well above the sum of the named ports, so the client makes steady background attempts to ports not tracked here. It is present during a working session, so it is not the failure signature, and it is not chased further here. verify-build-identity.sh now rejects an argument that is not a commit hash. Passing the binary path instead of its stamp previously produced a plausible "REFUSING: binary was built from ./target/release/... but HEAD is <sha>", which reads as a real stale-build finding rather than a caller mistake -- and a safeguard that cries wolf is one people learn to route around. Usage error is now exit 2, distinct from a genuine stale build (1) and success (0). |
||
|
|
c03702707b |
redirector: commit stamp + shared build-identity verifier that REFUSES
The binary records only the commit it was built from -- no dirty-tree flag. Cargo will not re-run a build script because another crate's source changed, so a compiled-in 'clean' claim can be stale and is not a safeguard; that was verified on the Blaze host. scripts/verify-build-identity.sh establishes both facts at LAUNCH, where they cannot go stale: the stamped commit equals HEAD, and the migration crates are clean. It REFUSES rather than warns, because for a migration gate a warning on stderr is something to scroll past. --identity prints the stamp without valid configuration. The launcher must be able to establish which commit a binary came from BEFORE deciding whether to run it; requiring a correct environment first would invert the check. redirector.sh mirrors sidecar.sh: refuses to start with an orphan present or the port busy, matches the resolved executable rather than the command line (pgrep -f matches any shell mentioning the name), and stop PROVES the process is gone and the port free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |