fc1fdcc5abc3ca4545e8fec7889099cec2c740ff
94 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
dddcfb917c |
feat(fifa17): serve offline Seasons instead of an empty body
Single-player Seasons failed with "There was a problem communicating with the
FIFA Ultimate Team Servers". Two independent faults, both fixed:
1. The client never reached a season endpoint at all. It aborts on a
prerequisite web file, captured live by the deployed trace:
SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
That is the hook's side (launcher 5294f58): the CDN base is empty in the
emulator, so the url stays relative and never reaches the POW content server
on 8085 that actually serves it.
2. `season/list` and `season/user` were not served. Only the EXACT tail
"season" was classified (as FeatureOffEmpty); every sub-path fell through to
Passthrough — the deliberately-dead Python upstream — so the mode could not
have worked even once the web file resolved.
Adds `fut::season_wire` with the reversed element schema (parser FUN_180167740,
stride 0x318; matches elements via FUN_180167fb0) and a `Route::Season` owning
`season…` for GET plus the state-storing PUT. Anything else still proxies rather
than being claimed without evidence.
Two things the types encode because getting them wrong is fatal:
* `matches` is NEVER empty. StartSeason (FUN_1800fc500) indexes
`matches[*(x+0x70)].teamId` off `elem+0x2e8`; an empty vector makes that a
NULL dereference and the client dies at CardsDLL+0xfc5b5. A full ten-round
schedule is emitted, with opponents drawn from team ids observed in this
client's own database.
* `type` MUST serialise before `divisionId`. `serde_json::Value` is a BTreeMap
here (no preserve_order), so `json!` sorts keys ALPHABETICALLY and emitted
divisionId first — caught by a test written for exactly this. The wire shapes
are therefore `#[derive(Serialize)]` structs (declaration order) rendered
straight to text via a new `json_text_status`, never round-tripped through
Value.
Verified on staging: season/list returns the ten-round OFFLINE season with
type before divisionId, season/user the round-1 position, history an empty
list, and the bare tail still {}.
Season progress is not yet persisted: the state-storing PUT is acknowledged
with {} (what the retail wire answers) rather than pretending a round advanced.
|
||
|
|
d6aa704b01 |
fix(fifa17): a dangling manager ref must not refuse the squad save
Every real squad save was failing with 400 unresolved_wire_ids. Reproduced on staging with the repo's own seeder, which sends the captured retail body: route=squad-replace status=400 outcome=unresolved_wire_ids detail=[[100000427]] FIFA 17 always sends a manager ref, and on a real profile it does not resolve to an owned instance. scripts/sold-staging-seed-squad.py already recorded why: production's own squad points at instance 100000427, which is absent from production's /club/staff (1975 items spanning 100000001..100004826), and the client accepts that squad back unchanged -- so the client never validates the manager against the club, and the pre-0023 server accepted it. Making the manager ownership-backed ( |
||
|
|
3442eac6f0 |
fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate
Four defects found by running the suites and the staging lifecycle end to end after the kit milestone. 1. club-stats kits were half-implemented. The global `kits` counter was real but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded 0, so the same screen reported two owned kits and zero home/away kits. `kits` is a total with a family split, exactly like players/playersGold and staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home family and 15 the away family, verified across all 1482 rows of the kit table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15 exactly the 64xxxxx ones, 654 rows; no exceptions either way). ClubStatInput now carries `asset_id`, and a kit buckets onto the team that wears it -- including a team the club owns no player from, the normal case for a kit won from a pack. The host reads both from the catalog through new NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a read-only stats query must never do as a side effect. 2. host_test.rs had 10 tests red since the squad-manager work ( |
||
|
|
db743ffd1f |
feat(fifa17): project owned kits with active home/away designation
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.
adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
(id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
the 101/102 integers are the client's post-deserialisation runtime enum
(item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.
host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
defaulting to no active kits so a Core without the endpoint degrades
instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
empty page and outcome=unsupported_type, and now always fetches
unpaginated from Core: kind and transfer-pile membership are host-side
concepts Core cannot express, so filtering and pagination must both
happen after shaping or pages come back short.
importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
kit counts/balances, and card_asset_id/team_id carried into the emitted
catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
(missing_kit_render_metadata) rather than defaulted; conflicting render
metadata across instances defers as render_metadata_conflict.
staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
|
||
|
|
afa5f620bd | style(fifa17): cargo fmt match wire + host match integration | ||
|
|
9ddd80993c |
feat(fifa17): route match completion to Core exactly-once economy
Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the reward-response projection (only reversed fields; never bidTokens/ qualifiedChampionEventId). Match logic removed from economy_policy.rs (kept pack/fee); registered match_wire in fut/mod.rs. Host: handle_match_end now applies the match to Core's authoritative complete_match (POST /matches/complete) fail-closed — any Core error is a 503, never a Python fallback — and renders Core's authoritative coins. Per-match identity from matchReportId or a body fingerprint keys Core's durable idempotency. New CoreEconomy::complete_match transport + CoreMatchCompletion/CoreMatchReceipt. Tests: adapter endReason/parse/projection; host shaping, fail-closed, malformed, identity dedupe; integration replay + rebased balance chains (match now also grants XP/level-up/achievement coins). |
||
|
|
25f4ad12bc |
feat(host): wire ownership-backed squad manager through Core
Thread the manager assignment between the FIFA squad path and Core: - CoreAccess gains get_squad_manager/set_squad_manager (GET/PUT /club/manager); HttpCoreClient implements both. - project_active_squad fetches the assigned manager owned item and passes it to the projector (non-fatal on error/absence). - handle_put_squad authorizes the resolved manager against the active club (like a slot) and persists it via set_squad_manager after the atomic squad replace; fails loudly, never silently drops it. |
||
|
|
1d6a6fffcc |
fifa17 store: make reward packs (SBC/draft/season/...) openable
SBC completion and the other Core reward services grant packs with symbolic
definition_ids ("silver_pack", "gold_pack", ...). The FIFA 17 pack system keys
entirely on numeric catalogue ids, and entitlement_pack_ids / handle_pack_open
resolved definition_ids with definition_id.parse::<u64>(), so every symbolic
reward pack was silently dropped from the openable My Packs list. The unopened
count (recoveredPacks, = entitlement count) still counted them, so the client
showed "you have N packs" but had no tile to open -> "no pack available".
Fix (adapter-layer, Core stays game-neutral): add owned-only reward pack
catalogue entries 71-75 (bronze/silver/gold/rare_gold/icon) and a resolver
owned_pack_id_for_definition() that maps both numeric owned ids and the symbolic
reward names to their numeric owned-only pack. entitlement_pack_ids and the
pack-open entitlement selection now use it, so reward packs render as openable
My Packs tiles and redeem their entitlement for free (consume-once, no debit).
Server-verified on staging: 3 Core reward entitlements now render 3 openable
mypacks tiles matching recoveredPacks=3. Tests: adapter resolver + rendering,
host symbolic-reward open flow; full adapter + host suites green.
|
||
|
|
c68c10cf04 |
fifa17 store: real 6-pack economy + full-DB pool; drop extPrice
- store_catalog: replace the invented catalogue with the real always-available FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at real prices + tier composition; PackDef now carries per-tier quantities. - pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s" FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4). - pack_content: tier-aware generator draws each pack bronze/silver/gold composition with special_chance bias + empty-tier fallback. - host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the FULL card universe via non-minting catalog lookup, owned-inventory fallback. - economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the authoritative store; Python oracle stays the untouched rollback baseline). - fixtures/tests updated to the real catalogue. Odds are DESIGNED placeholders (FUT17 pack probabilities were never published); club items remain excluded (cardtype-9 mapping unknown). Full regression green; real prices + tier-correct draws verified server-side on staging. |
||
|
|
e8d1c1ddac | test(fifa17): harden SBC retail acceptance | ||
|
|
f9740f640d | feat(fifa17): route SBCs through atomic Rust Core | ||
|
|
bf6db98f0d | Migrate club rename and numeric squad reads | ||
|
|
468bc0fba9 |
feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.
PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.
OPENFUT_FIFA17_SOLD_EXPERIMENT = highest | buyNow (else OFF)
OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1 (else 0)
OPENFUT_FIFA17_SOLD_COUNT_MODE = active_plus_sold (else active)
WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.
CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.
FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.
STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
* scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
responder and asserts every substitution applied, so a silent no-op cannot leave
it pointing at production. Kills only recorded pids whose cmdline contains the
staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
* docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
* src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
(CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
leaves the listing live with nothing moved. Refuses any path containing
openfut-promotion or the production ports.
* scripts/sold-wire-check.py -- proves the whole flow headless before any operator
time is spent.
WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.
Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.
Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.
The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
|
||
|
|
f6606accb3 |
feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live here. FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and match_reward_total because 5% is a game policy constant and Core must stay game-neutral — Core only validates 0 <= fee <= gross and never computes a rate: TRANSFER_MARKET_FEE_PERCENT = 5 transfer_market_fee(gross) = floor(gross * 5 / 100), i128 intermediate seller_proceeds(gross) = gross - fee Integer only. Floating point is never used for coin settlement: 0.05 is not representable in binary and a f64 round trip can create or destroy a coin at large prices. Widening to i128 makes overflow unreachable for any i64 price, so no price ceiling has to be assumed. ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller keeps the fractional coin, chosen because it makes fee + proceeds == gross hold exactly at every input — the property the accounting invariant rests on. The discriminating case against flooring the seller's 95% instead is a gross of 150: this rule pays 143, the alternative 142. Nothing in the corpus or the client binary settles which the real server did (the client is only ever told the gross; no tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/ 150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep. HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on HttpCoreClient as POST /economy/settle-sale. Request field names were checked against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club ids are OMITTED from the body (not null), which is what Core's Outside/active-club defaults depend on, so a unit test pins that body shape. handle_market_buy is deliberately untouched: the synthetic buy path has no counterparty, so minting there is correct. HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a hard deny-list checked in three places), seeds the canonical two-party fixture, prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31 pass. It found the rejection-precedence bug fixed in Core, and that Core's content preflight aborts startup on an owned card whose CardDefinitionId no pack defines. Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code fmt-clean. Nothing deployed; no production process, port or database was touched. |
||
|
|
a57f4930f0 |
market: emit FIFA 17's own forSale itemState, not the oracle's listFS
Single-field protocol-correctness fix, deployed as a candidate for a live A/B. `itemData.itemState: "listFS"` on the seller's own auction rows is not a FIFA 17 token at all: zero occurrences in `CardsDLL_Win64_retail.dll` (md5 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory, and it decodes to -1 through `FUN_180166660` — so the client was handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0. Changed only where the invalid token was emitted: `handle_market_query` (GET …/tradePile) and `handle_market_status` (GET …/trade/status). The market search path already emitted `forSale` and is untouched — which is also why the risk here was lower than it looked: the client has been decoding `forSale` on a live route all along, and only the seller's own pile carried the bad value. Wire A/B on the same expired row: EXACTLY one field differs. tradeId, tradeState, expires, startingBid, buyNowPrice, currentBid, bidState, sellerName, sellerEstablished, watched, coinsProcessed, the twelve-atom count and the whole itemData card are byte-identical; coins unchanged at 29,843,976; Fix A's zero `inactive` rows intact. The differential asserted PARITY on this field and therefore passed while BOTH sides were wrong — the exact mechanism by which the defect survived every run. `market query tradePile` is now DIFFERENT-BY-DESIGN, pinning oracle == "listFS" and rust == "forSale" so the divergence cannot silently close again. Where the FIFA 17 binary contradicts the Python oracle, the binary wins. Gates: 126 host tests, 214 adapter tests, fmt clean, clippy clean. NOT claimed: that this preserves the list -> expire -> Return-to-Club lifecycle. That needs an operator FIFA 17 session and has NOT been observed yet. Also not claimed: anything about the Flash action gate — `CARD_OFFERSTATE` is one of three still-confounded candidates and this change does not test it. Revert is one line if the live test fails. |
||
|
|
f9ca901a50 |
market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left open, and the answer retracts Q2 rather than completing it. `tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at `0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of `[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0 inbox=0). The value is then handed to the movie verbatim as the Flash property `STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders and can never be acted on, which is exactly what the operator saw. So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER, which is not the same claim, and I treated it as if it were. The corpus said this before any of it was built — `plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path treats it specially; do not emit it." The earlier note explaining that the warning "was written about the PRESENTATION function" was motivated reasoning. This also fires the corpus's own pre-registered falsifier E3 (:368-373). Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`, `UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`, `Server::resolve_trade_pile`, and the two helpers that existed only to feed them (`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`). Unlisted trade-pile membership is now internal state with no wire expression. Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an unlisted pile member stays visible in the club, which is where the client can act on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive` rows, `/trade/status` resolving only the real auction, coins unchanged at 29,843,976, and all six former rows present in `/club` (1965 items). Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and `trade_status_answers_only_about_real_auctions`. NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17 token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in 4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and the Python oracle emits `listFS` too — which is why the differential never caught it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and every actionable row observed carried -1, so that change ships alone with its own live A/B. |
||
|
|
11c028e6eb |
fix(market): /trade/status must resolve the unlisted ids /tradePile advertises
Explains and fixes the Phase C partial failure WITHOUT changing a single wire field. The operator saw a difference between the one-item probe (Time Remaining "-") and the generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding. /tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status) resolved ids from the market store only -- and an unlisted pile member has no listing row, so the poll returned an empty auctionInfo. Observed live as `route=market-status requested=1 returned=0` repeating for the row the operator had selected, while that same id was present in /tradePile. The client polls status for the row it displays and degrades it when the answer is empty, which is also why no actions were offered. The probe showed "-" only because the client had not yet polled that id (logs of the time show only tradeIds=1000000097). So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was guessed and no field changed: both routes now share one pile enumeration (Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on /trade/status. The corpus predicted exactly this -- tradeId must resolve across /transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not coverage. Same defect class as the original empty-trade/status bug. Status still answers only the ids actually asked about, and a real auction always wins over an inactive row for the same tradeId. Regression test covers all four cases. Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle discriminator; whether the full actionable contract is now complete is the operator's next test. itemState/pile recovery was queued on the assumption the encoding was incomplete -- neither was touched, and both remain the next candidates if actions are still absent. 342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from returned=0 to returned=6. |
||
|
|
afadb13de4 |
feat(market): PHASE C — expose every unlisted trade-pile item as tradeState "inactive"
Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded one-item probe to the real behaviour: the env gate is gone and /tradePile now enumerates the whole trade pile. Mechanism: read the pile (async), resolve each member to a shaped card (sync, because the identity/Core resolvers are not `Send`), then build the response (async). The core->wire lookup is `wire_for_owned_id`, which uses the identity store's NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a wire id for an item the client has not seen. Items with no mapping, no Core record or no resolvable FIFA identity are skipped, never faked. Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its auction, so after a sale the seller's `trade` row is stale, and filtering only on ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now by listing state via `blocking_core_items()` -- active (real auction shown instead), reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT suppressed, because a cancelled listing means the card came back to the pile. New test covers all three plus the store-level rule. counts semantics deliberately unchanged: `count`/`selling` still track auctions only. 341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of the pile. Body preserved as phase-c-full-pile-exposed.json. |
||
|
|
a2bd048ace |
feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:
'active' = 1 'inactive' = 2 'expired' = 3 'closed' = 4
The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.
PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.
Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.
Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.
340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
|
||
|
|
4e31fb98a2 |
fix(market): returning an item to the club ends its auction; close the panel probe
CLOSES the active-own-auction Actions-panel investigation. Live client plus the RE corpus plus historical FUT behaviour all agree: an active auction is COMMITTED until sale or expiry and is not seller-actionable, while an expired unsold item becomes actionable (relist / return to club). Every observation fits that lifecycle -- active+frozen expires was non-selectable, expired was selectable and relisted fine, relisting made it active and non-selectable again, and the client never emits a cancel. Documented with confidence tags, and the dead ends are named so they are not retried: MAY_BE_REMOVED is a constant 1, and the eight-flag array is the CLUB-CARD menu with no auction-cancellation flag in it. Implements the return-to-club transition that closure exposes. A pile move to `club` now cancels any ACTIVE listing on that item, because the auction that put the card in the pile has to end with it. Otherwise the pile reads `club` while the row stays `active`, so the card is filtered out of /club (exclusion keys on active listings) AND still rendered in the Transfer List: the move appears to do nothing. This is the same limbo class as the earlier pile-vs-listing bug, found by reasoning about the transition rather than by another live failure. Scoped to `active` only: a `reserved` row is mid-sale and a `sold` row is already gone, so cancelling either would let one card be both sold and returned. Two tests cover exactly that boundary. 338 tests pass, 0 failed, clippy clean. |
||
|
|
b1d7ed2570 |
fix(market): relisting an expired auction actually relists it
The client's relist arrives as a fresh ISStart (`POST /auctionhouse`) for an item that ALREADY has a listing row, so `create_listing` hit a primary-key conflict. The handler treated `Err(Conflict)` as success: it logged `listed=true`, handed the client its trade id, and persisted nothing. The stale row kept its old `created_at`, so the card stayed expired and the relist appeared to do nothing -- observed live, with the client's price-limits fetch and the ISStart POST both in the log. The PK conflict IS the relist path. `relist_listing` now resets `created_at` to now and takes the new prices and duration, so the auction actually returns to the market with a fresh countdown. Refuses to revive a `sold` or `reserved` row: re-opening a sold auction would sell the same card twice. `cancelled` rows ARE relistable (the card is back in the pile). Missing rows report NotFound rather than silently succeeding. The failure paths still ack so the screen cannot wedge, but they now say `relisted=false reason=...` in the log instead of claiming success. Three store tests: the clock/price reset, the sold+reserved revival guard (plus the cancelled-is-relistable case), and NotFound. 336 tests pass, 0 failed, clippy clean. |
||
|
|
772f8a615a |
fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock
Corrects the record against the CLIENT BINARY rather than library hearsay, using
the project's own reverse-engineering record
(fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE).
REVERTED (refuted): `tradeOwner`, `sellerId`, `offers`. FIFA 17's auctionInfo
deserializer (0x18013e410) reads exactly TWELVE atoms -- bidState, buyNowPrice,
currentBid, expires, itemData, sellerEstablished, sellerName, startingBid,
coinsProcessed, tradeId, tradeState, watched -- and value-SKIPs everything else at
0x180135ff0. Those three fields were added last commit on the strength of
contemporaneous FIFA 17 libraries; the PE says the client never reads them, so they
were inert and could not have been the Actions-panel gate. A preservation emulator
must not emit fields the client does not consume. New test pins the exact set.
ADDED: the auction clock. `expires` is SECONDS REMAINING (never an epoch) and the
client renders a LIVE COUNTDOWN it expects to reach 0. We hardcoded 3600, so no
auction ever aged or ran out. Now `duration` is taken from the ISStart body
(additive `duration_secs` column, defaulting to 3600) and `expires` is derived from
created_at + duration - now, clamped at 0. An active listing whose clock has run
out projects as `expired`/`none`/`expires: 0` -- FIFA 17's relistable state, per the
lifecycle table (active=1 inactive=2 expired=3 closed=4; none=0 outbid=1 highest=2
buyNow=3, both closed vocabularies). Pure projection: no row is mutated, so no
sweeper and no race with the economy.
ADDED: `duplicateItemIdList: []` on GetTradePile, which shares one deserializer
(0x18013e7f0) with ISSearch/ISWatchList over four members and we were omitting one.
CONFIRMED by the same source, so kept: `GET ut/{ns}/trade/status?tradeIds=a,b,c` is
real (ISVIEWTRADE) and my handler matches it exactly, including the comma list.
`ISREMOVETRADE` is `DELETE ut/delete/{ns}/trade/{tradeId}` -- our ORIGINAL spelling
was right. The plain-DELETE arm stays because the same source advises dispatching
on path and being method-agnostic (HTTP verbs are not statically recoverable).
Differential returns to strict key-set parity, with a comment recording WHY parity
is not sufficient: a field absent from both sides is invisible to it.
333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the
four-member envelope, and the listing correctly reading expires=0 / expired after
aging past its hour.
|
||
|
|
58d1f9426f |
fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.
1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
(bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
purpose-built "this auction is mine" flag, and without it the Transfer List has
nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
configured persona so it agrees with `tradeOwner` and `sellerName` instead of
telling three different stories. Persona is threaded from config, never baked in.
2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
Transfer List polls this continuously to refresh live auction state. The tail has
no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
client asked for the state of its own listings and was repeatedly told there was
none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
unknown ids are absent rather than an error, so a poll never fails closed.
3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
`/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
map to MarketCancel. Kept the oracle spelling: the differential exercises it.
Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.
Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.
332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
|
||
|
|
3cd31c4322 |
fix(market): stamp the player's persona as sellerName, not EA's house name
A card listed on the Transfer Market rendered correctly in the Transfer List but pressing it opened NO Actions panel, so Remove / Re-list were unreachable. The one field where our auction record diverged from the oracle was the seller: we stamped "EASFC" while the oracle stamps the account's persona name. `fut_account.py` annotates that very property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName", so EA's house name on the player's OWN listing is simply wrong, whether or not it proves to be the gate on the Actions panel. Introduces `non_economy::PERSONA_DISPLAY_NAME` as the single source of truth and uses it both for the `account/sync` default (previously a bare "CAGE" literal) and as the market seller. Every listing in this store is the player's own -- there is no NPC seller in a single-account emulator -- so the fallback is the player. Also strengthens the differential: it compared only auctionInfo LENGTH and tradeState, so it was structurally blind to this. It now compares the record key set and each shared field against the live Python oracle, asserts the seller is the persona rather than EA, and asserts itemData is the full card rather than a stub. That strengthened comparison passes against the real oracle subprocess, which establishes two things: our record's key set is IDENTICAL to the oracle's (we are missing no field relative to it), and sellerName was the only divergence. NOTE the limit of that evidence: the oracle's own Transfer List remove flow has never been confirmed against a real client either (the only live datapoint is a counts-tile bug), so parity is necessary but may not be sufficient. If the client still offers no dialog, the missing field is missing on BOTH sides and must come from client instrumentation, not from the oracle. 14 targets green, clippy clean. Deployed and verified live: sellerName='CAGE', listing intact, coins unchanged. |
||
|
|
f2c4927ea6 |
fix(club): hide only ACTIVELY-LISTED cards, not the whole trade pile
Keying the club exclusion on the `trade` pile put cards in limbo: the pile can hold cards with no active listing (a bare "Place on Transfer Market" move, or a listing later cancelled/sold), and `/tradePile` renders ONLY active listings — so those cards were invisible in BOTH views. Live prod had 5 trade-pile rows but 1 active listing, so 4 owned cards had no reachable screen (clubPlayers 1966->1961). Key on the ACTIVE LISTING instead (market store `core_item_id` of `state=active`). This is self-healing: the moment a listing stops being active the card is back in the club, with no extra transition to maintain and no need to invent an "unlisted transfer-list" wire shape (`tradeState` has no verified spelling for that state, and guessing enum spellings is the documented client-freeze class). A bare pile move therefore no longer hides a card. That is deliberate: our `/tradePile` shows only active listings, so hiding on the move alone would reintroduce the limbo it is meant to prevent. Verified live: clubPlayers 1961 -> 1965 (exactly the one listed card hidden, the 4 stranded cards recovered); listed wire still absent from /club; counts and tradePile unchanged. 14 targets green + clippy clean. |
||
|
|
aa2abc2772 |
fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:
1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
client greyed out "Place/List on Transfer Market" for the whole club. Owned
and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
for owned copies too (item_def keeps `true`; instances do not).
2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
the handler fail-closed and returned 200 while persisting NOTHING. It now
resolves server-side: wire id -> Core owned instance -> its card_id (minted on
a synthetic buy) + FIFA resourceId (the auction record). This also enforces
that a listing can only name a card the club actually owns.
3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
row the client could not draw -> "1 item listed" but no visible sale. A
listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
additive migration) built by the same `shape_item` shaper `/club` and the
squad projection use, so the auction card renders identically to the club
card. The seller's own pile stamps `itemState: listFS`; market search keeps
`forSale` (the oracle distinguishes these).
4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
deserializers: `/counts` is FutGetAuctionCount, five scalar ints
(count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
everything else. Served the `auctionInfo` body it left every count at 0, so
the Transfer List screen showed no active sale while the hub tile showed one.
New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
also accepts the /counts path).
Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.
Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.
Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.
Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
|
||
|
|
1aa84afa9a |
feat(host): migrate item-defs + marketdata UTAS reads to Rust
Two more real client-hit reads move off the Python proxy:
- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
for every >=3-digit id in the query, replicating the oracle's item_def
(assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
"Player" 75 CM placeholder). The client renders the real card from its local
DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
{defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
array froze a live client at the listing screen, so the handler picks it from
the path.
Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).
Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
|
||
|
|
33e9118329 |
feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust
FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.
- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.
Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated.
|
||
|
|
e06fd57211 |
feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
(nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.
Launcher: bump gitlink to
|
||
|
|
71fcf5e251 |
feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)
Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing Core-accurate counts: player tiers + rare from the collection, staff/consumable families from catalog kind+subtype, per-nation buckets via the reverse entity resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts + context_rows). Unlike the oracle (stale profile + synthetic consumable shelf), this reflects the real imported content (incl. the content-gap consumables/staff). Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain Python (documented). Adds adapter club_stats module (5 tests), host handler + classify arm + resolver subtype_of/rareflag_of, ownership + integration tests; reachability tool splits club/stats global(migrated) vs context(residual). |
||
|
|
70eb3fc13f |
feat(fifa17): own FUT hub tile counts in Rust
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff excluded via catalog kind; may be lower than Python's profile count by the deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from the durable market store via the async bridge. Fail-closed 503 on Core error; market read failure degrades cosmetic counts to 0. Adds classify arm, handler, ownership + integration tests; reachability tool marks hub migrated. |
||
|
|
67cc33cfee |
feat(fifa17): own club/stats/staff (empty stat set) in Rust
Migrate GET club/stats/staff from the Python proxy to a Rust static handler.
The production oracle returns {} for the staff-bonus stat set (deliberately
empty); the Rust host now owns it (owner=RUST route=club-stats-staff). Updates
classify(), the pre-existing near-miss test (now club/stats/year), the ownership
matrix test, and the no-fallback integration test. club/stats/{year,consumables}
remain Python (aggregation) pending the Core-derived club-stats migration.
|