Commit Graph

419 Commits

Author SHA1 Message Date
funman300 755f237f17 fifa17: carry the staff rating the client re-rates to, verified live
Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the
client overwrites the rating and rare flag we send with values from its own card
database, and a staff wire record carries no rating, no rareflag and no
discardValue at all. The server had no way to know the displayed price from what
it sent, so pricing declined for staff and fell back to the placeholder ladder.

The missing input was read straight out of the running client (pid 6580), no UI
interaction required:

  * tools/coach_probe.py grades all four resident staff records HIT, which by
    construction requires record +0xb4 == the table's `value` and +0x58 == its
    `rare`. That settles `value`-is-the-rating, which was previously an inference
    and was deliberately not shipped on that basis.
  * tools/discard_probe.py (new) reads both discard slots -- +0x38, the value we
    sent, and +0x3c, the value the client computed for itself:

      1000509  sub 4  ct 2  rat 88  rare 1   sent 0   calc 282   predicted 282
      9000081  sub 6  ct 10 rat 66  rare 0   sent 0   calc  36   predicted  36
      3000083  sub 8  ct 4  rat 66  rare 0   sent 0   calc  36   predicted  36

    4 of 4 agree, 0 disagree. 36 on the value-66 GK coach was the exact falsifier
    written for this last commit.

Entities::enrich_staff now fills rating from `value` and rareflag from `rare` for
the five staff families, and the catalog emits the real rareflag instead of a
hardcoded 0 (it is not cosmetic -- it selects the discard price column, which is
why the rare-1 manager prices at 282 and a rare-0 coach at 36). Players and
consumables are untouched; their wire values are authoritative.

Verified on staging: a GK coach quick-sells for 36, not the 150 floor. The
catalog diff is exactly the two coach entries gaining rating 66; 1710 entries in
and out, nothing else changed.

The same probe shows what production does to PLAYERS today: every resident player
carries sent+38 = 1500, which suppresses the client's own computation, against a
real 688..752 for a gold rare and 72,800 / 74,400 for the two legends.

Still open, and not a discard problem: manager fifa17_1000509 is owned in Core
but has no catalog entry or definition (it reaches the client through the opaque
squad extension), so it declines to the ladder. That is definition coverage.

Importer 41 tests, fmt and clippy clean.
2026-08-21 22:51:16 +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 52df78d24a docs(fifa17): correct ENDPOINT_MAP club routes, and close plan section 7
Rows 12, 13 and 16 carried guessed or placeholder URLs (`ut/%s/item?type=…`,
`ut/%s/…`). The real binding is a table, not an inference: the 125-row action
table at 0x1802caa20 indexes the 48-entry URL-base table at 0x18021df80 through
column 1, and base index 3 = ut/%s/club is carried by exactly four rows. So the
client can emit exactly four families on that base:
ClubSearch, ClubStats, StaffStats, ConsumablesSearch — which also corrects those
three rows to /club?<query>, /club/stats/staff and /club/consumables/<cat>, and
updates their status now that the Rust host serves them.

Added the complete club query grammar (ordered, with its suppression rules and
sub-vocabularies, including that the request spells it onSale where the response
says forSale), the seven /club/stats forms, the fact that /club/stats/team does
NOT exist, and the two base-table holes that are composed outside CardsDLL so
nobody re-derives them as findings.

Section 7 of the plan is now marked APPLIED and kept as the audit trail.
2026-08-21 21:33:36 +00:00
funman300 22cfae830f docs(fifa17): apply the plan's corrections to CARD_SYSTEM.md and fut_store.py
Section 7 of plan-2026-08-06-card-subsystem.md listed these and they were never
applied, so the stale text kept misleading readers — it already cost this project
a ten-row itemState table.

CARD_SYSTEM.md:
  - "STILL UNKNOWN, AND NOT GUESSED" is ANSWERED. Its candidate set was wrong:
    it asked which of 0x1e/0x1f/0x91..0x96 meant kit/badge/stadium, but three of
    the five families are cardtype 7 (kit 9, stadium 10, badge 11) and are not in
    that set at all, and 0x91..0x96 are trophies. Replaced with the settled map
    and how each family's caption resolves.
  - itemState table starts at 0x180229cc0, not 0x180229d20 — the recorded address
    points MID-table, which is why six rows were missing. Added that
    WAITING_FOR_GAME/inGame are aliases, that omitting the key yields invalid and
    not free, and that the match is case-sensitive (measured).
  - the consumables route claim "the /consumables/%s template ... the client has
    still never used" is false; it IS that template, with base index 3 = ut/%s/club.
  - added the dated field-map correction block, extended with the +0x60 and
    definitionId findings measured on 2026-08-21.

tools/fut_store.py: the discard_value premise "that lookup returns no row for our
cards" / "WHY its lookup misses is still UNKNOWN" is false — it does not miss, the
tile reads a different property. That story sent one round of work chasing a table
defect that never existed.
2026-08-21 21:31:59 +00:00
funman300 404e859cb6 docs(fifa17): the caption path is not in CardsDLL, and a 4th definitionId check
Chased the league-logo lead to a useful boundary and stopped there.
FUN_180119bd0 — the cardtype-7 caption resolver the whole club-item story rests
on — has ZERO references anywhere in CardsDLL: no call, no jmp, address never
taken in .text/.rdata/.data. It is nonetheless a real function. An unreferenced
real function in a DLL is almost certainly an export, which puts its caller in
FIFA17.exe. So the owned cardtype-9 caption path is not in CardsDLL and looking
for it there is wasted effort; the launch probe remains far cheaper than parsing
the export table and 79 MB of EXE.

Also verified definitionId a fourth way, by a different method than the existing
three: every real atom name appears exactly once in CardsDLL's .rdata
(resourceId, cardsubtypeid, itemState, assetId, cardassetid, rareflag, owners,
contract, discardValue, and localizedName), while definitionId is absent
entirely. Recorded but NOT applied — the path carrying it is live-proven and the
saving is payload only.

Method note added: CardsDLL is .text 0x180001000, .rdata 0x1801e5000, .data
0x18028a000. Confusing a live mapping offset with an image offset reads the wrong
section and returns false negatives — it made every atom lookup, controls
included, come back ABSENT until corrected. Validate scans against a known key.
2026-08-21 21:28:13 +00:00
funman300 59c249e76a chore: bump openfut-core for reclassify dry-run 2026-08-21 21:23:52 +00:00
funman300 bea3b49070 docs(fifa17): a LeagueName_Abbr_15 path exists — recorded as a lead, not a fix
FUN_180098f20, previously described as the league-logo function with a hedged
"localizedName, probably", read in full: it queries fcc_leaguelogos WHERE
leagueid == %d, reads carddbid/value/cardassetid, and captions with
'LeagueName_Abbr_15_%d' in the 'FUT String' domain. A database-backed league
name therefore exists, in exactly the shape kits use for teamid — so "cardtype 9
has no DB name resolver" is too strong for league logos.

Deliberately NOT concluded: its only caller passes [rbx+0x20] as the league id,
and rbx there is a loop cursor over small list elements (int/double/int), not the
0x158-byte card record. Reading that as the record's assetId and shipping "send
leagueid as assetId" would be the exact inference this document exists to
prevent. Recorded as a lead with the next question named: does the OWNED render
path reach this resolver, and which field feeds it?
2026-08-21 21:17:43 +00:00
funman300 e44c88dd68 docs(fifa17): close the eight-flag chain link, and separate the two databases
FUN_1801aa190 (the plan's "two minutes of work" item) is eleven instructions and
resolves TWO parallel arrays, not the one the earlier claim described:
f(self, idx, which) reads item+0x104+idx*4 when the flag is clear and
item+0x124+idx*4 when it is set — eight ints each, 0x20 apart. Live, BOTH read
all zeros on every resident record including a rating-94 player, so neither can
be the reason any action is greyed today.

The FUT roster database question is partly answered. Scanning FIFA17.exe in the
live process recovers the full API name set — StartFUTRosterDownload,
DL_FUT_LIVEDB, APPLY_FUT_LIVEDB, LoadFUTDatabase, UnLoadFUTDatabase,
SetFUTDatabaseUnloaded, UpdateFUTDBVersion, GetFUTDBCRC, RosterXMLDownloadedFail,
.dbFUTVer/.dbMajor/.dbMinor/CRCs — none of which exists in CardsDLL. That is a
downloaded, versioned, CRC-checked live database with its own lifecycle, which is
categorically not the shipped card tables. Whether it is loaded RIGHT NOW is
still open: the load flag was not located, and the absence of an open DB file
proves nothing since the process only holds Frostbite bundles.
2026-08-21 21:15:02 +00:00
funman300 a842c5ffb0 tools(fifa17): answer "who writes item +0x60" — nothing does
The plan called this "the single blocker between 'we can mark a kit equipped'
and 'we can equip a kit'", and recorded that two attempts to find the writer
drowned at 1688 and 4144 instructions.

They drowned because +0x60 is a common struct offset. Two filters make it
readable: only an IMMEDIATE store can introduce a constant (a register store
just propagates one), and item-record code is recognisable by touching +0x4c
(cardtype) or +0x5c (itemState) within a few instructions.

Measured read-only against pid 6580:
  - live +0x60 over all 27 resident records: {1: 23 players, 0: 4 staff}, never 4
  - CardsDLL has 4 comparisons of +0x60 (0, 0, 1, 4); the 4 is the kit gate and
    is the ONLY such comparison in the process
  - CardsDLL has 29 immediate stores to +0x60, constants {-2,0,1,908,0x3f800000}
  - FIFA17.exe, across 79 MB of code: ZERO stores of 4, zero comparisons with 4
  - the gate function has one xref (a jmp) and its address is never taken
  - every register store to +0x60 in CardsDLL is a struct copy or an init

So the gate is not a wire field we failed to send: the value it demands is never
produced by anything. Decoding it fully also shows every OTHER input is already
served — cardtype 7, itemState 101/102, teamid — leaving only the +0xba variant
selector beneath it, which makes a client-side patch the only remaining avenue.
2026-08-21 21:10:35 +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 622a6ab353 docs(fifa17): the three withheld families are one cardtype-9 name gap
Ball (30), league logo (31) and misc (231/232/233/236) were tracked as three
separate holes. They are one: cardtype 9 has no database name resolver, so the
displayed name can only come from `localizedName` on the wire, and that single
unproven step gates all three. The cardtype-7 families caption themselves from
the client's own tables, which is why kit, badge and stadium now project.

Ownership, content_kind, club/stats counting and restart durability are already
in place for all three, so the outstanding launch probe is the only remaining
work.
2026-08-21 20:59:12 +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 beb505b0fa tools(fifa17): resolve the itemState comparator live — it is CASE-SENSITIVE
The plan recorded this as "almost certainly unresolvable statically", because
`FUN_180008190` is only a forwarding stub through a slot the host fills at
runtime: `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`.

It IS resolvable — just not from disk. Read read-only out of the running client
(pid 6580): the slot forwards through two FIFA17.exe thunks into
msvcr120.dll+0x3c330, whose body is strncmp (`test r8,r8` count, `test al,al`
NUL stop, `cmp al,[rcx+rdx]`, then MSVC's 0x8080../0xfefe.. NUL-detect fast
path). No `or ..,0x20`, no folding table: the compare is raw bytes.

So the casing in the table at 0x180229cc0 is a CONTRACT. A mis-cased token does
not degrade gracefully — FUN_180166660 returns 0xffffffff, the record keeps 0 =
invalid, and the item fails the squad builder. This confirms what
fut::item_state already emits; it was previously true by convention and is now
true by measurement.

The probe follows the chain and attributes each hop to its module, which needs
care under Wine: PE sections are mapped anonymously, so a module is identified
by the nearest preceding named mapping rather than the containing one.
2026-08-21 20:54:29 +00:00
funman300 43aa114bcd style(import-fifa17): rustfmt the club-family classifier and its tests 2026-08-21 20:47:05 +00:00
funman300 4b66906adc feat(fifa17): definition-coverage guard, and a complete-club staging fixture
The guard classifies every definition id the game ships into exactly one of
KNOWN_OWNABLE / KNOWN_PRESENTATION_ONLY / KNOWN_UNSUPPORTED / UNKNOWN, and fails
when anything lands in UNKNOWN, so a table we cannot place is loud instead of
quietly assumed cosmetic. All 149 tables place: UNKNOWN = 0, 20,876 ownable ids.

Two tables are KNOWN_UNSUPPORTED with stated reasons rather than guessed at:
`fut_storymodehero` (80 ids; the shipped row is only {carddbid, teamid}, so no
item can be shaped without inventing one) and `fcc_misccards` (42 ids; these ARE
owned items, but their per-subtype semantics are not reverse-engineered).

It also counts by SHARED ID SPACE: `fcc_leaguelogos` and
`fcc_leaguelogostickers` both start at carddbid 8010000 and all 39 sticker ids
collide, so the union is 44 and never 83. The collision is printed so summing
cannot regress silently.

Staging now seeds the remaining club families (badge, ball, stadium, league
logo) from the client's own tables, so the rig exercises EVERY ownable class
instead of only the two the real club happens to hold.
2026-08-21 20:45:45 +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 7a4dab04d2 feat(import-fifa17): classify every club family, not just kits
Kits were recognised by the id range 6_300_000..=6_400_654, so stadiums,
badges, balls and league logos all fell through to `Other` and were silently
dropped from the import — a club could own them and Core would never hear.

Club items are now settled by `cardsubtypeid` (kit 9, stadium 10, badge 11,
ball 30, league logo 31), which is the discriminator the client's own club-item
resolver uses and cannot collide with the other classes: consumables occupy
51..=341 and staff 4/6/8.

The render gate generalises with it. Each family ships a CONSTANT cardassetid
(kit 35, stadium 36, ball 37, badge 39, logo 40 — verified across all 2302
shipped rows), so a copy carrying anything else is deferred rather than drawn
as the wrong art. Only kits additionally require a teamid, because their
identity resolver keys on it and real owned kits carry it; demanding one of the
other families would defer every legitimate badge, ball and stadium.

League logos map to `misc`: they have no equipped slot, so Core holds them as
generic owned content rather than inventing a designation.

The real profile is unaffected (it owns no club items beyond its kits) and its
emitted catalog is byte-identical.
2026-08-21 20:19:04 +00:00
funman300 23f120f889 fix(staging): reclassify the restored club, and assert Core's ownership truth
The snapshot predates the content taxonomy, so every fresh bring-up restored a
club whose coaches, kits and consumables were recorded as players. The wire
still looked right (the adapter classifies from its own catalog), which is
exactly the kind of divergence that hides until something keys off ownership.

Bring-up now runs Core's reclassify and asserts the club really owns something
of each kind. A Core binary predating the subcommand ignores it and boots the
server instead, so the step is bounded and says so rather than hanging.
2026-08-21 20:13:35 +00:00
funman300 09db5413cd feat(import-fifa17): emit the Core reclassify request from the catalog
The importer already knows every definition's kind, so it writes the mapping
Core needs to correct a club imported before the taxonomy existed. Applied to
the real 1989-item club: 20 rows corrected (17 consumables + 3 staff), 1966
players already right, 0 unmatched definitions.
2026-08-21 19:55:46 +00:00
funman300 1a6355cad5 fix(staging): re-stamp the squad extension after seeding, and prove it
Filling the bench writes squad rows behind Core's back, which invalidates the
stored FIFA17 opaque extension: Core saw a canonical squad that no longer
matched the extension's fingerprint, the host refused to apply it
(`stale_integrity`), and the client got a squad with zero players and no
manager. Nothing failed loudly — the rig just came up empty.

The seeder now recomputes the fingerprint exactly as
`services::squad::squad_fingerprint` does, and bring-up asserts the squad really
projects (>= 18 occupied, a manager present) instead of trusting that it did.

Seeded owned rows also state their content_kind, so Core does not record a kit
or a manager as a player.
2026-08-21 19:50:21 +00:00
funman300 770029f207 fix(import-fifa17): carry the fields a consumable needs to exist
Two defects that together made the club's 17 owned consumables invisible while
club/stats still counted them — the count gate promised 17, the item route
served 0.

1. The catalog omitted `card_asset_id`, `amount`, `contract` and `rating` for
   non-player definitions. Without an art id the adapter refuses to emit the
   card (it would draw the notfound box), and the families that read
   `amount`/`contract` would render "-1" or grant nothing. All four values are
   present in the source wire and were simply dropped on the way out.

2. Owned rows were imported without a `content_kind`, and Core defaults an
   unstated row to `player` — durably recording a fitness coach and a contract
   card as players in the ownership authority, even though the catalog-driven
   wire looked right.

These are definition-level fields, so every owned copy must agree; a group that
disagrees is deferred rather than resolved by taking the first copy's value.
Measured on the real profile: observed `amount` equals the `fcc_*` table row for
every consumable that carries one (1, 2, 4, 5, 10, 15), and a wire omission
corresponds to a table amount of 0. It is therefore NOT a stack count — two
copies of 5003068 are two instances — so the import states no `quantity` at all.
2026-08-21 19:50:13 +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 6c7d0856b6 feat(fifa17): project every owned content kind, from one recovered vocabulary
Extends the FIFA17 adapter past players so the wire can carry the rest of a
real club's inventory.

itemState: the recovered 12-row table at 0x180229cc0 becomes the single source
(`fut::item_state`), replacing scattered literals. Every shaper draws from it
and the tests assert no shaper can emit a state the client does not know.
CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start.

ContentKind covers all nine tokens. Managers stay inside the staff family for
counting, because the client's own club-stats model puts a manager INSIDE the
staff total with staffManager as a sub-bucket — a parallel Manager kind would
silently under-count.

Consumables get their own route (`club/consumables/<category>`) and a
stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a
`?type=` family. This path previously fell through to Python, so owned
inventory was being served by the oracle.

The shaper refuses to emit a card it cannot render: no known art id, or a
missing `amount`/`contract` for the families that read them, or the subtype-219
rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped
card is counted and logged, never faked.
2026-08-21 19:49:52 +00:00
funman300 dcd470cddc tools(fifa17): measure subtype->cardtype and itemState from the running client
Two things this project kept carrying as INFERRED are directly observable in the
card record, so this reads them instead of trusting the decompile:

  rec+0x18 resourceId, rec+0x4c cardtype (derived by FUN_1800d8330),
  rec+0x50 cardsubtypeid (as sent), rec+0x5c itemState (decoded enum value).

Measured against the live client (pid 6580, 27 records):

  subtype 0 -> cardtype 1   (23 records)  agrees with FUN_1800d8330
  subtype 4 -> cardtype 2   (1)           agrees
  subtype 6 -> cardtype 10  (1)           agrees
  subtype 8 -> cardtype 4   (2)           agrees
  itemState runtime value 1 on all 27, and every one of those was served as "free"

So the cardtype map is now runtime-confirmed for every subtype we actually serve,
and `free == 1` is an empirical anchor for the itemState enum rather than a
reading of the table at 0x180229cc0. The probe prints the Ghidra prediction
beside each measurement and says DISAGREES rather than quietly matching, so it
stays useful as new families are served.

It also states the obvious limit in its own output: a runtime value only appears
if the client was actually served an item in that state, so absence is not
evidence of absence. The equipped states (activeBadge 100, activeHomeKit 101,
activeAwayKit 102, activeBall 103, activeStadium 104) remain table-recovered and
un-measured until a kit is fetched by the client.

Read-only: /proc/PID/mem is opened 'rb' and there is no write path.
2026-08-21 18:55:59 +00:00
funman300 8f98e6adda tools(fifa17): probe the manager-only chemistry slots to settle inferred vs proven
`card_identity_probe` reads the PLAYER slots (F_NATION 0x148, F_LEAGUE 0x154). A
manager does not use those, so grading a manager with it reports nation=0 /
leagueId=0 and reads like a server bug when it is only the wrong offsets.

The manager layout, from the Ghidra reversal already recorded in fut_staff.py, is
teamid rec+0x94, nation rec+0xde, leagueId rec+0xe0, talkrating rec+0xe2,
negotiation rec+0xe3. The managercards merge (FUN_1801356c0) NEVER writes +0xde
or +0xe0, which is exactly what makes them a clean test: whatever sits there came
from our JSON and nowhere else.

Read against the live client (pid 6580, 27 records in the CardsDb map):

  resource   teamid  nation  league  talkrating  negot   verdict
  1000509    241     45      53      0           3       SERVER FIELDS LANDED

So the manager's chemistry fields DO reach the record, and `negotiation=3` agrees
with managercards row 1000509, i.e. the merge ran as well. That moves manager
nation/league from INFERRED to PROVEN without a screenshot.

Read-only: /proc/PID/mem is opened 'rb' and there is no write path.
2026-08-21 18:53:20 +00:00
funman300 106cb83988 fix(staging): fill the real club's bench to the client's 18-player minimum
FIFA refuses to kick off with "your squad must have at least 11 players and 7
subs … currently below the minimum number of players (18)". The imported club's
squad carries ONLY its starting XI, so a freshly installed real club is
unplayable until someone fills the bench by hand in the hub.

`fill_bench_to_minimum` tops the squad up with the club's best spare players,
writing EMPTY bench slots from index 11 upward. The 23 slots are 0..10 pitch and
11..22 bench/reserves, derived from the index alone, so the starting XI — and
any bench the operator has already chosen — is never touched and a re-run is a
no-op. Only real PLAYER definitions are eligible: a kit, a manager or a
consumable in a squad slot is nonsense the client would drop anyway. Selection
is best-rating-first with a stable id tiebreak, so the same bench comes back on
a re-run rather than shuffling.

Scoped to the REAL club on purpose. The 14-item fixture is a market test bed
with a single spare player; demanding 18 there would abort a bring-up that never
needed to kick off. Fixture mode therefore does not call this at all.

Verified against a copy of the club snapshot (the live staging database was left
alone, since it currently holds a squad the operator saved by hand): 11 -> 18
players, slots 0..17, no duplicate instance in two slots, every filled pick a
real player definition, and a second run filling nothing.

This is NOT a diagnosis of the failure the operator just hit — that squad had
already been filled to 23 valid players before the match was created, and the
host log shows the client issued no request at all after `match-create` beyond
account-sync, so that refusal is decided entirely client-side. It removes the
variable: after a clean bring-up the club is now playable without hand-editing.
2026-08-21 18:12: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 12ad04c9d4 fix(fifa17): carry the manager's item in the squad, not just its id
The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.

`squad.manager[]` was emitted as a bare `[{id, dream}]`. That looked
retail-faithful, and the previous commit defended it on the grounds that no
capture had ever shown otherwise. Re-reading the captures with a populated
manager in hand shows why that was the wrong conclusion: every retail capture
carrying the bare form has `id: 0` — an EMPTY manager. None of them ever
demonstrated that a POPULATED ref renders without its item, because none of them
had one. `plan-2026-08-05-families.md` says as much outright: "FUN_18013d1f0 was
never read for a staff member".

The squad object is self-contained everywhere else: `players[].itemData` carries
the whole card rather than an id resolved out of band. The manager is the same
kind of slot in the same object, and the one implementation that ever drove a
working manager — the Python oracle's squad — emits `id` BESIDE `itemData`.
The element shapes differ and both are now pinned by tests: a player slot is
`{index, itemData, kitNumber}`, the manager is `{id, itemData, dream}`.

So the manager is projected through `resolve_staff` and its item embedded with
`shape_staff_item`, the same 11-key record `/club` serves. An assignment with no
resolvable staff identity still yields `[]` rather than a fabricated ref.

`STAFF_CONTRACT` moves next to `shape_staff_item` in `fut::item` (re-exported
from `club_response`) so `/club` and the squad cannot disagree about the
contract the client checks before kickoff.

Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
2026-08-21 17:30:14 +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 de747b79e6 feat(staging): let staging serve the operator's real club, not just the fixture
The operator could not field a starting XI because staging has only ever held the
14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The
real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it
sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts
list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays.

Worth recording while looking for the club: the LIVE production Core container
serves an EMPTY database (0 owned cards, schema predating even the game_id column).
The real club is not being served anywhere right now; it exists as state on disk.
So restoring it into staging is not a convenience, it is the only way to play it.

Two pieces:

`scripts/club-snapshot.py` is the ONE place allowed to read production state, and
it is read-only by construction: the Core database is opened `mode=ro` and copied
with sqlite's online backup API (a plain file copy can tear a database with a hot
WAL), every destination is asserted to be outside the production directory before
anything is opened for writing, and the sha256 of every source is compared before
and after -- a mismatch aborts, because that would mean the snapshot modified
production. It then proves the copy is faithful (same counts, coins, squad) and
that the identity store maps EVERY owned card to a wire id, since an unmapped card
would reappear under a freshly minted id and break the client's cached squad.

`sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE
Core first starts, so Core migrates the copy forward from schema v19 through
match_completions, squad managers and kit assignments. Seller A is then already
present -- it IS the imported persona -- so only Buyer B is seeded, the kit
fixtures are attached to the real club so the kit work stays exercisable, and the
real squad is left alone. The up script still never reads production state: the
snapshot lives outside it, which is precisely what makes `--club real` compatible
with the `safe_path()` refusal.

The resolvability preflight now covers whichever club will actually be served. This
is the check that matters most for the real one: Core does not fail on an owned card
whose definition is missing, it silently filter_map-drops it, so a gap shows up as
an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct
card ids resolve in both the content pack and the identity catalog, 0 missing.

`sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club
is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have
overwritten the operator's own lineup with an auto-picked XI -- destructive and not
recoverable in place. `--show` still works in every mode; `--force` overrides.

Verified end to end against the restored club: /club 29,843,976 coins, /collection
1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated
90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from
the snapshot identity store. The fixture path was re-run afterwards and still seeds
exactly 14 items, so the SOLD experiment is unaffected.
2026-08-21 16:35:06 +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 ff915a306e chore(submodules): bump Core + Bridge for the closed legacy match path
Core bae0a2b: `POST /matches/result` fails closed (it was a second economy
authority with no transaction and no idempotency key); loan expiry and Core
season progression move into `complete_match`'s transaction behind opt-in flags
that default OFF, so the FIFA 17 retail path is unchanged; notifications emit
post-commit and only when applied; `/auth/reset` now clears `match_completions`,
which previously made any profile that completed a match unresettable.

Bridge 07e83fe: the two EA result routes and the dashboard submit to
`/matches/complete` with a per-submission `match_identity`. Pushed to
`fix/matches-complete-migration` — the bridge pin is deliberately behind its
origin/main, so this does not touch that branch.
2026-08-21 04:48:45 +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 054a912357 fix(fifa17): key the kit home/away split on the carddbid, not assetid
Staging served `kits=2 kitsHome=0 kitsAway=0`: the split compared the catalog
`asset_id` against `fcc_kitcards.assetid` (14/15), but a kit's catalog
`asset_id` IS its carddbid (6300006), not that column, so neither family ever
matched.

The carddbid range is the same fact in the form we actually carry: across all
1482 kit rows, assetid 14 covers precisely the 828 `63xxxxx` ids and assetid 15
precisely the 654 `64xxxxx` ids, with no exceptions either way. Keying on the id
we already have avoids carrying `assetid` as a second source of truth for the
same split. Tests now use the real team-21 pair (6300006 home / 6400003 away).

Verified against the staging stack: `kits=2 kitsHome=1 kitsAway=1`, team-21
bucket 2, `?type=kit` still activeHomeKit/activeAwayKit, `?type=player` 12.
2026-08-21 04:13:11 +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 ab62440dbf Make FIFA17 roster hostname configurable 2026-08-21 02:35:27 +00:00
funman300 92520de6c3 chore: update launcher WinSock fix 2026-08-21 00:17:33 +00:00
funman300 be4c52ae89 chore: bump openfut-launcher (Milestone B: in-process FIFA17 TLS patch)
Points at launcher b098617: FIFA17 ProtoSSL cert gates + empty-My-Packs store
guard patched in-process by version.dll (fail-closed, ASLR-safe), replacing the
external autopatch's cert pass. External autopatch retained for parity/rollback.
2026-08-20 21:15:35 +00:00
funman300 967a808d73 chore: bump openfut-launcher (retire FIFA 23; FIFA17 config-driven redirect)
Points at launcher 00ad631: retire FIFA 23 as a build target/template while
keeping the hook game-generic by per-game feature, plus the earlier config-driven
FIFA17 socket redirect (16f3452). Build invariant --features fifa17 unchanged.
2026-08-20 20:57:05 +00:00
funman300 f60dd4da31 chore(launcher): bump submodule for Windows LSX-local fix 2026-08-20 19:57:17 +00:00
funman300 c59c7d88f7 chore(launcher): bump submodule for continuous-VRR present fix 2026-08-20 19:38:43 +00:00
funman300 b24e96b7e6 chore(launcher): bump submodule for VRR flicker fix 2026-08-20 19:35:18 +00:00
funman300 a8078e1d8e docs(windows): document native OpenFUT Launcher GUI + elevation model 2026-08-20 19:21:25 +00:00
funman300 c6abc435cb chore(launcher): bump submodule to native Windows support (057cf92) 2026-08-20 19:21:06 +00:00
funman300 9f1fc1b47c feat(windows): native client preflight + launch/RE docs
Add read-only preflight verifier and Windows-client documentation for the
reimaged native-Windows FIFA17 client host (10.10.0.105). No launcher script:
the native model is _fifa17.exe run as admin (RUNASADMIN + shortcut). Covers
routing (openfut.cfg -> 10.10.0.120), rollback (version.dll swap), and the
x64dbg RVA<->VA (ASLR) attach workflow (ImageBase 0x180000000 CardsDLL/powdll).
2026-08-20 18:43:19 +00:00
funman300 d8d704d441 chore(submodules): bump openfut-core -> 2fb8352 (match economy + club manager, pushed) 2026-08-20 18:21:27 +00:00