9cc5188e5cd6119c43c5a64a13d4fea410db51b5
467 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9cc5188e5c |
host: claim GET ut/%s/item (FutViewCards), which fell through to Python
Fifth instance of this project's recurring dead-route defect: a handler exists and
is correct, but classify() never produces the route, so every request falls
through to the Python upstream. Invisible in production, where the oracle answers;
on staging, where the upstream is deliberately dead, it is a 502.
GET ut/%s/item is FutViewCards (deser 0x1801293d0, top-level itemData via the
shared card element 0x18013fe00). The oracle answers it with defs_route - the SAME
handler already wired for item/resource and defid: pull every integer out of the
query (idList=a,b,c / definitionId= / resourceId=) and return one definition per
id, or {"itemData": []} when there are none (tools/utas_server.py:1103). So
claiming it is byte-identical parity, not new behaviour.
The query handling is the substance of the fix, not decoration. ut_tail does NOT
strip the query string, so an equality-only arm (`Some("item")`) misses every real
request while passing a no-query unit test - which is precisely how the route
stayed unclaimed. The same latent bug applied to the two arms that were already
there: item/resource and defid only matched with no query, so
`item/resource?resourceId=` and `defid?definitionId=` were ALSO falling through.
All three now mirror the oracle's own `item(\?|$)` pattern.
Verified on staging, all 200 where they were 502:
GET /item -> itemData 0
GET /item?idList=100000003,100000004 -> itemData 2
GET /ut/v2/game/fifa17/item -> itemData 0
GET /item/resource?resourceId=5003012 -> itemData 1
GET /defid?definitionId=200389 -> itemData 1
Does NOT by itself fix the kit selector - GET /item is a definition lookup keyed
by ids the client already holds, not the thing that seeds the club collection, and
the observed session never requested it. It is a real production-masked gap and a
prerequisite for testing anything else on staging.
get_item_is_claimed_and_the_other_item_verbs_are_unaffected pins the claim plus
the three verbs that share the prefix: PUT item stays FutMoveCard on the economy
path and DELETE item/<id> stays QuickSellPath.
cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
|
||
|
|
6baa673252 |
kits: recover the selector data path from CardsDLL; residency tracks the ROUTE, not itemType
RETRACTION FIRST. The previous commit added itemType to club items on the theory
that it gated ingestion, because player/staff sent it and were resident while
kit/badge/stadium omitted it and were not. Relaunched the client with itemType on
all three: ?type=kit answered total=2 emitted=2, and still no cardtype-7 record.
The correlation was an artefact of the control. Measured read-only over
/proc/PID/mem with full coverage (3605 MiB, nothing skipped): the "resident"
players and staff were all SQUAD members, which arrive via userMassInfo. Players
that appear in /club?type=player but NOT in userMassInfo are not resident either -
0 records for 6 of 6 sampled, 5 with no byte match at all, out of 1966 served.
Residency tracks the ROUTE. /club?type= responses never enter the persistent card
collection, and no value of itemType changes that. itemType is kept as wire
fidelity (every real EA item carries it) and relabelled; its doc no longer claims
to fix anything. The diagnostic KIT_PROBE is removed - it could only have tested
shape hypotheses that this result makes moot.
RECOVERED from the unpacked CardsDLL, no archive extraction, no instrumentation:
Packed kit id, both directions present and agreeing:
id = (teamid << 14) | (year ? (year-1800) << 5 : 0) | kittype
so a kit is addressed by the triple (teamid, year, kittype).
FUN_180033770 answers ONLY for team 130000 - 0x1800d8ab0 is literally
`mov $0x1fbd0,%eax ; ret`. Every other team id falls through to the engine's
catalogue kits, which are the lockable ones.
sub_180033430 writes the tile: NAME = "HOME_SIDE"/"AWAY_SIDE", TYPE = the
localised Kit_type_0 / Kit_type_1 / Kit_type_historical, and LOCKED (always
value 0, never 1). If the queried triple matches NEITHER active triple it
writes NOTHING - which is exactly why one tile rendered "undefined". A missing
write, not a bad string. There is no Kit_type_2.
FUN_1800d73d0 selector 2/3 does `setne dil ; add $0x65,%edi` then compares
itemState: active home = 101, active away = 102, derived arithmetically and
independent of the enum table. year at +0xba is movzbl - a byte INDEX.
Above all of it: FUT_GET_MATCH_KITS_DP (0x7565) handler FUN_1800be6a0 gates on
`cmpb $0x1,0x152(%r14)` and returns early otherwise. KITS_AVAILABLE IS
ctx+0x152. Constructor zeroes it; the only setter is case index 6 (message
0x757a) of the jump table at 0x1800c00d4. Live value is 0, so no kit list is
ever built. 0x757a has no name in CardsDLL and that is bounded, not sloppy: the
registration run ends at 0x7575 with the epilogue immediately after, and 70
other ids resolve from the same table as the positive control.
Tables (audit_fifa17_kits.py, full-table counts): category 2/3/5 -> engine kit
type 0/1/2 with 0 counterexamples against 54/166/145 discriminating keys; the id
band is NOT home/away (band 63 holds 740 home AND 88 third).
Vault: "Kit Selector Data Path.md". cargo test 429 passed 0 failed across the two
crates; clippy -D warnings clean; fmt clean.
|
||
|
|
eefa98c961 |
kits: canonical table-proven kit map, and category is the home/away key (not the id band)
Answers from the extracted client tables, before touching a binary. Every number is a count over the full table. fcc_kitcards 1482 rows, teamkits 2576 rows. CATEGORY -> ENGINE KIT TYPE, with a test that can actually fail. Asserting "category 3 means away" because away kits usually exist is not evidence: types 0/1/2 are present for most teams, so it is true by construction. The discriminating cases are the teams that LACK a type. category 2 -> type 0 HOME 54 keys lack type 0, 0 counterexamples category 3 -> type 1 AWAY 166 keys lack type 1, 0 counterexamples category 5 -> type 2 THIRD 145 keys lack type 2, 0 counterexamples and there is never more than one card per (team, year, category). THE ID BAND IS NOT HOME/AWAY. Band 6300000 holds 740 HOME cards AND 88 THIRD cards; band 6400000 holds the 654 AWAY cards. assetid is fully determined by the band (14 for 828/828 of 63xxxxx, 15 for 654/654 of 64xxxxx), so it carries no information the band does not. cardassetid is 35 on all 1482 rows - it is the FUT card frame, not the kit art. This matters for openfut-adapter-fifa17: KIT_AWAY_FLOOR splits home from away at 6_400_000, which is right for home vs away but silently classifies all 88 THIRD kits as HOME. Recorded here, not yet fixed - third kits are not currently ownable, so nothing observable depends on it. teamkits.islocked is 0 on all 2576 rows, so the DB lock flag is NOT what makes the pre-match selector call a kit locked. 6 rows are embargoed. Team 21, the staging club, resolves exactly: 6300006 cat 2 HOME year 0 assetid 14 -> teamkitid 1376 6400003 cat 3 AWAY year 0 assetid 15 -> teamkitid 1377 6300007 cat 2 HOME year 1972 assetid 14 -> teamkitid 5126 6300008 cat 5 THIRD year 0 assetid 14 -> teamkitid 1378 so the two kits OpenFUT serves are the correct home/away pair. NOT recoverable from data/tables: the kit's own name string. fcc_kitcards name/header/description/biodescription are byte OFFSETS into the table's string blob (583, 597, 608, 619 on one row), and that blob is not among the extracted tables. Adds audit_fifa17_kits.py (the tool, with the discriminating test inline) and fifa17-kit-map.json (its output) so this is reusable data rather than terminal scrollback. |
||
|
|
88ae754b0f |
launcher: record the detour revert (d764117)
The submodule pointer still referenced the rate-limited engine-provider build. d764117 removes those detours entirely; they froze the client once and crashed it once, and the fault was the detours themselves rather than their logging. |
||
|
|
e0f1e379b7 |
kit probe: settle the ingest question in one restart; itemType alone did NOT fix it
RESULT OF THE PREVIOUS COMMIT, recorded before anything else: sending itemType on
cardtype-7 club items did NOT make them ingest. Client relaunched, ?type=kit
answered total=2 emitted=2 with itemType="kit" on both, and afterwards there is
still no cardtype-7 record resident and the hook still traces
KITS_AVAILABLE = 0. The player/staff-vs-kit/badge/stadium correlation was real
but it is NOT the cause. The field is kept because every real EA item in the
capture corpus carries it and the two ingesting families already did, but it is
now labelled wire fidelity, not a fix.
Also already refuted, so neither is the answer: ?type=equippables answered with a
kits-only body (kits stayed undefined), and the kit ids are correct - 6300006 and
6400003 are the real fcc_kitcards carddbids for team 21 home/away with matching
category 2/3 and year 0.
This adds OPENFUT_FIFA17_KIT_PROBE (default OFF, staging armed) which appends two
synthetic kits to ?type=kit so ONE client restart discriminates the two remaining
hypotheses instead of one restart each:
6300007 MINIMAL - exactly the field set a STAFF item carries, which is known to
ingest, plus cardsubtypeid 9. If only this appears, one of the kit-only
extras (assetId, cardassetid, teamid, category, year) makes the client
discard the item.
6300008 NAMED - full kit shape plus name/localizedName/description, the three
fields the cardtype-7 parse arm is documented to copy and which OpenFUT has
never sent. If only this appears, they are required, not optional.
If NEITHER appears, ?type=kit is not the route that populates the collection
FUN_1800d73d0 scans, and the search moves to which route does.
Both ids are real team-21 carddbids, served free so they cannot disturb the
active-kit assignment, with instance ids outside Core's range. Two items in one
family: the response that crashed this client on 2026-08-05 was thirty across
five.
|
||
|
|
6d89d62e41 |
club items: send itemType, which every ingested family already carries
Measured live 2026-08-23 against the client parked on the pre-match kit selector
(pid 8793, read-only /proc/PID/mem). Whether a served club item becomes a
resident item record correlates perfectly with whether we send itemType:
family itemType sent resident record?
player "player" yes
staff "staff" yes
kit (absent) NO
badge (absent) NO
stadium (absent) NO
Two of two families that carry it are ingested; none of the three that omit it
is. With no cardtype-7 record resident the club scan FUN_1800d73d0 matches
nothing, the FUT match-kit DataProvider is built empty (traced: KITS_AVAILABLE
= 0), and the selector falls back to catalogue-gated engine kits - which is the
"This kit is currently locked. To unlock and use it, please go to the Football
Club Catalogue." dialog the operator sees. That string lives in the Flash UI, and
Blaze receives no catalogue or unlock request at any point, so the lock is
decided client-side from data the client already holds.
CARD_SYSTEM.md records that itemType "is parsed into a heap string and never
stored". That stays true of the RECORD; it does not follow that the string is
unused, and the correlation is evidence it is consulted before the record is
kept.
Tokens come from the ?type= vocabulary decoded from the FUN_18012ec50 jump table
(kit 12, stadium 13, badge 11) - the same vocabulary the two working families
use. INFERRED, not proven: no real EA club item exists anywhere in the capture
corpus, so the exact token is taken from the atom vocabulary, not observed wire.
Near miss worth recording: STADIUM_SUBTYPE was not imported at module scope, so
match treated it as a fresh binding instead of a constant, made that arm
irrefutable and typed badges as "stadium". It compiled with only an
unused-variable warning. every_cardtype7_family_carries_its_own_item_type
asserts three distinct tokens, which is what catches that.
cargo test --workspace: 1240 passed, 0 failed.
|
||
|
|
40e53ed02c |
kit selector: withdraw the "client dead end" verdict, measure what is actually resident
The 2026-08-21 entry concluded the pre-match kit selector "is a client dead end,
not a missing wire field" because nothing stores 4 into item +0x60. Withdrawn.
It rested on two mistakes:
1. +0x60 == 4 DOES occur live - a record with +0x4c == 2 and +0x60 == 4 reached
the art-clone driver FUN_1801c3480. The immediate-store scan cannot see it,
so "nothing can satisfy the gate" was never licensed by that evidence.
2. It annotated `cmp [rdi+0x4c], 7` with "<- we produce this" without measuring
it. Its own live half showed only {1: players, 0: staff}: zero cardtype-7
records. That is the finding, and it was read as the opposite.
Measured now against the live client parked on the kit selector, read-only via
/proc/PID/mem over 3047 MiB, searching the exact u32 values the server sent:
players and staff are resident with sane fields; kit, badge and stadium are all
absent by resourceId AND by instance id. The host served ?type=kit total=2
emitted=2 at 17:50:09 this session and neither kit produced a record.
So the blocker sits upstream of the +0x60 gate: no cardtype-7 record is ever
created, so the club scan FUN_1800d73d0 has nothing to match, KIT_DESC never
fires and KITS_AVAILABLE reads 0. Cause is not yet settled - either our wire
shape (the cardtype-7 arm wants name/localizedName/description, which we do not
send) or cardtype-7 items being transient. Neither is recorded as fact.
Also: kit_gate_probe.py's live half is unreliable. On pid 8793 it reported
"CardsDb is empty" while a byte scan found 1966 resident players, so its
structural chain is stale and its record counts understate reality. Adds
club_record_residency_probe.py, which is read-only and cannot disturb the game.
|
||
|
|
b55dd16a46 | chore: bump openfut-launcher (engine-provider kit traces) | ||
|
|
11b7991d81 |
feat(fifa17): gated kits-only club?type=equippables, to test the locked-kit cause
A live kit_trace run on the retail client showed the kit clone driver only ever sees PLAYERS - about 19 records, all cardtype 1 / itemState 1 / +0x60 = 1 - and never a kit, with no KIT_DBCLONE line at all. So the locked-kit failure is UPSTREAM of the item+0x60 == 4 gate that this arm's comment blamed. In the same session the client asked for ?type=kit six times, which we answer and which feeds the items browser, and ?type=equippables twice, which we answer empty. If the equippable view is what populates the collection FUN_1800d73d0 scans to build the active-kit triple, an empty answer explains precisely why the triple stays zero and the engine falls back to its own catalogue kit. The arm now selects ContentKind::Kit behind OPENFUT_FIFA17_EQUIPPABLES=1, so it answers with TWO items rather than the thirty across five families that crashed the client on 2026-08-05. Default OFF: that crash is real and reproducible, and the narrow body is a hypothesis under test rather than an established safe response. Reverting is an env change plus a restart, no rebuild. |
||
|
|
e18304d365 |
fix(fifa17): serve the full kit identity triple so the selector can match
Operator report: selecting a kit in the pre-match selector gives "This kit is
currently locked. To unlock and use it, please go to the football club
catalogue."
Root cause, from static RE of the UNPACKED CardsDLL. CardsDLL registers a kit
provider into the FIFA engine (singleton FUN_1800338f0, vtable 0x1801f1d68):
slot +0x08 FUN_180033770 enumerate kits for a team
slot +0x10 sub_180033430 describe one kit <- the lock gate
The describe function decodes a packed kit id into (teamid, year, slot) and
compares it against the club's ACTIVE HOME and ACTIVE AWAY triples. On a match
it writes NAME/TYPE (and, for historical kits, LOCKED). On no match it writes
NOTHING AT ALL and the descriptor falls through to the engine's own default,
which is where the locked-catalogue message comes from.
The active triple is 100% server-driven. FUN_1801c26d0 -> FUN_1800d73d0 scans
club items for cardtype 7 + cardsubtypeid 9 + itemState 101/102, then reads:
teamid <- item+0x94 (atom 0x306) we were sending this
year <- item+0xba (atom 0x389) WE WERE NOT SENDING THIS
slot <- item+0xb8 (category) WE WERE NOT SENDING THIS
category 2 -> slot 0 home, 3 -> slot 1 away, 5 -> slot 3 third
So we shipped kits carrying only teamid, and the triple could never match.
fifa17-recon/data/club_items.json already has category and year per kit
resourceId (1482 kits; category {2:740, 3:654, 5:88}; 85 historical years), so
this is carried through the catalog rather than invented: Fifa17CardIdentity
gains category/year, Fifa17KitIdentity carries them, and shape_club_item emits
them for KIT_SUBTYPE only. Badges and stadiums deliberately do not gain the
fields - the slot mapping is kit-specific, and sending a family a field its
resolver does not read is how this project previously froze the client.
Also corrects the Vault note: item+0xba is `year`, not `kittype`. The side comes
from itemState 101/102 and the slot from category.
Two things this does NOT fix, both client-owned and recorded rather than
guessed:
- the runtime teamkits clone into FUT club 130000 (the kit ART) is gated on
item+0x60 == 4, and +0x60 has no wire atom at all: the deserialiser
unconditionally zeroes it, the only CMP against 4 in the whole DLL is
0x1801c34f1, and a live probe measured it as 1 for players / 0 for staff,
never 4. The existing lib.rs claim that a server can never produce 4 is
CONFIRMED, though its stated reason was a live observation rather than the
real one (no wire atom exists).
- whether a year==0 kit needs an explicit LOCKED write is UNKNOWN: CardsDLL
only writes LOCKED for year != 0, and the engine's default for an untouched
descriptor is in Denuvo-packed FIFA17.exe.
|
||
|
|
8614acff57 | chore: bump openfut-core (one-match training expiry in the match transaction) | ||
|
|
3ff09ae62c | chore: bump openfut-launcher (config BOM tolerance + corrupt-config quarantine) | ||
|
|
34be38260d |
chore: bump openfut-launcher to 9ba88c7 (roster socket redirect)
Records the submodule commit carrying ea_ports::FIFA17_ROSTER, which the version.dll build deployed to the Windows client is built from. |
||
|
|
1e0124c197 |
hook: redirect the FIFA 17 roster dial in-process, drop the DNS workaround
The client fetches the roster from the URL our own Blaze hands it, https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml, and ProtoSSL verifies that certificate by dNSName only. An IP-addressed roster host is refused even with IP Address:10.10.0.120 in the SANs (retested on Windows 2026-08-23), so the hostname has to survive into SNI while the connection lands on us. The hook log showed the dial was ALREADY being intercepted: connect_hook: call 20.51.153.159:8081 (octets logged reversed) It simply was not rewritten, because 8081 was not in the EA port table. So this is a table entry, not new hook surface: ea_ports::FIFA17_ROSTER makes the existing connect/WSAConnect/ConnectEx detour rewrite the destination while leaving the URL untouched, and the certificate still validates. That removes the scoped DNS responder and the client NRPT rule from the normal path. scoped-dns.py stays as the documented contingency for EA withdrawing the public record, which is the one dependency this cannot remove: the name must still resolve to something for connect() to be reached at all. roster_port is accepted in openfut.cfg but written only when non-default. The parser rejects unknown keys, so emitting it unconditionally would make every already-deployed hook reject the file and install NO redirect, breaking the game rather than degrading. Also corrects two documented claims that are false on the real machine: elevation comes from the shortcuts' RunAsAdmin bit, not from an AppCompatFlags RUNASADMIN entry (there is none), and hook_dll_path must point at a source copy rather than the deployed version.dll it is copied onto. |
||
|
|
286a44461d |
tools/windows: scoped DNS resolver for the FIFA 17 roster hostname
FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so the client must reach the roster as winter15.gosredirector.ea.com. Retested on Windows 2026-08-23: an IP-addressed roster host is refused even though the certificate carries IP Address:10.10.0.120 as a SAN. Public DNS points that name at EA's dead 159.153.51.20, so the client has to resolve it to us. scoped-dns.py pins exactly that one name and forwards every other query upstream verbatim, so a client pointed at it cannot lose general resolution -- verified against www.microsoft.com, github.com and www.msftconnecttest.com. Paired with a Windows NRPT rule rather than a hosts entry: per-name, auditable via Get-DnsClientNrptRule, and revertible in one command. A hosts edit on this machine had previously taken its whole internet down. |
||
|
|
8c353c6c66 | chore: bump openfut-core (training replace + all-six card) | ||
|
|
3a038fe406 |
feat(fifa17): apply the rare all-six training card
Passes a null attribute slot for the rare card -- Core reads absence as "every slot", so sending 0 would silently train pace alone -- and declares the per-family ceiling rather than a single constant. Tests pin the null-slot serialisation, both ceilings, and that all 36 single-attribute plus all 6 rare cards resolve. |
||
|
|
3bb6b4fc9d |
fix(fifa17): subtypes 57/67 are the rare all-six training card
They were failing closed as "squad fitness". Four facts say otherwise: each family holds exactly 21 rows = 7 types x 3 levels matching the published "6 single attributes + 1 ALL" list; those six rows are the ONLY ones with weightrare=2 while all 36 single-attribute rows are 0, and the published list marks ALL rare; their amounts are exactly 3/6/10 against the documented ALL card's +3/+6/+10; and bc=6 is one past the six real slots, an "all" sentinel, with c0=0 reading as "not single-ATTRIBUTE" rather than "not single-target". The misreading came from consumables.json's FUT_FITNESS_UC/MC label, which is tool-authored -- build_consumables.py names the 7th element of its attribute array -- and has no documented provenance. The bc/c0 values beside it ARE reversed; only the name was not. The genuine squad-fitness card is subtype 220 in fcc_healingcards (10/20/30) and player fitness is 219; that family stays unsupported. The two families carry different authored ceilings, 15 single-attribute and 10 all-six, so ceiling_for() reports the right one per effect -- sending the single-attribute ceiling for an all-six card would let a +15 all-six boost through, granting 90 attribute points from a card worth 60. Also corrects ENDPOINT_MAP row 7: ApplyCardByRes carries urlIndex 0x0e, which resolves to ut/%s/item/resource, and the live verb is POST. The row claimed PUT ut/%s/item for both apply RPCs, and that conflation is what kept the "apply must ride PUT ut/%s/item" hypothesis alive until the POST capture settled it -- every observed PUT ut/%s/item is a pile move. |
||
|
|
a9bac8be8e | chore: bump openfut-core to a45155e (attribute training effect) | ||
|
|
3c28b0d1af |
feat(fifa17): apply training cards, and project trained attributes
Opens the 409 `apply_effect_unproven` gate for attribute training, the second family after contracts to have its effect settled rather than merely its magnitude. `ApplyEffect` replaces the single-family `AddContractMatches` struct: Core dispatches on `kind`, so an unproven family must be impossible to express, not merely discouraged. The shared half of an apply -- the exactly-once key, Core's transaction, the error mapping and the client's payload -- is now one `finish_consumable_apply`, so a new family cannot quietly acquire its own idempotency format or its own success shape. Two refusals are training-specific and both prevent silent corruption rather than merely being tidy: a non-player target has no attributes to write, and a cross-class target would train a different attribute from the one printed on the card, because a keeper's slots mean DIV/HAN/KIC/REF/SPD/POS where an outfielder's mean PAC/SHO/PAS/DRI/DEF/PHY. `attributeList` now prefers Core's `effective_attributes` and only falls back to the immutable definition when Core does not send them -- reading the definition regardless would silently drop every applied training off the card the client draws. Tests pin the verb split on `item/resource/<rid>` (POST applies, PUT stays quick-sell, GET is not an economy route at all), the digit guard, the exact JSON Core deserialises for both effects, and that no shipped training card exceeds the ceiling the host declares to Core. |
||
|
|
4936654f84 |
feat(fifa17): map training subtypes to attribute slots
Which attribute a FIFA 17 training card trains is recoverable after all -- not from a table, but from the client's own dispatch: `FUN_18013f4d0` derives a consumable's whole presentation from `cardsubtypeid` and writes an attribute selector to `rec+0xbc`. Paired with `fcc_trainingcards.amount` (TABLE_PROVEN, matching the wire 8/8), that settles both halves of the effect for all 36 attribute training cards -- 12 families x 5/10/15, 18 goalkeeper and 18 outfield. The subtype order is NOT the slot order: 54 is SPEED at slot 4 while 56 is REFLEXES at slot 3, and 65 is HEADING at slot 5 while 66 is DEFENDING at slot 4. Reading them sequentially trains a different stat from the one on the card, invisibly, so the table is explicit and a test pins those four. The two SQUAD training cards (57, 67) share the table and the client's training UI bucket but are the only ones whose single-target byte is 0: they act on a squad and move fitness, not an attribute. They resolve to None and fail closed rather than being mistaken for a +3 attribute card. A keeper's six slots mean DIV/HAN/KIC/REF/SPD/POS and an outfielder's mean PAC/SHO/PAS/DRI/DEF/PHY -- same numbers, different attributes -- so the class gate is not cosmetic. |
||
|
|
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).
|
||
|
|
3c67fea074 |
feat(fifa17): serve the consumable quick-sell (PUT item/resource/<rid>)
Fixing the display was only half of it. Quick-selling a consumable from the
repaired screen produced "There was a problem communicating with the FIFA
Ultimate Team servers", because the client's consumable quick-sell is a route
neither stack had ever served:
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
Live-captured on staging. That path now carries three verbs -- GET is the
definition lookup, POST applies the consumable (ApplyCardByRes), PUT quick-sells
it -- and it is keyed by the stack's RESOURCE id, not an owned instance, unlike
the player quick-sell (DELETE item/<instanceId>).
This had to be Rust-owned rather than proxied: the Python oracle maps
item/resource method-agnostically to its definition route, so on production --
where the oracle is alive -- a PUT would return 200 with a definition list and
sell nothing, and the client would show a successful sale of a card the player
still owns.
Implementation reuses the retail-proven quick-sell path verbatim
(handle_quick_sell_path), so pricing comes from the same
ItemIdentityResolver::discard_value that stamps the number on the stack. Display
and payout are the same call; they cannot drift.
Two decisions, both documented in the code as decisions rather than discoveries:
* ONE copy per request. The request carries no quantity, and the screen prices
a CARD, so consuming a whole stack on one keypress would pay one card's
price for N cards. Selling one is the conservative reading.
* The copy sold is Core's first matching owned instance -- the same one whose
wire id the consumables screen already published as the stack's `item`, so
the player sells the card they were shown.
Verified against the running staging host:
displays 38 -> PUT -> coins +38, owned -1, consumables -1, stack 2 -> 1
replay sold the one remaining copy (+38, -1), no double credit
exhausted -> 404 not_owned, coins +0, owned +0 (no phantom payment)
123 host tests (+1 locking all three verbs on the shared path, and that the bare
`item` PUT stays the pile move), clippy -D warnings clean, fmt clean.
|
||
|
|
2a9507cb6a | docs(re): consumable quick-sell is PUT item/resource, live-captured | ||
|
|
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.
|
||
|
|
ba19954ffb |
fix(fifa17): consumable stacks carry their real quick-sell value
A production club displayed "Quick sell for 0 coins" for contract cards that
Core would have paid 3/13/32 for. The consumables stack wrapper hard-coded
discardValue (atom 0xd7) to 0.
The old rationale was that the client prices the card itself, the way it does
when we omit discardValue from an item. That is true of the ITEM record and not
of the STACK, and the evidence separates them cleanly:
* item+0x38 non-zero makes the client SKIP its local computation and show our
number -- re-proven on the live production client, 16/16 resident cards
"SERVER-SHOWN (local calc skipped)", including the acceptance card
235066 -> 40.
* We send no discardValue inside a consumable's item, so +0x38 is 0 and the
local computation DOES run and fills +0x3c correctly -- Milestone 1 measured
3/3/32/38 there, matching this table.
* The screen still showed 0. So the screen is not reading the item's computed
+0x3c; it reads the stack's atom 0xd7, which we were sending as 0.
So the number belongs on the stack, and it is the SAME
discard::value_for_definition that computes the payout -- one source, so the
screen and the wallet cannot disagree. Per CARD, not per stack: FUT prices a
card and the stack is only a quantity badge over identical copies. An
unpriceable definition stays 0 rather than inventing a number.
Verified on staging across every populated family, 16/16 stacks shown ==
recovered, none zero:
contracts 32/3/13 · healing 32/3 · training 3/13/34 · playstyle 38/38/38
· position 36/38/38/38/38
Two tests lock it: the payout equality (with the exact 3/13/32 the production
club would have been shortchanged on) and per-card-not-per-stack pricing for a
collapsed count=3 stack.
No payout logic changed, no taxonomy change, no ownership change, no Python.
250 adapter tests, 122 host, clippy -D warnings clean, fmt clean.
|
||
|
|
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)".
|
||
|
|
a4c6aeed49 | docs(re): ApplyCardByRes post-ACK protocol is outcome B, live-proven | ||
|
|
97498c560e |
docs(re): refute the contract:7 effect source; record the competing development reading
Two corrections found while trying to close the effect boundary statically.
1. `contract: 7` IS OUR OWN PLACEHOLDER. fut_store.py:232's generic _item()
factory -- which builds every item the oracle serves -- hardcodes
playStyle 250 / contract 7 / fitness 99 on players and consumables alike. The
staging GK reads back exactly those three constants. So the production
catalog's contract:7 for resource 5001004 is an oracle placeholder
round-tripped through an observed profile, not an EA value. Its status is not
INFERRED, it is KNOWN-BOGUS as a source. Had the effect been implemented on
it, it would have been a fabricated game rule wearing observed-data clothing.
2. fcc_contractcards is NOT amount-less. An earlier note here claimed it "has no
amount column, so this value comes from observed data". It has 13 rows with
gold/silver/bronze/rating, 6 player + 6 manager paired by rating plus a
99/99/99 special. The sibling fcc_healingcards shares every column except
that it carries a single `amount`, which argues the differing columns ARE the
effect payload (per target tier). Against that: the values are non-monotonic
across tiers, which suits weights better than amounts; and no column of
5001004 is 7, so neither reading explains the placeholder.
The reader that would settle amount-vs-weight is in FIFA17.exe, not CardsDLL
(the table and column literals are absent from the DLL), so this stays
EFFECT_UNKNOWN rather than being guessed.
Also records, in content_taxonomy.rs, the competing reading of `development`:
fut_consumables.py's TYPE_CATEGORIES groups it as card-categories {6,7,8,9,10}
(modifiers only), explicitly flagged there as inferred from UI-bucket names and
never observed on the wire. Different enum space from the CONSUMABLE_TYPE switch
that actually emits the segment, and the switch gives formation/position/
playStyle/managerLeagueModifier their own segments rather than folding them into
development -- so the unfiltered reading is better supported, but it is still a
reading and the doc now says so instead of sounding settled.
248 adapter tests, fmt clean. No behaviour change.
|
||
|
|
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.
|
||
|
|
9f445904a5 | docs(re): record the reversed ApplyCardByRes success contract and the nine-segment consumables vocabulary | ||
|
|
ce5d4204ac |
feat(host): staging-only consumable-apply probe; reverse the success contract
Claims POST ut/<sku>/item/resource/<resourceId> -- the consumable apply captured
live 2026-08-21 -- behind OPENFUT_FIFA17_APPLY_PROBE=1, default OFF. With the
gate off the route takes the extracted `passthrough` method, i.e. byte-for-byte
the behaviour that existed before this commit, so production cannot serve a
diagnostic even if the route is reached.
The handler is NON-AUTHORITATIVE BY CONSTRUCTION: it consumes no source card,
mutates no target, touches no contract/fitness/chemistry/training/injury state,
mints no coins and changes no ownership. It exists only to observe the client's
success path, because the EFFECT of a consumable is still unreversed and
implementing one on an inferred value is not acceptable.
RESPONSE SHAPE, from static RE rather than convenience (the brief was explicit
that `{}` must not be chosen because it is easy):
* The apply completion handler is CardsDLL 0x180035520. It does
`mov ecx,[rdx+0x1c]; test ecx,ecx; jne FAILURE`, raising
EVENT_CARDS_APPLY_CARD_SUCCESS (0x1801f37f0) on zero and
EVENT_CARDS_APPLY_CARD_FAILURE (0x1801f3810) otherwise. It tests exactly one
field -- the transport code -- and never inspects the body.
* That is materially different from the MOVE ack (0x180128600), which builds
per-item verdict records and reports FAILURE when the vector is EMPTY. The
`{}`-is-broken precedent does not transfer.
* The response object's constructor (0x1800a4ce0) initialises its record vector
(+0x50/+0x58/+0x60, 0x20-byte elements) EMPTY, so an empty parse result is a
legal state here, and the destructor (0x1800682b0) frees it accordingly.
* The legacy oracle routes `item/resource` method-agnostically to defs_route,
so historically this path answered with an `itemData` OBJECT.
`{"itemData":[]}` is the smallest candidate consistent with all four, and it is
labelled a PROBE, not a proven contract.
`apply` is an array, but only len==1 has ever been observed, so a multi-target
request is logged and refused (400 apply_batch_unsupported) rather than given
invented batch semantics.
Operands are identified READ-ONLY for the capture: the source by Core card id
(`<sku>_<resourceId>`, no new resolver method for a probe) with a copy count, the
target by reversing the wire id through the identity store -- never a guess,
`UNRESOLVED_WIRE_ID` when unknown.
Also records the reversed protocol and the `development` finding in
CLIENT_ROUTE_SURFACE.md.
122 host tests (+2: the verb/resource-id classification boundary, and target
parsing incl. the exact captured bytes). clippy and fmt clean.
|
||
|
|
6ca735749e |
fix(fifa17): serve the development and formation consumable categories
The live client asked for `club/consumables/development` and got an empty
screen: `consumable_families_for_category` had no arm for it. Tracing that
segment recovered the client's OWN category vocabulary, and it is nine segments,
not the seven this file assumed.
CardsDLL, live 2026-08-22: the literal table at 0x1801f5a38 (under
MyClubAdapterClass / CONSUMABLE_TYPE) and the switch at 0x180048820, which
indexes by `enum + 1` through the byte table at 0x180048a90 into the case table
at 0x180048a6c:
enum -1 (unset) -> development
enum 1, 2 -> contracts
enum 3 -> healing
enum 4 -> fitness
enum 16 -> formation
enum 17 -> position
enum 23 -> playStyle
enum 24 -> managerLeagueModifier
enum 0, 5..15, 18..22 -> training (switch default)
Two consequences:
1. `formation` HAS a segment (enum 16). This file claimed the two formation
modifier families "have NO group code, so no segment can reach them -- that is
the client's own gap, not an omission here", and a test asserted it. Both were
wrong, and wrong in the direction that hides a server bug: it was our gap.
`formation` now maps to manager_formation_mod + formation_mod, so all
THIRTEEN families are reachable instead of eleven.
2. `development` is the type-UNSET bucket -- index 0 of a table indexed by
`enum + 1` -- i.e. no type filter. It is therefore the unfiltered view and
maps to every family via ALL_CONSUMABLE_FAMILIES. That is consistent rather
than overlapping by accident: the eight TYPED segments already reach all
thirteen families exactly once, so there is no family for `development` to
own privately.
The partition test now asserts the eight typed segments cover all thirteen
families with no duplicates, and that `development` is exactly their union, so a
family added to the taxonomy cannot silently vanish from the unfiltered screen.
Ownership and classification are untouched; this is projection only.
248 adapter tests, clippy and fmt clean.
|
||
|
|
739228efdb |
feat(host): capture unclaimed request bodies; record the consumable-apply wire
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.
Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.
With it, one operator apply captured the whole thing:
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
target item(s) : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
verb : POST
There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.
Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.
NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.
Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.
Host 120 lib tests, fmt clean.
|
||
|
|
db5fb37980 |
feat(host): name unclaimed requests, and record the FUT task vocabulary
Milestone 2 groundwork. The passthrough arm forwarded to Python without ever recording WHAT was asked for, so on staging -- where the upstream is deliberately dead -- an unhandled request produced an anonymous 502. It now logs method, path and body length before forwarding, which is how the next unclaimed route gets identified: utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=0 Also records the FUT TASK vocabulary read out of the live client. The client drives UTAS through named tasks held in a CardsDLL .rdata table of 0x20-byte MixedCase/UPPERCASE slots, with a .data descriptor table giving each a task id: ApplyCard 0x0d, ApplyCardByRes 0x0e, ConsumeCard, ActivateCard, AssingCard(sic), MoveCard, MoveCardByRes, SwapCard, DiscardCard, DiscardCardByRes, ViewCards, ... So consumable application IS a first-class client action even though the route table contains no /apply endpoint -- it must ride an existing route. The descriptor's function pointer is a `mov [rip+flag], cl; ret` setter, not a request builder, so the request is assembled elsewhere keyed by task id; that is cheaper to answer with one live capture than with more static tracing. Search tooling carries mandatory positive controls (tradePile, ut/%s/item, squad -- all FOUND), so the "no /apply route" result is a valid negative rather than a failed scan. Host 120 lib tests, fmt and clippy clean. |
||
|
|
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. |
||
|
|
06d94bb37d |
fix(fifa17): club items are zero-value, not a fallback to an invented price
`value_for_definition` declined for anything non-player without a catalog rating,
which sent club items into the legacy ladder and paid an invented 150 each.
That is wrong, and the client says so. `shape_club_item` sends neither `rating`
nor `discardValue`, and cardtype 7/9 are NOT re-rated by the client (the merge
jump table sends them to the shared tail), so the client computes for itself from
record +0xb4 == 0: level 1, `0 * price / 100` == 0. It DISPLAYS 0. Paying 150
invents value the player was never shown.
Move the decline boundary onto the real distinction, which is `client_rerates`:
* NOT re-rated (cardtypes 1, 6, 7, 8, 9) -> the server's rating is what the
client prices with, so Core's value is authoritative even at 0.
* RE-RATED (2, 3, 4, 5, 10 -- the staff families) -> the client substitutes its
own database value, so without a catalog rating we genuinely cannot match it
and must decline rather than guess.
Over the 1717-definition corpus this takes "declined -> legacy" from 6 to ZERO:
every definition is now priced by the one authoritative table and no generic
fallback is reachable in the current corpus. The six club items price at exactly
0; staff and consumables are unchanged.
Adapter 248 lib, host 120 lib, fmt and clippy clean.
|
||
|
|
f371349dd5 |
refactor(fifa17): one authoritative discard implementation + corpus matrix
The pricing DECISION (which rating to trust, when to decline) lived in the host
while the TABLE lived in the adapter, so FIFA semantics were split across two
crates and no single function could be pointed at as authoritative.
Move the decision into the adapter as `discard::value_for_definition(subtype,
rareflag, catalog_rating, core_rating) -> Option<i64>` and have the host call it.
Its three tests move with it. There is now exactly one table implementation, one
decision point (`ItemIdentityResolver::discard_value`), and one deliberately
retained rollback ladder (`legacy_discard_value`).
Add `examples/discard_matrix.rs`, which audits an entire FIFA17 corpus using the
SHIPPED implementation rather than reimplementing the formula, so the matrix
cannot drift from what the server pays. Over the current 1717-definition corpus:
declined -> legacy : 6 (badge, ball, kit x2, misc, stadium -- no catalog rating)
priced zero : 0
negative : 0
implausible : 0
rating boundaries : OK (1/2/3 at <65 / 65..74 / >=75)
Also re-verified both numeric cores against the LIVE client rather than trusting
the earlier notes:
level 0x180141e8a cmp al,0x4b -> 3 ; cmp al,0x41 ; sbb/add 2 -> 2 else 1
value 0x180141119 imul rating*price ; /100 via 0x51eb851f ; imul 0x64 ; sub ;
cmp remainder,0x32 ; jl/inc == round-half-up
`(rating*price + 50)/100` is identical to that for non-negative inputs.
Adapter 247 lib, host 120 lib, fmt and clippy clean.
|
||
|
|
fb38ee6087 |
fifa17: claim five routes whose handlers were already unreachable
Read the client's COMPLETE UTAS route surface out of CardsDLL's .rdata in the
running process (new tools/url_template_probe.py) and probed every one against
staging, where the Python upstream is deliberately dead so anything the Rust host
does not own answers 502 instead of being silently proxied.
That found five routes whose handlers already existed and were dead code because
`classify` never produced their Route -- the same defect as `season/list` and
`watchList`, whose fix comments are still in the file. This is the third and
fourth time:
captcha -> handle_static_ack, which already returns the oracle's exact
{encodedImg,sequence,sizeBeforeEncode}
tfa -> handle_static_ack, {}
livemessage -> handle_static_ack, {}
activeMessage -> handle_static_ack, {}
tournament/user-> FeatureOffEmpty, {} == the oracle with FUT_MODES off
(tools/utas_server.py:1504); the client builds this literal
at CardsDLL 0x18021e540 and the bare `tournament` arm never
matched it
Route's own doc comment already claimed the first four as "Rust-owned
UNCONDITIONAL", so the documentation was wrong rather than the intent. All five
are byte-identical to the oracle, so claiming them is parity, not new behaviour.
Invisible in production because the upstream answers there.
Two tests pin the vocabularies so a handler cannot go unreachable a fifth time;
both are mutation-checked (removing the captcha arm fails the first).
Also documents the surface in docs/CLIENT_ROUTE_SURFACE.md, including the trap
that bit me repeatedly: an .rdata literal is a FRAGMENT, not a callable path.
`clientdata`, `purchasegroup`, `sbs/challenges`, `squadBuildingSets`, `club/items`
and `item` all looked unserved and are not. Only `squad/mode` is genuinely
unserved, and correctly so -- it is Draft-only, which is out of scope.
L5 finding: there is NO consumable-apply route anywhere in the binary. The only
owned-item mutations the client can express are PUT item (move/pile), DELETE
item/<id> and POST delete/item (quick sell), and PUT squad. So applying a
consumable is not a dedicated endpoint; L5/L6 must be pursued by capturing the
PUT item payload, not by implementing a route that does not exist.
Host 123 lib + 45 host_test, fmt and clippy clean. tournament/user, livemessage
and activeMessage verified 200 on staging (were 502).
|
||
|
|
cd5983ecdd |
fifa17: cardtype 9 is unnameable -- measured, and the gap closes as a negative
Serving owned balls (subtype 30), league logos (31) and fcc_misccards
(231/232/233/236) was the last projection gap. The open guess was that their
caption would come from `localizedName` on the wire, "probably", and they were
withheld out of caution.
Measured against the running client instead (new
tools/cardtype_dispatch_probe.py, read-only, reproducible, every step with a
positive control). They cannot be named at all:
1. The merge jump table at rva 0x141eb4 is indexed cardtype-1 with 10 entries.
Cardtypes 1..5 and 10 each get a DB-merge arm; cardtypes 6,7,8,9 ALL land on
one shared tail at 0x180141e8a that runs no query and writes no name.
2. `cmp [reg+0x4c], 9` (cardtype): ZERO sites in .text. For contrast, cardtype
1 has 13 and cardtype 7 has 6.
3. `cmp [reg+0x50], 30` and `..., 31` (cardsubtypeid -- the field that actually
selects a club-item caption): ZERO sites each, while kit 9, stadium 10 and
badge 11 all appear, which is the control. The only cardtype-9 subtypes
present anywhere are the four misccards ids, and all four are one boolean
predicate near 0x1801a72da that returns FALSE for them: an exclusion, not a
resolver. That predicate is NOT identified and is not claimed to be.
4. The cardtype-7 resolver is gated `cmp [rax+0x4c], 7` at 0x1800f6f04, so a
cardtype-9 item never reaches it. Its jne path formats AWARD_LABEL_%i --
the trophy path, not a fallback that would name a ball.
Nothing reads a localizedName for these subtypes, so sending one cannot become a
caption. Withholding them is a measured limit of the client, not caution, and no
server change can lift it.
CORRECTION: FUN_180119bd0 was recorded as "zero refs in CardsDLL -> almost
certainly an export, its caller is in FIFA17.exe". It is not an export. Its
address occurs exactly once in the whole process, at 0x18021c738 in CardsDLL's
own .rdata, and nothing in FIFA17.exe references it. It is virtual: vtable base
0x18021c2a0, slot +0x498, index 147 -- independently reproducing the recorded
"manager vtable slot +0x498" by a different method. Finding the boundary needs
the constructor-LEA trick; walking back over .text-pointing qwords runs 826 slots
through several adjacent vtables.
Bonus: the shared tail cardtypes 6-9 fall into IS the discard level ladder
(movzx [rdi+0xb4]; cmp 0x4b; cmp 0x41; store [rdi+0x54]), confirming
discard::discard_level instruction for instruction against the live client.
Adapter 244 tests, fmt clean.
|
||
|
|
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. |
||
|
|
755f237f17 |
fifa17: carry the staff rating the client re-rates to, verified live
Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the
client overwrites the rating and rare flag we send with values from its own card
database, and a staff wire record carries no rating, no rareflag and no
discardValue at all. The server had no way to know the displayed price from what
it sent, so pricing declined for staff and fell back to the placeholder ladder.
The missing input was read straight out of the running client (pid 6580), no UI
interaction required:
* tools/coach_probe.py grades all four resident staff records HIT, which by
construction requires record +0xb4 == the table's `value` and +0x58 == its
`rare`. That settles `value`-is-the-rating, which was previously an inference
and was deliberately not shipped on that basis.
* tools/discard_probe.py (new) reads both discard slots -- +0x38, the value we
sent, and +0x3c, the value the client computed for itself:
1000509 sub 4 ct 2 rat 88 rare 1 sent 0 calc 282 predicted 282
9000081 sub 6 ct 10 rat 66 rare 0 sent 0 calc 36 predicted 36
3000083 sub 8 ct 4 rat 66 rare 0 sent 0 calc 36 predicted 36
4 of 4 agree, 0 disagree. 36 on the value-66 GK coach was the exact falsifier
written for this last commit.
Entities::enrich_staff now fills rating from `value` and rareflag from `rare` for
the five staff families, and the catalog emits the real rareflag instead of a
hardcoded 0 (it is not cosmetic -- it selects the discard price column, which is
why the rare-1 manager prices at 282 and a rare-0 coach at 36). Players and
consumables are untouched; their wire values are authoritative.
Verified on staging: a GK coach quick-sells for 36, not the 150 floor. The
catalog diff is exactly the two coach entries gaining rating 66; 1710 entries in
and out, nothing else changed.
The same probe shows what production does to PLAYERS today: every resident player
carries sent+38 = 1500, which suppresses the client's own computation, against a
real 688..752 for a gold rare and 72,800 / 74,400 for the two legends.
Still open, and not a discard problem: manager fifa17_1000509 is owned in Core
but has no catalog entry or definition (it reaches the client through the opaque
squad extension), so it declines to the ladder. That is definition coverage.
Importer 41 tests, fmt and clippy clean.
|
||
|
|
49b18dd4ac |
fifa17: price quick-sell from the client's own discard table
Quick-sell paid an invented five-tier rating ladder (its own comment said "PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a 94-rated TOTW special and a 94-rated gold common both sold for 1500, and every non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder existed in three places (adapter wire, host payout, an integration test's private copy), which is a drift waiting to happen. Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins table and its formula, round_half_up(rating * price / 100), keyed (cardtype, level, rare). All of it is already reversed in plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live club items, 22 of 22 exact. DISCARD_COINS is generated from fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and asserts row-for-row agreement, so the transcription cannot drift. Collapse the three ladders into one method. ItemIdentityResolver::discard_value both stamps the wire discardValue and prices the sale, because a non-zero discardValue suppresses the client's local computation -- whatever is sent is what the player is promised. The host's quick_sell_value is deleted and the integration test's copy now calls the single implementation. A test with a resolver double returning an impossible price proves the credit follows the wire; reverting the payout to a ladder fails it. Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real 1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an operator's decision. Staff decline to the ladder rather than pay 0: the client re-rates cardtypes 2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not guessed -- see the falsifier in the doc. Verified on staging with the real club, both modes: flag off 1500 wire / 1500 paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100). Consumables price from their catalog rating and agree with the client's own computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy clean. |