09bb2dc30660ff0f5a2df9b35a5f0cdc7e7cf47d
486 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
9823bdac78 |
feat(fifa17): project badges and stadiums, the other two cardtype-7 club items
Kit, badge and stadium are ONE record with ONE client-side resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`); they differ only in the field their caption reads. Kits already ship and render, so the record is live-proven — badges and stadiums were being withheld as if unreversed when the authority (`plan-2026-08-06-card-subsystem.md`) marks both CONFIRMED, and its own rollout order is "kits first, then badges, then stadia". So the shaper generalises to the family, and carries exactly what each caption resolves: `teamid` for kit and badge (`TeamName_Abbr15_<teamid>`), withheld for stadium, whose resolver reads `StadiumName_<assetId>` and never looks at teamid. Sending a field the resolver does not read is how this project earned a client freeze. Ball (30) and league logo (31) stay withheld. They are cardtype 9 with NO database name resolver, so their name can only come from `localizedName`: the offset is confirmed, but "the parser reads it" is not "sending it is safe". Verified against the real club on staging: badge 6000005 emits cardsubtypeid 11 / cardassetid 39 / teamid 21, stadium 6200000 emits cardsubtypeid 10 / cardassetid 36 and no teamid, ball and logo emit nothing. |
||
|
|
7a4dab04d2 |
feat(import-fifa17): classify every club family, not just kits
Kits were recognised by the id range 6_300_000..=6_400_654, so stadiums, badges, balls and league logos all fell through to `Other` and were silently dropped from the import — a club could own them and Core would never hear. Club items are now settled by `cardsubtypeid` (kit 9, stadium 10, badge 11, ball 30, league logo 31), which is the discriminator the client's own club-item resolver uses and cannot collide with the other classes: consumables occupy 51..=341 and staff 4/6/8. The render gate generalises with it. Each family ships a CONSTANT cardassetid (kit 35, stadium 36, ball 37, badge 39, logo 40 — verified across all 2302 shipped rows), so a copy carrying anything else is deferred rather than drawn as the wrong art. Only kits additionally require a teamid, because their identity resolver keys on it and real owned kits carry it; demanding one of the other families would defer every legitimate badge, ball and stadium. League logos map to `misc`: they have no equipped slot, so Core holds them as generic owned content rather than inventing a designation. The real profile is unaffected (it owns no club items beyond its kits) and its emitted catalog is byte-identical. |
||
|
|
23f120f889 |
fix(staging): reclassify the restored club, and assert Core's ownership truth
The snapshot predates the content taxonomy, so every fresh bring-up restored a club whose coaches, kits and consumables were recorded as players. The wire still looked right (the adapter classifies from its own catalog), which is exactly the kind of divergence that hides until something keys off ownership. Bring-up now runs Core's reclassify and asserts the club really owns something of each kind. A Core binary predating the subcommand ignores it and boots the server instead, so the step is bounded and says so rather than hanging. |
||
|
|
09db5413cd |
feat(import-fifa17): emit the Core reclassify request from the catalog
The importer already knows every definition's kind, so it writes the mapping Core needs to correct a club imported before the taxonomy existed. Applied to the real 1989-item club: 20 rows corrected (17 consumables + 3 staff), 1966 players already right, 0 unmatched definitions. |
||
|
|
1a6355cad5 |
fix(staging): re-stamp the squad extension after seeding, and prove it
Filling the bench writes squad rows behind Core's back, which invalidates the stored FIFA17 opaque extension: Core saw a canonical squad that no longer matched the extension's fingerprint, the host refused to apply it (`stale_integrity`), and the client got a squad with zero players and no manager. Nothing failed loudly — the rig just came up empty. The seeder now recomputes the fingerprint exactly as `services::squad::squad_fingerprint` does, and bring-up asserts the squad really projects (>= 18 occupied, a manager present) instead of trusting that it did. Seeded owned rows also state their content_kind, so Core does not record a kit or a manager as a player. |
||
|
|
770029f207 |
fix(import-fifa17): carry the fields a consumable needs to exist
Two defects that together made the club's 17 owned consumables invisible while club/stats still counted them — the count gate promised 17, the item route served 0. 1. The catalog omitted `card_asset_id`, `amount`, `contract` and `rating` for non-player definitions. Without an art id the adapter refuses to emit the card (it would draw the notfound box), and the families that read `amount`/`contract` would render "-1" or grant nothing. All four values are present in the source wire and were simply dropped on the way out. 2. Owned rows were imported without a `content_kind`, and Core defaults an unstated row to `player` — durably recording a fitness coach and a contract card as players in the ownership authority, even though the catalog-driven wire looked right. These are definition-level fields, so every owned copy must agree; a group that disagrees is deferred rather than resolved by taking the first copy's value. Measured on the real profile: observed `amount` equals the `fcc_*` table row for every consumable that carries one (1, 2, 4, 5, 10, 15), and a wire omission corresponds to a table amount of 0. It is therefore NOT a stack count — two copies of 5003068 are two instances — so the import states no `quantity` at all. |
||
|
|
802f0f580f |
feat(host): serve owned non-player content from Core's ownership truth
Follows Core's kit designations becoming generic active-item slots: the host reads `GET /club/active-items` (five always-present slots) instead of the removed `/club/kits`. Adds the consumables route and widens the club families to every content kind, all resolved from Core ownership + the FIFA catalog. An item the client sees is now an item Core actually owns. |
||
|
|
6c7d0856b6 |
feat(fifa17): project every owned content kind, from one recovered vocabulary
Extends the FIFA17 adapter past players so the wire can carry the rest of a real club's inventory. itemState: the recovered 12-row table at 0x180229cc0 becomes the single source (`fut::item_state`), replacing scattered literals. Every shaper draws from it and the tests assert no shaper can emit a state the client does not know. CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start. ContentKind covers all nine tokens. Managers stay inside the staff family for counting, because the client's own club-stats model puts a manager INSIDE the staff total with staffManager as a sub-bucket — a parallel Manager kind would silently under-count. Consumables get their own route (`club/consumables/<category>`) and a stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a `?type=` family. This path previously fell through to Python, so owned inventory was being served by the oracle. The shaper refuses to emit a card it cannot render: no known art id, or a missing `amount`/`contract` for the families that read them, or the subtype-219 rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped card is counted and logged, never faked. |
||
|
|
dcd470cddc |
tools(fifa17): measure subtype->cardtype and itemState from the running client
Two things this project kept carrying as INFERRED are directly observable in the card record, so this reads them instead of trusting the decompile: rec+0x18 resourceId, rec+0x4c cardtype (derived by FUN_1800d8330), rec+0x50 cardsubtypeid (as sent), rec+0x5c itemState (decoded enum value). Measured against the live client (pid 6580, 27 records): subtype 0 -> cardtype 1 (23 records) agrees with FUN_1800d8330 subtype 4 -> cardtype 2 (1) agrees subtype 6 -> cardtype 10 (1) agrees subtype 8 -> cardtype 4 (2) agrees itemState runtime value 1 on all 27, and every one of those was served as "free" So the cardtype map is now runtime-confirmed for every subtype we actually serve, and `free == 1` is an empirical anchor for the itemState enum rather than a reading of the table at 0x180229cc0. The probe prints the Ghidra prediction beside each measurement and says DISAGREES rather than quietly matching, so it stays useful as new families are served. It also states the obvious limit in its own output: a runtime value only appears if the client was actually served an item in that state, so absence is not evidence of absence. The equipped states (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104) remain table-recovered and un-measured until a kit is fetched by the client. Read-only: /proc/PID/mem is opened 'rb' and there is no write path. |
||
|
|
8f98e6adda |
tools(fifa17): probe the manager-only chemistry slots to settle inferred vs proven
`card_identity_probe` reads the PLAYER slots (F_NATION 0x148, F_LEAGUE 0x154). A manager does not use those, so grading a manager with it reports nation=0 / leagueId=0 and reads like a server bug when it is only the wrong offsets. The manager layout, from the Ghidra reversal already recorded in fut_staff.py, is teamid rec+0x94, nation rec+0xde, leagueId rec+0xe0, talkrating rec+0xe2, negotiation rec+0xe3. The managercards merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, which is exactly what makes them a clean test: whatever sits there came from our JSON and nowhere else. Read against the live client (pid 6580, 27 records in the CardsDb map): resource teamid nation league talkrating negot verdict 1000509 241 45 53 0 3 SERVER FIELDS LANDED So the manager's chemistry fields DO reach the record, and `negotiation=3` agrees with managercards row 1000509, i.e. the merge ran as well. That moves manager nation/league from INFERRED to PROVEN without a screenshot. Read-only: /proc/PID/mem is opened 'rb' and there is no write path. |
||
|
|
106cb83988 |
fix(staging): fill the real club's bench to the client's 18-player minimum
FIFA refuses to kick off with "your squad must have at least 11 players and 7 subs … currently below the minimum number of players (18)". The imported club's squad carries ONLY its starting XI, so a freshly installed real club is unplayable until someone fills the bench by hand in the hub. `fill_bench_to_minimum` tops the squad up with the club's best spare players, writing EMPTY bench slots from index 11 upward. The 23 slots are 0..10 pitch and 11..22 bench/reserves, derived from the index alone, so the starting XI — and any bench the operator has already chosen — is never touched and a re-run is a no-op. Only real PLAYER definitions are eligible: a kit, a manager or a consumable in a squad slot is nonsense the client would drop anyway. Selection is best-rating-first with a stable id tiebreak, so the same bench comes back on a re-run rather than shuffling. Scoped to the REAL club on purpose. The 14-item fixture is a market test bed with a single spare player; demanding 18 there would abort a bring-up that never needed to kick off. Fixture mode therefore does not call this at all. Verified against a copy of the club snapshot (the live staging database was left alone, since it currently holds a squad the operator saved by hand): 11 -> 18 players, slots 0..17, no duplicate instance in two slots, every filled pick a real player definition, and a second run filling nothing. This is NOT a diagnosis of the failure the operator just hit — that squad had already been filled to 23 valid players before the match was created, and the host log shows the client issued no request at all after `match-create` beyond account-sync, so that refusal is decided entirely client-side. It removes the variable: after a clean bring-up the club is now playable without hand-editing. |
||
|
|
33300f2ad1 |
fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:
utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502
Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.
The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.
`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.
MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.
ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.
THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.
One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.
Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.
Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
|
||
|
|
12ad04c9d4 |
fix(fifa17): carry the manager's item in the squad, not just its id
The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.
`squad.manager[]` was emitted as a bare `[{id, dream}]`. That looked
retail-faithful, and the previous commit defended it on the grounds that no
capture had ever shown otherwise. Re-reading the captures with a populated
manager in hand shows why that was the wrong conclusion: every retail capture
carrying the bare form has `id: 0` — an EMPTY manager. None of them ever
demonstrated that a POPULATED ref renders without its item, because none of them
had one. `plan-2026-08-05-families.md` says as much outright: "FUN_18013d1f0 was
never read for a staff member".
The squad object is self-contained everywhere else: `players[].itemData` carries
the whole card rather than an id resolved out of band. The manager is the same
kind of slot in the same object, and the one implementation that ever drove a
working manager — the Python oracle's squad — emits `id` BESIDE `itemData`.
The element shapes differ and both are now pinned by tests: a player slot is
`{index, itemData, kitNumber}`, the manager is `{id, itemData, dream}`.
So the manager is projected through `resolve_staff` and its item embedded with
`shape_staff_item`, the same 11-key record `/club` serves. An assignment with no
resolvable staff identity still yields `[]` rather than a fabricated ref.
`STAFF_CONTRACT` moves next to `shape_staff_item` in `fut::item` (re-exported
from `club_response`) so `/club` and the squad cannot disagree about the
contract the client checks before kickoff.
Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
|
||
|
|
9c2edc4eee |
feat(fifa17): serve staff, so the club has a manager and matches can start
FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.
The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.
Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:
* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
client's merge never writes, so the server is their only source — they are the
flag, the league badge and both halves of manager chemistry. Coaches get
neither, because the four coach tables have no nation/league/team column and
emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
mask (players are the only masked family), so `version` must stay 0 or the
lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
omitted because the merge overwrites them from the client's own tables. A
staff card is therefore never routed through `shape_item`.
Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.
`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.
TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:
1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
indistinguishable from "no manager". Now reads `id`, and a present-but-
unreadable manager is an error rather than a silent absence. The projection
also now warns when an assignment cannot be resolved to an owned instance,
which is the documented "Core drops an owned card with no CardDefinition from
/collection without erroring" trap.
2. `Route::WatchList` was produced by NO classifier arm, so its handler was
unreachable and every `watchList` request fell through to Passthrough — the
same defect class as `season/list`. Against a stack whose Python upstream is
deliberately dead this 502'd. This was failing
`sbc_survives_complete_core_and_host_restart` at HEAD before this change.
The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.
Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
|
||
|
|
de747b79e6 |
feat(staging): let staging serve the operator's real club, not just the fixture
The operator could not field a starting XI because staging has only ever held the 14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays. Worth recording while looking for the club: the LIVE production Core container serves an EMPTY database (0 owned cards, schema predating even the game_id column). The real club is not being served anywhere right now; it exists as state on disk. So restoring it into staging is not a convenience, it is the only way to play it. Two pieces: `scripts/club-snapshot.py` is the ONE place allowed to read production state, and it is read-only by construction: the Core database is opened `mode=ro` and copied with sqlite's online backup API (a plain file copy can tear a database with a hot WAL), every destination is asserted to be outside the production directory before anything is opened for writing, and the sha256 of every source is compared before and after -- a mismatch aborts, because that would mean the snapshot modified production. It then proves the copy is faithful (same counts, coins, squad) and that the identity store maps EVERY owned card to a wire id, since an unmapped card would reappear under a freshly minted id and break the client's cached squad. `sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE Core first starts, so Core migrates the copy forward from schema v19 through match_completions, squad managers and kit assignments. Seller A is then already present -- it IS the imported persona -- so only Buyer B is seeded, the kit fixtures are attached to the real club so the kit work stays exercisable, and the real squad is left alone. The up script still never reads production state: the snapshot lives outside it, which is precisely what makes `--club real` compatible with the `safe_path()` refusal. The resolvability preflight now covers whichever club will actually be served. This is the check that matters most for the real one: Core does not fail on an owned card whose definition is missing, it silently filter_map-drops it, so a gap shows up as an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct card ids resolve in both the content pack and the identity catalog, 0 missing. `sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have overwritten the operator's own lineup with an auto-picked XI -- destructive and not recoverable in place. `--show` still works in every mode; `--force` overrides. Verified end to end against the restored club: /club 29,843,976 coins, /collection 1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated 90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from the snapshot identity store. The fixture path was re-run afterwards and still seeds exactly 14 items, so the SOLD experiment is unaffected. |