Commit Graph

307 Commits

Author SHA1 Message Date
funman300 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.
2026-08-17 20:50:51 +00:00
funman300 b6398c44e6 docs: record the LIVE-CONFIRMED inactive UI contract and the expired->Club transition
Operator confirmed in the real client that a tradeState "inactive" row lands under the
right-hand TRANSFER LIST section and renders Start Price 0 with Buy Now, Current Bid
and Time Remaining all absent, while an active row in the same body continued to
render separately under LISTED ITEMS. That is the Q2 representation confirmed live,
with the token itself dumped from the client's own string table rather than guessed.

Records the observed wire->UI contract as a table plus a fixture
(inactive-row-live-confirmed.json), and names the regression tests that pin it,
including the two guards that the row is never emitted for an item outside the trade
pile and never duplicates a real auction.

Also records the expired->Club coupled transition verified server-side: the listing
went to `cancelled`, the pile went to `club`, /tradePile dropped the item, /club
regained it, and clubPlayers went 1964 -> 1965. That is durable store state rather
than a client-local view, so it survives a session boundary by construction; tagged
pending the operator's final exit/re-enter confirmation.

Adds the counts observation table. `count` currently tracks AUCTION entries and not
total Transfer List membership; semantics deliberately left unchanged until the full
state set has been observed.

No behaviour change in this commit.
2026-08-17 20:31:20 +00:00
funman300 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.
2026-08-17 20:25:46 +00:00
funman300 2e97ff1461 docs+tools: dump the CardsDLL route table; narrow Q2 to one candidate by elimination
Re-entry discriminator came back a CONFIRMED BUG: an unlisted transfer-list item does
not survive a fresh FUT session, so our representation cannot reconstruct trade-pile
membership. Evidence acquisition per instruction, corpus and PE first, no guessing.

Adds route_table_dump.py: static read-only dump of CardsDLL's route table from the
on-disk PE, resolving VA->file offset through the real section table instead of
assuming a single .text mapping. Output preserved as evidence. It settles "is
/tradePile the only relevant route?" -- the table holds 45 routes plus 3 empty admin
slots, and row 30 `ut/%s/tradePile` is the ONLY trade-pile route. There is no
trade-pile items route.

That plus three existing PE facts narrows the representation to exactly one candidate
by ELIMINATION rather than choice: the route carries only twelve-atom auction records;
`pile` (0x226) has no arm in the item deserializer so membership is conferred by the
owning list and cannot be added as a field; of the twelve atoms only tradeState
expresses lifecycle; and tradeState's closed vocabulary (active=1 inactive=2
expired=3 closed=4) has exactly one value not already spoken for.

So an unlisted item can only be an auctionInfo record with tradeState "inactive".
Tagged INFERRED-BY-ELIMINATION, not CONFIRMED: the remaining unknown is whether the
Flash Transfer List RENDERS such a record in the unlisted section. Records the
acceptance test (survive a full FUT reload) and the revised invariant that a
transition is complete only when a fresh session reconstructs the same visible state.

No behaviour change in this commit.
2026-08-17 20:10:26 +00:00
funman300 7f37b37be3 docs: capture the unlisted transfer-list state and model the pile/auction boundary
Q2 measured, not guessed. The operator moved a card Club -> Transfer List without
listing it (PUT /item, no POST /auctionhouse) and the state was captured read-only.

Finding: we do not represent the unlisted state on the wire AT ALL. Such an item is
byte-identical to a club item -- itemState `free`, no `pile` field emitted, still
returned in /club and counted in clubPlayers -- while an actively-listed item is
correctly excluded from /club and present in tradePile. Only the host's own pile
store knows the difference. Trade pile held 6 items: 1 listed, 5 unlisted.

The FIFA 17 ENCODING of that state stays UNKNOWN on purpose: returned itemData.pile
is numeric with an unrecovered mapping, and tradeState is a closed table walk where
an unrecognised bidState is silently swallowed as `none`, so a wrong enum produces a
plausible-looking but wrong UI. The corpus warns `inactive` decodes but no client
path treats it specially. One client-only discriminator is recorded instead.

Also models the domain boundary both limbo bugs came from: pile membership and
auction lifecycle are separate facts requiring coordinated transitions. States the
testable invariant -- an item must never be simultaneously excluded from /club and
absent from /tradePile -- with the two ways it was reachable and the commits that
closed each.
2026-08-17 20:01:51 +00:00
funman300 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.
2026-08-17 19:56:06 +00:00
funman300 6cc22e5cc5 docs: freeze the known-good auction state and mark tradeOwner DISPROVEN
Records the live-client resolution so the market shape is now a fixture rather than
folklore, and so a future agent cannot burn deployments on tradeOwner again.

Confidence notes updated: tradeOwner remains FIFA17-HISTORICAL (it does exist in
the FIFA 17-era API) but "required by FIFA17.exe Transfer List Actions" is now
DISPROVEN for this client path -- it is not among the twelve atoms the client's
auctionInfo deserializer reads, and it was implemented, deployed, observed inert
and removed.

The real blockers are recorded as CONFIRMED live-client findings: trade/status
polling is load-bearing, `expires` must EVOLVE with wall-clock time (a frozen
value is structurally valid and behaviourally broken), and relist must persist
through the PK conflict that FIFA's re-sent ISStart necessarily causes.

Freezes the known-good bodies under docs/evidence/market-lifecycle-2026-08-17/
with a machine-checkable countdown proof (_index.json._countdown_proof records
expires decrementing, frozen:false) rather than asserting the clock in prose.

States the general rule this cost us: a response can pass differential parity and
render perfectly while still being wrong, because FIFA expects an evolving
server-side state machine, not a static object that resembles one.
2026-08-17 19:42:15 +00:00
funman300 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.
2026-08-17 19:17:35 +00:00
funman300 dcbef721f2 docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.

Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.

This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
2026-08-17 19:09:03 +00:00
funman300 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.
2026-08-17 19:06:33 +00:00
funman300 bf9ae20367 docs: FIFA 17 transfer-market wire findings with confidence tags
Records the auction-record field set, route spellings, pile encoding and the four
open UNKNOWNs so future agents neither reopen settled questions nor re-guess enum
values. Each claim tagged CONFIRMED / FIFA17-HISTORICAL / INFERRED / UNKNOWN.

Captures the key methodological lesson: oracle parity is necessary but NOT
sufficient for a flow the oracle itself never served -- our auction record matched
the oracle key-for-key while both omitted the FIFA 17 ownership fields.
2026-08-17 18:51:13 +00:00
funman300 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).
2026-08-17 18:50:19 +00:00
funman300 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.
2026-08-17 18:29:19 +00:00
funman300 ae5feb05b7 docs: record the external FIFA 17 FUT hub behavioural spec + cross-check
Operator-supplied research document (authored outside this repo) describing the
player-visible FUT hub state machine. Stored verbatim so it cannot drift, with a
provenance header pinning its standing: it is a BEHAVIOUR target, never a protocol
reference. Its own §43 already forbids inventing route/field/sentinel/empty-state
details from it, which matches project policy (guessing wire spellings is the
documented client-freeze class).

Appended a repo-grounded cross-check that tags each relevant claim CONFIRMED /
CONFLICT / GAP / UNVERIFIED, so a future agent cannot mistake the aspirational
parts for observed behaviour. Notably it CONFLICTS with the recovered client
tables twice (Manager League is deliberately excluded from the consumable
overlay; there is no apply-consumable endpoint upstream at all), and it usefully
confirms that "sent to the Transfer List but not currently listed" is a real FUT
state -- which is exactly the limbo f2c4927 worked around.
2026-08-17 18:25:23 +00:00
funman300 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.
2026-08-17 18:10:32 +00:00
funman300 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.
2026-08-17 18:03:06 +00:00
funman300 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).
2026-08-17 16:21:17 +00:00
funman300 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.
2026-08-17 16:10:53 +00:00
funman300 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 c277213 (shareholder-grade redesign + live account panel).

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
2026-08-17 16:04:20 +00:00
funman300 42fd3c7e90 core: deploy correctness fixes (SBC exploit + economy TOCTOU) + docs
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card
exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions
club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19).

Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and
docs/OVERNIGHT_HANDOFF_2026-08-17.md.
2026-08-17 16:01:27 +00:00
funman300 0bc71dbd74 docs(evidence): capture full-length UTAS responses + userMassInfo envelope
Fix extractor truncation (bound each HTTP message by Content-Length): userMassInfo
(8 KB) and purchasegroup responses are now complete in the committed corpus, not
cut at 4 KB. Document the userMassInfo envelope contract (target shape for a future
full-Rust migration; needs the clubAbbr/established account triad Core lacks).
2026-08-17 04:13:55 +00:00
funman300 2ecd830d75 docs(evidence): commit fresh sanitised UTAS wire captures (2026-08-15 live A/B)
Rebuilds the primary-capture corpus lost to .gitignore (Known Issues #200): 10
real-client requests + 12 responses captured during the post-P1 staging A/B on a
real FIFA 17 client, sanitised (SID/authCode/deviceId/MAC/tokens redacted; raw pcap
withheld). Documents the account/sync, empty-My-Packs 65534 sentinel, and userMassInfo
contracts, incl. the finding that account/sync is coupled to Python active-profile
selection (so it can't migrate standalone from the userMassInfo hybrid).
2026-08-17 04:09:56 +00:00
funman300 3a51b0ebd4 docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader
trap called out in Known Issues:
- heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The
  round-trip only validates the payload length + TDF body; decode->encode with the
  same mislabelled header trivially reproduces the capture, so it never tested the
  [10:16] field boundaries. Marked superseded; cite the proven layout; warn at
  build_fire2_frame. Code unchanged (dead tooling).
- fifa-blaze frame.rs: see submodule commit f4f3396.

Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod
container; no prod impact).
2026-08-16 21:29:52 +00:00
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes
them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the
certificate mismatch that cost three live gates. The redirector classified the
opening before the acceptor to keep a benign probe from forging a TLS fault, but
the roster host (the second FIFA-facing TLS host) did not, so the documented
hazard 'remains in any other TLS host that has not adopted it' was live there.

Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream)
peek helper into the shared openfut-tls crate (game-independent; +unit tests).
The redirector now re-exports them (public API + its probe_classification test
unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a
probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of
failing the handshake. New roster probe_classification integration test (3 cases:
bare probe classified, real client after a probe still served 200, speaks-then-
fails still reported as a fault). Full workspace tests green; clippy -D clean.
2026-08-16 20:30:50 +00:00
funman300 12fb9fc38b chore: update workspace Cargo.lock after excluding openfut-hook
openfut-hook (now its own workspace root) and its windows-sys deps are no longer
part of this workspace's lockfile.
2026-08-15 19:32:18 +00:00
funman300 7b580a0070 chore: bump openfut-hook clippy -D warnings cleanup (0d3f33c -> d1a71bd) 2026-08-15 19:31:25 +00:00
funman300 22443a3810 build(hook): exclude openfut-hook from workspace so its release profile applies
openfut-hook (Windows version.dll injected into the FIFA client) declared a
[profile.release] with panic=abort/strip/opt-level=s that Cargo silently ignored
because it was a non-root workspace member (per-package `panic` overrides are
forbidden). Move it out of `members` into `exclude`; the submodule now carries a
matching empty [workspace] table so it builds as its own root. Fixes cross-FFI
panic-unwind UB in the injected DLL, shrinks it 1200126 -> 861696 B, and lands the
artifact in openfut-hook/target/ (matching launcher config.rs hook_dll_path).

Bumps openfut-launcher submodule ca7ce26 -> 0d3f33c.
2026-08-15 19:25:19 +00:00
funman300 0fce1e521c docs: mark club/stats/{year,consumables} Rust-owned in authority matrix
Global MY-CLUB stat set now Rust (staging-verified RUST route=club-stats
owned=1982 players=1962); only club/stats/{country,league,team} nation-bucket
context sub-screens remain proxied (low value, inert atoms).
2026-08-15 18:04:23 +00:00
funman300 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).
2026-08-15 17:56:37 +00:00
funman300 979e71fbea docs: record precise blockers for remaining Python non-economy routes 2026-08-15 17:31:15 +00:00
funman300 45e0b0bd95 docs: mark hub + club/stats/staff migrated in authority matrix 2026-08-15 17:18:55 +00:00
funman300 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.
2026-08-15 17:18:06 +00:00
funman300 b30aa352f6 docs: record post-P1 candidate migration status + clientdata Core blocker 2026-08-15 17:13:47 +00:00
funman300 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.
2026-08-15 17:13:16 +00:00
funman300 6eec3b9ec7 test(fifa17): add UTAS route-reachability reporter (Python-hit gate)
Parses the host owner= dispatch log into per-owner + per-domain counts and gates
on the post-P1 invariants: economy Python hits == 0 (P1 regression), migrated
non-economy routes (accountinfo/settings/leaderboards/match-reset/phishing) ==
0, and residual Python domains == documented set. Read-only; staging-preflight
and Phase 40 live-ownership use.
2026-08-15 17:01:11 +00:00
funman300 57773b98ec docs: Python retirement readiness classification (post-P1) 2026-08-14 05:36:32 +00:00
funman300 fe9b899a0e docs(fifa17): record .105 Legends unrecoverable verdict (dcplayernames empty) 2026-08-14 05:26:02 +00:00
funman300 abe9e663c1 feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
2026-08-14 05:26:02 +00:00
funman300 f5a33eb58c test(fifa17): prove non-economy routes are Rust-owned with no Python fallback 2026-08-14 05:08:37 +00:00
funman300 a85090c3c6 feat(fifa17): own non-economy static + security-question routes in Rust
Migrate 5 non-economy UTAS route families from the Python oracle proxy to
Rust host ownership: user/accountinfo, settings, leaderboards/options,
match/reset, and phishing/{trusteddevice,question,validate}.

- adapter fut::non_economy: pure IO-free shapers matching the observed prod
  oracle bodies + a verbatim port of security_question_route (stateless ack;
  answer never stored/compared; trusted-device is an invariant constant).
- host: Route variants + classify() arms + owner=RUST dispatch; the
  security-question X-UT-SID gate reuses SessionStore::session_known.
- tests: 9 adapter unit tests (contract) + host non_economy_route_ownership
  (classify + classify_economy negatives).
2026-08-14 04:57:28 +00:00
funman300 97d48d8371 docs: record post-P1 production authority matrix + FIFA17 content completeness 2026-08-14 04:57:28 +00:00
OpenFUT Agent 5020137050 docs(fifa17): record retail economy route grammar (purchased/items, tradePile) 2026-08-14 00:50:03 +00:00
OpenFUT Agent cf05ab2a9e test(fifa17): replay retail purchased/items BUY + route matrix via dispatch
- pure_economy_routes (NEVER-BOTH + no-fallback) gains POST/GET purchased/items,
  lowercase tradepile, tradePile/counts -> all asserted Rust-owned, proxy 0.
- retail_purchased_items_buy_debits_core_through_dispatch: the exact round-2
  live failure -> POST /purchased/items now debits Core, reveal shows minted
  items, repeat reveal idempotent, Python proxy count 0.
2026-08-14 00:50:03 +00:00
OpenFUT Agent a6416a3f1d fix(fifa17): classify full retail economy route shapes
Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
  GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
  (hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
2026-08-14 00:50:03 +00:00
OpenFUT Agent 47ced228de docs(route-authority): record v1/v2 economy URL prefix contract
Accepted prefixes /ut/game/<sku>/ and /ut/v2/game/<sku>/ for every economy
route; StoreBuy accepts store/transaction and store/transaction/<txn-id>.
2026-08-13 23:53:44 +00:00
OpenFUT Agent b8beeba98d test(fifa17): cover retail v2 Store route shapes
Through real handle_with_ip dispatch against live Core:
- pure_economy_routes gains the retail v2 Store family (transaction/0,
  purchasegroup, purchased GET/POST) so NEVER-BOTH + no-fallback assert them
  Rust-owned (Python proxy count 0) and fail-closed 503 on dead Core.
- Part-7 repro: PUT /ut/v2/game/fifa17/store/transaction/0 returns a Rust
  createPackResponse + debits Core (NOT the Python TRANSACTIONCANCEL no-op).
- retail_v2_store_flow_matches_v1_through_dispatch: v1 and v2 BUY of the same
  pack debit + mint identically; v2 purchasegroup + reveal Rust-owned.
2026-08-13 23:53:44 +00:00
OpenFUT Agent df6994c957 fix(fifa17): classify retail v2 economy routes
Live staging (S2) showed the retail FIFA17 client issues the Store family
under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0),
which escaped Rust economy authority to Python. Fix classify_economy:
- ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the
  same tail (generic sku, never hard-coded fifa17); delete family likewise
  accepts /ut/v2/delete/game/.
- StoreBuy matches store/transaction and store/transaction/<digits> via a
  bounded is_store_transaction_tail (never store/transactions, ...foo, or
  .../<id>/extra), mirroring the Python bare /store/transaction regex.
Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2
unit tests (lib 74).
2026-08-13 23:53:44 +00:00
OpenFUT Agent d74e86c065 docs(fifa17): record Rust economy authority proof (E1 cutover ready)
Final per-route authority table: every economy route owner=Rust, Python
proxy=NO (userMassInfo the one hybrid: Python envelope + Rust economy/squad
overlay). Barrier 93a46d4; from_config 43917a0. Proofs: differential 15 PARITY
+ 1 DIFFERENT-BY-DESIGN, concurrency 8x50, failure 10 (complete-sale SAFE, no
E3), importer 5-step, from_config E2E, NEVER-BOTH / no-fallback / stale-reader.
Python source byte-unchanged; oracle suite 32/32.
2026-08-13 22:44:12 +00:00
OpenFUT Agent 76512f6048 test(fifa17): prove post-barrier economy authority (never-both / no-fallback / stale-reader)
barrier_never_both_no_fallback_and_stale_reader drives the REAL post-barrier
handle_with_ip against a live in-process Core + a mock Python upstream that
counts every request and answers with a coins=111 marker:
- STALE READER: credits + userMassInfo show the Core balance, never 111
  (userMassInfo proxies the Python envelope but the Rust economy overlay wins).
- NEVER BOTH (Core up): every pure economy route returns a Rust body (no
  __python__ marker) and the Python proxy call-count stays 0.
- NO FALLBACK: a server pointed at a dead Core port (built without probing Core:
  empty catalog + empty pool) fails closed (credits/match -> 503) and STILL never
  proxies to Python (call-count unchanged).

Also parametrizes build_econ_server's pass URL so the mock upstream can be
injected. host 71 lib + 4 integration + differential + concurrency + failure +
24 host_test all green; clippy -D warnings + fmt clean.
2026-08-13 22:39:45 +00:00
OpenFUT Agent 93a46d4de7 feat(fifa17): cut over FUT economy authority to Rust
The economy authority barrier. `handle_with_ip` now dispatches every
economy-touching route to Rust/Core via `try_handle_economy` BEFORE consulting
`classify()`, so a migrated route can never also reach the Python passthrough
(NEVER BOTH). With economy services wired (production `from_config`) an economy
route ALWAYS returns Some — fail-closed 503 on any Core error — so there is no
Python economy fallback. `userMassInfo` stays a hybrid by design: Python
supplies the non-economy envelope; Rust overlays BOTH the squad and the economy
fields (coins + unopened-pack count from Core), so no stale Python economy value
is visible.

Routing only — no handler/test changes buried here. Routes now Rust-owned:
/user/credits, /store/purchasegroup, /store/transaction, POST+GET /purchased,
PUT /item, item DELETE forms, /match (ut/delete), /auctionhouse, /tradePile,
/trade, ut/delete trade; plus the userMassInfo economy overlay.
2026-08-13 22:39:36 +00:00