3a038fe40679e6d3fe9d0f3cdd9abeb3f81afa87
451 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
274838cc2e |
test(host): pin the ?type= vocabulary to the client's own 30 arms
Decoded the vocabulary from the binary rather than trusting a case count: FUN_18012ec50 is `cmp ecx,0x1d` plus a 30-entry jump table at 0x18012ed9c, each case `mov ecx,<atom>; jmp <atom->string>`. Resolving those atoms against fut_atoms.tsv yields the exact token list, and it matches club_type_filter one-for-one — 30 implemented, none missing, none invented. That is worth a test rather than a note. A MISSING arm answers a real tab with unsupported_type and an empty screen; an INVENTED arm is worse, because it is dead code that looks like coverage. Mutation-checked: renaming the leaguelogos arm fails the test. Two facts fall out that were previously guesswork. There is no `playergoalkeeper` token — the client has only DEF/MID/FWD tabs — so a goalkeeper appearing under playerdefender is CORRECT and not a filter bug, which I had flagged as suspicious while sweeping. And `healing`/`contract`/`training` exist as ?type= arms even though consumables have their own route. Also completes the last unapplied item of plan section 7: the full vocabulary is now written into ENDPOINT_MAP.md with how it was derived. |
||
|
|
d76c184cf1 |
fix(fifa17): make an unhandled club defId= list visible instead of silent
A health sweep of all 51 host routes found no errors, but did find a gap against the documented club grammar: the client may send a comma-joined `defId=` list INSTEAD of the filter block, and `parse_club_query` handled nine parameters without it. Today such a request is answered with the whole filtered club rather than the requested definitions — silently. Deliberately NOT implementing the filter. That grammar is single-source (one decompile plus one live log line, and the log line carried no defId), so the reading of "definition id" is unconfirmed against any observed request. Narrowing on a wrong reading would turn "too many items" into "zero items", which is the worse failure and the harder one to diagnose. So the parameter is parsed and reported instead: the filter summary gains `defId=<n>` and the host logs a NOTICE naming the ids and saying plainly that the response was not narrowed. The first real occurrence is then impossible to miss, and the filter can be written against a captured request rather than a guess. Verified live: the notice fires and total stays 1966. |
||
|
|
d74aee33f7 |
feat(fifa17): opt-in commerce settings, the server half of the transfer-list fix
"Place on Transfer List" is greyed for two reasons. This crate already fixes one
(owned copies emit `untradeable: false`). The other is `tradingEnabled`: the
client's struct defaults it to 0 — it is not a flag we have been overwriting, it
is a flag nobody has ever sent — and it gates the service half of the
TO_TRADE_PILE predicate (vtable slot +0x270, gate byte 0x1fd2e, measured 0 live).
`GET /settings` has always answered `{"configs": []}`.
The schema is high-confidence: FutGetSettingsServerResponse (deser 0x18013c6d0,
read end to end) has a single `configs` key holding `{type, value}` rows, and the
key ladder holds nothing else. `type` is the setting NAME. The row set is ported
from the shape the Python oracle would emit rather than invented.
Default OFF (`OPENFUT_FIFA17_COMMERCE_SETTINGS=1` opts in), because the flags are
RECOVERED BUT UNTESTED and the empty list is the live-proven body — the house
rule is that a flag defaults to the live-proven value. This also moves the
capability out of the oracle we are retiring and into Rust, where it can actually
be reached once Python is gone.
Verified against a real host on both settings: OFF returns {"configs":[]}
byte-identical to today, ON returns the 8-row body with tradingEnabled. It
explains why the menu entry is greyed; it does not promise the market works.
|
||
|
|
52df78d24a |
docs(fifa17): correct ENDPOINT_MAP club routes, and close plan section 7
Rows 12, 13 and 16 carried guessed or placeholder URLs (`ut/%s/item?type=…`, `ut/%s/…`). The real binding is a table, not an inference: the 125-row action table at 0x1802caa20 indexes the 48-entry URL-base table at 0x18021df80 through column 1, and base index 3 = ut/%s/club is carried by exactly four rows. So the client can emit exactly four families on that base: ClubSearch, ClubStats, StaffStats, ConsumablesSearch — which also corrects those three rows to /club?<query>, /club/stats/staff and /club/consumables/<cat>, and updates their status now that the Rust host serves them. Added the complete club query grammar (ordered, with its suppression rules and sub-vocabularies, including that the request spells it onSale where the response says forSale), the seven /club/stats forms, the fact that /club/stats/team does NOT exist, and the two base-table holes that are composed outside CardsDLL so nobody re-derives them as findings. Section 7 of the plan is now marked APPLIED and kept as the audit trail. |
||
|
|
22cfae830f |
docs(fifa17): apply the plan's corrections to CARD_SYSTEM.md and fut_store.py
Section 7 of plan-2026-08-06-card-subsystem.md listed these and they were never
applied, so the stale text kept misleading readers — it already cost this project
a ten-row itemState table.
CARD_SYSTEM.md:
- "STILL UNKNOWN, AND NOT GUESSED" is ANSWERED. Its candidate set was wrong:
it asked which of 0x1e/0x1f/0x91..0x96 meant kit/badge/stadium, but three of
the five families are cardtype 7 (kit 9, stadium 10, badge 11) and are not in
that set at all, and 0x91..0x96 are trophies. Replaced with the settled map
and how each family's caption resolves.
- itemState table starts at 0x180229cc0, not 0x180229d20 — the recorded address
points MID-table, which is why six rows were missing. Added that
WAITING_FOR_GAME/inGame are aliases, that omitting the key yields invalid and
not free, and that the match is case-sensitive (measured).
- the consumables route claim "the /consumables/%s template ... the client has
still never used" is false; it IS that template, with base index 3 = ut/%s/club.
- added the dated field-map correction block, extended with the +0x60 and
definitionId findings measured on 2026-08-21.
tools/fut_store.py: the discard_value premise "that lookup returns no row for our
cards" / "WHY its lookup misses is still UNKNOWN" is false — it does not miss, the
tile reads a different property. That story sent one round of work chasing a table
defect that never existed.
|
||
|
|
404e859cb6 |
docs(fifa17): the caption path is not in CardsDLL, and a 4th definitionId check
Chased the league-logo lead to a useful boundary and stopped there. FUN_180119bd0 — the cardtype-7 caption resolver the whole club-item story rests on — has ZERO references anywhere in CardsDLL: no call, no jmp, address never taken in .text/.rdata/.data. It is nonetheless a real function. An unreferenced real function in a DLL is almost certainly an export, which puts its caller in FIFA17.exe. So the owned cardtype-9 caption path is not in CardsDLL and looking for it there is wasted effort; the launch probe remains far cheaper than parsing the export table and 79 MB of EXE. Also verified definitionId a fourth way, by a different method than the existing three: every real atom name appears exactly once in CardsDLL's .rdata (resourceId, cardsubtypeid, itemState, assetId, cardassetid, rareflag, owners, contract, discardValue, and localizedName), while definitionId is absent entirely. Recorded but NOT applied — the path carrying it is live-proven and the saving is payload only. Method note added: CardsDLL is .text 0x180001000, .rdata 0x1801e5000, .data 0x18028a000. Confusing a live mapping offset with an image offset reads the wrong section and returns false negatives — it made every atom lookup, controls included, come back ABSENT until corrected. Validate scans against a known key. |
||
|
|
59c249e76a | chore: bump openfut-core for reclassify dry-run | ||
|
|
bea3b49070 |
docs(fifa17): a LeagueName_Abbr_15 path exists — recorded as a lead, not a fix
FUN_180098f20, previously described as the league-logo function with a hedged "localizedName, probably", read in full: it queries fcc_leaguelogos WHERE leagueid == %d, reads carddbid/value/cardassetid, and captions with 'LeagueName_Abbr_15_%d' in the 'FUT String' domain. A database-backed league name therefore exists, in exactly the shape kits use for teamid — so "cardtype 9 has no DB name resolver" is too strong for league logos. Deliberately NOT concluded: its only caller passes [rbx+0x20] as the league id, and rbx there is a loop cursor over small list elements (int/double/int), not the 0x158-byte card record. Reading that as the record's assetId and shipping "send leagueid as assetId" would be the exact inference this document exists to prevent. Recorded as a lead with the next question named: does the OWNED render path reach this resolver, and which field feeds it? |
||
|
|
e44c88dd68 |
docs(fifa17): close the eight-flag chain link, and separate the two databases
FUN_1801aa190 (the plan's "two minutes of work" item) is eleven instructions and resolves TWO parallel arrays, not the one the earlier claim described: f(self, idx, which) reads item+0x104+idx*4 when the flag is clear and item+0x124+idx*4 when it is set — eight ints each, 0x20 apart. Live, BOTH read all zeros on every resident record including a rating-94 player, so neither can be the reason any action is greyed today. The FUT roster database question is partly answered. Scanning FIFA17.exe in the live process recovers the full API name set — StartFUTRosterDownload, DL_FUT_LIVEDB, APPLY_FUT_LIVEDB, LoadFUTDatabase, UnLoadFUTDatabase, SetFUTDatabaseUnloaded, UpdateFUTDBVersion, GetFUTDBCRC, RosterXMLDownloadedFail, .dbFUTVer/.dbMajor/.dbMinor/CRCs — none of which exists in CardsDLL. That is a downloaded, versioned, CRC-checked live database with its own lifecycle, which is categorically not the shipped card tables. Whether it is loaded RIGHT NOW is still open: the load flag was not located, and the absence of an open DB file proves nothing since the process only holds Frostbite bundles. |
||
|
|
a842c5ffb0 |
tools(fifa17): answer "who writes item +0x60" — nothing does
The plan called this "the single blocker between 'we can mark a kit equipped'
and 'we can equip a kit'", and recorded that two attempts to find the writer
drowned at 1688 and 4144 instructions.
They drowned because +0x60 is a common struct offset. Two filters make it
readable: only an IMMEDIATE store can introduce a constant (a register store
just propagates one), and item-record code is recognisable by touching +0x4c
(cardtype) or +0x5c (itemState) within a few instructions.
Measured read-only against pid 6580:
- live +0x60 over all 27 resident records: {1: 23 players, 0: 4 staff}, never 4
- CardsDLL has 4 comparisons of +0x60 (0, 0, 1, 4); the 4 is the kit gate and
is the ONLY such comparison in the process
- CardsDLL has 29 immediate stores to +0x60, constants {-2,0,1,908,0x3f800000}
- FIFA17.exe, across 79 MB of code: ZERO stores of 4, zero comparisons with 4
- the gate function has one xref (a jmp) and its address is never taken
- every register store to +0x60 in CardsDLL is a struct copy or an init
So the gate is not a wire field we failed to send: the value it demands is never
produced by anything. Decoding it fully also shows every OTHER input is already
served — cardtype 7, itemState 101/102, teamid — leaving only the +0xba variant
selector beneath it, which makes a client-side patch the only remaining avenue.
|
||
|
|
7f17cfe439 |
test(host): name the catalog-card fixture instead of a six-tuple
clippy::type_complexity, and the struct reads better at the call site: the fixture rows now say which field is the subtype and which is the art id. |
||
|
|
622a6ab353 |
docs(fifa17): the three withheld families are one cardtype-9 name gap
Ball (30), league logo (31) and misc (231/232/233/236) were tracked as three separate holes. They are one: cardtype 9 has no database name resolver, so the displayed name can only come from `localizedName` on the wire, and that single unproven step gates all three. The cardtype-7 families caption themselves from the client's own tables, which is why kit, badge and stadium now project. Ownership, content_kind, club/stats counting and restart durability are already in place for all three, so the outstanding launch probe is the only remaining work. |
||
|
|
67d896615c |
test(host): lock the whole ownable taxonomy end to end
A club holding every ownable class, served through the REAL catalog resolver, so each family travels the production classification path rather than a stub. Asserts each family reaches its own `?type=` arm, that the staff arm carries the manager too, and that `teamid` appears only where a caption resolves TeamName_Abbr15 (badge yes, stadium no). The cardtype-9 families are asserted WITHHELD with their catalog entries RESOLVABLE, so an empty ball list is provably a decision about the family and not an accident of a missing asset id — the two failure modes are otherwise indistinguishable from the response. Mutation-checked: reverting `is_cardtype7_club_item` to Kit-only fails this test on the badge arm, so it guards the behaviour rather than merely describing it. |
||
|
|
beb505b0fa |
tools(fifa17): resolve the itemState comparator live — it is CASE-SENSITIVE
The plan recorded this as "almost certainly unresolvable statically", because `FUN_180008190` is only a forwarding stub through a slot the host fills at runtime: `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`. It IS resolvable — just not from disk. Read read-only out of the running client (pid 6580): the slot forwards through two FIFA17.exe thunks into msvcr120.dll+0x3c330, whose body is strncmp (`test r8,r8` count, `test al,al` NUL stop, `cmp al,[rcx+rdx]`, then MSVC's 0x8080../0xfefe.. NUL-detect fast path). No `or ..,0x20`, no folding table: the compare is raw bytes. So the casing in the table at 0x180229cc0 is a CONTRACT. A mis-cased token does not degrade gracefully — FUN_180166660 returns 0xffffffff, the record keeps 0 = invalid, and the item fails the squad builder. This confirms what fut::item_state already emits; it was previously true by convention and is now true by measurement. The probe follows the chain and attributes each hop to its module, which needs care under Wine: PE sections are mapped anonymously, so a module is identified by the nearest preceding named mapping rather than the containing one. |
||
|
|
43aa114bcd | style(import-fifa17): rustfmt the club-family classifier and its tests | ||
|
|
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.
|