Commit Graph

114 Commits

Author SHA1 Message Date
funman300 e7893a0162 fix(fifa17): route a partial squad PUT to a role patch, not a replacement
FIFA 17 sends two different operations to `PUT …/squad/<id>` and distinguishes
them only by body shape. Across 73 captured squad PUTs in five captures there
are exactly two:

  * 68x with `players` -- a full replacement (also carrying squadName,
    formation, squadType, manager, chemistry/rating, and redundantly
    captain/kicktakers);
  * 5x without `players` -- `{id, custom, captain, kicktakers}`, emitted by the
    captain/kick-taker screen.

`players` has `#[serde(default)]`, so an absent key and an explicit `[]`
collapsed to the same empty vec and every partial update was handed to Core as
a replacement with zero slots. Core's empty-replacement guard refused it (400)
and the host reported 502, losing the user's captain/kick-taker change.

`classify_squad_put` now tests key PRESENCE on the raw JSON before
deserialising, so absence ("the squad was not part of this edit") stays
distinct from an explicit empty array ("replace with nothing"). An explicit
`"players": []` still classifies as a replacement and still meets the guard --
the patch path is not a way around it.

The patch path carries the contract correction: omitted `players`, `manager`
and actives mean UNCHANGED, never cleared. That is structural --
`CoreRolePatchRequest` has no field able to express them. The extension is
MERGED rather than overwritten, because the partial body carries only `custom`
and `kicktakers`; overwriting would drop every kit number in the squad.
`custom` IS taken from the patch, since the role screen writes per-slot values
into it and the two shapes genuinely differ there.

Also fixes the error mapping on this route: a Core 400 means the REQUEST was
invalid, so it is reported as 400, not as a 502 that blames the server and
hides a client error behind "upstream unavailable".

Tests use the real captured body and assert it takes the patch path
(`replace_squad` call count unchanged), that the manager survives, that kit
numbers survive the merge, that an explicit empty `players` still reaches the
replacement path, and that an unresolvable captain refuses the whole patch
rather than half-applying the kick-takers.
2026-08-25 01:52:51 +00:00
funman300 a2b0c32a70 docs(fifa17): numeric squad ids are real; our collapsing is safe, not authentic
The numeric id in squad/<n> was being justified as "matching the oracle". That
justification does not survive inspection, and the code now says why.

FIFA 17 has genuine multi-squad semantics. The client's own shipped action
table has SelectSquadById, RetrieveSquad as an action DISTINCT from
LoadActiveSquad, CreateSquadWithName, RenameSquad, DeleteSquad, CopySquad,
indexed SQUAD_ID-%d list entries and FUT_MAX_NUM_SQUAD_REACHED. The base
template is `ut/%s/squad` with the id appended. The number identifies a squad.

The inherited behaviour came from a bare prefix regex in the Python oracle -
`re.compile(G + r"/squad")` calling squad_route(), which never reads the URL id
(GET returns current_squad(), PUT echoes the id from the BODY). The comments
around it show /squad/list and the draft routes had to be registered first
because that rule was swallowing them. It was expedient, not evidence-driven.

Collapsing the id is nonetheless SAFE today, and only for a specific reason:
we advertise exactly one squad. ACTIVE_SQUAD_WIRE_ID is a constant 0,
/squad/list returns a single-element array carrying it, and no
create/rename/delete/copy route exists, so the client can only echo back the id
we gave it. Every numeric path in retained captures is squad/0, all PUTs whose
body id also reads 0; no numeric GET has ever been recorded.

Behaviour is therefore UNCHANGED - no evidence justifies changing it, and
unknown-id semantics are deliberately not invented. What changes is that the
assumption is now explicit and enforced:
numeric_squad_routing_is_safe_only_while_one_squad_is_advertised pins the wire
id at 0 and /squad/list at one entry, and fails the moment a second squad
becomes addressable. Verified by simulating a second advertised squad.

Workspace 1252 passed (1251 + this test), 0 failed.
2026-08-24 22:35:28 +00:00
funman300 e49c1f211c fix(fifa17): route the client's lowercase usermassinfo to the Rust handler
The retail client sends BOTH casings. Observed twice on staging, each time
inside a genuine client sequence:

  16:56:56  route=squad-active 200
  16:56:56  GET /ut/game/fifa17/usermassinfo -> passthrough -> 502
  16:56:56  route=userMassInfo 200

classify matched the exact literal `userMassInfo`, so the lowercase request
fell through to the Python upstream. Today that is a harmless 502 because the
upstream is dead and the client immediately retries with the canonical casing -
but on a deployment with Python ALIVE that request would be ANSWERED there,
silently splitting authority away from Rust for a route Core owns. That is the
real defect, not the wasted round trip.

Fixed with the smallest possible alias: this one tail is matched
case-insensitively, the rest of the table stays exact since no other route has
ever shown a casing variant. Paths are NOT globally lowercased.

Tests cover the canonical casing, lowercase, uppercase, two adjacent tails that
must NOT be swept up by the alias (`usermassinfox`, `usermass`), and method
semantics (PUT/POST still passthrough). With the alias reverted the test fails.
2026-08-24 22:00:08 +00:00
funman300 96f24e799a fix(test): restore #[test] on the SBC fault guard test
The squad_actives default test was inserted between #[test] and the function
it belonged to, which stacked a duplicate attribute on the new test and left
sbc_post_commit_faults_require_all_staging_guards with none - silently
disabling it while the new test ran twice.

Caught by clippy (-D duplicate-macro-attributes, -D dead-code); the doubled
test name in the earlier run was the tell. Lib tests go 129 -> 131 with both
now executing.
2026-08-24 21:39:48 +00:00
funman300 f315f16e8e feat(fifa17): emit squad.actives by default
Serving the club's active home and away kit is normal FIFA 17 behaviour, not
an experiment: it is what lets the client make the kits resident and render
the pre-match selector. A correct deployment should not have to opt in, so the
default is now ON and the environment variable survives only as a diagnostic
off switch (OPENFUT_FIFA17_SQUAD_ACTIVES=0).

The gate was added when a populated actives array was once seen to empty the
squad. That justification no longer holds:

  - it never reproduced, and the feature is now proven end to end on a retail
    client - 29 resident nodes, both cardtype-7 kits in club slots 0 and 1 with
    itemState 101/102 and category 4, alongside 23/23 players and a resident
    manager, with the selector rendering correct distinct home and away kits;
  - it can no longer cause durable damage, because both squad write-back paths
    are guarded in Core (empty replacement refused; an absent manager field no
    longer read as "clear").

Scope audited before flipping: squad_actives() emits ONLY the home and away
kit, each resolved from Core's active designations and required to be genuinely
owned. Badge, ball and stadium are never emitted, so enabling this cannot
surface an unproven active family - confirmed on the wire, where the emitted
itemTypes are exactly {"kit"}.

Env parsing moved into a pure parse_squad_actives() so the DEFAULT is testable
rather than depending on process environment. Unrecognised values stay ON
rather than silently disabling the feature.

Verified on staging with the env var REMOVED from host.env entirely: the host
logs squad_actives=true and serves both kits (assetId 14/15, states 101/102)
with 23/23 players and the manager intact.
2026-08-24 21:38:33 +00:00
funman300 74768693ec fix(fifa17): send a club item's real wire assetId, not its carddbid
A club item's `assetId` (record +0x20) is family specific and is NOT the
carddbid: per the client's own tables a kit carries the art class from
fcc_kitcards.assetid - 14 for the 63xxxxx home/third band, 15 for the 64xxxxx
away band - a badge carries its team id, and a ball and stadium their own
asset number. The catalog shipped `asset_id`, the carddbid, in that slot.

Measured on the live client with both kits resident: record +0x20 held
6300006 (home) and 6400003 (away) where the table says 14 and 15, while every
other field - resourceId, cardassetid 35, category 2/3, teamid 21, year 0,
itemState 101/102 - already matched. Operator reports both pre-match kit tiles
rendering identically. assetId is the only field that diverges from the
client's own data, and an assetId that is not a valid kit art class cannot
resolve to distinct art.

`resource_id` is derived from `asset_id`, and every home kit shares art class
14, so the two cannot be the same field: catalogs now carry an optional
`club_asset_id`, defaulting to `asset_id` so a catalog predating the field and
every non-club kind are unchanged. resolve_kit emits it as the wire `assetId`.

Fixed at the source too - scripts/sold-staging-up.py emitted asset_id as the
wire assetId for all four club families, so a re-emit would have regressed it.

Field-offset note: club_items.json's _record_map is authoritative and my
earlier working note had these transposed - assetId is +0x20 and cardassetid
is +0x1c, not the reverse.

Adds tools/live/diff_kit_records.py, which byte-diffs the two resident kit
records and names the fields the decoded clone query consumes.

Staging wire now reads assetId 14/15 with cardassetid 35 on both
squad.actives and /club?type=equippables. Workspace 1250 passed, 0 failed.
Client re-parse still to be confirmed visually.
2026-08-24 20:35:58 +00:00
funman300 6bbc0eaf4f fix(fifa17): never turn a missing manager ref into a manager deletion
The host called set_squad_manager unconditionally on every squad save, passing
the resolved manager or None. None was serialised as {"owned_card_id": null},
an EXPLICIT removal, so a save that merely said nothing about the manager
deleted the assignment. That is how a client whose squad model had been
destroyed wiped a real manager row (WAL commit 468, squad_managers 1 -> 0).

FIFA 17 has no wire shape that removes a manager: the client always sends a
ref. So None never means "the user cleared the slot" - it means the ref was
absent, zero, or unmappable, i.e. this save carries no manager decision. The
assignment is now left untouched and the skip is logged.

The capability is removed at the TYPE level: CoreAccess::set_squad_manager
takes &str, not Option<&str>, so the host cannot express a clear at all. Core
still supports deliberate removal via an explicit null for other callers.

The existing test asserted the destructive behaviour as intended ("a later save
without a manager CLEARS it"). That contract was the bug; it now asserts the
manager survives and that both saves still commit their slots. With the fix
reverted the test fails.

Live-proven on staging against the real route (PUT /ut/game/fifa17/squad/<n>;
squad/active is a GET-only tail and falls through to the dead Python upstream,
which is why an earlier replay attempt proved nothing):

  exact original shape (no resolvable players, manager: [])
    -> 502 core_error, core returned status 400, nothing mutated
  valid 23-player save carrying manager: []
    -> 200 {"id":0}, manager_write_skipped logged, manager PRESERVED
  valid 23-player save carrying the real manager ref
    -> 200 {"id":0}, manager assigned

Players 23/23, manager 1, captain, active club items, coins, integrity and FK
identical before and after, and again after a Core+host restart.
2026-08-24 19:59:09 +00:00
funman300 d929efdffe fix(fifa17): disable squad.actives by default after it emptied the squad
Populating `squad.actives` is the proven way to make a club item resident —
the squad parser writes each element straight into a club-item slot, both
kits came back resident with the client writing `category 4` itself, and the
pre-match kit selector worked.

But a populated array was then observed to cost the rest of the squad. On a
full client relaunch the resident item map fell from 24 entries to just the
2 kits, the 23-slot player vector came back fully null, and the starting-11
screen was empty; the manager and staff were gone too. Every host response
was 200/outcome=ok with no warning, so the loss is entirely client-side
parse behaviour. `actives` sorts first in the squad object, so `captain`,
`formation`, `manager`, `players` and everything else after it are lost —
consistent with the element loop leaving the tokenizer misaligned.

The same payload produced a correct 24-node map on an earlier relaunch, so
the interaction is not yet understood and is not safely shippable. An empty
squad is far worse than a missing kit, so the array is now gated behind
`HostConfig::squad_actives` (env `OPENFUT_FIFA17_SQUAD_ACTIVES`) and off by
default. The projection, identity plumbing and tests are kept intact: they
are correct and are what the investigation will re-enable.

Staging redeployed with the flag off and verified back to 23/23 populated
player slots and `actives: []`.
2026-08-24 18:53:28 +00:00
funman300 98f30931a0 fix(fifa17): never schedule the club's own kit team as a season opponent
The pre-match kit clone resolves BOTH sides out of the client's own
teamkits table keyed on teamtechid, with only teamkittypetechid (0 home,
1 away) telling the strips apart:

    teamtechid        == record+0x94   (wire teamid)
    teamkittypetechid == 0 for activeHomeKit, 1 for activeAwayKit
    year              == record+0xba   (wire year)

Our club wears team 21's kit (fcc_kitcards carddbid 6300006/6400003 are
both teamid 21), and the offline-season ladder cycled a fixed opponent
list whose first entry was also 21. So round 0 put the club against the
team whose kit it wears and both sides rendered the same strip. It is
also simply wrong data: a club playing itself.

The schedule now excludes the club's own kit team, which the host derives
from Core's active kit designations via resolve_kit. An exclusion that
would empty the rotation is ignored, because an empty matches array makes
StartSeason dereference NULL at CardsDLL+0xfc5b5.

This is not a kit-pipeline change: squad.actives already produces the two
resident cardtype-7 records with the correct itemStates, and the clone
query is satisfied by that data unchanged.
2026-08-24 18:26:10 +00:00
funman300 0e200758f0 fix(fifa17): project active club items through squad.actives
FIFA 17 makes a club item resident ONLY through squad.actives. The squad
parser's arm for atom 11 computes the address of the i-th element of the
client's five-element club-item array and hands it to the item
deserializer as the out-handle:

    cmp  edi,0x5                    ; at most five entries are read
    mov  rax,QWORD PTR [r13+0x108]  ; the club-item array
    lea  rcx,[rax+rcx*8]            ; &array[edi]
    call 0x18013fe00                ; item deserializer, writing that slot

That deserializer inserts the record into the client's resident item map
- keyed by wire instance id, gated only on the id being non-zero - and
binds the slot handle to it. So each element must be a full item object
like squad.manager[].itemData; an id reference alone installs nothing,
because the manager installer looks its id up in that same map and does
nothing on a miss.

We emitted actives: [] as an 'observed constant', which was circular: it
came from our own captures and the Python oracle seeded it. The client
then read the array-end token immediately, parsed nothing, and left all
five slots null, so every lookup resolved to the static not-found
sentinel whose item pointer is NULL. That is why the pre-match kit
selector had no kits, and it is also why /club?type=kit could never fix
it: no /club response feeds that array.

Core already owns the designations via /club/active-items, so the host
reuses get_active_kits() and the adapter shapes each entry with the same
shape_club_item primitive /club?type=kit uses, keeping one wire dialect.
A Core transport error yields no actives and is reported rather than
silently empty. userInfo.actives already mirrors the squad's.

Verified against the client's own fcc_kitcards table: 6300006 is team
21's home card (category 2) and 6400003 the away card (category 3).
2026-08-24 17:59:11 +00:00
funman300 e48d659bce host: fix stale test that still asserted equippables was withheld
d146f9c flipped `club?type=equippables` from withheld to answering the KIT
family — the change that made kits render in My Club, operator-confirmed — but
it only touched src/lib.rs. `withheld_club_type_arms_are_empty_with_a_recorded_reason`
still listed that arm, so the suite asserted the exact behaviour the fix
removed. It failed on `cargo test --workspace`; the "1241 passed" figure carried
in the notes was measured before the flip and was stale.

Drops the arm from the withheld table and adds a test for what actually ships:
the tab answers, serves exactly the kit family with cardsubtypeid 9, and returns
the same body as `?type=kit`, so the two tabs cannot drift apart.

`OPENFUT_FIFA17_EQUIPPABLES=0` is deliberately not exercised: it is read through
a OnceLock, so flipping the process-wide env in one test would leak into every
other test in the binary.
2026-08-23 23:08:24 +00:00
funman300 d146f9c3fc host: answer club?type=equippables by default — kits now render in My Club
OPERATOR-CONFIRMED on the retail client: the My Club kit tab shows both kits, the
first time kits have ever rendered in this project.

The tab asks for `?type=equippables`, and that arm was withheld, so the screen got
an empty body and showed nothing. That is exactly what "0 kits in my club" was —
not a shaping problem, a refused question. Log line that identified it:

    route=club outcome=withheld filter=[type=equippables,
                    reason=multi_family_crash_2026_08_05] total=0 emitted=0

TWO fixes were both required, so neither alone is the cause:
  * this arm answers (KITS ONLY), and
  * GET ut/%s/item (FutViewCards) returns the OWNED INSTANCE rather than a
    definition placeholder, so the kit arrives as cardsubtypeid 9 /
    itemState active{Home,Away}Kit instead of "a free player" (d4a39ba).

The 2026-08-05 crash that motivated the withholding was 30 items across FIVE
families. This arm serves one family, and the live body is two items. Serving the
other four families here is untested and stays unserved.

Default flips from opt-in to opt-out: OPENFUT_FIFA17_EQUIPPABLES=0 restores the
withheld body with a restart and no rebuild. The switch is kept rather than
deleted because the only live evidence is a club holding exactly TWO kits; a club
holding many is untested and the original crash was about size and family mixing.

Verified with no env flag set: ?type=equippables -> 2 items, family {9}.
cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
Production untouched — staging only.
2026-08-23 21:34:05 +00:00
funman300 d4a39ba75d host: ViewCards must return OWNED INSTANCES, not definition placeholders
GET ut/%s/item is FutViewCards and its ids are OWNED INSTANCE ids - the client
builds the query as ?idList=%lld (CardsDLL .rdata 0x220080) from ids it already
holds. It was being answered with the definition body, which echoes the queried
id straight back. Asked about the active home kit, instance 100004874, the server
replied:

    resourceId 100004874, cardsubtypeid 0, itemType "player", itemState "free"

i.e. "your active home kit is a free player". No kit can ever be seen as active
through that. Real players were equally wrong: instance 100000003 came back as
resourceId 100000003 instead of the actual card 200389 rating 87.

This is on the active-kit path, and the evidence for that is the client's own UI.
KitAssignmentPopup.BIG (exported from Frosty today) decompiles to
external.ion_fut.components.KitAssignmentPopup and contains OSDKCards_ViewCards,
OSDKCards_ActivateCard, mHomeKitID/mAwayKitID/mSourceKitID, and the search states
SEARCH_STATE_ACTIVE_HOME_KIT / SEARCH_STATE_ACTIVE_AWAY_KIT. Those states can
only come from the itemState this route returns.

Route::ViewCards is now distinct from Route::ItemDefs. Owned instances are shaped
by the SAME projector /club uses, so there is one wire dialect and no drift; ids
that are not owned instances still fall back to the definition placeholder, and
the empty query still answers {"itemData": []}, preserving oracle parity for the
definition-style callers (item/resource, defid).

Verified on staging:
  ?idList=100004874,100004873 -> resourceId 6300006/6400003, cardsubtypeid 9,
        itemType kit, itemState activeHomeKit/activeAwayKit, teamid 21, cat 2/3
  ?idList=100000003           -> resourceId 200389, rating 87 (the real card)
  ?idList=999999999           -> definition fallback
  no query                    -> {"itemData": []}
  item/resource? and defid?   -> unchanged

cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
2026-08-23 21:20:07 +00:00
funman300 9cc5188e5c host: claim GET ut/%s/item (FutViewCards), which fell through to Python
Fifth instance of this project's recurring dead-route defect: a handler exists and
is correct, but classify() never produces the route, so every request falls
through to the Python upstream. Invisible in production, where the oracle answers;
on staging, where the upstream is deliberately dead, it is a 502.

GET ut/%s/item is FutViewCards (deser 0x1801293d0, top-level itemData via the
shared card element 0x18013fe00). The oracle answers it with defs_route - the SAME
handler already wired for item/resource and defid: pull every integer out of the
query (idList=a,b,c / definitionId= / resourceId=) and return one definition per
id, or {"itemData": []} when there are none (tools/utas_server.py:1103). So
claiming it is byte-identical parity, not new behaviour.

The query handling is the substance of the fix, not decoration. ut_tail does NOT
strip the query string, so an equality-only arm (`Some("item")`) misses every real
request while passing a no-query unit test - which is precisely how the route
stayed unclaimed. The same latent bug applied to the two arms that were already
there: item/resource and defid only matched with no query, so
`item/resource?resourceId=` and `defid?definitionId=` were ALSO falling through.
All three now mirror the oracle's own `item(\?|$)` pattern.

Verified on staging, all 200 where they were 502:
  GET /item                                  -> itemData 0
  GET /item?idList=100000003,100000004       -> itemData 2
  GET /ut/v2/game/fifa17/item                -> itemData 0
  GET /item/resource?resourceId=5003012      -> itemData 1
  GET /defid?definitionId=200389             -> itemData 1

Does NOT by itself fix the kit selector - GET /item is a definition lookup keyed
by ids the client already holds, not the thing that seeds the club collection, and
the observed session never requested it. It is a real production-masked gap and a
prerequisite for testing anything else on staging.

get_item_is_claimed_and_the_other_item_verbs_are_unaffected pins the claim plus
the three verbs that share the prefix: PUT item stays FutMoveCard on the economy
path and DELETE item/<id> stays QuickSellPath.

cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
2026-08-23 20:22:54 +00:00
funman300 6baa673252 kits: recover the selector data path from CardsDLL; residency tracks the ROUTE, not itemType
RETRACTION FIRST. The previous commit added itemType to club items on the theory
that it gated ingestion, because player/staff sent it and were resident while
kit/badge/stadium omitted it and were not. Relaunched the client with itemType on
all three: ?type=kit answered total=2 emitted=2, and still no cardtype-7 record.

The correlation was an artefact of the control. Measured read-only over
/proc/PID/mem with full coverage (3605 MiB, nothing skipped): the "resident"
players and staff were all SQUAD members, which arrive via userMassInfo. Players
that appear in /club?type=player but NOT in userMassInfo are not resident either -
0 records for 6 of 6 sampled, 5 with no byte match at all, out of 1966 served.
Residency tracks the ROUTE. /club?type= responses never enter the persistent card
collection, and no value of itemType changes that. itemType is kept as wire
fidelity (every real EA item carries it) and relabelled; its doc no longer claims
to fix anything. The diagnostic KIT_PROBE is removed - it could only have tested
shape hypotheses that this result makes moot.

RECOVERED from the unpacked CardsDLL, no archive extraction, no instrumentation:

  Packed kit id, both directions present and agreeing:
    id = (teamid << 14) | (year ? (year-1800) << 5 : 0) | kittype
  so a kit is addressed by the triple (teamid, year, kittype).

  FUN_180033770 answers ONLY for team 130000 - 0x1800d8ab0 is literally
  `mov $0x1fbd0,%eax ; ret`. Every other team id falls through to the engine's
  catalogue kits, which are the lockable ones.

  sub_180033430 writes the tile: NAME = "HOME_SIDE"/"AWAY_SIDE", TYPE = the
  localised Kit_type_0 / Kit_type_1 / Kit_type_historical, and LOCKED (always
  value 0, never 1). If the queried triple matches NEITHER active triple it
  writes NOTHING - which is exactly why one tile rendered "undefined". A missing
  write, not a bad string. There is no Kit_type_2.

  FUN_1800d73d0 selector 2/3 does `setne dil ; add $0x65,%edi` then compares
  itemState: active home = 101, active away = 102, derived arithmetically and
  independent of the enum table. year at +0xba is movzbl - a byte INDEX.

  Above all of it: FUT_GET_MATCH_KITS_DP (0x7565) handler FUN_1800be6a0 gates on
  `cmpb $0x1,0x152(%r14)` and returns early otherwise. KITS_AVAILABLE IS
  ctx+0x152. Constructor zeroes it; the only setter is case index 6 (message
  0x757a) of the jump table at 0x1800c00d4. Live value is 0, so no kit list is
  ever built. 0x757a has no name in CardsDLL and that is bounded, not sloppy: the
  registration run ends at 0x7575 with the epilogue immediately after, and 70
  other ids resolve from the same table as the positive control.

Tables (audit_fifa17_kits.py, full-table counts): category 2/3/5 -> engine kit
type 0/1/2 with 0 counterexamples against 54/166/145 discriminating keys; the id
band is NOT home/away (band 63 holds 740 home AND 88 third).

Vault: "Kit Selector Data Path.md". cargo test 429 passed 0 failed across the two
crates; clippy -D warnings clean; fmt clean.
2026-08-23 20:08:08 +00:00
funman300 e0f1e379b7 kit probe: settle the ingest question in one restart; itemType alone did NOT fix it
RESULT OF THE PREVIOUS COMMIT, recorded before anything else: sending itemType on
cardtype-7 club items did NOT make them ingest. Client relaunched, ?type=kit
answered total=2 emitted=2 with itemType="kit" on both, and afterwards there is
still no cardtype-7 record resident and the hook still traces
KITS_AVAILABLE = 0. The player/staff-vs-kit/badge/stadium correlation was real
but it is NOT the cause. The field is kept because every real EA item in the
capture corpus carries it and the two ingesting families already did, but it is
now labelled wire fidelity, not a fix.

Also already refuted, so neither is the answer: ?type=equippables answered with a
kits-only body (kits stayed undefined), and the kit ids are correct - 6300006 and
6400003 are the real fcc_kitcards carddbids for team 21 home/away with matching
category 2/3 and year 0.

This adds OPENFUT_FIFA17_KIT_PROBE (default OFF, staging armed) which appends two
synthetic kits to ?type=kit so ONE client restart discriminates the two remaining
hypotheses instead of one restart each:

  6300007 MINIMAL - exactly the field set a STAFF item carries, which is known to
    ingest, plus cardsubtypeid 9. If only this appears, one of the kit-only
    extras (assetId, cardassetid, teamid, category, year) makes the client
    discard the item.
  6300008 NAMED - full kit shape plus name/localizedName/description, the three
    fields the cardtype-7 parse arm is documented to copy and which OpenFUT has
    never sent. If only this appears, they are required, not optional.

If NEITHER appears, ?type=kit is not the route that populates the collection
FUN_1800d73d0 scans, and the search moves to which route does.

Both ids are real team-21 carddbids, served free so they cannot disturb the
active-kit assignment, with instance ids outside Core's range. Two items in one
family: the response that crashed this client on 2026-08-05 was thirty across
five.
2026-08-23 19:29:33 +00:00
funman300 11b7991d81 feat(fifa17): gated kits-only club?type=equippables, to test the locked-kit cause
A live kit_trace run on the retail client showed the kit clone driver only ever
sees PLAYERS - about 19 records, all cardtype 1 / itemState 1 / +0x60 = 1 - and
never a kit, with no KIT_DBCLONE line at all. So the locked-kit failure is
UPSTREAM of the item+0x60 == 4 gate that this arm's comment blamed.

In the same session the client asked for ?type=kit six times, which we answer
and which feeds the items browser, and ?type=equippables twice, which we answer
empty. If the equippable view is what populates the collection FUN_1800d73d0
scans to build the active-kit triple, an empty answer explains precisely why the
triple stays zero and the engine falls back to its own catalogue kit.

The arm now selects ContentKind::Kit behind OPENFUT_FIFA17_EQUIPPABLES=1, so it
answers with TWO items rather than the thirty across five families that crashed
the client on 2026-08-05. Default OFF: that crash is real and reproducible, and
the narrow body is a hypothesis under test rather than an established safe
response. Reverting is an env change plus a restart, no rebuild.
2026-08-23 17:38:32 +00:00
funman300 e18304d365 fix(fifa17): serve the full kit identity triple so the selector can match
Operator report: selecting a kit in the pre-match selector gives "This kit is
currently locked. To unlock and use it, please go to the football club
catalogue."

Root cause, from static RE of the UNPACKED CardsDLL. CardsDLL registers a kit
provider into the FIFA engine (singleton FUN_1800338f0, vtable 0x1801f1d68):

  slot +0x08 FUN_180033770  enumerate kits for a team
  slot +0x10 sub_180033430  describe one kit   <- the lock gate

The describe function decodes a packed kit id into (teamid, year, slot) and
compares it against the club's ACTIVE HOME and ACTIVE AWAY triples. On a match
it writes NAME/TYPE (and, for historical kits, LOCKED). On no match it writes
NOTHING AT ALL and the descriptor falls through to the engine's own default,
which is where the locked-catalogue message comes from.

The active triple is 100% server-driven. FUN_1801c26d0 -> FUN_1800d73d0 scans
club items for cardtype 7 + cardsubtypeid 9 + itemState 101/102, then reads:

  teamid    <- item+0x94   (atom 0x306)   we were sending this
  year      <- item+0xba   (atom 0x389)   WE WERE NOT SENDING THIS
  slot      <- item+0xb8   (category)     WE WERE NOT SENDING THIS
               category 2 -> slot 0 home, 3 -> slot 1 away, 5 -> slot 3 third

So we shipped kits carrying only teamid, and the triple could never match.

fifa17-recon/data/club_items.json already has category and year per kit
resourceId (1482 kits; category {2:740, 3:654, 5:88}; 85 historical years), so
this is carried through the catalog rather than invented: Fifa17CardIdentity
gains category/year, Fifa17KitIdentity carries them, and shape_club_item emits
them for KIT_SUBTYPE only. Badges and stadiums deliberately do not gain the
fields - the slot mapping is kit-specific, and sending a family a field its
resolver does not read is how this project previously froze the client.

Also corrects the Vault note: item+0xba is `year`, not `kittype`. The side comes
from itemState 101/102 and the slot from category.

Two things this does NOT fix, both client-owned and recorded rather than
guessed:
  - the runtime teamkits clone into FUT club 130000 (the kit ART) is gated on
    item+0x60 == 4, and +0x60 has no wire atom at all: the deserialiser
    unconditionally zeroes it, the only CMP against 4 in the whole DLL is
    0x1801c34f1, and a live probe measured it as 1 for players / 0 for staff,
    never 4. The existing lib.rs claim that a server can never produce 4 is
    CONFIRMED, though its stated reason was a live observation rather than the
    real one (no wire atom exists).
  - whether a year==0 kit needs an explicit LOCKED write is UNKNOWN: CardsDLL
    only writes LOCKED for year != 0, and the engine's default for an untouched
    descriptor is in Denuvo-packed FIFA17.exe.
2026-08-23 04:30:19 +00:00
funman300 3a038fe406 feat(fifa17): apply the rare all-six training card
Passes a null attribute slot for the rare card -- Core reads absence as
"every slot", so sending 0 would silently train pace alone -- and declares
the per-family ceiling rather than a single constant.

Tests pin the null-slot serialisation, both ceilings, and that all 36
single-attribute plus all 6 rare cards resolve.
2026-08-22 23:33:43 +00:00
funman300 3c28b0d1af feat(fifa17): apply training cards, and project trained attributes
Opens the 409 `apply_effect_unproven` gate for attribute training, the
second family after contracts to have its effect settled rather than merely
its magnitude.

`ApplyEffect` replaces the single-family `AddContractMatches` struct: Core
dispatches on `kind`, so an unproven family must be impossible to express,
not merely discouraged. The shared half of an apply -- the exactly-once key,
Core's transaction, the error mapping and the client's payload -- is now one
`finish_consumable_apply`, so a new family cannot quietly acquire its own
idempotency format or its own success shape.

Two refusals are training-specific and both prevent silent corruption rather
than merely being tidy: a non-player target has no attributes to write, and
a cross-class target would train a different attribute from the one printed
on the card, because a keeper's slots mean DIV/HAN/KIC/REF/SPD/POS where an
outfielder's mean PAC/SHO/PAS/DRI/DEF/PHY.

`attributeList` now prefers Core's `effective_attributes` and only falls
back to the immutable definition when Core does not send them -- reading the
definition regardless would silently drop every applied training off the
card the client draws.

Tests pin the verb split on `item/resource/<rid>` (POST applies, PUT stays
quick-sell, GET is not an economy route at all), the digit guard, the exact
JSON Core deserialises for both effects, and that no shipped training card
exceeds the ceiling the host declares to Core.
2026-08-22 22:59:46 +00:00
funman300 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.
2026-08-22 20:08:26 +00:00
funman300 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).
2026-08-22 18:23:22 +00:00
funman300 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.
2026-08-22 17:02:14 +00:00
funman300 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.
2026-08-22 00:49:23 +00:00
funman300 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.
2026-08-22 00:23:15 +00:00
funman300 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.
2026-08-22 00:14:11 +00:00
funman300 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.
2026-08-21 23:49:01 +00:00
funman300 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).
2026-08-21 23:28:29 +00:00
funman300 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.
2026-08-21 22:40:04 +00:00
funman300 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.
2026-08-21 22:02:53 +00:00
funman300 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.
2026-08-21 21:58:29 +00:00
funman300 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.
2026-08-21 21:47:13 +00:00
funman300 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.
2026-08-21 21:00:25 +00:00
funman300 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.
2026-08-21 20:58:21 +00:00
funman300 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.
2026-08-21 20:45:29 +00:00
funman300 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.
2026-08-21 19:49:59 +00:00
funman300 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.
2026-08-21 18:00:28 +00:00
funman300 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.
2026-08-21 17:13:44 +00:00
funman300 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.
2026-08-21 16:20:01 +00:00
funman300 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 (d37a9d5 / 25f4ad1) turned that ref into a
hard refusal, which took out the primary FUT write path: no squad save means no
squad, which means the client will not enter the FUT hub at all.

A manager ref is not a squad slot. An unresolvable PLAYER slot must still refuse
the save -- committing it would silently drop an owned card from the club. An
unresolvable MANAGER ref just means there is no ownership-backed manager, which
is exactly the state before migration 0023: the save commits, the assignment is
cleared as a full replacement should, and the id is reported on
ProposedSquad::unresolved_manager_wire_id so the host can log what it could not
map instead of letting it vanish. A ref that DOES resolve is still assigned and
still authorized against the club.

Verified end to end on staging: the seeder now answers {"id": 0} with 11
occupied slots, the host logs
`manager_ref_unresolved=100000427 (saved with no manager assignment)`, and the
projected squad carries `manager: []`.
2026-08-21 04:28:43 +00:00
funman300 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 (25f4ad1 /
   d37a9d5); 56bd9dd updated the squad_projection integration test and stopped
   there. `put_body` hardcoded the captured manager ref 100000427 into EVERY
   save, including tests with no manager fixture, so each one was refused with
   `unresolved_wire_ids` -- the tests were reporting a real invariant against a
   fixture that could not satisfy it. The manager is now an explicit
   `Option<i64>` per test, and FakeCore models Core's manager persistence
   instead of inheriting the "not implemented" default that 502'd every save.
   Added the coverage whose absence let this rot: a manager assignment
   round-trips as a Core owned id, a later save without one CLEARS it, and an
   unowned manager ref refuses the whole save with nothing committed.

3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit`
   forwarding to Core, which the kit commit deliberately replaced with
   host-side pagination. It only ever passed because FakeCore ignored the
   window -- against a real Core, `start=10` over a one-item club was always an
   empty page. Retargeted to the real contract (Core gets semantic filters and
   NO window) plus a new test that the window is applied locally after
   filtering, which the old fake made vacuous.

4. The staging lifecycle scripts identified production by hardcoded pids, so a
   correct teardown FATAL'd: production moved into containers and pids
   3631953/3374264 died with a container restart days ago. A pinned pid rots
   into the worst of both worlds -- a kill-refusal gate that no longer names
   any real production process, and a liveness gate that fails a healthy
   teardown. New shared `scripts/openfut_production.py` resolves production
   pids AND published ports from the container runtime at the moment they are
   needed, refuses to signal anything it cannot see, and proves production is
   the same processes serving the same ports before and after. Both lifecycle
   scripts use it, which also closed a real gap: port 8085 is published by
   openfut-fut-backend but was missing from the up script's forbidden list, so
   staging could have bound a production port.

Also fixes the economy differential, red because `complete_match` unlocks
achievements in the same transaction that pays the match reward -- a deliberate
Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted
that progression did not exist; it now asserts the delta is the 400 match reward
plus exactly the achievements the match unlocked, read from Core's own report.
2026-08-21 04:10:34 +00:00
funman300 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.
2026-08-21 03:17:57 +00:00
funman300 afa5f620bd style(fifa17): cargo fmt match wire + host match integration 2026-08-20 17:59:03 +00:00
funman300 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).
2026-08-20 17:30:17 +00:00
funman300 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.
2026-08-20 16:44:40 +00:00
funman300 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.
2026-08-19 01:27:22 +00:00
funman300 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.
2026-08-18 23:26:55 +00:00
funman300 e8d1c1ddac test(fifa17): harden SBC retail acceptance 2026-08-18 19:01:33 +00:00
funman300 f9740f640d feat(fifa17): route SBCs through atomic Rust Core 2026-08-18 18:26:35 +00:00
funman300 bf6db98f0d Migrate club rename and numeric squad reads 2026-08-18 17:29:24 +00:00