82 Commits

Author SHA1 Message Date
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 cd5983ecdd fifa17: cardtype 9 is unnameable -- measured, and the gap closes as a negative
Serving owned balls (subtype 30), league logos (31) and fcc_misccards
(231/232/233/236) was the last projection gap. The open guess was that their
caption would come from `localizedName` on the wire, "probably", and they were
withheld out of caution.

Measured against the running client instead (new
tools/cardtype_dispatch_probe.py, read-only, reproducible, every step with a
positive control). They cannot be named at all:

  1. The merge jump table at rva 0x141eb4 is indexed cardtype-1 with 10 entries.
     Cardtypes 1..5 and 10 each get a DB-merge arm; cardtypes 6,7,8,9 ALL land on
     one shared tail at 0x180141e8a that runs no query and writes no name.
  2. `cmp [reg+0x4c], 9` (cardtype): ZERO sites in .text. For contrast, cardtype
     1 has 13 and cardtype 7 has 6.
  3. `cmp [reg+0x50], 30` and `..., 31` (cardsubtypeid -- the field that actually
     selects a club-item caption): ZERO sites each, while kit 9, stadium 10 and
     badge 11 all appear, which is the control. The only cardtype-9 subtypes
     present anywhere are the four misccards ids, and all four are one boolean
     predicate near 0x1801a72da that returns FALSE for them: an exclusion, not a
     resolver. That predicate is NOT identified and is not claimed to be.
  4. The cardtype-7 resolver is gated `cmp [rax+0x4c], 7` at 0x1800f6f04, so a
     cardtype-9 item never reaches it. Its jne path formats AWARD_LABEL_%i --
     the trophy path, not a fallback that would name a ball.

Nothing reads a localizedName for these subtypes, so sending one cannot become a
caption. Withholding them is a measured limit of the client, not caution, and no
server change can lift it.

CORRECTION: FUN_180119bd0 was recorded as "zero refs in CardsDLL -> almost
certainly an export, its caller is in FIFA17.exe". It is not an export. Its
address occurs exactly once in the whole process, at 0x18021c738 in CardsDLL's
own .rdata, and nothing in FIFA17.exe references it. It is virtual: vtable base
0x18021c2a0, slot +0x498, index 147 -- independently reproducing the recorded
"manager vtable slot +0x498" by a different method. Finding the boundary needs
the constructor-LEA trick; walking back over .text-pointing qwords runs 826 slots
through several adjacent vtables.

Bonus: the shared tail cardtypes 6-9 fall into IS the discard level ladder
(movzx [rdi+0xb4]; cmp 0x4b; cmp 0x41; store [rdi+0x54]), confirming
discard::discard_level instruction for instruction against the live client.

Adapter 244 tests, fmt clean.
2026-08-21 23:10:44 +00:00
funman300 f97654af86 fifa17: give the staging manager the rating the client re-rates it to
The bring-up mints a manager (neither club owns one, and FIFA refuses to start a
match without one). Its catalog entry carried nation/league/team read out of
managercards but not `value` or `rare`, so discard pricing declined for it and
fell back to the placeholder ladder: 150 paid against the 282 the client computes
for itself.

Carry managercards.value 88 and managercards.rare 1, from the same table and with
the same provenance as the fields already there. `rare` is not cosmetic -- it
selects the discard price column, which is the whole difference between 282 and
97.

Both are live-confirmed on the running client: coach_probe grades the manager
record HIT (so +0xb4 == value and +0x58 == rare) and discard_probe reads the
value it computed for itself at +0x3c as 282.

Verified on staging: the manager now quick-sells for exactly 282. With this, every
card the client prices for itself -- manager, GK coach, both fitness coaches --
is paid the number it displays.
2026-08-21 22:55:01 +00:00
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
funman300 07d4a92309 fix(clippy): use slice::from_ref instead of clone in sbc challenge test 2026-08-20 18:17:30 +00:00
funman300 afa5f620bd style(fifa17): cargo fmt match wire + host match integration 2026-08-20 17:59:03 +00:00
funman300 56bd9ddc85 test(fifa17): update squad_projection integration test to ownership-backed manager
The manager moved from an opaque extension field to an ownership-backed
canonical assignment: SquadProjectionInput.manager (owned item) replaces
Fifa17SquadExtensionV1.manager.

- project_put stand-in passes manager: None; baseline asserts the manager
  projects empty when none is owned.
- persisted_read registers the manager's owned instance + identity and
  passes it as the assignment, asserting the resolved [{id,dream}] ref
  round-trips to the read oracle's manager.
2026-08-20 17:38:32 +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 d37a9d5b5e feat(fifa17): ownership-backed squad manager, not opaque round-trip
Move the squad manager from an opaque, unvalidated wire ref in the squad
extension to a resolved, ownership-backed assignment (Core migration 0023
squad_managers).

- squad::to_proposed reverse-resolves the manager wire ref to a Core
  owned_card_id on ProposedSquad; an unresolvable manager is reported as
  an unresolved wire id (a live save is refused rather than assigning a
  manager the club does not own).
- squad_ext: drop the opaque manager field from Fifa17SquadExtensionV1
  (clean cutover) and the now-unused SquadEntityRef->WireItemRef From.
- squad_projection: project the manager as the STATIC_REVERSED [{id,dream}]
  wire ref resolved from the owned assignment; absent -> [] (never faked).
  Richer manager itemData (contract/league/nation) is INFERRED-only and
  left out pending wire reversal.
- import(apply): drop a historical dangling manager ref rather than
  failing the whole import (live PUTs still refuse an unresolved manager).
2026-08-20 16:43:39 +00:00
funman300 e5d356e8be chore(submodules): bump core/launcher/bridge pointers to pushed commits
openfut-core -> a034e74 (cargo fmt pass, pushed)
openfut-launcher -> 8d5bb62 (fifa17 season diag commits, pushed)
openfut-bridge -> c58e732 (consolidate backup HEAD, on origin/main)
All targets verified present on their remotes. fifa-blaze unchanged.
2026-08-20 16:10:22 +00:00
funman300 0b189b36c5 chore(tools): add utas-filter-diff.py diagnostic
Read-only UTAS capture diff helper. Retained pre-existing WIP verified.
2026-08-20 16:06:29 +00:00
funman300 11c17f3039 style(adapter-fifa17): apply cargo fmt to fut store/pack/non_economy/club_stats
Pure rustfmt reflow; git diff -w confirms logic byte-identical (PACK_CATALOG
values, pack tiers, item_def stubs unchanged). Builds clean. Retained WIP.
2026-08-20 16:06:29 +00:00
funman300 871d02406f chore(deploy): add root core+bridge compose stack + .env.example
Localhost-default (127.0.0.1) compose for the Rust core+bridge; env-driven
CORE_PUBLISH. No secrets/staging-prod defaults. Retained WIP verified.
2026-08-20 16:06:29 +00:00
funman300 6811caeab1 feat(fifa17-docker): OPENFUT_SERVERS component selection for staged Py->Rust migration
entrypoint.sh validates/selects among lsx blaze roster utas pow and skips
unselected responders (errors if none). docker-compose threads the env
through; .env.example documents it. Retained pre-existing WIP verified.
2026-08-20 16:06:28 +00:00
funman300 d71234b03d docs: add AGENTS.md canonical entry point; mark FIFA23 README/CLAUDE/setup stale
AGENTS.md is the new canonical AI-agent entry point (FIFA17 active target,
repo map, FIFA23->FIFA17 pivot history). README/CLAUDE/setup.sh get stale
banners pointing to it. Retained pre-existing WIP brought forward.
2026-08-20 16:06:28 +00:00
funman300 8e1fb640b6 tools: authoritative Core-state snapshot for FUT loop verification
Reads openfut-core's authoritative API directly (balance, entitlements, profile,
club, collection, squad/ext) with the fifa17 game header and emits a
machine-readable JSON snapshot plus integrity checks: coin cross-check, duplicate
owned_card_id, squad-references-non-owned, owned-set + card-multiset hashes for
drift/resurrection detection across operations and restarts. Read-only oracle
tooling; used to verify Phases 1-9 of the Core-backed FUT loop audit.
2026-08-19 19:20:28 +00:00
funman300 0019806a3b tools(fifa17): refuse to stage/deploy a non-fifa17-profile hook DLL
openfut-hook builds two mutually exclusive injection paths from one crate. The
default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin_spy transport
hooks; `--features fifa17` installs only the FIFA-17-safe logic (module map,
FIFA 17 cert-verify, SBC dispatch, store tab bind).

Deploying a default-feature build into FIFA 17 hijacks the login transport: the
client reports "Unable to connect to the EA servers at this time" and none of the
FIFA 17 repairs are present in the binary at all.

That happened today: artifact 1c71a17a was built by hand without the feature and
deployed, costing two failed launches. It was diagnosed only by comparing embedded
strings between the deployed DLL and the last known-good one (the deployed DLL had
0 occurrences of CardsDLL_Win64_retail.dll and SBC_DISPATCH, and 6 of cert-verify
plus 1 of "connect: inline-hooked" -- the inverse of a fifa17 build).

`build` already passes --features fifa17, but OPENFUT_FIFA17_HOOK_DLL lets a
hand-built DLL reach stage/deploy, so verify_fifa17_profile asserts the profile on
the bytes: CardsDLL_Win64_retail.dll and SBC_DISPATCH must be present, and the
FIFA-23-only markers must be absent. Wired into verify_inputs (stage/inspect) and
into deploy's staged-artifact checks.

Verified: the gate rejects 1c71a17a, accepts 3641d581 (last known good) and
f0ef528f (the corrected fifa17 build now deployed).
2026-08-19 18:31:27 +00:00
funman300 c7c057a31a superproject: bump launcher submodule to consolidated main
Records the store-entry category-clamp hook (launcher 9aecc65) as the
launcher tip on main. Only the launcher gitlink is bumped; core stays
271c363 and bridge stays 0f581eb (both as already recorded), and the
in-tree formatting/doc churn is left untouched.
2026-08-19 15:35:34 +00:00
funman300 89dc1b1d85 sbc: document reversed elgReq ordinal finding; elgReq stays empty
Reversed from the pinned CardsDLL (4706a881): eligibilityKey and
eligibilityOperation are localization ordinals (LOC_SBC_ELG_KEY_%d),
not the atom hex ids. The client's only consumer is the requirement-
display string builder at ~0x1800ef900 (formats via indexed locale
keys, no comparison/gate). The ordinal->string map lives only in the
packed locale (absent from all assets we hold), so any emitted value
would render the WRONG requirement text. Submission stays fully
validated server-side by Core; the empty elgReq is display-only.
Correct ENDPOINT_MAP.md's implied atom-id==ordinal assumption and
pin the exact remaining blocker at the emit site.
2026-08-19 15:00:18 +00:00
funman300 13a22c4507 Bump launcher: plain-language launch status 2026-08-19 04:28:13 +00:00
funman300 1db9acdf6d Bump launcher: persist hook DLL override in the prefix registry
Removes the manual Steam launch-options step; the launcher now writes
version=native,builtin into the Wine prefix so any launch path loads the hook.
2026-08-19 03:01:47 +00:00
funman300 911c7a34fd Bump launcher: promote FIFA17 SBC dispatch (no env arming)
Picks up openfut-launcher 94feaec, which makes the guarded native SBC dispatch
repair a build-armed promoted feature instead of an OPENFUT_SBC_DISPATCH env gate.
Any launch path (Steam, the launcher Launch button, bare umu-run) now gets the
repair, so the SBC screen no longer depends on a harness script exporting a
variable. Every runtime guard is unchanged; rollback is a version.dll file swap.
2026-08-19 02:45:58 +00:00
funman300 fcc314afb1 fifa17 store: render cover art on My Packs reward tiles
A reward pack advertised its own id (70-75) as assetId, which is not a client art
asset, so its My Packs tile rendered blank. Reward tiles now carry their tier
store pack as the cover asset (bronze->1, silver->3, gold->5) while `id` stays the
pack own id (the open packId / SERVER_ID); the sentinel keeps its own id so it
stays an inert placeholder.

LIVE-MAPPED on the retail client 2026-08-18 across all three store tabs and two
reward tiles, which corrected an earlier wrong assumption:
  * assetId only gates whether art renders AT ALL (unknown id -> blank tile).
  * WHICH art is drawn comes from packType + packContentInfo.rareQuantity, NOT
    assetId: BRONZE+1rare -> bronze card, BRONZE+3 -> silver, SILVER+1 -> gold,
    SILVER+3 -> silver trio, GOLD+1 -> blue special, GOLD+3 -> red inform.
    Remapping assetId 5->3 and 3->2 left both frames unchanged and only rotated
    the featured player, which proves art is content-driven.

So a reward tile now shows the same cover as the equivalent purchasable pack (a
gold reward shows the blue-special art the 5000-coin Gold Pack shows - EA art
advertises an aspirational card rather than the tier colour). Regression test
reward_tiles_carry_a_renderable_cover_asset added; pack70 golden regenerated.
2026-08-19 02:08:37 +00:00
funman300 b323244ac9 fifa17 store: make My Packs reward tiles openable (free coins row)
Reward/My-Packs tiles were emitted with no currencies row (dropped to avoid an
"undefined" payment label). But FIFA 17 opens My Packs through the store PURCHASE
flow (My Packs is a client-side filter over the store catalogue; the open-vs-buy
fork is client-side and no response selects it), so a tile with no purchase path
is not actionable — clicking it navigates instead of opening, sending no request.

Fix: keep the coins currency at the pack price (0 for reward packs; no extPrice,
so no `or %1s` mtx bug). The client then treats the tile as free and clicking
sends POST /purchased, which the server opens for free (owned-only redeem, no
debit). The empty-My-Packs sentinel keeps no currency row so it stays
non-openable. pack70 golden regenerated.

LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards on the
retail client (host log: POST /purchased/items 200 -> GET /purchased/items).
2026-08-19 01:50:51 +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 f56aa613da fifa17 SBC: keep repeatable challenges re-enterable after completion
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the
repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry
even when repeatable=true. So a repeatable challenge now always projects
timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0
(sets_body); a non-repeatable challenge keeps its true count and stays locked
once completed. Core keeps the authoritative completion record — economy is
unaffected; this is presentation only.

Live-proven on the retail client 2026-08-18: a completed repeatable Bronze
Upgrade now re-opens for a fresh submit instead of blocking. Regression test
repeatable_completed_challenge_stays_enterable added; adapter + full host suite
(incl. differential and the concurrency race) green.
2026-08-19 01:18:36 +00:00
funman300 8c17f896b4 fifa17 store: native category art via displayGroupAssetId
The all-groups landing (shown on first store entry) renders one tile per group
whose background is the client-bundled packs_backgrounds_%d.dds, selected by
displayGroupAssetId. Live-probed on the retail client 2026-08-18: index 0 is
blank; indices 1/2/3 render real pack art. Assign non-zero per category
(bronze=1, silver=2, gold/mypacks=3) so that landing shows native pack art
instead of blank shields. The persistent tabbed store (MY PACKS/BRONZE/SILVER/
GOLD PACKS) draws pack art from the packs themselves and is unaffected.

LIVE-CONFIRMED end-to-end: native tabbed store renders the real 6-pack catalogue
with correct prices (Gold 5000 / Premium Gold 7500), counts (12 items, 10 gold,
1/3 rares), pack art, and no "or %1s" line.
2026-08-19 00:02:13 +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 7116046195 Lock FIFA17 SBC challenge-squad parser to captured retail wire body
Retail Gate C captured PUT /sbs/challenge/101/squad: 23-slot players[] of
{index,itemData:{id,dream}} plus manager/chemistry/rating/formation siblings.
parse_wire_item_ids already handles it (players[].itemData.id, non-zero only);
update the stale "captures unavailable" note and add a verbatim regression test.
2026-08-18 21:29:47 +00:00
funman300 dcac2c546b Bump launcher: FIFA17 SBC dispatch notifier lifecycle correction 2026-08-18 20:59:07 +00:00
funman300 5c8e2dc0bd Bump launcher: FIFA17 SBC dispatch response-class live vtable fix 2026-08-18 20:53:05 +00:00
funman300 bd03aec82a Gate FIFA17 SBC dispatch acceptance 2026-08-18 20:20:38 +00:00
77 changed files with 12888 additions and 1425 deletions
+25
View File
@@ -0,0 +1,25 @@
# OpenFUT Docker stack configuration. Copy to .env and adjust.
# All values have sensible defaults in docker-compose.yml; override as needed.
# --- Container registry (Gitea) ---
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
# e.g. git.aleshym.co/openfut/openfut-core:latest
REGISTRY=git.aleshym.co
NAMESPACE=openfut
TAG=latest
# --- Networking ---
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
BRIDGE_PUBLISH=0.0.0.0
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
CORE_PUBLISH=127.0.0.1
# --- Behaviour ---
# Bridge returns placeholder JSON + captures unknown routes when true.
PLACEHOLDER_MODE=true
# --- Logging (RUST_LOG filters) ---
CORE_LOG=openfut_core=info,tower_http=info
BRIDGE_LOG=openfut_bridge=info,tower_http=info
+163
View File
@@ -0,0 +1,163 @@
# AGENTS.md — OpenFUT
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
## Project
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
project does not bypass ownership checks — it only re-serves the dead online services locally.
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
Three moving parts, kept strictly separate:
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
## Repository map
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
| Path | What it is | Status |
|---|---|---|
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
a task explicitly targets the FIFA 23 port.
## Architecture (live path)
```
FIFA 17 client (Wine/Proton, base 0x140000000)
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
└─ roster :8081 → roster_server.py (FUT roster-update XML)
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
```
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
## Development commands (verified)
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
(sqlite://openfut.db), `DATA_DIR` (data).
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
## Coding conventions
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
→ `models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
design:** every service reads "the active profile" as the first DB row — intentional, don't
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
names). When changing a responder, preserve byte-exactness — the client is the oracle.
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
## AI-agent rules
1. Read this file before exploring the repo.
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
vault as canonical.
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
stale, update the vault, then its repo `docs/` mirror.
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
6. Don't refactor or rewrite unrelated working code.
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
confident guesses. The live client is the only oracle for whether a gate is satisfied.
9. Clearly separate discovered behavior from hypotheses; record findings in
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
tier — never silently promote a hypothesis to a fact.
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
prefer vault + repository evidence over them when they conflict.
## AI Session Bootstrap
Future agents should start with:
1. Read `AGENTS.md`.
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
Project State, Roadmap/Current Priorities, or Protocol Findings.
4. Inspect only the relevant source directories.
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
anything about FIFA/EA behavior.
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
7. Implement the smallest coherent change.
8. Run the narrowest relevant tests.
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
inaccurate.
Do not reread the entire repository during every session.
## OpenFUT Knowledge Base
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
vault wins on any disagreement. Consult it before starting substantial work and update it after
durable discoveries.
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
Canonical files:
- Dashboard: `00 Dashboard/OpenFUT.md`
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
- Direction history: `04 Decisions/Direction History.md`
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
- Current Priorities: `06 Agent Memory/Current Priorities.md`
- Known Issues: `06 Agent Memory/Known Issues.md`
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
When editing knowledge that exists in both places, edit the vault first, then update the matching
`docs/` mirror so they stay in sync.
+2
View File
@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
## Repository Layout
This is a monorepo containing three independent Rust crates as git submodules:
+4
View File
@@ -1,5 +1,9 @@
# OpenFUT
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
>
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
**Offline Ultimate Team — like SPT, but for FIFA 23.**
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
+93
View File
@@ -0,0 +1,93 @@
# ============================================================================
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
#
# Canonical server (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
#
# `core` (openfut-core) IS still the shared, game-independent backend and is
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
# is the retired FIFA 23 integration, kept for reference.
# Status source of truth: docs/PROJECT_STATE.md
# ============================================================================
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
#
# Bring up: docker compose up -d
# Tear down: docker compose down (keeps data/captures volumes)
# Wipe state: docker compose down -v (also drops volumes)
# Rebuild: docker compose build (or ./scripts/registry.sh build)
# Logs: docker compose logs -f
#
# Images are pulled from / pushed to the Gitea container registry. Override the
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
name: openfut
services:
core:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
build:
context: ./openfut-core
dockerfile: Dockerfile
restart: unless-stopped
environment:
LISTEN_ADDR: 0.0.0.0:8080
DATABASE_URL: sqlite:///app/db/openfut.db
DATA_DIR: /app/data
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
volumes:
- core-db:/app/db
# Bound to localhost by default — the bridge reaches core over the internal
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
# .env if you want to hit the REST API directly from other hosts.
ports:
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 10s
bridge:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
build:
context: ./openfut-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
core:
condition: service_healthy
environment:
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
CORE_URL: http://core:8080
CAPTURES_DIR: /app/captures
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
TLS_ENABLED: "true"
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
volumes:
- bridge-captures:/app/captures
# The FIFA client connects here — publish on all interfaces by default so
# LAN clients (e.g. 10.10.0.0/24) can reach it.
ports:
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 8s
networks:
openfut:
driver: bridge
volumes:
core-db:
bridge-captures:
+29 -3
View File
@@ -1,11 +1,37 @@
# Copy to .env in this directory. Required for remote deployment.
#
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# OPENFUT_BIND — address the listeners bind inside the container.
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
# uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
# Advertise the certificate's DNS identity, then resolve that one hostname to
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
# Default (unset) = the server-side set: "blaze roster utas pow".
#
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
# name here so the two never serve the same role at once.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (Rust utas-host still proxies its non-/club routes here for now)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
# on this server. Leave it OUT unless client and server share one box.
#
# Example — Rust already owns roster, so Docker should not also serve it:
# OPENFUT_SERVERS=blaze utas pow
# When you drop a component, also stop advertising / DNAT'ing its port to this
# container so the client is routed to the Rust host instead.
#OPENFUT_SERVERS=blaze roster utas pow
+7 -11
View File
@@ -37,18 +37,14 @@ RUN set -eu; \
COPY data/ /app/data/
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine — but the
# client dials the roster and redirector BY IP, and that path still checks the
# SAN against the dialed address (it is NOT covered by the two patched gates), so
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
# reissues with IP:$OPENFUT_ADVERTISE at start.
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
# entrypoint validates this stable certificate; it never reissues it for an IP
# SAN that the verifier ignores.
#
# openssl therefore has to remain in the image for the entrypoint, not be dropped
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
# so a fresh checkout builds without extra steps.
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
# checkout and to validate the configured DNS identity at startup.
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
@@ -3,9 +3,9 @@
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
# certificate DNS identity handed out for roster HTTPS.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
@@ -25,6 +25,9 @@ services:
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
# resolve this hostname to OPENFUT_ADVERTISE.
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
@@ -36,10 +39,14 @@ services:
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
# Which Python responders this SERVER runs. Default excludes lsx (that is
# a client-side responder — see below). Drop a name once it is migrated to
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX (Origin bootstrap)
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
+52 -23
View File
@@ -8,45 +8,39 @@
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 203.0.113.10)
# Address behaviour is driven by three env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
# POW keys advertised by blaze must also point at the server, not loopback.
export POW_HOST="${POW_HOST:-$ADV:8094}"
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
echo "[openfut] bind=$BIND advertise=$ADV"
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
# The client dials the roster (:8081) and redirector by that IP, and that path
# validates the cert's SAN against the dialed address — it is NOT covered by the
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
# only when the current cert does not already carry it, so a restart reuses the
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
CERT=redir_cert.pem KEY=redir_key.pem
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
>/dev/null 2>&1 \
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
# if the configured roster hostname is not already one of its DNS identities.
CERT=redir_cert.pem
ROSTER_NAME="${ROSTER_HOST%%:*}"
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
exit 1
fi
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
# name script extra-env
declare -a SERVERS=(
@@ -57,10 +51,40 @@ declare -a SERVERS=(
"pow|pow_server.py|-"
)
# ── Component selection ──────────────────────────────────────────────────────
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
# This container is the SERVER side (.120). It serves ONLY components that have
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
# Docker during migration), drop its name so the two never serve the same role.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (the Rust utas-host currently reverse-proxies its non-/club routes
# back here, so keep this enabled until UTAS is fully migrated)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
# NOT on the server. Excluded by default; enable ONLY for an
# all-on-one-box dev setup where client and server share a host.
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
want=" ${OPENFUT_SERVERS//,/ } "
known=" lsx blaze roster utas pow "
for w in $want; do
case "$known" in
*" $w "*) ;;
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
esac
done
echo "[openfut] servers=$OPENFUT_SERVERS"
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
case "$want" in
*" $name "*) ;;
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
esac
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
@@ -69,6 +93,11 @@ for entry in "${SERVERS[@]}"; do
names+=("$name")
done
if [ "${#pids[@]}" -eq 0 ]; then
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
exit 2
fi
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
+106 -21
View File
@@ -347,10 +347,17 @@ The client's own dialog names the class: "Search Type: Consumables Search".
times a session with the PLAYER stat set, so the panel read seven zeros and never
proceeded. Two rounds of item-shape work sat unrequested for want of a counter.
2. THE ROUTE IS GET club/consumables/<category>. Not club?type=, which a previous
round shipped four arms for, and not the "/consumables/%s" template in .rdata,
which the client has still never used. Worse, that path is a /club PREFIX, so it
fell through to the generic route and the consumables screen was answered with the
194-card player list.
round shipped four arms for. That path is a /club PREFIX, so a naive router
falls it through to the generic route and answers the consumables screen with
the 194-card player list.
**CORRECTED 2026-08-21.** This item used to add "and not the
`/consumables/%s` template in .rdata, which the client has still never used".
That is false, and the same sentence is in commit `ccb736f`. It IS exactly
that template: action row 9 `ConsumablesSearch` carries base index 3 =
`ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was
`ut/%s/club` all along, which is why the observed URL and the template look
like different things and are not.
3. THE ELEMENT IS A STACK WRAPPER, NOT AN ITEM. FutConsumablesSearchServerResponse
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
0x180130d10, 6873 chars) reads itemData(0x16b) at the root like the club list, but
@@ -402,21 +409,40 @@ the same mapping: balls 37, kits 35, stadium 36, badges 39, league logos 40.
# Club items: what the research established, 2026-08-05
Researched after a guessed field crashed the client. Facts first, and the one thing
still unknown is named as unknown.
> **SUPERSEDED 2026-08-21 in part.** `docs/plan-2026-08-06-card-subsystem.md` is
> the authority for club items and for the `itemState` vocabulary; where this
> file and that one disagree, that one wins. The corrections are applied inline
> below and marked. The subtype question this section calls UNKNOWN is ANSWERED.
Researched after a guessed field crashed the client. Facts first.
## VERIFIED IN BINARY
1. THE CARDTYPE MAP IS EXACT. FUN_1800d8330 (714 chars, read in full) returns cardtype
9 for cardsubtypeid 0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9 and 0xec, and nothing else.
fcc_misccards carries cardsubtype 231 = 0xe7, which anchors the 0xe7..0xe9 block to
misc cards. That leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls
and league logos.
2. ITEMSTATE CARRIES THE EQUIPPED STATE. The enum table at 0x180229d20 (stride 0x10)
is: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active. So an EQUIPPED club item is not a
misc cards.
**CORRECTED 2026-08-21.** The first half is right; the inference that followed
it was wrong. It read "that leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits,
stadia, balls and league logos". In fact `0x91..0x96` are TROPHIES, and three
of the five club families are **cardtype 7, not 9** — `FUN_1800d8330` contains
`case 9: case 10: case 0xb: return 7;`. Only ball (0x1e) and league logo
(0x1f) are cardtype 9.
2. ITEMSTATE CARRIES THE EQUIPPED STATE. So an EQUIPPED club item is not a
different subtype, it is the same item with itemState set to one of those five.
"free" is correct for owned-but-not-equipped, which is what we send.
**CORRECTED 2026-08-21.** The table starts at **`0x180229cc0`**, not
`0x180229d20` — the recorded address points into the MIDDLE of it, which is why
only ten rows were seen. The full vocabulary is TWELVE rows; the six missing
from the reading below are `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`,
`forSale` and `offered`. Two further consequences the ten-row reading hid:
`WAITING_FOR_GAME` and `inGame` are genuine ALIASES (both decode to 2), and
OMITTING the key yields `0` = `invalid`, which is NOT the same as `free` — an
item left at 0 fails the squad builder's `state == 1 || state == 2` test. The
match is also CASE-SENSITIVE (measured 2026-08-21: the comparator is
`msvcr120.dll+0x3c330`, a plain `strncmp` with no case folding), so the casing
in the table is a contract. See `openfut-adapter-fifa17/src/fut/item_state.rs`.
3. CLUB ITEMS HAVE NO CATEGORY GROUP TABLE. Consumables have one at 0x180203260 (seven
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
position) and staff have one at 0x180203310 (five codes). There is no equivalent
@@ -427,16 +453,30 @@ still unknown is named as unknown.
type=ball, type=equippables (the combined customisation view). Not the plural stat
names, and not a club/<family> path.
## STILL UNKNOWN, AND NOT GUESSED
## ANSWERED 2026-08-06 (was "STILL UNKNOWN, AND NOT GUESSED")
Which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus badge versus kit.
It is in none of the 149 dumped tables, there is no group table, and cardtype 9 has NO
arm in the merge, so a wrong subtype cannot announce itself the way a coach's "DB
Error" does. Two ways to settle it, in order of preference:
a. more RE: find the consumer that switches on subtype for a club item, most likely
in the equip path that writes itemState = activeBadge and friends;
b. FUT_CLUBITEMS=probe:<family>, which serves ONE family as eight items, one per
candidate subtype, so the screen names the right one.
The question was "which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus
badge versus kit". It was the wrong candidate set — three of the families are not
in it at all. The settled map:
| family | cardsubtypeid | cardtype | how the caption resolves |
|---|---|---|---|
| kit | **9** | 7 | `TeamName_Abbr15_<teamid>` |
| stadium | **10** | 7 | `StadiumName_<assetId>` |
| badge | **11** | 7 | `TeamName_Abbr15_<teamid>` |
| ball | **30** (0x1e) | 9 | no DB resolver; `FUT_UC_BALL` caption only |
| league logo | **31** (0x1f) | 9 | by elimination |
`0x91..0x96` are TROPHIES, not club items. Route (a) of the two proposals above is
what paid off — the consumer is the manager vtable slot `+0x498` =
`FUN_180119bd0`, dispatched when `item+0x4c == 7`. Route (b),
`FUT_CLUBITEMS=probe:<family>`, would have FAILED for three of the five families,
because its candidate set never contained 9, 10 or 11.
Kit, badge and stadium are served by OpenFUT today. Ball and league logo are
withheld: cardtype 9 has no database name resolver, so their name could only come
from `localizedName` on the wire, and that is not established as safe to send.
One residual probe remains, specified in `plan-2026-08-06-card-subsystem.md` §3.
## WHY THE CRASH HAPPENED, recorded so it is not repeated
@@ -447,3 +487,48 @@ taking its time and then dies. None of the three was needed to draw a card. Comp
it, the response that crashed was type=equippables carrying 30 items across FIVE
unverified subtypes at once, so even the crash taught us nothing about which subtype
was wrong. Both are fixed: no extras, equippables withheld, one family per test.
---
# Field-map corrections (dated)
This file's earlier field notes predate the deserializer frame arithmetic. Where
they disagree with the table in `plan-2026-08-06-card-subsystem.md` §2, that
table wins — it is derived structurally (`FUN_18013fe00` builds the record as a
stack struct and hands `&local_188` to the merge, so `record_offset = 0x188 - X`)
rather than inferred backwards from an accessor.
```
CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X):
+0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR
+0x48 owners (atom 0x207, u8; constructor default 0)
+0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1)
+0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum
+0x5c itemState (atom 0x172 via FUN_180166660, u32)
+0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17)
+0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY
+0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles)
+0xbf amount (atom 0x1b, u8) for the other consumable classes
+0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7
+0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7
+0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field
+0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm
itemType (atom 0x173) is parsed into a heap string and never stored
definitionId is NOT AN ATOM
```
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
right, and the stronger statement is now measured: the pre-match kit selector
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
(live: `{1: players, 0: staff}`). Every OTHER input to that gate is already
served. So the empty kit-selection screen is a client dead end, not a missing
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
`cardsubtypeid`, `itemState`, `assetId`, `cardassetid`, `rareflag`, `owners`,
`contract`, `discardValue`, `localizedName` — while `definitionId` is absent
entirely. It is still sent on the live-proven player path; it is inert, not
harmful, and has not been removed.
+107
View File
@@ -0,0 +1,107 @@
# The client's complete UTAS route surface
Read out of the running client's own `.rdata` on 2026-08-21 (pid 6580) with
`fifa17-recon/tools/url_template_probe.py`, then each route probed against
staging. This bounds the server: FIFA 17 cannot ask for a route that is not in
this list.
Staging's Python upstream is deliberately dead, so a `502` there means the Rust
host does not own the route — which makes the coverage column a measurement
rather than an audit of the source.
## Route templates in CardsDLL
`%s` is the sku segment, built from `game/%s` (`0x18021fac8`) → `game/fifa17`.
```
ut/auth ut/delete/auth
ut/%s/user ut/delete/%s/user ut/%s/user/list
ut/%s/club ut/%s/clubUser
ut/%s/item ut/%s/item/resource ut/delete/%s/item
ut/%s/defid
ut/%s/squad ut/delete/%s/squad ut/%s/squad/mode
ut/%s/purchased ut/%s/store ut/v2/%s/store
ut/%s/trade ut/delete/%s/trade
ut/%s/tradePile ut/%s/watchList ut/delete/%s/watchList
ut/%s/auctionhouse ut/%s/marketdata
ut/%s/match ut/%s/sbs
ut/%s/season ut/%s/season/user ut/%s/season/%%s/user
ut/%s/season/%%s/reset ut/%s/season/friendly
ut/%s/tournament ut/%s/tournament/user ut/delete/%s/tournament/user
ut/%s/champion ut/%s/draft/mode
ut/%s/leaderboards ut/%s/leaderboards/options
ut/%s/activeMessage ut/%s/livemessage
ut/%s/clientdata ut/%s/phishing ut/%s/captcha ut/%s/tfa
```
Suffixes appended to the above, not standalone routes:
`/consumables/%s`, `/items`, `/purchasegroup`, `/squadBuildingSets`,
`/challenge/%d/squad`, `/choices/manager`, `/purchase/mode/%d/draft`,
`/transfermarket?type=%s&start=%d&num=%d`.
## THE TRAP when reading this list
A literal in `.rdata` is a **fragment**, not necessarily a callable path. Probing
fragments bare manufactures fake gaps. Every one of these looked unserved and was
not:
| looked missing | actually |
|---|---|
| `clientdata` | real route is `clientdata/<key>`; served (`clientdata/userHubData` → 200) |
| `purchasegroup` | a suffix of `store`; `store/purchasegroup/all` is served |
| `sbs/challenges` | not a route; the real ones are `sbs/sets`, `sbs/setId/<n>/challenges`, `sbs/challenge/<n>` — all served |
| `squadBuildingSets` | not a route in the oracle either |
| `club/items` | `items/...` literals are ART ASSET paths, not UTAS |
| `item` | only ever PUT (move/pile) and DELETE (quick-sell) |
Check a candidate gap against `tools/utas_server.py`'s regex table before
believing it.
## Genuinely unserved, and why that is correct
* `squad/mode` — bare form is never used. The oracle only has Draft sub-paths
(`squad/mode/draft/state`, `squad/mode/<n>/draft/choices/*`). Draft is out of
scope, so this correctly stays on Python.
## Fixed by this measurement
Four handlers existed and were unreachable because `classify` never produced
their route, so every request fell through to Python. This is a **recurring
defect class** in `openfut-utas-host` — `season/list` and `watchList` were the
first two, and their fix comments are still in the file:
| route | handler | was |
|---|---|---|
| `captcha` | `handle_static_ack`, returns the oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` | fell to Python |
| `tfa` / `livemessage` / `activeMessage` | `handle_static_ack`, `{}` | fell to Python |
| `tournament/user` | `FeatureOffEmpty`, `{}` — the oracle's answer with `FUT_MODES` off | fell to Python |
`Route`'s own doc comment already claimed the first four as "Rust-owned
UNCONDITIONAL", so the documentation had been wrong rather than the intent. All
five are byte-identical to the oracle, so claiming them is parity, not new
behaviour. Invisible in production (the upstream answers); a 502 on staging.
Two regression tests now pin the vocabularies —
`every_static_ack_tail_is_actually_routed` and
`the_disabled_mode_reads_are_all_claimed` — so a handler cannot go unreachable a
fifth time.
## No consumable apply endpoint exists
Support level L5 for consumables was open, with an inherited note saying there is
"no training/position/chemistry/manager-league endpoint at all". **The route
table confirms it from the binary**: there is no apply/training/position/
chemistry route anywhere in CardsDLL. The only owned-item mutations the client
can express are:
```
PUT ut/%s/item move / pile
DELETE ut/%s/item/<id> quick sell
POST ut/delete/%s/item bulk quick sell
PUT ut/%s/squad squad write
```
So applying a consumable is **not** a dedicated server route. If it reaches the
server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by
capturing that PUT's payload while applying a card — not by looking for an
endpoint that does not exist.
+91 -3
View File
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
> confirmed consumer of these fields is the requirement-display string builder at
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
> the empty `elgReq`.
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
@@ -925,11 +944,80 @@ freezes any of these — GAPs are "feature missing", not "crash".
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item` (move by res) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool (+ 2 str/1 int minor) | GAP | HIGH / extra-fields MED |
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/item?type=…` (GetFilteredConsumableSearchResults) | `itemData`(0x16b) → **array[consumable-item]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | GAP | deser HIGH / scalars MED |
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/…` (CardsGetStaffBonuses) | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | GAP | MED |
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/club/consumables/<cat>` (ConsumablesSearch) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[consumable-stack]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | SERVED (Rust host) | deser HIGH / scalars MED |
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/club/stats/staff` (StaffStats, thunk `0x18012b080`) **[CORRECTED 2026-08-21]** | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | SERVED (`{}`, the oracle body) | MED |
| 14 | FutGetAvailableLoanPlayers | `0x18014e030` → sub `0x18013a1c0` | GET `ut/%s/item` (FUT_AVAILABLE_LOAN_PLAYERS_DP) | `loans`(0x19b) → **array** [FREEZE-RISK]; `itemData`(0x16b) → **array[card-item]** [FREEZE-RISK]; `default`(0xcd) → int | GAP | deser HIGH / fields MED |
| 15 | FutSignLoanPlayer | `0x1801642c0` | PUT `ut/%s/item` (sign loan) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/…` (stickerbook search) | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/club?<query>` (ClubSearch, `FUN_18012ddf0`) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | SERVED (Rust host) | HIGH |
### The four `ut/%s/club` routes are a TABLE, not an inference (2026-08-21)
The URLs for rows 12, 13 and 16 above were previously guessed as `ut/%s/item?…`
or left as `ut/%s/…`. The binding is exact: 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 request families on that base and no others.
```
| ClubSearch | FUN_18012ddf0 | GET ut/%s/club?<query> | FutStickerBookSearchServerResponse |
| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/<f>[/<id>] | FutStickerBookStats2ServerResponse |
| StaffStats | thunk 0x18012b080 | GET ut/%s/club/stats/staff | FutStaffBonusServerResponse |
| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/<cat> | FutConsumablesSearchServerResponse |
```
**Club query grammar**, complete and ordered: `?year=2017` (always, hardcoded),
then `type`, `start` (omitted at 0), `count` (omitted at 100), `filter`, then
EITHER the filter block (`position, formation, state, level, rare, nation,
country, league, playStyle, team, sort`) OR a comma-joined `defId=` list, never
both. Live control from the log:
`GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`
matches the predicted order and every suppression rule.
Sub-vocabularies: `filter` = available/base/exact/any; `level` =
bronze/silver/gold/any; `sort` = asc/desc; `rare` = the literal string `SP`, not
a boolean; `state` = the itemState names plus `any` — and note the REQUEST spells
it `onSale` where the RESPONSE value is `forSale`.
`?type=` has 30 values. Decoded 2026-08-21 from the jump table itself rather
than from a case count: `FUN_18012ec50` is `cmp ecx,0x1d` + a 30-entry table at
`0x18012ed9c`, and each case is `mov ecx,<atom>; jmp 0x180180cd0` (atom → string).
Resolving those atoms against `fut_atoms.tsv` gives the vocabulary in table order:
```
0 any 1 player 2 manager 3 headcoach
4 fitnesscoach 5 physio 6 development 7 custom
8 unlocks 9 gkcoach 10 staff 11 badge
12 kit 13 stadium 14 ball 15 equippables
16 leaguelogos 17 offlinetrophy 18 onlinetrophy 19 featuredofflinetrophy
20 featuredonlinetrophy 21 allofflinetrophy
22 allonlinetrophy 23 healing 24 contract
25 training 26 misc 27 playerdefender
28 playermidfielder 29 playerforward
```
Notes worth having: there is **no `playergoalkeeper`** — the client has only
DEF/MID/FWD tabs, so goalkeepers belong to `playerdefender`, and a GK appearing
there is correct rather than a filter bug. `healing`, `contract` and `training`
exist here as `?type=` arms even though consumables have their own
`club/consumables/<cat>` route. Six of the thirty are trophy arms.
`openfut-utas-host`'s `club_type_filter` implements all 30 with no extras; a unit
test pins the list so a missing arm (an empty real tab) or an invented one (dead
code that looks like coverage) fails the build.
**`/club/stats` has exactly seven forms**: `club`, `year`, `country/<id>`,
`league/<id>`, `newcards`, `consumables`, and the separately-dispatched `staff`.
**There is no `/club/stats/team/<id>`** — verified twice (the switch has six cases
with no such arm, and an exhaustive PE string scan finds no literal containing
`stats/team`). Any handling of a `team` stats mode is dead code.
**Two holes in the base table**, recorded so nobody re-derives them as findings:
base index 43 = `ut/v2/%s/store` is carried by no action row and has zero
references in `.text`, yet `ut/v2/store` is live-proven; base index 9 =
`ut/%s/activeMessage` is a second hole of the same kind. So at least one route is
composed OUTSIDE CardsDLL, most likely in the packed exe — every "the table bounds
it" statement here is bounded to CardsDLL only.
Notes:
- **`0x1801642c0`** is a shared no-op deserializer (function body = `ret`). Three responses
@@ -607,6 +607,62 @@ cardtype 6, live-confirmed on the two resident consumables, so for exactly the
items the warning was aimed at, the server's rating and rare flag are
authoritative.
**APPLIED (2026-08-21), behind a default-off flag.** The table and the formula
above are now in Rust as `openfut-adapter-fifa17::fut::discard`:
`cardtype_for_subtype` is the decode, `discard_level` the 3/2/1 ladder,
`table_price` the 141-row lookup (`0` for an absent key) and `discard_value` the
`round_half_up(rating * price / 100)` formula. `DISCARD_COINS` is generated from
`fifa17-recon/data/tables/fcc_discardcoins.json` and a test re-reads that file
and asserts they still agree row for row, so the two cannot drift. The four
worked examples above (`8 * rating`, `4 * rating`, the 50-rated bronze at 15,
and an absent key paying 0) are tests.
Wire and wallet are now ONE method. `ItemIdentityResolver::discard_value` both
stamps the card's `discardValue` and prices the sale, because a non-zero
`discardValue` suppresses the client's local computation — so whatever is sent
is what the player is promised. The host's separate `quick_sell_value` ladder is
deleted (it was a second copy that could drift), and a test with a resolver
double returning an impossible price proves the credit follows the wire.
`OPENFUT_FIFA17_DISCARD_TABLE=1` turns the table on; the default keeps the old
placeholder ladder because switching revalues an existing club by **10.5x**
(measured over the real 1991-item club: 1,820,400 -> 19,128,955 coins if wholly
liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables
move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x).
STAFF: CLOSED, and the `value`-is-the-rating question is now SETTLED against the
running client rather than inferred. A staff wire record carries no `rating`, no
`rareflag` and no `discardValue`, so the displayed price had to be read back out
of memory. `tools/coach_probe.py` grades the four resident staff records HIT,
which requires record `+0xb4` == the table's `value` and `+0x58` == its `rare`;
`tools/discard_probe.py` (new) then reads the two discard slots directly —
`+0x38` is what we sent, `+0x3c` is what the client computed:
```
resource sub ct rat lvl rar sent+38 calc+3c predicted
1000509 4 2 88 3 1 0 282 282 AGREES (manager)
9000081 6 10 66 2 0 0 36 36 AGREES (gk coach)
3000083 8 4 66 2 0 0 36 36 AGREES (fitness)
```
4 of 4 agree, 0 disagree, and 36 on the `value`-66 GK coach was the stated
falsifier. `openfut-import-fifa17::Entities::enrich_staff` now carries `value` ->
rating and `rare` -> rareflag for the five families, so the catalog holds what
the client re-rates to; verified on staging, a GK coach quick-sells for 36 rather
than the 150 floor. The catalog diff is exactly the two coach entries.
The same probe shows what production is doing to PLAYERS today: all 23 resident
player records carry `sent+38 = 1500`, which suppresses the local computation, so
the client displays 1500 for every one of them — against its own table's 688..752
for a gold rare, 11,102..11,468 for the 21/23/24 specials, 22,080..23,280 for
rareflag 11, and 72,800 / 74,400 for the two rareflag 5/6 legends. A 50x underpay
at the top and a 2x overpay at the bottom.
STILL OPEN, and NOT a discard problem: the manager `fifa17_1000509` is owned in
Core but has no catalog entry and no card definition (it reaches the client
through the opaque squad extension), so pricing declines for it and falls back to
the ladder — 150 against the client's 282. That is definition coverage.
### 3.7 `duplicateItemIdList`
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
@@ -304,8 +304,44 @@ elimination:**
| kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` | `teamid` |
| stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_<assetId>` | `assetId` |
| badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_<teamid>` | `teamid` |
| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` |
| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably |
| ball | **30** (0x1e) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
| league logo | **31** (0x1f) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
**MEASURED 2026-08-21 against the running client (`tools/cardtype_dispatch_probe.py`,
pid 6580): no cardtype-9 family can be named, and no server change can alter that.**
Four independent reads, each with a passing positive control:
1. The merge switch's jump table at rva `0x141eb4` is indexed by `cardtype - 1`
and has exactly 10 entries. Cardtypes 1–5 and 10 each get their own DB-merge
arm; **cardtypes 6, 7, 8 and 9 all land on the shared tail `0x180141e8a`**,
which issues no query and writes no name — it only derives the discard level
from the rating.
2. Census of every `cmp [reg+0x4c], imm` (cardtype): 0 → 1 site, 1 → 13, 6 → 1,
7 → 6, **9 → ZERO**.
3. Census of every `cmp [reg+0x50], imm` (cardsubtypeid), which is what actually
selects a club-item caption: kit 9, stadium 10 and badge 11 all present
(control), **ball 30 → ZERO sites, league logo 31 → ZERO sites**. The only
cardtype-9 subtypes that appear at all are `fcc_misccards` 231/232/233/236,
and all four sites are one boolean predicate near `0x1801a72da` that returns
FALSE for them — an exclusion, not a resolver. (That predicate's identity is
NOT established; it reads `+0x49`, `+0x145` and a vtable slot `+0x270`.)
4. The cardtype-7 resolver is reached only under `cmp DWORD PTR [rax+0x4c], 0x7`
at `0x1800f6f04`, so a cardtype-9 item can never arrive there. Its `jne` path
formats `AWARD_LABEL_%i` (`0x1801fd5a0`) — the TROPHY path, not a fallback
that would name a ball.
So the earlier "`localizedName`, probably" for these two rows was optimistic:
there is no code that would read it for a caption. Withholding ball and league
logo from the projection is a measured limit of the client, not caution.
CORRECTION, same measurement: `FUN_180119bd0` was recorded elsewhere as having
"zero refs in CardsDLL → almost certainly an export, its caller is in
FIFA17.exe". It is **not** an export. Its address occurs exactly ONCE in the
whole process, at `0x18021c738` in CardsDLL's own `.rdata`, and nothing in
FIFA17.exe references it. It is a virtual function: vtable base `0x18021c2a0`,
slot **+0x498**, index 147 (ctor LEAs at `0x18010ce10` / `0x18011111b`) — which
independently reproduces the "manager vtable slot +0x498" recorded below, by a
different method. It has 7 distinct `call [reg+0x498]` sites.
The premise that all five live in cardtype 9 is wrong, and the root fact is not an
inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two
@@ -406,6 +442,91 @@ from an accessor. So: send `localizedName` and expect it to show; send
`description` and do not be surprised if nothing changes. The same `+0xba` also
holds the unresolved kit-variant selector, so these two gaps may be one gap.
### The cardtype-9 name gap is ONE gap, not three (2026-08-21)
Worth stating plainly, because it was being tracked as three separate holes.
Everything OpenFUT still refuses to project is cardtype 9, and for exactly the
same reason:
| family | subtype(s) | definition table | why withheld |
|---|---|---|---|
| ball | 30 | `fcc_balls` (42) | no DB name resolver |
| league logo | 31 | `fcc_leaguelogos` (44) | no DB name resolver |
| misc | 231, 232, 233, 236 | `fcc_misccards` (42) | no DB name resolver |
The cardtype-7 families (kit 9, badge 11, stadium 10) all resolve their caption
from the client's own tables through `FUN_180119bd0`, so the server sends only
identity and the name takes care of itself — which is why all three now project.
Cardtype 9 has no such resolver, so the displayed name can ONLY come from
`localizedName` on the wire, and that single unproven step gates all three
families at once.
Closing it closes the last of the ownable taxonomy. It needs the launch-driven
probe in "The one probe still outstanding" above — one item, one family — and
nothing else. Ownership, `content_kind`, club/stats counting and restart
durability are already in place for all three, so the probe is the only
remaining work: the projection arm is a two-line change once the name is proven.
#### A lead on league logos: a `LeagueName_Abbr_15_%d` path DOES exist
`FUN_180098f20` (named above as the league-logo function, hedged "localizedName,
probably") was read in full on 2026-08-21. It builds a real database query, and
the literals settle what it does:
```
table 'fcc_leaguelogos'
where 'leagueid' '==' %d ; the id arrives in r9d
columns 'carddbid' 'value' 'cardassetid'
caption 'LeagueName_Abbr_15_%d' ; a localisation key built from the league id
domain 'FUT String'
```
So a database-backed league NAME demonstrably exists in the client, keyed on
`leagueid`, in exactly the shape kits use (`TeamName_Abbr15_<teamid>`). That
makes the blanket claim "cardtype 9 has no DB name resolver" too strong for
league logos specifically.
WHAT THIS DOES NOT YET SHOW, stated plainly because the obvious next step is a
trap. Its ONLY caller is `0x180098da3`, and the `[rbx+0x20]` it passes as the
league id is NOT the item record: `rbx` is reloaded from `[rsp+0x48]` and
compared against an end pointer, i.e. it is a cursor over a list of small
elements (int at `+0x20`, double at `+0x24`, int at `+0x2c`), not the 0x158-byte
card record. So this is a CATALOG/BROWSE builder, and it is not established that
the owned-item render path reaches it at all. Reading `+0x20` as the record's
`assetId` and concluding "send the leagueid as assetId" would be exactly the
kind of inference this document exists to prevent.
The lead worth following: find whether the owned cardtype-9 render path reaches
this resolver, and if so which field feeds the league id. If it does, league
logos need no `localizedName` at all and separate from the ball/misc gap.
#### Where to look next, and where NOT to (2026-08-21)
The lead above was chased and stopped at a useful boundary. `FUN_180119bd0` —
the cardtype-7 caption resolver this whole section rests on — has **zero
references anywhere in CardsDLL**: no `call`, no `jmp`, and its address is never
taken in `.text`, `.rdata` or `.data`. It is nonetheless a genuine function
(clean `mov rax,rsp` entry after `int3` padding).
A real, unreferenced function in a DLL is almost certainly an **export**, which
puts its caller in FIFA17.exe. That matches the shape of everything else here:
CardsDLL owns the card model and the database, and the EXE owns the UI that asks
for captions. `FUN_180098f20`'s only caller likewise iterates a small list
element, not a card record — a browse/catalog builder, not the owned-item path.
So the practical guidance is: **stop looking for the owned cardtype-9 caption
path inside CardsDLL.** It is not there. Closing this by static reading means
parsing CardsDLL's export table and following the callers in FIFA17.exe's 79 MB,
which is a much larger job than the launch probe in "The one probe still
outstanding" — one item, one family, and the answer is visible on screen.
Method note for whoever does dump memory here: CardsDLL's sections are
`.text` at image `0x180001000`, `.rdata` at `0x1801e5000`, `.data` at
`0x18028a000`. Confusing a LIVE mapping offset with an IMAGE offset silently
reads the wrong section and produces false negatives — every atom-name lookup
came back ABSENT until the region was corrected, including controls like
`resourceId`. Always validate a memory scan against a key known to be present.
---
## 4. The card lifecycle
@@ -503,13 +624,6 @@ effects move in the permissive direction. There is also a second escape hatch in
that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if
squad submission behaves oddly afterwards, that is where to look.
**"List on Transfer Market" as a separate menu entry was not found.** The eight
flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes
`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but
whether it has its own enable predicate was not chased. The likely explanation is
that listing is only reachable from the trade pile, so both entries share one root
cause, but that is an inference and it is not established.
### Equipping club items
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
@@ -528,22 +642,60 @@ will not change the kit.
### Needs decompiling only
**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
instructions depending on method. The narrower anchor is the `/club` and
`/purchased` response handlers -- find the list-insert that assigns it, read the
constants. This is the single blocker between "we can mark a kit equipped" and "we
can equip a kit".
**Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit
swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60`
returns 1688 and 4144 instructions) because it is a common struct offset. Two
filters cut it to a readable set: only an IMMEDIATE store can introduce a
constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c`
nearby. Measured with `fifa17-recon/tools/kit_gate_probe.py` against pid 6580:
| evidence | result |
|---|---|
| live `+0x60`, all 27 resident records | `{1: 23 players, 0: 4 staff}` — never 4 |
| `cmp dword [reg+0x60], imm8` in CardsDLL | 4 sites: `0`, `0`, `1`, `4`; the `4` is the gate and is UNIQUE in the process |
| immediate stores to `[reg+0x60]`, CardsDLL | 29; constants `{-2, 0, 1, 908, 0x3f800000}` — no 4 |
| immediate stores of 4, FIFA17.exe (79 MB) | 0; also 0 comparisons against 4 |
| xrefs to the gate function | 1 (`jmp` from `0x1801a5329`); address never taken |
| register stores to `+0x60`, CardsDLL | all struct copies or inits to 0/1/-2 |
So the blocker is not a wire field we have not learned to send: the value the
gate demands is never produced by anything. Every OTHER input to the gate is
already served — `+0x4c == 7` (subtype 9), `+0x5c` 101/102
(`activeHomeKit`/`activeAwayKit`), `+0x94` teamid — leaving only the `+0xba`
variant selector below it. A client-side patch is therefore the only remaining
avenue, and a small one; it is not proposed here.
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
byte). Which wire atom sets it is unknown, so we cannot serve a specific kit
deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle.
**`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is
claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no
action today because we send no `statsList`, but it is two minutes of work and it
would close the chain.
**`FUN_1801aa190`. CLOSED 2026-08-21.** The one unopened link inside the
eight-flag chain. It is eleven instructions, and it resolves TWO parallel arrays
rather than the one the earlier claim described:
```
mov rax, [rcx+0x10] ; the ITEM record (same +0x10 hop the kit gate uses)
test r8b, r8b
jz .low
mov eax, [rax + rdx*4 + 0x124] ; array B
ret
.low:
mov eax, [rax + rcx*4 + 0x104] ; array A <- the claimed statsList
ret
```
So the signature is `f(self, int idx, bool which)`: `+0x104 + idx*4` when the
flag is clear, `+0x124 + idx*4` when it is set. The two arrays are 0x20 apart,
i.e. eight ints each (`+0x104..+0x123`, `+0x124..+0x143`).
LIVE (pid 6580, production-served records): BOTH arrays read all zeros on every
resident record, players included — e.g. resourceId 20801 rating 94 has
`A = [0]*8`, `B = [0]*8`. That confirms "changes no action today because we send
no statsList", and extends it: the sibling array at `+0x124` is equally empty.
Any action flag derived from either is reading 0 in production, so neither can
be the reason an action is greyed.
**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved.
What remains is whether the field is visible anywhere worth populating.
@@ -553,24 +705,73 @@ depend on it (`FUN_180108c00` carries the same mapping independently), but the
dispatch table that reaches it was not identified, and trophies are a whole
unimplemented family.
**Case sensitivity of the `itemState` string match.** Almost certainly
unresolvable statically: `FUN_180008190` is a single indirect call through
`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact
casing from the table and do not experiment on the live save.
**Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 —
CASE-SENSITIVE.** It was expected to be unresolvable statically, because
`FUN_180008190` is nothing but a forwarding stub through a runtime-populated
slot:
```
mov rax, [DAT_1802ddfd8] ; service object, handed to CardsDLL by the host
mov r9, [rax + 0x248]
jmp r9
```
Resolved read-only against the running client (pid 6580) with
`fifa17-recon/tools/service_ptr_probe.py`, which follows the chain and
attributes each hop to a module (Wine maps PE sections anonymously, so the
module comes from the nearest preceding named mapping):
```
*(service + 0x248) = 0x146d1c020 FIFA17.exe+0x20f9020 e9 … jmp rel32
→ 0x145e27fe0 FIFA17.exe+0x1204fe0 ff 25 jmp [rip+…]
→ 0x6ffffd11c330 msvcr120.dll+0x3c330 function body
```
The body is `strncmp`: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al`
(NUL stop) / `cmp al,[rcx+rdx]`, then MSVC's 8-byte fast path with the
`0x8080808080808080` and `0xfefefefefefefeff` NUL-detect constants. There is no
`or ..,0x20` and no folding table anywhere in the body, so the compare is raw
bytes.
CONSEQUENCE: a mis-cased token does not degrade, it matches nothing —
`FUN_180166660` returns `0xffffffff`, the record keeps `0` = `invalid`, and the
item fails the squad builder's `state == 1 || state == 2` test. The casing in
the table at `0x180229cc0` is a contract. Send it verbatim; do not experiment on
the live save.
### Needs a live probe (read-only, no launch)
**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string
comparator. That answers the casing question without a launch.
**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and
"sequence counter". Low value; nothing we send reaches it.
**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result
proves `g_db` is loaded and complete; it says nothing about the separate database
behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in
FIFA17.exe and not in CardsDLL. These are different databases and they should stop
being conflated.
**Confirm the FUT roster database is loaded. PARTLY ANSWERED 2026-08-21 — the
two databases are now definitively distinct; the load FLAG is still unlocated.**
The `fcc_discardcoins` result proves `g_db` is loaded and complete; it says
nothing about the separate database behind `LoadFUTDatabase` / `.dbFUTVer` /
`DL_FUT_LIVEDB`. Scanning FIFA17.exe's 79 MB of code+data in the live process
(pid 6580) recovers the whole API name set, and it settles the distinction:
```
SetFUTDatabaseUnloaded UpdateFUTDBVersion StartFUTRosterDownload
LoadFUTDatabase UnLoadFUTDatabase GetFUTDBCRC
CancelRosterDownload DL_FUT_LIVEDB APPLY_FUT_LIVEDB
RosterXMLDownloadedFail .dbFUTVer .dbMajor .dbMinor .dbMajorCRC .dbMinorCRC
```
Every one of those lives in FIFA17.exe; none is in CardsDLL. So the FUT roster
DB is a DOWNLOADED, versioned, CRC-checked live database with its own
download -> apply -> load/unload lifecycle (and its own failure state,
`RosterXMLDownloadedFail`), which is a different kind of thing from the shipped
card tables CardsDLL reads. They should stop being conflated, and this is the
evidence for saying so.
What is NOT answered: whether it is loaded right now. The process holds no
separate database file open — only Frostbite bundles (`.sb` / `.cas`) — which is
consistent with the roster DB living inside a bundle or in memory, so absence of
a file handle proves nothing either way. The `SetFUTDatabaseUnloaded` state
implies a boolean somewhere; that global was not located, so "is it loaded"
remains open and needs the flag found before it can be answered honestly.
### Needs a launch the user must drive -- ranked, and short
@@ -870,10 +1071,29 @@ be misrouted onto another field. **Freeze risk: none** -- removing a key the par
skips strictly reduces executed code. Low value, zero cost, and it removes a field
that three documents describe as if it did something.
**Fourth verification, 2026-08-21 (independent method).** Searched CardsDLL's
own `.rdata` in the running client for the literal key names. Every real atom is
present exactly once — `resourceId` `0x18022a3a8`, `cardsubtypeid` `0x180230520`,
`itemState` `0x180231490`, `assetId` `0x180230178`, `cardassetid` `0x180204200`,
`rareflag`, `untradeable`, `owners`, `contract`, `discardValue`, and notably
`localizedName` at `0x1802316d0` — while **`definitionId` is ABSENT entirely**.
The client has no string for it, so no arm can exist. That is a different method
from the three above (string table rather than key dictionary) and it agrees.
NOT applied all the same. The player path that carries `definitionId` is
live-proven in production, the saving is payload only, and this project's house
rule is that a flag defaults to the live-proven value. "Provably inert" is a good
reason to stop documenting it as meaningful; it is not on its own a reason to
change a working wire. Bundle it with the next change that needs a launch.
---
## 7. Proposed corrections to existing documents
> **APPLIED 2026-08-21.** Every correction below has been made in the named file
> and marked there with a dated note. This section is kept as the rationale and
> the audit trail, not as an outstanding to-do.
### `docs/CARD_SYSTEM.md`
**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered.
+12 -11
View File
@@ -147,14 +147,13 @@ def refresh_account_identity():
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
# Client/server split support (OpenFUT dev-container): bind and advertise default
# to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
# and (unless overridden) the roster service.
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
# Use a certificate dNSName and resolve it on the client.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
@@ -563,9 +562,11 @@ OSDK_TICKER = []
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "%s:8081" % _ADVERTISE
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
# deployment can advertise a certificate DNS name without changing other hosts.
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Prove, from the live client, which cardtypes CardsDLL can NAME -- and that
cardtype 9 (ball / league logo / fcc_misccards) is not one of them.
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
WHY
---
Serving an owned ball or league logo was blocked on one question: where does a
cardtype-9 item's caption come from? Three independent reads here say: nowhere.
MEASURED 2026-08-21, pid 6580, CardsDLL live base 0x6ffffc0f0000
(module-relative offsets below are stable; live addresses are not).
1. THE CLUB-ITEM CAPTION RESOLVER IS A VTABLE SLOT, NOT AN EXPORT.
An earlier note recorded FUN_180119bd0 as "zero refs in CardsDLL -> almost
certainly an export, its caller is in FIFA17.exe". That is WRONG and this
tool corrects it. Its address occurs exactly ONCE in the entire process, at
image 0x18021c738, inside CardsDLL's own .rdata -- a vtable entry. Nothing in
FIFA17.exe references it.
Walking backwards over "qwords pointing into .text" overshoots the vtable
boundary (it runs 826 slots through several adjacent vtables). The reliable
discriminator is that a vtable's START is referenced by its constructor via a
RIP-relative LEA while interior slots never are:
vtable base image 0x18021c2a0 (ctor LEAs at 0x18010ce10, 0x18011111b)
FUN_180119bd0 slot +0x498, index 147
which independently reproduces the previously recorded "manager vtable slot
+0x498". There are 7 distinct `call [reg+0x498]` sites.
2. THE CAPTION CALL IS GATED ON cardtype == 7, AND THE ELSE IS TROPHIES.
At 0x1800f6f04:
cmp DWORD PTR [rax+0x4c], 0x7 ; cardtype
jne 0x1800f6f82
...
mov r9d, [rdx+0x94]
mov r8d, [rdx+0x50] ; cardsubtypeid
mov ecx, [rdx+0x20] ; assetid
call QWORD PTR [r10+0x498] ; FUN_180119bd0
The jne path formats [rdi+0x8] into 'AWARD_LABEL_%i' (0x1801fd5a0) and
localises it -- that is the TROPHY path (subtypes 0x91..0x96), not a fallback
that would name a ball.
3. NO CARDTYPE-9 HANDLING EXISTS, BY TWO INDEPENDENT MEASURES.
a) Census of every `cmp [reg+0x4c], imm8` in .text:
cardtype 0 : 2 sites
cardtype 1 : 14 sites
cardtype 6 : 1 site
cardtype 7 : 6 sites
cardtype 9 : 0 sites
b) The merge switch's jump table at rva 0x141eb4, indexed by cardtype-1,
10 entries:
idx 0..4 -> cardtypes 1..5 distinct DB-merge arms
idx 5..8 -> cardtypes 6..9 ALL to the shared tail 0x180141e8a
idx 9 -> cardtype 10 distinct arm (gkcoach)
The shared tail does no DB query and writes no name: it only derives the
discard level from the rating.
A cmp census alone would miss a jump-table switch, and a jump table alone
would miss an explicit compare. Both say the same thing.
CONSEQUENCE
-----------
A cardtype-9 item cannot receive a client-resolved caption: it has no merge arm
to fill a name and it can never reach the cardtype-7 resolver. Withholding ball
and league logo from the projection is therefore an evidence-backed limit of the
client, not caution -- and no server-side change can lift it.
BONUS, and it validates the discard work: the shared tail at 0x180141e8a IS the
discard level ladder, live --
movzx eax,[rdi+0xb4] ; cmp al,0x4b ; -> 3
cmp al,0x41 ; sbb eax,eax ; add eax,2 ; -> 2 or 1
mov [rdi+0x54], eax
which is `discard::discard_level` instruction for instruction.
Usage:
python3 cardtype_dispatch_probe.py
"""
import collections
import struct
import sys
import watch_club_model as W
TEXT_LO, TEXT_HI = 0x180001000, 0x1801E5000
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
CAPTION_FN = 0x180119BD0
JUMP_TABLE = 0x180141EB4
SHARED_TAIL = 0x180141E8A
REGS = {0x78: "rax", 0x79: "rcx", 0x7A: "rdx", 0x7B: "rbx",
0x7D: "rbp", 0x7E: "rsi", 0x7F: "rdi"}
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
dll = W.dll_base(pid)
if dll is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
live = lambda i: dll + (i - W.IMG_BASE)
print("pid=%d CardsDLL live base %#x" % (pid, dll))
text, bad = mem.read_pages(live(TEXT_LO), TEXT_HI - TEXT_LO)
text = bytes(text)
print("read %#x bytes .text (%d bad pages)" % (len(text), len(bad)))
# --- 1. locate the caption fn's single reference, and its vtable base ----
target = live(CAPTION_FN)
rdata, _ = mem.read_pages(live(RDATA_LO), RDATA_HI - RDATA_LO)
rdata = bytes(rdata)
slots = []
needle = struct.pack("<Q", target)
i = rdata.find(needle)
while i != -1:
slots.append(RDATA_LO + i)
i = rdata.find(needle, i + 1)
print("\n[1] FUN_%x referenced from .rdata at: %s"
% (CAPTION_FN, [hex(s) for s in slots]) or "nowhere")
lea_t = set()
for i in range(len(text) - 7):
if text[i] in (0x48, 0x4C) and text[i + 1] == 0x8D and text[i + 2] in (
0x05, 0x0D, 0x15, 0x1D, 0x25, 0x2D, 0x35, 0x3D):
tgt = TEXT_LO + i + 7 + struct.unpack_from("<i", text, i + 3)[0]
if RDATA_LO <= tgt < RDATA_HI:
lea_t.add(tgt)
for slot in slots:
base = max((t for t in lea_t if t <= slot), default=None)
if base is not None:
print(" vtable base %#x -> slot +%#x (index %d)"
% (base, slot - base, (slot - base) // 8))
# --- 2. cardtype compare census -----------------------------------------
hits = collections.defaultdict(list)
for i in range(len(text) - 4):
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x4C:
hits[text[i + 3]].append(TEXT_LO + i)
print("\n[2] cardtype tests `cmp [reg+0x4c], imm`:")
for ct in sorted(hits):
print(" cardtype %2d : %3d site(s) e.g. %s"
% (ct, len(hits[ct]), ", ".join("%#x" % v for v in hits[ct][:4])))
ok_control = 7 in hits and 1 in hits
print(" CONTROL (cardtypes 1 and 7 must both appear): %s"
% ("OK" if ok_control else "WRONG REGION -- results are meaningless"))
print(" cardtype 9 sites: %d" % len(hits.get(9, [])))
# --- 3. merge jump table -------------------------------------------------
jt, _ = mem.read_pages(live(JUMP_TABLE), 0x40)
jt = bytes(jt)
print("\n[3] merge jump table at %#x (index = cardtype - 1):" % JUMP_TABLE)
tail_types = []
for n in range(16):
rva = struct.unpack_from("<I", jt, n * 4)[0]
if not (0x1000 <= rva < 0x1E5000):
break
va = W.IMG_BASE + rva
ct = n + 1
mark = " <- SHARED TAIL (no DB query, no name)" if va == SHARED_TAIL else ""
print(" cardtype %2d -> %#x%s" % (ct, va, mark))
if va == SHARED_TAIL:
tail_types.append(ct)
# --- 4. cardsubtypeid census -------------------------------------------
# The club-item CAPTION is chosen by subtype (+0x50), not cardtype, so the
# cardtype census alone does not settle whether a ball or logo is nameable.
sub = collections.defaultdict(list)
for i in range(len(text) - 8):
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x50:
sub[text[i + 3]].append(TEXT_LO + i)
elif text[i] == 0x81 and text[i + 1] in REGS and text[i + 2] == 0x50:
sub[struct.unpack_from("<I", text, i + 3)[0]].append(TEXT_LO + i)
print("\n[4] cardsubtypeid tests `cmp [reg+0x50], imm`:")
for st in sorted(k for k in sub if k <= 400):
print(" subtype %3d : %2d site(s) e.g. %s"
% (st, len(sub[st]), ", ".join("%#x" % v for v in sub[st][:4])))
print(" CONTROL (kit 9 / stadium 10 / badge 11 must appear): %s"
% ("OK" if all(s in sub for s in (9, 10, 11)) else "WRONG REGION"))
print(" ball(30)=%d leaguelogo(31)=%d misc(231/232/233/236)=%d"
% (len(sub.get(30, [])), len(sub.get(31, [])),
sum(len(sub.get(s, [])) for s in (231, 232, 233, 236))))
print(" NOTE: the misc sites are all one boolean predicate near"
" 0x1801a72da that returns FALSE for them -- an exclusion, not a"
" caption. Its identity is NOT established.")
print("\nVERDICT: cardtypes with no merge arm: %s" % tail_types)
print(" cardtype 9 named by CardsDLL: %s"
% ("NO -- no merge arm and no compare site" if 9 in tail_types
and not hits.get(9) else "reconsider"))
return 0
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back the DISCARD (quick-sell) value the live client holds for every
resident card, and check it against the client's own `fcc_discardcoins` table.
READ-ONLY. Walks the same CardsDb node tree as card_identity_probe / coach_probe
via /proc/PID/mem; there is no write path in this file.
WHAT THE TWO SLOTS MEAN (FUN_18013fe00 / FUN_180141660)
-------------------------------------------------------
item+0x38 the `discardValue` WE sent (atom 0xd7), stored verbatim.
item+0x3c the value the CLIENT computed for itself.
At 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` SKIPS the whole local
computation when +0x38 is non-zero. So:
* +0x38 non-zero -> the client displays OUR number and +0x3c is not filled.
* +0x38 zero -> the client computes, and +0x3c is what the player sees.
The local computation is
SELECT price FROM fcc_discardcoins WHERE cardtype==? AND level==? AND rare==?
value = round_half_up(rating * price / 100)
with `level` = 3 if rating >= 0x4b, 2 if >= 0x41, else 1 (item+0x54), and
cardtype derived from cardsubtypeid by FUN_1800d8330.
WHY THIS TOOL EXISTS
--------------------
For cardtypes 2/3/4/5/10 (the five staff families) the client OVERWRITES the
rating and rare flag we send with values from its own card database before
computing. The server therefore cannot know the displayed price from what it
sent -- it has to be read back. +0x3c is that read-back, and it is the ground
truth for what the server must credit on a quick sell.
Usage:
python3 discard_probe.py # table of every resident card
python3 discard_probe.py --kind staff # only the staff families
python3 discard_probe.py --json out.json
"""
import argparse
import json
import os
import sys
import card_identity_probe as P
import watch_club_model as W
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
F_SERVER_DISCARD = 0x38
F_CLIENT_DISCARD = 0x3C
F_LEVEL = 0x54
F_RARE = 0x58
F_RATING = 0xB4
def cardtype_for_subtype(sub):
"""FUN_1800d8330, read out of its raw two-level jump table."""
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 236) or 145 <= sub <= 150 or 231 <= sub <= 233:
return 9
if 51 <= sub <= 136 or 201 <= sub <= 220 or 250 <= sub <= 273 or 300 <= sub <= 341:
return 6
return 0
def load_prices():
"""{(cardtype, level, rare): price} from the client's own dumped table."""
path = os.path.join(TABLES, "fcc_discardcoins.json")
if not os.path.isfile(path):
return None
doc = json.load(open(path))
rows = doc["rows"] if isinstance(doc, dict) else doc
return {(r["cardtype"], r["level"], r["rare"]): r["price"] for r in rows}
def predict(prices, cardtype, rating, rare):
"""The client's formula, reproduced. An absent key pays 0, never a floor."""
if prices is None or cardtype == 0 or rating is None:
return None
level = 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1)
price = prices.get((cardtype, level, rare), 0)
if price == 0:
return 0
return (rating * price + 50) // 100
STAFF_SUBTYPES = (4, 5, 6, 7, 8)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--kind", choices=("all", "staff", "player", "other"), default="all")
ap.add_argument("--json", metavar="PATH")
a = ap.parse_args()
prices = load_prices()
if prices is None:
print("WARNING: no fcc_discardcoins.json under %s -- predictions disabled\n" % TABLES)
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
out = []
for n in ns:
buf = mem.read(n + P.REC, P.REC_LEN)
if buf is None or len(buf) < P.REC_LEN:
continue
sub = P.u32(buf, P.F_SUBTYPE)
ct = P.u32(buf, P.F_CARDTYPE)
rating = P.u8(buf, F_RATING)
rare = P.u32(buf, F_RARE)
rec = {
"resourceId": P.u32(buf, P.F_RESOURCE),
"subtype": sub,
"cardtype": ct,
"decoded_cardtype": cardtype_for_subtype(sub),
"rating": rating,
"level": P.u32(buf, F_LEVEL),
"rare": rare,
"server_discard": P.u32(buf, F_SERVER_DISCARD),
"client_discard": P.u32(buf, F_CLIENT_DISCARD),
"predicted": predict(prices, ct, rating, rare),
}
if a.kind == "staff" and sub not in STAFF_SUBTYPES:
continue
if a.kind == "player" and ct != 1:
continue
if a.kind == "other" and (ct == 1 or sub in STAFF_SUBTYPES):
continue
out.append(rec)
out.sort(key=lambda r: (r["cardtype"], r["subtype"], r["resourceId"]))
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
% ("resource", "sub", "ct", "rat", "lvl", "rar", "sent+38", "calc+3c",
"predict", "verdict"))
agree = disagree = notcomputed = 0
for r in out:
if r["server_discard"]:
verdict = "SERVER-SHOWN (local calc skipped)"
notcomputed += 1
elif r["predicted"] is None:
verdict = "?"
elif r["client_discard"] == r["predicted"]:
verdict = "AGREES"
agree += 1
else:
verdict = "DISAGREES"
disagree += 1
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
% (r["resourceId"], r["subtype"], r["cardtype"], r["rating"],
r["level"], r["rare"], r["server_discard"], r["client_discard"],
r["predicted"], verdict))
print("\nAGREES=%d DISAGREES=%d server-shown=%d total=%d"
% (agree, disagree, notcomputed, len(out)))
if a.json:
json.dump(out, open(a.json, "w"), indent=2)
print("wrote %s" % a.json)
return 0
if __name__ == "__main__":
sys.exit(main())
+44 -17
View File
@@ -55,6 +55,34 @@ verify_exports() {
done
}
# Refuse any DLL that is not a FIFA-17-profile build.
#
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
# into FIFA 17 hijacks the login transport and the client reports "Unable to
# connect to the EA servers", with none of the FIFA 17 repairs present.
#
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
# the feature): two failed launches, diagnosed only by comparing embedded strings.
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
verify_fifa17_profile() {
local dll=$1 marker
# Markers that MUST be present: the FIFA 17 target module and its repairs.
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
grep -qaF -- "$marker" "$dll" ||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
done
# Markers that MUST be absent: the FIFA-23-only transport hooking.
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
if grep -qaF -- "$marker" "$dll"; then
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
fi
done
}
verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null ||
@@ -62,6 +90,7 @@ verify_inputs() {
need_file "$hook_dll"
need_file "$system_version"
verify_pe64 "$hook_dll"
verify_fifa17_profile "$hook_dll"
}
inspect() {
@@ -129,6 +158,7 @@ deploy() {
need_file "$manifest"
verify_pe64 "$staged"
verify_exports "$staged"
verify_fifa17_profile "$staged"
local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")"
@@ -158,7 +188,7 @@ launch() {
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local commit_enabled=0
local dispatch_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -177,14 +207,11 @@ launch() {
request_trace_enabled=1
notifier_trace_enabled=1
;;
commit)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
hook_enabled=1
trace_enabled=1
dispatch)
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
request_trace_enabled=1
notifier_trace_enabled=1
commit_enabled=1
dispatch_enabled=1
;;
*) die "unknown launch mode: $mode" ;;
esac
@@ -207,7 +234,7 @@ launch() {
done
mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir"
env \
GAMEID=fifa17 \
@@ -218,8 +245,8 @@ launch() {
OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \
OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
OPENFUT_SBC_DISPATCH_TRACE=0 \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -227,7 +254,7 @@ launch() {
usage() {
cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect.
@@ -240,11 +267,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace
Start the single M3 passive factory/deserializer trace; requires:
Start the M3-M6 passive parser/request/notifier trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-commit
Trace and arm the SBC cache only after a validated native parse; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
launch-dispatch
Trace and repair only a fully validated native status-999 completion; requires:
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -260,7 +287,7 @@ case "${1:-inspect}" in
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-commit) launch commit ;;
launch-dispatch) launch dispatch ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
esac
+10 -4
View File
@@ -299,10 +299,16 @@ def player_item(item_id, player, special=False):
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
# client runs its own fcc_discardcoins lookup and the price register stays 0.
#
# CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup
# returns no row for our cards" and "WHY its lookup misses is still UNKNOWN" --
# are both FALSE. The lookup does not miss; real rows exist for both rare values
# on (cardtype 6, level, rare). The tile reads a DIFFERENT property, which is why
# the wallet and the screen disagreed. Sending a non-zero value still fixes the
# display, for the reason below -- it bypasses the local computation entirely --
# but do not carry the "missing row" story forward: it sent one round of work
# looking for a table defect that was never there.
#
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Settle the pre-match kit selector gate: who, if anyone, writes item `+0x60`.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`plan-2026-08-06-card-subsystem.md` section 5 calls `+0x60` "the single blocker
between 'we can mark a kit equipped' and 'we can equip a kit'", and records that
two attempts to find its writer drowned: scanning for the offset returned 1688
and 4144 instructions depending on method.
The scan drowns because `+0x60` is a common struct offset. Two cheap filters cut
it to something a person can read:
* only IMMEDIATE stores can introduce a constant (a register store propagates
one from somewhere else), and
* item-record code is recognisable by touching `+0x4c` (cardtype) or `+0x5c`
(itemState) within a few instructions.
WHAT IT REPORTS
---------------
1. The live `+0x60` distribution over every resident CardsDb record.
2. Every `cmp dword [reg+0x60], imm8` in CardsDLL .text -- the readers.
3. Every immediate store to `[reg+0x60]` and the constants they use.
4. Which of those stores sit next to item-record code.
MEASURED 2026-08-21 (pid 6580, 27 resident records):
live +0x60 : {1: 23 (players), 0: 4 (staff)} -- never 4
readers : 4 total; exactly ONE compares against 4, at 0x1801c34f2,
which is the kit gate in FUN_1801c3480
immediate stores: 27 total; constants {-2, 0, 1, 908, 0x3f800000} -- NO 4
FIFA17.exe : 0 immediate stores of 4 to +0x60 across its 79MB of code,
and 0 comparisons against 4
gate xrefs : 1 (a jmp from 0x1801a5329); address never taken
The gate at 0x1801c34f2 decodes as:
cmp [rdi+0x4c], 7 cardtype 7 = kit/stadium/badge <- we produce this
cmp [rdi+0x60], 4 <- THE BLOCKER
mov eax, [rdi+0x5c] itemState
cmp eax, 0x65 / 0x66 101 activeHomeKit / 102 activeAwayKit <- we produce
mov r8d, [rdi+0x94] teamid <- we produce
mov r9d, [rdi+0xba] kit variant selector (unresolved)
So every input EXCEPT `+0x60` is already satisfied by what OpenFUT serves, and
no instruction in either module ever stores the constant 4 there.
Usage: python3 kit_gate_probe.py
"""
import collections
import struct
import sys
import watch_club_model as W
try:
import card_identity_probe as P
except Exception: # pragma: no cover - probe is optional for the static half
P = None
TEXT_START = 0x180001000
FIELD = 0x60
REGS = ["rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi"]
REC_SIZE = 0x158
F_SUBTYPE = 0x50
def live_distribution(mem, base):
"""(+0x60 histogram, (subtype,+0x60) histogram) over resident records."""
if P is None:
return None, None
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
return None, None
by_value = collections.Counter()
by_pair = collections.Counter()
for node in P.nodes(mem, obj):
buf = mem.read(node + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
subtype = struct.unpack_from("<I", buf, F_SUBTYPE)[0]
value = struct.unpack_from("<i", buf, FIELD)[0]
by_value[value] += 1
by_pair[(subtype, value)] += 1
return by_value, by_pair
def scan_text(text):
"""(readers, immediate stores, item-record markers) over a .text image."""
readers, stores, markers = [], [], set()
for i in range(len(text) - 8):
op, modrm = text[i], text[i + 1]
mod, reg, rm = modrm >> 6, (modrm >> 3) & 7, modrm & 7
if mod != 1 or rm == 4:
continue
disp = text[i + 2]
if disp in (0x4C, 0x5C) and op in (0x8B, 0x89, 0x83, 0x39, 0x3B, 0xC7, 0x0F):
markers.add(TEXT_START + i)
if disp != FIELD:
continue
if op == 0x83 and reg == 7: # cmp dword [reg+0x60], imm8
readers.append((TEXT_START + i, REGS[rm], text[i + 3]))
elif op == 0xC7 and reg == 0: # mov dword [reg+0x60], imm32
stores.append((TEXT_START + i, REGS[rm], struct.unpack_from("<i", text, i + 3)[0], "dword"))
elif op == 0xC6 and reg == 0: # mov byte [reg+0x60], imm8
stores.append((TEXT_START + i, REGS[rm], text[i + 3], "byte"))
return readers, stores, markers
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
print()
by_value, by_pair = live_distribution(mem, base)
print("── live records ──")
if by_value is None:
print(" CardsDb is empty (no FUT session loaded); static half still runs.")
else:
print(" +0x60 distribution : %s" % dict(by_value))
print(" (cardsubtypeid, +0x60) : %s" % dict(by_pair))
print(" holds the gate value 4 : %s" % ("YES" if 4 in by_value else "NO"))
print()
# .text is the second CardsDLL mapping; read it whole and scan.
size = 0x1E4000
buf, bad = mem.read_pages(base + 0x1000, size)
if bad:
print(" WARNING: %d unreadable page(s); the scan is incomplete." % len(bad))
text = bytes(buf)
readers, stores, markers = scan_text(text)
print("── readers: cmp dword [reg+0x60], imm8 ──")
for va, reg, imm in readers:
flag = " <-- THE KIT GATE" if imm == 4 else ""
print(" %#x cmp [%s+0x60], %d%s" % (va, reg, imm, flag))
print()
print("── immediate stores to [reg+0x60] ──")
consts = collections.Counter(s[2] for s in stores)
print(" %d store(s); constants %s" % (len(stores), dict(sorted(consts.items()))))
near = [s for s in stores if any(abs(m - s[0]) <= 96 for m in markers)]
print(" %d of them sit within 96B of item-record code (+0x4c/+0x5c):" % len(near))
for va, reg, imm, width in near:
print(" %#x mov %s [%s+0x60], %d" % (va, width, reg, imm))
print()
print("=" * 70)
if any(s[2] == 4 for s in stores):
print("A store of 4 EXISTS -- the gate is reachable. Follow the sites above.")
return 0
print("NO instruction in CardsDLL stores the constant 4 into +0x60.")
print("Combined with the live records (never 4) and the fact that every OTHER")
print("gate input is already served, the pre-match kit selector cannot be")
print("opened by anything the server sends. This is a CLIENT-side dead end,")
print("not a missing wire field.")
print()
print("Scope of the claim: immediate stores, all widths, disp8 form. A value")
print("could still arrive by register copy -- but in CardsDLL every register")
print("store to +0x60 is a field-by-field struct copy or an init to 0/1/-2.")
print("=" * 70)
return 0
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back the MANAGER-ONLY chemistry slots the client resolved, and prove
whether the server's `nation`/`leagueId` actually land in the record.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`card_identity_probe` reads the PLAYER slots (F_NATION = 0x148, F_LEAGUE =
0x154). A manager does not use those, so grading a manager with that tool
reports nation=0 / leagueId=0 and looks like a server bug when it is only the
wrong offsets.
`fifa17-recon/tools/fut_staff.py` records the manager layout from Ghidra:
rec+0x94 teamid (read by the card view-model)
rec+0xde nation MANAGER-ONLY slot, u16
rec+0xe0 leagueId MANAGER-ONLY slot, u16
rec+0xe2 talkrating written by the managercards merge
rec+0xe3 negotiation written by the managercards merge
The merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, so whatever sits there
came from OUR JSON and nowhere else. That makes those two u16s a direct,
unambiguous test of the server's manager chemistry fields: if they read back as
the values we served, the wire contract is PROVEN rather than inferred; if they
read zero, the client discarded them and manager chemistry cannot be rendering.
Usage: python3 manager_chem_probe.py # grade every manager in the map
"""
import sys
import watch_club_model as W
import card_identity_probe as P
MANAGER_CARDTYPE = 2 # FUN_1800d8330: cardsubtypeid 4 -> cardtype 2
F_CARDTYPE = 0x4C
F_RESOURCE = 0x18
F_TEAMID = 0x94
F_NATION_MGR = 0xDE
F_LEAGUE_MGR = 0xE0
F_TALKRATING = 0xE2
F_NEGOTIATION = 0xE3
REC_SIZE = 0x158
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d" % (pid, obj, len(ns)))
print()
print("%-10s %-8s %-8s %-8s %-10s %-10s %s"
% ("resource", "teamid", "nation", "league", "talkrating", "negot", "verdict"))
found = 0
for n in ns:
rec = n + 0x28
buf = mem.read(rec, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
if P.u8(buf, F_CARDTYPE) != MANAGER_CARDTYPE:
continue
found += 1
resource = P.u32(buf, F_RESOURCE)
teamid = P.u32(buf, F_TEAMID)
nation = P.u16(buf, F_NATION_MGR)
league = P.u16(buf, F_LEAGUE_MGR)
talk = P.u8(buf, F_TALKRATING)
negot = P.u8(buf, F_NEGOTIATION)
# +0xde and +0xe0 are never written by the merge, so a non-zero value
# can only have come from the server's JSON.
if nation and league:
verdict = "SERVER FIELDS LANDED"
elif nation or league:
verdict = "PARTIAL -- one slot empty"
else:
verdict = "EMPTY -- client kept nothing we sent"
print("%-10d %-8d %-8d %-8d %-10d %-10d %s"
% (resource, teamid, nation, league, talk, negot, verdict))
if not found:
print("(no manager record in the map -- the client has not been served one)")
return 0
if __name__ == "__main__":
sys.exit(main())
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Dump the CLASSIFICATION fields the client stored for every card it holds, so
the subtype->cardtype map and the itemState runtime values are read from the
running game instead of inferred.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
Two things this project has repeatedly had to treat as INFERRED:
1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra
(0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio,
8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480`
branches on cardtype == 7. Serving a subtype whose cardtype we guessed
wrong fails SILENTLY, because cardtype 9 has no arm in the merge.
2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME
values the strings deserialize to (notably activeHomeKit/activeAwayKit ->
101/102) have been carried as inferred.
Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the
derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading
those back for every record turns the pair into measurements.
rec+0x18 resourceId
rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid)
rec+0x50 cardsubtypeid (as sent)
rec+0x5c itemState (decoded enum value)
Usage: python3 record_vocab_probe.py
"""
import collections
import sys
import watch_club_model as W
import card_identity_probe as P
F_RESOURCE = 0x18
F_CARDTYPE = 0x4C
F_SUBTYPE = 0x50
F_ITEMSTATE = 0x5C
REC_SIZE = 0x158
# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud.
EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4,
9: 7, 10: 7, 11: 7}
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
pairs = collections.Counter()
states = collections.Counter()
rows = []
for n in ns:
buf = mem.read(n + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
resource = P.u32(buf, F_RESOURCE)
cardtype = P.u8(buf, F_CARDTYPE)
subtype = P.u8(buf, F_SUBTYPE)
state = P.u8(buf, F_ITEMSTATE)
pairs[(subtype, cardtype)] += 1
states[state] += 1
rows.append((resource, subtype, cardtype, state))
print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState"))
for r in sorted(rows):
print("%-12d %-9d %-9d %d" % r)
print("\n--- MEASURED cardsubtypeid -> cardtype ---")
for (sub, ct), n in sorted(pairs.items()):
want = EXPECTED_CARDTYPE.get(sub)
if want is None:
verdict = "no Ghidra prediction for this subtype"
elif want == ct:
verdict = "agrees with FUN_1800d8330"
else:
verdict = "DISAGREES -- Ghidra said %d" % want
print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict))
print("\n--- MEASURED itemState runtime values ---")
for st, n in sorted(states.items()):
print(" %-5d %d record(s)" % (st, n))
print("\nNOTE: a runtime value only appears here if the client was actually")
print("served an item in that state. Absence is not evidence of absence.")
return 0
if __name__ == "__main__":
sys.exit(main())
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Resolve the runtime string comparator behind `DAT_1802ddfd8 + 0x248`, and
settle whether the `itemState` match is case-sensitive.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`itemState` arrives on the wire as a STRING ("free", "activeHomeKit", ...) and
the client turns it into its runtime enum by comparing that string against its
own table. The compare goes through `FUN_180008190`, whose whole body is:
mov rax, [DAT_1802ddfd8] ; the service object, populated at runtime
mov r9, [rax + 0x248] ; slot 0x248
jmp r9 ; tail-jump
The slot is empty on disk, so `plan-2026-08-06-card-subsystem.md` section 5
recorded the casing question as "almost certainly unresolvable statically" and
listed this as a read-only live probe. It is worth answering: every shaper in
openfut-adapter-fifa17 emits these tokens, and if the comparator folded case then
our table's casing would be a convention rather than a contract.
WHAT IT DOES
------------
Reads the slot in the live process and follows the forwarding chain
(`e9` rel32 thunk -> `ff 25` IAT jump -> body), attributing each hop to a module.
Wine maps PE images as anonymous, so a mapping's own path is usually empty; the
module is recovered from the nearest PRECEDING named mapping, which is the PE
header page.
At the body it decides case sensitivity from the instruction stream rather than
from a symbol name: a case-insensitive comparator MUST fold case, so it carries
an `or ..,0x20` / lowercase-table lookup. A byte compare with no folding is
case-SENSITIVE.
MEASURED 2026-08-21 (pid 6580):
slot -> 0x146d1c020 (thunk) -> 0x145e27fe0 (IAT) -> msvcr120.dll + 0x3c330
body is strncmp: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` (NUL) /
`cmp al,[rcx+rdx]` with NO case folding, plus the MSVC NUL-detect constants
0x8080808080808080 and 0xfefefefefefefeff.
=> the itemState match is CASE-SENSITIVE. Emit the table's exact casing.
Usage: python3 service_ptr_probe.py
"""
import re
import struct
import sys
import watch_club_model as W
DAT_SERVICE = 0x1802DDFD8
SLOT = 0x248
MAX_HOPS = 8
# A case-insensitive comparator has to fold case somewhere. These are the two
# ways MSVC does it; neither appears in a plain strcmp/strncmp/memcmp.
FOLD_OR_IMM8 = b"\x0c\x20" # or al, 0x20
FOLD_OR_EAX = b"\x83\xc8\x20" # or eax, 0x20
def mappings(pid):
out = []
with open("/proc/%d/maps" % pid) as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
if m:
out.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)))
return out
def attribute(maps, va):
"""(module_path, perms, offset_from_module_base) for `va`.
Wine maps PE sections anonymously, so the owning mapping usually has no
path; the module is the nearest preceding NAMED mapping (its header page).
"""
named = None
for start, end, perms, path in maps:
if path:
named = (start, path)
if start <= va < end:
if named:
return named[1], perms, va - named[0]
return path or "[anonymous]", perms, None
return None, None, None
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
maps = mappings(pid)
glob = base + (DAT_SERVICE - W.IMG_BASE)
svc = mem.q(glob)
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
print("DAT_1802ddfd8 @ %#x -> service %#x" % (glob, svc or 0))
if not svc:
print("service pointer is NULL; the host has not handed CardsDLL its table yet.")
return 2
va = mem.q(svc + SLOT)
print("*(service + %#x) = %#x" % (SLOT, va or 0))
if not va:
print("slot %#x is empty." % SLOT)
return 2
print()
body = None
for hop in range(MAX_HOPS):
buf = mem.read(va, 16)
if not buf or len(buf) < 6:
print("hop %d: %#x unreadable" % (hop, va))
return 2
path, perms, off = attribute(maps, va)
where = "%s+%#x" % (path, off) if off is not None else str(path)
print("hop %d: %#x [%s] %s %s" % (hop, va, perms, where, buf[:8].hex()))
if buf[0] == 0xE9: # jmp rel32
va = va + 5 + struct.unpack("<i", buf[1:5])[0]
elif buf[0] == 0xFF and buf[1] == 0x25: # jmp [rip+rel32]
nxt = mem.q(va + 6 + struct.unpack("<i", buf[2:6])[0])
if not nxt:
print(" IAT slot is empty.")
return 2
va = nxt
else:
body = (va, path, off)
print(" -> function body")
break
if body is None:
print("chain did not settle within %d hops." % MAX_HOPS)
return 2
addr, path, off = body
code = mem.read(addr, 256) or b""
folds = FOLD_OR_IMM8 in code or FOLD_OR_EAX in code
print()
print("=" * 70)
print("COMPARATOR: %s+%#x (%#x)" % (path, off if off is not None else 0, addr))
print("case folding in first %d bytes: %s" % (len(code), "YES" if folds else "NO"))
if folds:
print("VERDICT: case-INSENSITIVE. itemState casing is a convention, not a contract.")
else:
print("VERDICT: case-SENSITIVE. A byte compare with no folding means the")
print(" wire token must match the table's casing EXACTLY -- a")
print(" mis-cased token silently resolves to itemState 0 (invalid).")
print(" openfut-adapter-fifa17's fut::item_state table is therefore")
print(" a contract: emit its casing verbatim.")
print("=" * 70)
return 0
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Standalone contract test for Blaze roster-host advertisement."""
import importlib
import os
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
ADVERTISE = "192.0.2.10"
DNS_HOST = "winter15.gosredirector.ea.com:8081"
def assert_roster_config(blaze, host):
config = dict(blaze.OSDK_ROSTER)
assert blaze.ROSTER_HOST == host
assert config["ROSTERUPDATE_URL"] == (
f"https://{host}/fifa17/fut/rosterupdate.xml"
)
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
assert config["ROSTER_VER"] == "0"
assert config["ROSTER_CSUM"] == ""
def main():
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
try:
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ.pop("OPENFUT_ROSTER_HOST", None)
import blaze_responder_v3b as blaze
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
blaze = importlib.reload(blaze)
assert_roster_config(blaze, DNS_HOST)
os.environ["OPENFUT_ROSTER_HOST"] = ""
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
finally:
if old_advertise is None:
os.environ.pop("OPENFUT_ADVERTISE", None)
else:
os.environ["OPENFUT_ADVERTISE"] = old_advertise
if old_roster_host is None:
os.environ.pop("OPENFUT_ROSTER_HOST", None)
else:
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
print("PASS: roster host defaults, override, and URLs")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Enumerate every UTAS URL template CardsDLL can build, from live memory.
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
WHY
---
Support level L5 ("apply endpoint") for consumables was recorded as unreversed,
with an earlier note claiming there is "no training/position/chemistry/
manager-league endpoint at all" and that the only owned-item mutations upstream
are quick sell and move/pile. That claim is load-bearing -- if true, applying a
consumable is not a server route at all and L5/L6 cannot be implemented as one --
so it deserves to be checked against the binary rather than inherited.
This scans CardsDLL's .rdata for route-shaped strings and prints them, so the
full reachable surface can be read at once.
Positive control: known-live routes MUST appear (e.g. a 'item' path and a
'club' path). If the control is empty the region is wrong, not the game.
Usage:
python3 url_template_probe.py # route-shaped strings
python3 url_template_probe.py --all # every printable string >= 6 chars
python3 url_template_probe.py --grep pat # substring filter (case-insensitive)
"""
import argparse
import re
import sys
import watch_club_model as W
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
DATA_LO, DATA_HI = 0x18028A000, 0x1802F0000
# Route-ish: contains a slash and no spaces, or looks like a UTAS path fragment.
ROUTE_HINTS = ("ut/", "game/", "item", "club", "squad", "purchase", "consumable",
"apply", "training", "position", "chemistry", "contract",
"fitness", "healing", "playstyle", "manager", "pile", "delete",
"transfer", "market", "auction", "sbs", "pack", "store")
PRINTABLE = re.compile(rb"[\x20-\x7e]{6,}")
def strings(mem, lo, hi):
buf, bad = mem.read_pages(W_live(lo), hi - lo)
if not buf:
return [], bad
out = []
for m in PRINTABLE.finditer(bytes(buf)):
out.append((lo + m.start(), m.group().decode("ascii")))
return out, bad
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true")
ap.add_argument("--grep")
a = ap.parse_args()
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
global W_live
W_live = lambda i: base + (i - W.IMG_BASE)
print("pid=%d CardsDLL live base %#x" % (pid, base))
found = []
for lo, hi, name in ((RDATA_LO, RDATA_HI, ".rdata"), (DATA_LO, DATA_HI, ".data")):
ss, bad = strings(mem, lo, hi)
print(" %s: %d strings (%d bad pages)" % (name, len(ss), len(bad)))
found.extend(ss)
if a.grep:
pat = a.grep.lower()
sel = [(va, s) for va, s in found if pat in s.lower()]
elif a.all:
sel = found
else:
sel = [(va, s) for va, s in found
if "/" in s and " " not in s
and any(h in s.lower() for h in ROUTE_HINTS)]
print("\n%d matching string(s):" % len(sel))
for va, s in sel:
print(" %#x %s" % (va, s))
ctrl = [s for _, s in found if "ut/game" in s.lower()]
print("\nCONTROL ('ut/game' present): %s (%d)"
% ("OK" if ctrl else "EMPTY -> wrong region", len(ctrl)))
return 0
if __name__ == "__main__":
sys.exit(main())
+137 -1
View File
@@ -38,6 +38,38 @@ pub struct Fifa17CardIdentity {
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
/// staff role), `0` for a player or when absent.
pub subtype: i64,
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
/// the verified `fcc_kitcards.cardassetid` value (`35`).
pub card_asset_id: u32,
/// Source team id for a club kit, or a manager's real club. Zero for content
/// kinds that do not use it.
pub team_id: i64,
/// Manager chemistry nation (`managercards.nation`), zero when unused.
///
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
/// leaves the manager-only record slot `rec+0xde` untouched, so the server is
/// its only source. See `fifa17-recon/tools/fut_staff.py`.
pub nation: i64,
/// Manager chemistry league, zero when unused. Derived upstream through
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
pub league_id: i64,
/// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which
/// Core does not model: an imported consumable's Core `overall` is 0, while
/// the client's own copies carry 55..95 and the value drives the card level
/// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls
/// back to Core's rating, which stays authoritative for players.
pub rating: Option<u8>,
/// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA
/// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the
/// training, healing, fitness, play-style and manager-league families:
/// omitting the key draws "-1" on the card, not "0".
pub amount: Option<i64>,
/// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid`
/// 201/202) — the number of matches the card grants. `fcc_contractcards` has
/// no amount column, so this value comes from observed data; it is never
/// defaulted here.
pub contract: Option<i64>,
}
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
@@ -58,6 +90,29 @@ impl Fifa17WireItemIdPolicy {
pub fn owned_item_base_floor() -> i64 {
Self::OWNED_ITEM_BASE + 1
}
/// Identity scope for MATCH session ids.
///
/// A match id is deliberately NOT drawn from the owned-item scope. The
/// oracle mints both from one counter, which is why an observed match id
/// looks like an item id — but that is an artifact of a single-counter save
/// file, not a client requirement. 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. The store is generic over `(game, kind)`, so a
/// separate scope costs one constant and cannot collide with, or advance,
/// the owned-item watermark.
pub const MATCH_KIND: &'static str = "match";
/// Base for match session ids. Clear of the owned-item range
/// (`100_000_000+`) and of every synthetic overlay range the responder
/// reserves (`≥ 9e8`). The client only requires a non-zero int.
pub const MATCH_BASE: i64 = 200_000_000;
/// First match wire id (`200_000_001`).
pub fn match_base_floor() -> i64 {
Self::MATCH_BASE + 1
}
}
/// Highest representable asset id (24 bits); above this `version` would be
@@ -131,6 +186,27 @@ struct RawCard {
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
#[serde(default)]
subtype: i64,
/// Separate card-art id for non-player definitions; absent → `asset_id`.
#[serde(default)]
card_asset_id: Option<u32>,
/// Source team id for a kit or manager definition; absent → `0`.
#[serde(default)]
team_id: Option<i64>,
/// Manager chemistry nation; absent → `0`.
#[serde(default)]
nation: Option<i64>,
/// Manager chemistry league; absent → `0`.
#[serde(default)]
league_id: Option<i64>,
/// EA-authored rating for a non-player definition; absent → Core's rating.
#[serde(default)]
rating: Option<u8>,
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
#[serde(default)]
amount: Option<i64>,
/// Contract-card grant (atom 0xb8); absent → key omitted.
#[serde(default)]
contract: Option<i64>,
}
fn default_rareflag() -> i64 {
@@ -187,6 +263,13 @@ impl Fifa17CardCatalog {
rareflag: rc.rareflag,
kind: ContentKind::from_str(&rc.kind),
subtype: rc.subtype,
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
team_id: rc.team_id.unwrap_or(0),
nation: rc.nation.unwrap_or(0),
league_id: rc.league_id.unwrap_or(0),
rating: rc.rating,
amount: rc.amount,
contract: rc.contract,
},
);
}
@@ -272,6 +355,52 @@ mod tests {
assert_eq!(cat.lookup("card_missing"), None);
}
/// The non-player definition fields a consumable needs, and the ABSENCE that
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
/// a defaulted `contract` would invent the number of matches a card grants.
#[test]
fn consumable_definition_fields_are_carried_and_never_defaulted() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,
"card_asset_id":3,"rareflag":0,"rating":85,"amount":15},
"fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201,
"card_asset_id":7,"rareflag":0,"rating":60,"contract":7},
"fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91,
"card_asset_id":34,"rareflag":0,"rating":95},
"fifa17_20801":{"asset_id":20801}
}}"#,
)
.unwrap();
// A training card: art id 3 (NOT the carddbid), EA's rating, amount 15.
let training = cat.lookup("fifa17_5003012").unwrap();
assert_eq!(training.kind, ContentKind::Consumable);
assert_eq!(training.subtype, 54);
assert_eq!(training.card_asset_id, 3);
assert_eq!(training.rating, Some(85));
assert_eq!(training.amount, Some(15));
assert_eq!(training.contract, None);
// A contract card takes its number from `contract`, not `amount`.
let contract = cat.lookup("fifa17_5001004").unwrap();
assert_eq!(contract.contract, Some(7));
assert_eq!(contract.amount, None);
// A position modifier needs neither.
let position = cat.lookup("fifa17_5003059").unwrap();
assert_eq!(position.amount, None);
assert_eq!(position.contract, None);
assert_eq!(position.card_asset_id, 34);
// A player carries none of them and keeps Core's authoritative rating.
let player = cat.lookup("fifa17_20801").unwrap();
assert_eq!(player.kind, ContentKind::Player);
assert_eq!(player.rating, None);
assert_eq!(player.amount, None);
assert_eq!(player.contract, None);
assert_eq!(
player.card_asset_id, player.asset_id,
"a player's card art IS its asset id"
);
}
#[test]
fn two_cards_same_resource_is_a_conflict() {
let err = Fifa17CardCatalog::from_json_str(
@@ -397,7 +526,9 @@ mod tests {
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0},
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
"card_asset_id":35,"team_id":21,"rareflag":0}
}}"#,
)
.unwrap();
@@ -407,5 +538,10 @@ mod tests {
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
let kit = cat.lookup("fifa17_6300006").unwrap();
assert_eq!(kit.kind, ContentKind::Kit);
assert_eq!(kit.subtype, 9);
assert_eq!(kit.card_asset_id, 35);
assert_eq!(kit.team_id, 21);
}
}
+301 -20
View File
@@ -11,33 +11,103 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::shape_item;
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
pub use crate::fut::item::{
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
};
/// Shape the whole `/club` response. Items without a resolvable real asset id
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
/// Active club-level kit roles, keyed by Core owned-instance id.
#[derive(Debug, Clone, Copy, Default)]
pub struct ActiveKitAssignments<'a> {
pub home: Option<&'a str>,
pub away: Option<&'a str>,
}
/// Shape the player portion of `/club` (the historical/default query).
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
) -> (Value, ShapeStats) {
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
}
/// Shape `/club` items, including ownership-backed active kit designations.
///
/// This envelope carries the two families whose record shape it can carry:
/// players and kits, plus the staff family (manager + the four coach families).
/// Consumables have their own route and their own STACK envelope, and the
/// club-customisation families are counted and withheld — see each arm.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
active_kits: ActiveKitAssignments<'_>,
) -> (Value, ShapeStats) {
let mut out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default();
for item in items {
// Exclude non-player content (consumables/staff): a `/club` player list
// must never render them as 0-rated players. Counted, never emitted.
if ident.kind_of(item) != ContentKind::Player {
stats.excluded_non_player += 1;
continue;
}
match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent, ident.discard_value(item)));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Kit, badge and stadium are ONE cardtype-7 record with one
// client-side resolver; only the equipped designation differs.
ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => {
match ident.resolve_kit(item) {
Some(id) => {
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_AWAY_KIT
} else {
item_state::FREE
};
out.push(shape_club_item(id, state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
}
// A manager is a staff card: both Core kinds resolve through the one
// staff record shape, discriminated on the wire by `cardsubtypeid`
// (the same set as `ContentKind::is_staff_family`, spelled out here
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Consumables have their OWN route and their own envelope:
// `GET club/consumables/<category>`, whose element is a stack
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
// consumable item in THIS envelope is accepted by the client and
// silently discarded, so emitting one here would be a 200 that does
// nothing — the worst failure shape in this project. Counted.
ContentKind::Consumable => {
stats.excluded_non_player += 1;
}
// The cardtype-9 families. Unlike kits/badges/stadia these have NO
// database name resolver at all, so the displayed name can only come
// from `localizedName` on the wire. That offset is confirmed
// (`+0xd9`), but "the parser reads it" is NOT "sending it is safe",
// and this project pays for that distinction with a client freeze.
// Counted and withheld rather than guessed: ownership stays
// authoritative in Core either way, and club/stats still counts the
// families so the screen's own numbers are right.
ContentKind::Ball | ContentKind::Misc => {
stats.excluded_non_player += 1;
}
None => stats.dropped_no_asset += 1,
}
}
(json!({ "itemData": out }), stats)
@@ -207,11 +277,19 @@ mod tests {
struct KindMapIdentity {
ids: HashMap<String, Fifa17Identity>,
kinds: HashMap<String, ContentKind>,
kits: HashMap<String, Fifa17KitIdentity>,
staff: HashMap<String, Fifa17StaffIdentity>,
}
impl ItemIdentityResolver for KindMapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.ids.get(&it.card_id).copied()
}
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
self.kits.get(&it.card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.staff.get(&it.card_id).copied()
}
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
self.kinds
.get(&it.card_id)
@@ -221,7 +299,7 @@ mod tests {
}
#[test]
fn consumable_and_staff_are_excluded_from_club_players() {
fn consumables_are_excluded_but_staff_is_shaped() {
let ent = entities();
let id = |item_id: u32, asset: u32| Fifa17Identity {
item_id,
@@ -233,8 +311,19 @@ mod tests {
ids: HashMap::from([
("card_player".to_string(), id(100000001, 20801)),
("card_consumable".to_string(), id(100000002, 5003012)),
("card_staff".to_string(), id(100000003, 3000083)),
]),
kits: HashMap::new(),
staff: HashMap::from([(
"card_staff".to_string(),
Fifa17StaffIdentity {
item_id: 100000003,
resource_id: 3000083,
subtype: 8,
nation: 0,
league_id: 0,
team_id: 0,
},
)]),
kinds: HashMap::from([
("card_consumable".to_string(), ContentKind::Consumable),
("card_staff".to_string(), ContentKind::Staff),
@@ -254,12 +343,204 @@ mod tests {
item("oc3", "card_staff", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1, "only the player is emitted");
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
assert_eq!(
stats.emitted, 2,
"the player and the staff card are emitted"
);
assert_eq!(
stats.excluded_non_player, 1,
"only the consumable is excluded; staff has a wire envelope of its own"
);
assert_eq!(stats.dropped_no_asset, 0);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], 100000001, "the player survives");
assert_eq!(arr[0]["itemType"], "player");
let coach = &arr[1];
assert_eq!(coach["id"], 100000003);
assert_eq!(coach["resourceId"], 3000083);
assert_eq!(coach["cardsubtypeid"], 8);
assert_eq!(coach["itemType"], "staff");
assert_eq!(coach["contract"], STAFF_CONTRACT);
assert!(
coach.get("nation").is_none()
&& coach.get("leagueId").is_none()
&& coach.get("teamid").is_none(),
"a COACH has no nation/league/team column in the client's tables, so \
those keys must be absent rather than invented as zeroes"
);
assert!(
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
"both survive the client's merge and are read by the card view-model"
);
}
#[test]
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
let ent = entities();
let ident = KindMapIdentity {
ids: HashMap::new(),
kits: HashMap::new(),
staff: HashMap::from([(
"card_manager".to_string(),
Fifa17StaffIdentity {
item_id: 100004871,
resource_id: 1000509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
};
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
let mgr = &body["itemData"][0];
assert_eq!(
mgr["cardsubtypeid"], 4,
"subtype alone selects managercards"
);
assert_eq!(
mgr["resourceId"], 1000509,
"the merge key is read RAW: it must equal the carddbid with no version byte"
);
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
// omission here is an unrecoverable blank flag and zero chemistry.
assert_eq!(mgr["nation"], 45);
assert_eq!(mgr["leagueId"], 53);
assert_eq!(mgr["teamid"], 241);
assert_eq!(mgr["contract"], STAFF_CONTRACT);
assert_eq!(mgr["itemState"], "free");
assert_eq!(mgr["owners"], 1);
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
11,
"exactly the 11 justified keys, no more: {keys:?}"
);
}
#[test]
fn kits_project_with_owned_active_home_and_away_states() {
let ent = entities();
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
item_id,
asset_id: resource_id,
resource_id,
card_asset_id: 35,
subtype: 9,
team_id,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
staff: HashMap::new(),
kits: HashMap::from([
("kit-home".into(), kit(100000010, 6300006, 21)),
("kit-away".into(), kit(100000011, 6400003, 21)),
]),
kinds: HashMap::from([
("kit-home".into(), ContentKind::Kit),
("kit-away".into(), ContentKind::Kit),
]),
};
let items = vec![
item("owned-home", "kit-home", 0, "", "", "", ""),
item("owned-away", "kit-away", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: Some("owned-home"),
away: Some("owned-away"),
},
);
assert_eq!(stats.emitted, 2);
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
assert_eq!(body["itemData"][0]["cardassetid"], 35);
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
assert_eq!(body["itemData"][0]["teamid"], 21);
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
assert!(body["itemData"][0].get("attributeList").is_none());
assert!(body["itemData"][0].get("itemType").is_none());
}
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
/// Ball and league logo are cardtype 9, have no database name resolver, and
/// stay withheld until `localizedName` is established as safe to send.
/// Counting a family in club/stats while never shaping it is the divergence
/// this test pins: the wire set and the withheld set are both asserted.
#[test]
fn cardtype7_club_items_project_and_cardtype9_stay_withheld() {
let ent = entities();
let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity {
item_id,
asset_id: resource,
resource_id: resource,
card_asset_id: art,
subtype,
team_id: 21,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
kinds: HashMap::from([
("c_kit".to_string(), ContentKind::Kit),
("c_badge".to_string(), ContentKind::Badge),
("c_stadium".to_string(), ContentKind::Stadium),
("c_ball".to_string(), ContentKind::Ball),
("c_logo".to_string(), ContentKind::Misc),
]),
kits: HashMap::from([
("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)),
("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)),
("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)),
// Resolvable on purpose: withholding must be a decision about the
// FAMILY, not an accident of a missing identity.
("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)),
("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)),
]),
staff: HashMap::new(),
};
let items: Vec<CoreOwnedItem> = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"]
.iter()
.map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", ""))
.collect();
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: None,
away: None,
},
);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(stats.emitted, 3, "kit + badge + stadium");
assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld");
assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop");
let subtypes: Vec<i64> = arr
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
assert_eq!(subtypes, vec![9, 11, 10]);
// teamid only where the caption resolves TeamName_Abbr15_<teamid>.
assert_eq!(arr[0]["teamid"], 21, "kit");
assert_eq!(arr[1]["teamid"], 21, "badge");
assert!(
arr[2].get("teamid").is_none(),
"stadium caption reads assetId"
);
for it in arr {
assert!(
item_state::is_recovered(it["itemState"].as_str().unwrap()),
"every emitted state must be a recovered token"
);
}
}
}
+219 -14
View File
@@ -6,7 +6,8 @@
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
//! type,typeValue}, …]}`:
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
//! staff-by-family, consumables-by-family, and honest zeros for club items;
//! staff/consumable families, owned-kit count, and honest zeros for other
//! club-item families;
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
//!
@@ -23,14 +24,18 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
/// One owned item, already classified from the catalog + entity tables by the
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/
/// `league_id`/`team_id` from the reverse entity resolver for players and from
/// the kit table for kits (None = unresolved, bucket skipped).
#[derive(Debug, Clone)]
pub struct ClubStatInput {
pub kind: ContentKind,
pub subtype: i64,
pub rating: i64,
pub rare: bool,
/// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family
/// discriminator, which is what splits the home/away kit counters.
pub asset_id: i64,
pub nation_id: Option<i64>,
pub league_id: Option<i64>,
pub team_id: Option<i64>,
@@ -57,7 +62,26 @@ const S_RARE: i64 = 0x05;
const S_STAFF: i64 = 0x0A;
const S_CONSUMABLES: i64 = 0x3C;
const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D;
const S_STADIA: i64 = 0x14;
const S_BALLS: i64 = 0x1E;
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
/// `assetid` column agrees exactly: across all 1482 rows, assetid 14 covers
/// precisely the 828 `63xxxxx` ids and assetid 15 precisely the 654 `64xxxxx`
/// ids, with no exceptions either way. A kit's catalog `asset_id` IS its
/// carddbid, so the id itself is the family key -- the `assetid` column is not
/// carried on the wire and would be a second source of truth for the same fact.
const KIT_AWAY_FLOOR: i64 = 6_400_000;
/// Which kit family an owned kit belongs to. Only meaningful for
/// [`ContentKind::Kit`]; the caller filters first.
fn is_home_kit(kit: &ClubStatInput) -> bool {
kit.asset_id < KIT_AWAY_FLOOR
}
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
fn staff_stat(subtype: i64) -> Option<i64> {
@@ -184,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
g.insert(sid, 0);
}
let mut staff_total = 0i64;
for it in items
.iter()
.filter(|i| matches!(i.kind, ContentKind::Staff))
{
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
// it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
// alone — a `manager`-classified row would otherwise vanish from the panel.
for it in items.iter().filter(|i| i.kind.is_staff_family()) {
if let Some(sid) = staff_stat(it.subtype) {
*g.get_mut(&sid).unwrap() += 1;
staff_total += 1;
@@ -215,12 +239,31 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// club items: honest zeros (Core holds none; each is read by some panel).
for sid in [
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
// family's items until club/stats reports a non-zero count for it (proven by
// the consumables round, where two rounds of item work sat unrequested
// because this panel answered zero). They are plain ints read by the same
// getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
// family Core can own is counted here — including the ones whose ITEM record
// shape is still withheld, because a count cannot desync a parser and a zero
// guarantees the family is never even asked about. Unowned families stay
// honest zeros.
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
g.entry(sid).or_insert(0);
}
let count_kind =
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
g.insert(S_BALLS, count_kind(ContentKind::Ball));
g.insert(S_BADGES, count_kind(ContentKind::Badge));
let kits: Vec<&ClubStatInput> = items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
.collect();
let home = kits.iter().filter(|kit| is_home_kit(kit)).count() as i64;
g.insert(S_KITS, kits.len() as i64);
g.insert(S_KITS_HOME, home);
g.insert(S_KITS_AWAY, kits.len() as i64 - home);
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
@@ -238,10 +281,30 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
by_ctx.entry(id).or_default().push(p);
}
}
// A kit belongs to the team that wears it and has no nation/league of its
// own, so it only buckets on the team screen -- and it buckets there even if
// the club owns no player from that team, which is the normal case for a kit
// won from a pack.
let mut kits_by_team: BTreeMap<i64, i64> = BTreeMap::new();
if ctx == ContextField::Team {
for kit in &kits {
if let Some(id) = kit.team_id {
*kits_by_team.entry(id).or_insert(0) += 1;
by_ctx.entry(id).or_default();
}
}
}
for (cid, sel) in &by_ctx {
if ctx == ContextField::Team {
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
stat.push(row(3, *cid, S_KITS, 0));
stat.push(row(
3,
*cid,
S_KITS,
kits_by_team.get(cid).copied().unwrap_or(0),
));
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
} else {
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
@@ -270,6 +333,7 @@ mod tests {
subtype: 0,
rating,
rare,
asset_id: 158023,
nation_id: nation,
league_id: None,
team_id: None,
@@ -281,6 +345,7 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
@@ -292,11 +357,34 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
}
}
/// A kit worn by `team`. `carddbid` is the real `fcc_kitcards` id, which is
/// also the catalog `asset_id` and therefore the home/away family key.
fn kit_of(carddbid: i64, team: i64) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Kit,
subtype: 9,
rating: 0,
rare: false,
asset_id: carddbid,
nation_id: None,
league_id: None,
team_id: Some(team),
}
}
/// Real team-21 kits from `fcc_kitcards`: 6300006 is its home kit and
/// 6400003 its away kit.
const HOME_KIT: i64 = 6_300_006;
const AWAY_KIT: i64 = 6_400_003;
fn kit() -> ClubStatInput {
kit_of(HOME_KIT, 21)
}
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
body["stat"]
@@ -338,6 +426,59 @@ mod tests {
assert_eq!(g["staffManager"], 0);
}
/// A row Core classifies as `manager` must still land in the STAFF bucket and
/// in `staffManager`: the client's own model counts a manager inside its staff
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
/// same card.
#[test]
fn a_manager_counts_inside_staff_under_either_kind_token() {
for kind in [ContentKind::Manager, ContentKind::Staff] {
let mut manager = staff(4);
manager.kind = kind;
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
assert_eq!(
g["staff"],
2,
"the manager is INSIDE the staff total (kind={})",
kind.as_str()
);
assert_eq!(g["staffFitnessCoach"], 1);
assert_eq!(g["players"], 0, "a manager is not a player");
}
}
/// The count is the GATE: the client will not ask for a family's items until
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
/// must be counted even while its item record is withheld.
#[test]
fn owned_club_items_are_counted_per_family() {
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
kind,
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
};
let items = vec![
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Ball, 30),
club_item(ContentKind::Stadium, 10),
kit(),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["badges"], 2);
assert_eq!(g["balls"], 1);
assert_eq!(g["stadia"], 1);
assert_eq!(g["kits"], 1);
assert_eq!(g["players"], 0, "no club item is ever a player");
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
}
#[test]
fn consumables_by_family() {
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
@@ -355,6 +496,64 @@ mod tests {
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
}
#[test]
fn owned_kits_increment_global_kit_count() {
let items = vec![player(90, false, None), kit(), kit()];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["players"], 1);
assert_eq!(g["kits"], 2);
}
/// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the
/// same total/subset shape as players/playersGold and staff/staffManager.
#[test]
fn kit_counts_split_by_home_and_away_family() {
let items = vec![
kit_of(HOME_KIT, 21),
kit_of(6_300_010, 38),
kit_of(AWAY_KIT, 21),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["kits"], 3);
assert_eq!(g["kitsHome"], 2);
assert_eq!(g["kitsAway"], 1);
}
/// A kit buckets onto the team that wears it -- including a team the club
/// owns no player from, which is the normal case for a kit won from a pack.
#[test]
fn kits_bucket_onto_their_own_team_on_the_team_screen() {
let mut with_team = player(90, false, None);
with_team.team_id = Some(21);
let items = vec![
with_team,
kit_of(HOME_KIT, 21),
kit_of(AWAY_KIT, 21),
kit_of(6_300_010, 38),
];
let body = club_stats_body(&items, ContextField::Team);
let kits_for = |team: i64| {
body["stat"]
.as_array()
.unwrap()
.iter()
.find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits")
.map(|r| r["typeValue"].as_i64().unwrap())
};
assert_eq!(kits_for(21), Some(2));
// Team 38 has no players, so only the kit creates its bucket.
assert_eq!(kits_for(38), Some(1));
// A nation/league screen has no team context, so kits stay out of it.
let nation = club_stats_body(&items, ContextField::Nation);
assert!(nation["stat"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["contextId"] == 3 && r["type"] == "kits")
.all(|r| r["typeValue"] == 0));
}
#[test]
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
let items = vec![
@@ -379,7 +578,10 @@ mod tests {
#[test]
fn honest_zero_club_items_present() {
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
let g = global(&club_stats_body(
&[player(90, false, None)],
ContextField::Nation,
));
for atom in [
"stadia",
"balls",
@@ -411,7 +613,10 @@ mod tests {
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
.collect();
assert_eq!(league_rows.len(), 6);
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
let gold = league_rows
.iter()
.find(|r| r["type"] == "playersGold")
.unwrap();
assert_eq!(gold["typeValue"], 1);
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
@@ -0,0 +1,230 @@
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
//!
//! Consumables are NOT a `club?type=` family. A previous round shipped four
//! `?type=` arms for them and the screen stayed empty, because the client asks
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
//! this route is the door. Before that was known, the path fell through the
//! generic `/club` PREFIX and the consumables screen was answered with the club's
//! player list.
//!
//! ## The element is a STACK WRAPPER, not an item
//!
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
//! nothing — the client's card map afterwards held only the squad, and the screen
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
//! atom carries the item:
//!
//! | atom | key | |
//! |---|---|---|
//! | 0xbc | `count` | copies in the stack |
//! | 0xd7 | `discardValue` | |
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
//! | 0x287 | `resourceId` | the stack's identity |
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
//!
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
//! item was silently discarded. It is also why FUT draws consumables as one card
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
//! `resourceId` here.
use serde_json::{json, Value};
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// Build the consumables-screen body from the club's owned consumable copies.
///
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack.
///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
/// live-proven oracle sends on this route. Inventing a price from the player
/// quick-sell table would be a fabricated number the client does not need.
///
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
/// later item operation on the stack therefore addresses a card the club really
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
/// question, not a projection one, and is not decided here.)
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
let mut stats = ShapeStats::default();
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
// second sort, and a club holds tens of stacks, not thousands.
let mut order: Vec<u32> = Vec::new();
let mut stacks: Vec<Value> = Vec::new();
for id in items {
if !id.is_renderable() {
stats.dropped_incomplete += 1;
continue;
}
stats.emitted += 1;
match order.iter().position(|r| *r == id.resource_id) {
Some(i) => {
let stack = stacks[i].as_object_mut().expect("stack is an object");
let count = stack["count"].as_i64().unwrap_or(0) + 1;
stack["count"] = json!(count);
if id.untradeable {
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
stack["untradeableCount"] = json!(untradeable);
}
}
None => {
order.push(id.resource_id);
stacks.push(json!({
"count": 1,
"discardValue": 0,
"item": shape_consumable_item(*id),
"resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable),
}));
}
}
}
(json!({ "itemData": stacks }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
/// A play-style card (category 9): `amount` mandatory, art id 50.
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 50,
subtype: 258,
rareflag: 0,
rating: 95,
amount: Some(2),
contract: None,
untradeable: true,
}
}
#[test]
fn identical_copies_collapse_into_one_counted_stack() {
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
let items = vec![
playstyle(100000293, 5_003_103),
playstyle(100000326, 5_003_112),
playstyle(100000294, 5_003_103),
];
let (body, stats) = consumables_response(&items);
assert_eq!(stats.emitted, 3, "every copy is accounted for");
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
assert_eq!(stacks[0]["resourceId"], 5_003_103);
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["untradeableCount"], 2);
assert_eq!(stacks[1]["resourceId"], 5_003_112);
assert_eq!(stacks[1]["count"], 1);
// The five wrapper atoms and nothing else: anything extra falls to the
// value-SKIP handler and only misleads the next reader.
let mut keys: Vec<&str> = stacks[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"count",
"discardValue",
"item",
"resourceId",
"untradeableCount"
]
);
// The item rides inside the wrapper, not beside it.
assert_eq!(stacks[0]["item"]["id"], 100000293);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
}
#[test]
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
// The client's UI flag is `untradeableCount < count`, so the two numbers
// must be counted over the same copies.
let mut tradeable = playstyle(100000295, 5_003_103);
tradeable.untradeable = false;
let items = vec![playstyle(100000293, 5_003_103), tradeable];
let (body, _) = consumables_response(&items);
let stack = &body["itemData"][0];
assert_eq!(stack["count"], 2);
assert_eq!(stack["untradeableCount"], 1);
}
#[test]
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
// (a) a play style with no `amount` would draw "-1" on the card;
let mut no_amount = playstyle(100000293, 5_003_103);
no_amount.amount = None;
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
let trap = Fifa17ConsumableIdentity {
item_id: 100000300,
resource_id: 5_002_030,
asset_id: 5_002_030,
card_asset_id: 9,
subtype: 219,
rareflag: 1,
rating: 70,
amount: Some(10),
contract: None,
untradeable: true,
};
// (c) a subtype in no documented range renders as a plausible Squad
// Training (Pace) card with amount 0.
let mut dead_zone = playstyle(100000301, 5_003_999);
dead_zone.subtype = 137;
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
// the asset id and the client would draw the notfound.swf green box.
let mut no_art = playstyle(100000302, 5_003_104);
no_art.card_asset_id = no_art.asset_id;
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_incomplete, 4);
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
}
#[test]
fn a_contract_card_carries_contract_and_no_amount() {
let contract = Fifa17ConsumableIdentity {
item_id: 100000294,
resource_id: 5_001_004,
asset_id: 5_001_004,
card_asset_id: 7,
subtype: 201,
rareflag: 0,
rating: 60,
amount: None,
contract: Some(7),
untradeable: true,
};
let (body, stats) = consumables_response(&[contract]);
assert_eq!(stats.emitted, 1);
let item = &body["itemData"][0]["item"];
assert_eq!(item["contract"], 7);
assert!(
item.get("amount").is_none(),
"categories 2 and 3 ignore `amount` entirely"
);
}
#[test]
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
let (body, stats) = consumables_response(&[]);
assert_eq!(stats.emitted, 0);
assert!(body["itemData"].as_array().unwrap().is_empty());
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
}
}
@@ -8,8 +8,8 @@
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
//! `fcc_*.json` tables).
//! `FUN_1801bfac0`) and `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md`
//! (verified against the `.105` `fcc_*.json` tables).
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
//!
@@ -18,15 +18,33 @@
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
//! never fabricating a family.
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
/// The disjoint content classes a FIFA 17 owned item can belong to. Player is
/// the default so a catalog authored before this taxonomy existed (no `kind`
/// field) still classifies every entry as a player, unchanged.
///
/// The token set is OpenFUT Core's game-independent content vocabulary
/// (`player | manager | staff | consumable | kit | badge | ball | stadium |
/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class
/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges)
/// never leak out of this crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContentKind {
#[default]
Player,
Consumable,
/// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of
/// the STAFF family, never a class of its own: see
/// [`ContentKind::is_staff_family`]. The wire discriminator is
/// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager
/// as either `manager` or `staff` + subtype 4 and every consumer here
/// treats the two encodings identically.
Manager,
Staff,
Consumable,
Kit,
Badge,
Ball,
Stadium,
Misc,
}
impl ContentKind {
@@ -34,8 +52,14 @@ impl ContentKind {
pub fn as_str(&self) -> &'static str {
match self {
ContentKind::Player => "player",
ContentKind::Consumable => "consumable",
ContentKind::Manager => "manager",
ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit",
ContentKind::Badge => "badge",
ContentKind::Ball => "ball",
ContentKind::Stadium => "stadium",
ContentKind::Misc => "misc",
}
}
@@ -47,11 +71,62 @@ impl ContentKind {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> ContentKind {
match s {
"consumable" => ContentKind::Consumable,
"manager" => ContentKind::Manager,
"staff" => ContentKind::Staff,
"consumable" => ContentKind::Consumable,
"kit" => ContentKind::Kit,
"badge" => ContentKind::Badge,
"ball" => ContentKind::Ball,
"stadium" => ContentKind::Stadium,
"misc" => ContentKind::Misc,
_ => ContentKind::Player,
}
}
/// True for the two kinds that make up the FIFA 17 STAFF family.
///
/// A manager IS a staff card: the client's own club-stats model counts it
/// inside the `staff` total with `staffManager` as a bucket within it, its
/// STAFF tab asks for the whole family with `type=manager`, and one record
/// shape ([`crate::fut::item::shape_staff_item`]) serves all five families.
/// Every staff consumer MUST use this predicate rather than matching
/// `Staff` alone, or a `manager`-classified row silently leaves the staff
/// bucket and the STAFF tab.
pub fn is_staff_family(&self) -> bool {
matches!(self, ContentKind::Manager | ContentKind::Staff)
}
/// True for the three club-customisation kinds that share the **cardtype-7**
/// record: kit (9), stadium (10) and badge (11).
///
/// `FUN_1800d8330` maps all three subtypes to cardtype 7, and one client-side
/// resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`) captions all
/// three. They therefore share ONE wire record
/// ([`crate::fut::item::shape_club_item`]) and one identity resolver.
///
/// Ball (30) and league logo (31) are cardtype 9 and are deliberately NOT in
/// this family. They are not merely unproven — they are UNNAMEABLE, measured
/// against the running client on 2026-08-21
/// (`fifa17-recon/tools/cardtype_dispatch_probe.py`):
///
/// * the merge switch's jump table (rva `0x141eb4`, indexed `cardtype - 1`)
/// sends cardtypes 6/7/8/9 to a shared tail that runs no query and writes
/// no name;
/// * `cmp [reg+0x4c], 9` occurs ZERO times in `.text`;
/// * `cmp [reg+0x50], 30` and `… , 31` occur ZERO times, while kit 9,
/// stadium 10 and badge 11 all appear (the positive control);
/// * the cardtype-7 resolver is gated `cmp [rax+0x4c], 7`, so a cardtype-9
/// item can never reach it.
///
/// So no `localizedName` we send could become a caption: nothing reads one
/// for these subtypes. Serving them would draw unnamed cards, and no
/// server-side change can fix that.
pub fn is_cardtype7_club_item(&self) -> bool {
matches!(
self,
ContentKind::Kit | ContentKind::Stadium | ContentKind::Badge
)
}
}
/// The functional family + honest display label for a consumable `cardsubtypeid`,
@@ -80,6 +155,12 @@ pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
Some(pair)
}
/// `cardsubtypeid` of a MANAGER staff card. This value alone selects the
/// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 →
/// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach
/// families inside [`ContentKind::Staff`].
pub const MANAGER_SUBTYPE: i64 = 4;
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
/// family selector.
@@ -95,6 +176,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
Some(pair)
}
/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`].
///
/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs`
/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently
/// confirmed by the real profile import, where `amount` is present on exactly the
/// training/healing/fitness/play-style/league families and `contract` on exactly
/// the two contract families.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsumableNeeds {
/// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is
/// MANDATORY: the parser initialises its temp to -1 and both accessors read
/// it SIGNED, so omitting the key draws "-1" on the card, not "0".
Amount,
/// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants;
/// the two contract families IGNORE `amount` entirely.
Contract,
/// Nothing beyond the common key set — the card's whole meaning comes from
/// `cardsubtypeid` (formation and position modifiers).
None,
}
/// Which extra key a consumable family requires. An unknown family name is
/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`],
/// so an unknown one cannot arrive from the wire.
pub fn consumable_needs(family: &str) -> ConsumableNeeds {
match family {
"gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness"
| "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount,
"player_contract" | "manager_contract" => ConsumableNeeds::Contract,
_ => ConsumableNeeds::None,
}
}
/// The consumable families one `GET club/consumables/<category>` segment asks
/// for, or `None` for a segment outside the client's own group table.
///
/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a
/// previous round shipped four `?type=` arms for them and the screen stayed
/// empty, because the client asks here (and only once
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
/// and this route is the door).
///
/// The segment names are the consumable UI group table at `0x180203260` (seven
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
/// on the wire and the singular `contract` is accepted because the client has
/// used both spellings. Segments are matched lower-cased.
///
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
/// correspondence is checkable against the panel: training→42, contracts→13,
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
/// NO group code, so no segment can reach them — that is the client's own gap,
/// not an omission here.
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
Some(match segment {
"training" => &["gk_training", "player_training"],
"contracts" | "contract" => &["player_contract", "manager_contract"],
"fitness" => &["player_fitness", "squad_fitness"],
"healing" => &["healing"],
"position" => &["position_mod"],
"playstyle" => &["player_playstyle", "gk_playstyle"],
"managerleaguemodifier" => &["manager_league"],
_ => return None,
})
}
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
///
/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through
/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live
/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the
/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent
/// lines agree on kit = 9, including the deserializer's own `cardassetid` default
/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried
/// by all 1482 rows of `fcc_kitcards`.
///
/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table
/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`)
/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as
/// one swaps badge and kit and loses stadium.
pub const KIT_SUBTYPE: i64 = 9;
pub const STADIUM_SUBTYPE: i64 = 10;
pub const BADGE_SUBTYPE: i64 = 11;
pub const BALL_SUBTYPE: i64 = 30;
pub const LEAGUE_LOGO_SUBTYPE: i64 = 31;
/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a
/// subtype outside the settled set above. A league logo has no Core kind of its
/// own (it is not ownable club content in Core's vocabulary), so subtype 31
/// deliberately maps to `None` rather than being folded into `Misc`.
pub fn club_item_kind(subtype: i64) -> Option<ContentKind> {
let kind = match subtype {
KIT_SUBTYPE => ContentKind::Kit,
STADIUM_SUBTYPE => ContentKind::Stadium,
BADGE_SUBTYPE => ContentKind::Badge,
BALL_SUBTYPE => ContentKind::Ball,
_ => return None,
};
Some(kind)
}
/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder|
/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values
/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`,
/// so a position tab arrives as one of those three tokens with no other filter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionGroup {
Defender,
Midfielder,
Forward,
}
/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos`
/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0`
/// that it emits as the transfer-market `&pos=%s` parameter:
/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21
/// LF=22 RW=23 ST=25 LW=27`.
///
/// `None` = a label outside that table (never guessed): the item then belongs to
/// no position tab rather than to an invented one.
pub fn position_id(pos: &str) -> Option<i64> {
let id = match pos {
"GK" => 0,
"RWB" => 2,
"RB" => 3,
"CB" => 5,
"LB" => 7,
"LWB" => 8,
"CDM" => 10,
"RM" => 12,
"CM" => 14,
"LM" => 16,
"CAM" => 18,
"RF" => 20,
"CF" => 21,
"LF" => 22,
"RW" => 23,
"ST" => 25,
"LW" => 27,
_ => return None,
};
Some(id)
}
/// Which position tab a FUT position label belongs to, or `None` for a label
/// outside the client's own `pos` table.
///
/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c`
/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`,
/// `20..=27 → ATT`.
///
/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has
/// exactly three position tabs and no fourth, so a keeper must land in one of
/// them or vanish from every drill-down. Falsifier: if the DEF tab renders
/// without goalkeepers, move GK out (the group boundary becomes `1..=8`).
pub fn position_group(pos: &str) -> Option<PositionGroup> {
match position_id(pos)? {
0..=8 => Some(PositionGroup::Defender),
9..=19 => Some(PositionGroup::Midfielder),
20..=27 => Some(PositionGroup::Forward),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -102,19 +349,171 @@ mod tests {
#[test]
fn content_kind_round_trips_and_defaults_to_player() {
assert_eq!(ContentKind::default(), ContentKind::Player);
for k in [
// The FULL Core content vocabulary, every token round-tripping.
let all = [
ContentKind::Player,
ContentKind::Consumable,
ContentKind::Manager,
ContentKind::Staff,
] {
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
];
for k in all {
assert_eq!(ContentKind::from_str(k.as_str()), k);
}
let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect();
assert_eq!(
tokens,
vec![
"player",
"manager",
"staff",
"consumable",
"kit",
"badge",
"ball",
"stadium",
"misc"
],
"these exact strings are the cross-crate contract with Core"
);
// Unknown / absent tokens fall back to Player (backward compatible).
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
}
#[test]
fn only_manager_and_staff_are_the_staff_family() {
for k in [ContentKind::Manager, ContentKind::Staff] {
assert!(k.is_staff_family(), "{} is a staff card", k.as_str());
}
for k in [
ContentKind::Player,
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
] {
assert!(!k.is_staff_family(), "{} is not staff", k.as_str());
}
}
#[test]
fn every_consumable_family_needs_exactly_what_the_client_reads() {
// Grouped from data/consumables.json's per-subtype `needs`, and observed
// key-for-key in the real profile import.
for f in [
"gk_training",
"player_training",
"healing",
"player_fitness",
"squad_fitness",
"player_playstyle",
"gk_playstyle",
"manager_league",
] {
assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}");
}
for f in ["player_contract", "manager_contract"] {
assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}");
}
for f in ["manager_formation_mod", "formation_mod", "position_mod"] {
assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}");
}
}
#[test]
fn consumable_route_categories_partition_the_reachable_families() {
// The seven group codes, plus the singular `contract` spelling.
let segments = [
"training",
"contracts",
"fitness",
"healing",
"position",
"playstyle",
"managerleaguemodifier",
];
let mut seen: Vec<&str> = Vec::new();
for seg in segments {
for f in consumable_families_for_category(seg).unwrap() {
assert!(!seen.contains(f), "{f} claimed by two categories");
seen.push(f);
}
}
assert_eq!(
consumable_families_for_category("contract"),
consumable_families_for_category("contracts"),
"both spellings the client has used mean the same set"
);
// Eleven of the thirteen families are reachable; the two formation
// modifiers have no group code in the client's own table.
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
let (family, _) = consumable_family(subtype).unwrap();
assert!(seen.contains(&family), "no category serves {family}");
}
for unreachable in [71, 121] {
let (family, _) = consumable_family(unreachable).unwrap();
assert!(
!seen.contains(&family),
"{family} has no group code; claiming it would invent a segment"
);
}
// Not a consumables segment (and NOT a `?type=` token either).
for s in ["", "player", "kit", "Training", "development"] {
assert!(
consumable_families_for_category(s).is_none(),
"{s:?} is not a consumable category"
);
}
}
#[test]
fn club_item_subtypes_are_the_settled_five() {
assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit));
assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium));
assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge));
assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball));
assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11));
assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31));
// A league logo is not ownable Core content, so it maps to no kind.
assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None);
// Trophies (0x91..0x96) are NOT club items, and staff/consumable
// subtypes must never be mistaken for one.
for s in [0, 4, 8, 0x91, 0x96, 201, 231] {
assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item");
}
}
#[test]
fn position_groups_follow_the_clients_own_ladder() {
// Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27
// recompute. GK folded into DEF is the one named guess.
for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] {
assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}");
}
for p in ["CDM", "CM", "CAM", "LM", "RM"] {
assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}");
}
for p in ["RF", "CF", "LF", "RW", "ST", "LW"] {
assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}");
}
assert_eq!(position_id("ST"), Some(25));
assert_eq!(position_id("CDM"), Some(10));
// Not in the client's table → no tab, never an invented one.
for p in ["", "SW", "st", "MID", "SUB"] {
assert_eq!(position_group(p), None, "{p:?}");
assert_eq!(position_id(p), None, "{p:?}");
}
}
#[test]
fn consumable_family_range_boundaries() {
// Each contiguous range: lower boundary, upper boundary, family + label.
+380
View File
@@ -0,0 +1,380 @@
//! FIFA 17 **discard (quick-sell) pricing** — the client's OWN table and formula.
//!
//! Nothing here is invented. The client computes a card's discard value locally
//! whenever the server sends `discardValue == 0` (`FUN_18013fe00` stores our
//! value at item `+0x38`; `0x180141025` `cmp dword [rbp+0x198],0` / `ja` skips
//! the local path when it is non-zero). The local path runs
//!
//! ```sql
//! SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==?
//! ```
//!
//! and then, at `0x180141119..0x180141140`:
//!
//! ```text
//! value = (rating * price) / 100, rounded half up
//! ```
//!
//! Sources, all in-repo:
//! * `fifa17-recon/docs/plan-2026-08-05-store-subsystem.md` §3.6 — the SQL, the
//! formula, the `cardsubtypeid -> cardtype` decode (checked against every
//! subtype `0..599` with zero disagreements) and the `level` ladder.
//! * `fifa17-recon/data/tables/fcc_discardcoins.json` — the 141-row table itself.
//! [`DISCARD_COINS`] is generated from that file and a test re-reads the file
//! and asserts they still agree row for row, so the two cannot drift.
//!
//! The reversal was **verified against 22 live club items, 22 of 22 exact**.
//!
//! A key that is not in the table pays `0` (the client's price register stays 0),
//! so [`table_price`] returns `0` rather than panicking or substituting a floor.
/// `(cardtype, level, rare, price)`, generated verbatim from the client's
/// `fcc_discardcoins` table. Sorted; keys are unique.
const DISCARD_COINS: [(u8, u8, u8, i64); 141] = [
(1, 1, 0, 30),
(1, 1, 1, 75),
(1, 1, 2, 2000),
(1, 1, 3, 2000),
(1, 1, 4, 6000),
(1, 1, 5, 20000),
(1, 1, 6, 20000),
(1, 1, 7, 1500),
(1, 1, 8, 6000),
(1, 1, 9, 6000),
(1, 1, 10, 2000),
(1, 1, 11, 10000),
(1, 1, 12, 120000),
(1, 1, 13, 2000),
(1, 1, 17, 2000),
(1, 1, 18, 2000),
(1, 1, 19, 2000),
(1, 1, 20, 2000),
(1, 1, 21, 2000),
(1, 1, 22, 2000),
(1, 1, 23, 2000),
(1, 1, 24, 2000),
(1, 1, 25, 2000),
(1, 1, 26, 2000),
(1, 1, 27, 2000),
(1, 1, 28, 2000),
(1, 1, 29, 2000),
(1, 1, 30, 2000),
(1, 1, 31, 2000),
(1, 2, 0, 150),
(1, 2, 1, 350),
(1, 2, 2, 7000),
(1, 2, 3, 7000),
(1, 2, 4, 10000),
(1, 2, 5, 40000),
(1, 2, 6, 40000),
(1, 2, 7, 5000),
(1, 2, 8, 10000),
(1, 2, 9, 10000),
(1, 2, 10, 7000),
(1, 2, 11, 15000),
(1, 2, 12, 120000),
(1, 2, 13, 7000),
(1, 2, 17, 7000),
(1, 2, 18, 7000),
(1, 2, 19, 7000),
(1, 2, 20, 7000),
(1, 2, 21, 7000),
(1, 2, 22, 7000),
(1, 2, 23, 7000),
(1, 2, 24, 7000),
(1, 2, 25, 7000),
(1, 2, 26, 7000),
(1, 2, 27, 7000),
(1, 2, 28, 7000),
(1, 2, 29, 7000),
(1, 2, 30, 7000),
(1, 2, 31, 7000),
(1, 3, 0, 400),
(1, 3, 1, 800),
(1, 3, 2, 12200),
(1, 3, 3, 12200),
(1, 3, 4, 18000),
(1, 3, 5, 80000),
(1, 3, 6, 80000),
(1, 3, 7, 9000),
(1, 3, 8, 18000),
(1, 3, 9, 18000),
(1, 3, 10, 12200),
(1, 3, 11, 24000),
(1, 3, 12, 120000),
(1, 3, 13, 12200),
(1, 3, 17, 12200),
(1, 3, 18, 12200),
(1, 3, 19, 12200),
(1, 3, 20, 12200),
(1, 3, 21, 12200),
(1, 3, 22, 12200),
(1, 3, 23, 12200),
(1, 3, 24, 12200),
(1, 3, 25, 12200),
(1, 3, 26, 12200),
(1, 3, 27, 12200),
(1, 3, 28, 12200),
(1, 3, 29, 12200),
(1, 3, 30, 12200),
(1, 3, 31, 12200),
(2, 1, 0, 20),
(2, 1, 1, 25),
(2, 2, 0, 70),
(2, 2, 1, 120),
(2, 3, 0, 110),
(2, 3, 1, 320),
(3, 1, 0, 10),
(3, 1, 1, 50),
(3, 2, 0, 55),
(3, 2, 1, 100),
(3, 3, 0, 110),
(3, 3, 1, 300),
(4, 1, 0, 10),
(4, 1, 1, 50),
(4, 2, 0, 55),
(4, 2, 1, 100),
(4, 3, 0, 110),
(4, 3, 1, 300),
(5, 1, 0, 10),
(5, 1, 1, 50),
(5, 2, 0, 55),
(5, 2, 1, 100),
(5, 3, 0, 110),
(5, 3, 1, 300),
(6, 1, 0, 5),
(6, 1, 1, 20),
(6, 2, 0, 20),
(6, 2, 1, 50),
(6, 3, 0, 40),
(6, 3, 1, 70),
(7, 1, 0, 5),
(7, 1, 1, 20),
(7, 2, 0, 20),
(7, 2, 1, 50),
(7, 3, 0, 40),
(7, 3, 1, 70),
(8, 1, 0, 5),
(8, 1, 1, 20),
(8, 2, 0, 20),
(8, 2, 1, 50),
(8, 3, 0, 40),
(8, 3, 1, 70),
(9, 1, 0, 5),
(9, 1, 1, 20),
(9, 2, 0, 20),
(9, 2, 1, 50),
(9, 3, 0, 40),
(9, 3, 1, 70),
(10, 1, 0, 10),
(10, 1, 1, 50),
(10, 2, 0, 55),
(10, 2, 1, 100),
(10, 3, 0, 110),
(10, 3, 1, 300),
];
/// The `cardsubtypeid -> cardtype` decode (`FUN_1800d8330`, read out of its raw
/// two-level jump table). `0` = no table row, which prices at `0`.
///
/// The staff arms agree independently with
/// [`super::content_taxonomy::staff_role`]'s family selector (4=manager,
/// 5=headcoach, 6=gkcoach, 7=physio, 8=fitnesscoach) and with the five card
/// tables the client re-queries for those cardtypes — see [`client_rerates`].
pub fn cardtype_for_subtype(subtype: i64) -> u8 {
match subtype {
0..=3 => 1,
4 => 2,
5 => 3,
6 => 10,
7 => 5,
8 => 4,
9..=11 => 7,
30 | 31 | 145..=150 | 231..=233 | 236 => 9,
51..=136 | 201..=220 | 250..=273 | 300..=341 => 6,
_ => 0,
}
}
/// Discard `level` from rating: `3` if `>= 75`, `2` if `65..=74`, else `1`.
///
/// Derived purely from rating at the tail of `FUN_180141660`
/// (`0x180141e8a..0x180141ea3`). It is NOT a wire field — the slot at item
/// `+0x54` is never written through the deserializer's frame.
pub const fn discard_level(rating: u8) -> u8 {
if rating >= 75 {
3
} else if rating >= 65 {
2
} else {
1
}
}
/// Whether the client OVERWRITES the rating and rare flag we send with values
/// from its own card database before pricing.
///
/// True for cardtypes 2, 3, 4, 5 and 10 (the staff families — it re-queries
/// `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and
/// `gkcoachcards` by `carddbid`). For cardtypes 6, 7, 8 and 9 the jump table at
/// rva `0x141eb4` goes straight to the default arm with no DB query and no
/// overwrite, so for consumables and club items the server's values are
/// authoritative.
pub const fn client_rerates(cardtype: u8) -> bool {
matches!(cardtype, 2 | 3 | 4 | 5 | 10)
}
/// `fcc_discardcoins` price for a key, or `0` when the table has no such row.
pub fn table_price(cardtype: u8, level: u8, rare: i64) -> i64 {
if !(0..=255).contains(&rare) {
return 0;
}
let rare = rare as u8;
DISCARD_COINS
.iter()
.find(|&&(c, l, r, _)| c == cardtype && l == level && r == rare)
.map_or(0, |&(_, _, _, price)| price)
}
/// The client's discard value for a card: `round_half_up(rating * price / 100)`.
///
/// Returns `0` for a key the table does not carry, exactly as the client does.
pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 {
let price = table_price(cardtype, discard_level(rating), rare);
if price == 0 {
return 0;
}
(i64::from(rating) * price + 50) / 100
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated table MUST still equal the client's own file, row for row.
/// This is what makes [`DISCARD_COINS`] a transcription of evidence rather
/// than a hand-authored economy.
#[test]
fn the_table_still_matches_the_clients_own_file() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../fifa17-recon/data/tables/fcc_discardcoins.json"
);
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("client discard table missing at {path}: {e}"));
let doc: serde_json::Value = serde_json::from_str(&raw).expect("table is JSON");
assert_eq!(doc["table"], "fcc_discardcoins");
let declared = doc["rowcount"].as_u64().expect("rowcount") as usize;
let rows = doc["rows"].as_array().expect("rows array");
assert_eq!(rows.len(), declared, "file disagrees with its own rowcount");
let mut from_file: Vec<(u8, u8, u8, i64)> = rows
.iter()
.map(|r| {
let g = |k: &str| r.get(k).and_then(serde_json::Value::as_i64).unwrap();
(
g("cardtype") as u8,
g("level") as u8,
g("rare") as u8,
g("price"),
)
})
.collect();
from_file.sort_unstable();
let mut ours = DISCARD_COINS.to_vec();
ours.sort_unstable();
assert_eq!(
ours, from_file,
"generated table drifted from the client file"
);
assert_eq!(from_file.len(), 141);
}
/// The four worked examples stated in the reversal, which was checked
/// against 22 live club items.
#[test]
fn the_documented_live_verified_prices_reproduce() {
// "A gold rare player is 8 * rating: 75 gives 600, 94 gives 752."
assert_eq!(discard_value(1, 94, 1), 752);
assert_eq!(discard_value(1, 75, 1), 600);
for rating in 75..=99u8 {
assert_eq!(discard_value(1, rating, 1), 8 * i64::from(rating));
}
// "A gold common is 4 * rating."
for rating in 75..=99u8 {
assert_eq!(discard_value(1, rating, 0), 4 * i64::from(rating));
}
// "A 50-rated bronze common is 15."
assert_eq!(discard_value(1, 50, 0), 15);
}
/// "A key we do not have pays 0, so use a `.get(key, 0)`, not a subscript."
#[test]
fn an_absent_key_pays_zero_and_never_a_floor() {
// rare 14, 15, 16 are absent for cardtype 1 ...
for rare in [14, 15, 16] {
assert_eq!(table_price(1, 3, rare), 0);
assert_eq!(discard_value(1, 94, rare), 0);
}
// ... and cardtypes 2..=10 carry only rare 0 and 1.
for cardtype in 2..=10u8 {
assert_eq!(table_price(cardtype, 3, 2), 0);
}
// A subtype outside every documented range decodes to cardtype 0.
assert_eq!(cardtype_for_subtype(600), 0);
assert_eq!(discard_value(0, 94, 1), 0);
}
#[test]
fn the_level_ladder_is_the_clients() {
assert_eq!(discard_level(74), 2);
assert_eq!(discard_level(75), 3);
assert_eq!(discard_level(65), 2);
assert_eq!(discard_level(64), 1);
assert_eq!(discard_level(0), 1);
}
/// Rounding is half UP, not truncation: 66 * 55 / 100 = 36.3 -> 36, but
/// 67 * 55 / 100 = 36.85 -> 37.
#[test]
fn the_rounding_is_half_up() {
assert_eq!(discard_value(10, 66, 0), 36);
assert_eq!(discard_value(10, 67, 0), 37);
}
/// The subtype decode must agree with the settled club-item subtypes and the
/// staff family selector this crate already carries.
#[test]
fn the_decode_agrees_with_the_settled_subtypes() {
use crate::fut::content_taxonomy as tax;
// Kit, stadium and badge are cardtype 7 ...
for s in [tax::KIT_SUBTYPE, tax::STADIUM_SUBTYPE, tax::BADGE_SUBTYPE] {
assert_eq!(cardtype_for_subtype(s), 7, "subtype {s}");
}
// ... ball and league logo are cardtype 9 ...
for s in [tax::BALL_SUBTYPE, tax::LEAGUE_LOGO_SUBTYPE] {
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
}
// ... and `fcc_misccards` is cardtype 9 too.
for s in [231, 232, 233, 236] {
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
}
// A manager is cardtype 2, and every staff subtype is one the client
// re-rates from its own database.
assert_eq!(cardtype_for_subtype(tax::MANAGER_SUBTYPE), 2);
for subtype in 4..=8 {
assert!(
client_rerates(cardtype_for_subtype(subtype)),
"staff subtype {subtype} must be client-re-rated"
);
assert!(tax::staff_role(subtype).is_some());
}
// Consumables are cardtype 6, and the server's values ARE authoritative
// for them.
for subtype in [52, 54, 92, 98, 100] {
assert_eq!(cardtype_for_subtype(subtype), 6);
assert!(!client_rerates(6));
}
}
}
@@ -2,52 +2,13 @@
//!
//! These translate FIFA 17 wire semantics into the generic amounts the host
//! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
//! current OpenFUT economy (match rewards are the Python oracle's
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
//! from the Store catalogue.
//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
//! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
//!
//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
use crate::fut::store_catalog::pack_by_id;
/// Normalized match outcome for reward purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
}
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
/// default = 0).
pub const MATCH_PARTICIPATION: i64 = 0;
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
pub fn match_result_coins(result: MatchResult) -> i64 {
match result {
MatchResult::Win => 400,
MatchResult::Draw => 200,
MatchResult::Loss => 100,
}
}
/// Total match reward = per-result coins + participation.
pub fn match_reward_total(result: MatchResult) -> i64 {
match_result_coins(result) + MATCH_PARTICIPATION
}
/// Derive the outcome from the match `endReason` enum (the oracle's primary
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
/// the oracle's conservative default. Score-based derivation is a fallback the
/// oracle also supports; the enum is authoritative when present.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" | "DNF_WIN" => MatchResult::Win,
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
_ => MatchResult::Draw,
}
}
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> {
@@ -100,24 +61,6 @@ pub fn seller_proceeds(gross: i64) -> i64 {
mod tests {
use super::*;
#[test]
fn match_rewards_match_oracle() {
assert_eq!(match_reward_total(MatchResult::Win), 400);
assert_eq!(match_reward_total(MatchResult::Draw), 200);
assert_eq!(match_reward_total(MatchResult::Loss), 100);
}
#[test]
fn end_reason_maps_to_outcome() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
}
#[test]
fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some());
+576 -9
View File
@@ -24,8 +24,12 @@
use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
MANAGER_SUBTYPE,
};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item_state;
/// One owned item in game-independent terms, as read from Core's inventory.
#[derive(Debug, Clone)]
@@ -66,12 +70,172 @@ pub struct Fifa17Identity {
pub rareflag: i64,
}
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17KitIdentity {
pub item_id: u32,
pub asset_id: u32,
pub resource_id: u32,
pub card_asset_id: u32,
pub subtype: i64,
pub team_id: i64,
}
/// FIFA-side identity fields needed to render an owned staff card (manager or
/// coach). Unlike a player, a staff record carries NO attributes, rating,
/// position or rareflag: the client merges all of those from its own
/// `managercards`/`*coachcards` tables keyed on `resource_id`.
///
/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only
/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families,
/// whose tables carry no nation/league/team column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17StaffIdentity {
pub item_id: u32,
/// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff`
/// mask (players are the only family that is masked). It must equal the
/// table `carddbid` exactly — a non-zero version byte silently breaks the
/// lookup, and the manager branch has no else-arm to report the miss.
pub resource_id: u32,
/// `cardsubtypeid`. This ALONE selects which staff table the client merges.
pub subtype: i64,
pub nation: i64,
pub league_id: i64,
pub team_id: i64,
}
/// FIFA-side identity + definition facts needed to render an owned consumable.
///
/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a
/// DB handle, and category, artwork, name and both stat bytes all derive from
/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one
/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17ConsumableIdentity {
pub item_id: u32,
/// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side
/// constant, so this never reaches the screen), but kept as EA's own
/// `carddbid` so nothing drifts out of their space.
pub resource_id: u32,
pub asset_id: u32,
/// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`.
/// Observed values in the real profile: 3 (training), 7/8 (contracts),
/// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id`
/// here is right for players and wrong for every other family: the client
/// looks up art `5003001`, finds none, and draws the `notfound.swf` green
/// "NOT FOUND" box.
pub card_asset_id: u32,
/// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat
/// bytes derive from it.
pub subtype: i64,
/// `rec+0x58`. Observed 0 on every owned consumable in the real profile.
pub rareflag: i64,
/// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the
/// `fcc_discardcoins` price. Definition-level EA data (55..95 observed).
pub rating: u8,
/// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style.
/// `Some` exactly for the families [`ConsumableNeeds::Amount`] names.
pub amount: Option<i64>,
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
/// families only; they ignore `amount` entirely.
pub contract: Option<i64>,
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
/// than baked into the shaper because the consumables route's stack wrapper
/// reports `untradeableCount` over the copies in the stack.
pub untradeable: bool,
}
impl Fifa17ConsumableIdentity {
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
/// silent-failure guard rather than taste:
///
/// * the family's mandatory extra key is missing — the parser initialises
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
/// an omission draws "-1" on the card, not "0" (and a contract card with
/// no `contract` grants nothing);
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
/// entirely, with no error anywhere;
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
/// so this means the catalog carried no `card_asset_id` and the client
/// would draw `notfound.swf`, the green "NOT FOUND" box.
///
/// A subtype outside every documented range is also refused: it falls to
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
pub fn is_renderable(&self) -> bool {
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
return false;
}
if self.card_asset_id == self.asset_id {
return false;
}
match consumable_family(self.subtype) {
None => false,
Some((family, _)) => match consumable_needs(family) {
ConsumableNeeds::Amount => self.amount.is_some(),
ConsumableNeeds::Contract => self.contract.is_some(),
ConsumableNeeds::None => true,
},
}
}
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
/// Classify a Core item's definition as player/consumable/staff. Defaults to
/// Resolve one owned kit definition. Default `None` preserves existing
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
None
}
/// Resolve one owned staff definition (manager or coach). Default `None`
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
/// overrides it.
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
None
}
/// Resolve one owned consumable definition. Default `None` preserves
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
None
}
/// The card's discard (quick-sell) value in coins — BOTH the number the
/// client displays on the card and the number the server MUST credit when
/// it is sold. One method serves both so the wire and the wallet cannot
/// disagree: a non-zero `discardValue` suppresses the client's own local
/// computation (`0x180141025`), so whatever is sent here is what the player
/// is promised.
///
/// NON-MINTING by contract, like [`Self::subtype_of`] — it takes the Core
/// item, never a resolved [`Fifa17Identity`], so pricing a card on a read
/// path cannot allocate a wire id.
///
/// The default is the [`legacy_discard_value`] placeholder ladder, which
/// preserves the behaviour of every resolver without a catalog behind it.
/// The catalog-backed FIFA17 resolver overrides it with the client's own
/// `fcc_discardcoins` table (see [`super::discard`]).
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
legacy_discard_value(item.rating)
}
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
/// for every owned row, so allocating a wire id here would pollute the
/// identity store on a read.
fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 {
0
}
/// Classify a Core item's definition into the content vocabulary. Defaults to
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
/// `/club` exclude non-player content (which must never render as a
@@ -85,15 +249,32 @@ pub trait ItemIdentityResolver {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ShapeStats {
pub emitted: usize,
/// No real FIFA asset id for this definition — dropped, never faked.
pub dropped_no_asset: usize,
/// Consumable/staff items excluded from a player projection (they must never
/// render as a 0-rated player). Counted, never emitted.
/// The definition resolved but is INCOMPLETE or self-contradictory, so
/// drawing it would be a lie the client cannot detect (a consumable missing
/// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap).
/// Dropped and counted separately, because the fix is a catalog re-emit, not
/// an identity mapping.
pub dropped_incomplete: usize,
/// Owned content this envelope deliberately does not carry: a CONSUMABLE
/// (its own route serves it as a stack), or a club-customisation family
/// whose record shape is not yet verified (badge, ball, stadium, misc).
/// Core owns the row; the projection is withheld, never guessed.
pub excluded_non_player: usize,
}
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
/// non-fatal display field).
fn discard_value(rating: u8) -> i64 {
/// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not
/// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a
/// 94-rated TOTW special and a 94-rated gold common identically, and it pays a
/// flat floor for every non-player (whose Core rating is 0).
///
/// The client's real value is `round_half_up(rating * fcc_discardcoins.price /
/// 100)` — see [`super::discard`], which reproduces it exactly. This ladder is
/// retained as the [`ItemIdentityResolver::discard_value`] default so a resolver
/// with no catalog behind it keeps its existing behaviour, and so the deployed
/// economy only changes when an operator opts in.
pub fn legacy_discard_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
@@ -117,6 +298,7 @@ pub fn shape_item(
item: &CoreOwnedItem,
id: Fifa17Identity,
ent: &impl ReverseEntityResolver,
discard_value: i64,
) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
@@ -144,7 +326,7 @@ pub fn shape_item(
"leagueId": league_id,
"playStyle": 250,
"attributeList": attribute_list,
"itemState": "free",
"itemState": item_state::FREE,
"owners": 1,
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
// exception for SBC/promo rewards, which Core does not model). Emitting
@@ -154,13 +336,195 @@ pub fn shape_item(
"untradeable": false,
"contract": 7,
"fitness": 99,
"discardValue": discard_value(item.rating),
"discardValue": discard_value,
})
}
/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11)
/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or
/// one of the `active*` designations the client deserializes to 100..104.
///
/// All three families share one record and one client-side resolver
/// (`FUN_180119bd0`, dispatched when `item+0x4c == 7`), differing only in the
/// field their caption reads:
///
/// * kit `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * badge `Badge` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * stadium `Stadium` + `StadiumName_<assetId>` — needs `assetId`, which
/// `resourceId` already supplies
///
/// `teamid` (atom 0x306, record `+0x94`) is therefore emitted for kits and
/// badges and WITHHELD for stadiums, whose resolver never reads it. It is an
/// established scalar field, not a new shape.
///
/// The cardtype-9 families (ball 30, league logo 31) are deliberately NOT
/// shaped here: they have no DB name resolver, so their display name can only
/// come from `localizedName` on the wire, and while that offset is confirmed
/// it is NOT established that sending it is safe.
pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemState": item_state,
"owners": 1,
"untradeable": false,
});
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
item["teamid"] = json!(id.team_id);
}
item
}
/// Contracts remaining on an owned staff card.
///
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
/// refuses to start a match when the manager's has run out. Core does not model
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
/// for players rather than inventing a second, different default.
pub const STAFF_CONTRACT: i64 = 7;
/// Build one FIFA 17 staff item (manager or coach).
///
/// The key set is deliberately minimal and is taken field-by-field from the
/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every
/// key is justified by its CardsDLL record offset:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key),
/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged),
/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record
/// slots the client's merge never writes, so the server is their only source;
/// they drive the manager's flag, league badge and both halves of manager
/// chemistry. `teamid` → `rec+0x94` is read by the card view-model.
///
/// Everything else is omitted on purpose, because each is either overwritten by
/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`,
/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser
/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the
/// view-model, which would hang a position label or an attribute row on a
/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`).
/// A staff card must therefore never be routed through [`shape_item`].
///
/// The four coach families carry no nation/league/team columns in the client's
/// tables, so those three keys are emitted for a manager only rather than being
/// invented as zeroes for a coach.
pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"cardsubtypeid": id.subtype,
// Inert on the wire (the parser reads atom 0x173 into a stack string and
// frees it), but it is what every staff family reports, and our own
// readers use it to tell a staff card from a footballer at a glance.
"itemType": "staff",
"contract": contract,
"itemState": item_state::FREE,
"owners": 1,
"untradeable": false,
});
if id.subtype == MANAGER_SUBTYPE {
let obj = item.as_object_mut().expect("json! built an object");
obj.insert("nation".to_string(), json!(id.nation));
obj.insert("leagueId".to_string(), json!(id.league_id));
obj.insert("teamid".to_string(), json!(id.team_id));
}
item
}
/// Build one FIFA 17 consumable item.
///
/// The key set is EXACTLY what the real profile import holds for its 17 owned
/// consumables — i.e. what the client itself stored — and every key is a key the
/// live player path already proves, so this introduces NO new wire shape:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the
/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]),
/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`,
/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted
/// only for the families that read it (the caller has already gated on
/// [`Fifa17ConsumableIdentity::is_renderable`]).
///
/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this
/// client has ever been sent, it is what the real profile stores on all 17, and
/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the
/// string cannot affect the render. A consumable is discriminated by its subtype
/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a
/// fabricated token.
///
/// `untradeable` is carried per copy from
/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed
/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports
/// `untradeableCount` over a stack and the two must agree.
///
/// DELIBERATELY ABSENT, each for a named reason:
/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc
/// row that CRASHED the client on 2026-08-05. `value` is the established
/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is
/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three
/// is needed to draw a card.
/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` —
/// player-only, and `attributeList` is the very thing that distinguishes a
/// footballer from a consumable.
/// * `definitionId` — not an atom at all; the parser has always skipped it.
/// * `discardValue` — the client computes it from `fcc_discardcoins` on
/// `(cardtype 6, level, rare)`, and real rows exist for both rare values.
/// * `pile` — Core/host state (the transfer pile), not a wire atom: the
/// live-proven player path does not send it either.
pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemType": "player",
"rareflag": id.rareflag,
"rating": id.rating,
"itemState": item_state::FREE,
"owners": 1,
"untradeable": id.untradeable,
});
let obj = item.as_object_mut().expect("json! built an object");
if let Some(amount) = id.amount {
obj.insert("amount".to_string(), json!(amount));
}
if let Some(contract) = id.contract {
obj.insert("contract".to_string(), json!(contract));
}
item
}
/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where
/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads
/// it as the squad-fitness selector, so a rare Player Fitness card silently
/// becomes a SQUAD Fitness card — a different item, with no error anywhere.
pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219;
/// Tradeability of an owned consumable.
///
/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all,
/// so this is the observed value, not a policy: all 17 owned consumables in the
/// real profile import carry `untradeable: true`, and it is also the oracle's own
/// default for the family. When Core models per-instance tradeability, this
/// constant is what it replaces.
///
/// Note the lever it controls on screen: the consumables deserializer sets a UI
/// flag from `untradeableCount < count`, so an all-untradeable stack draws the
/// untradeable badge. That is correct for genuinely untradeable copies; it was
/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data
/// showing through.
pub const CONSUMABLE_UNTRADEABLE: bool = true;
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
@@ -197,6 +561,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(86),
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
@@ -230,6 +595,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
@@ -240,6 +606,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
);
assert_eq!(
a["resourceId"], b["resourceId"],
@@ -268,6 +635,7 @@ mod tests {
rareflag: 3,
},
&ent,
legacy_discard_value(92),
);
assert_eq!(
it["resourceId"], 117617092,
@@ -281,4 +649,203 @@ mod tests {
"special rareflag carried, not hardcoded 1"
);
}
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
/// rating 85, amount 15. Its key set is the acceptance criterion.
fn training_consumable() -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id: 100000239,
resource_id: 5_003_012,
asset_id: 5_003_012,
card_asset_id: 3,
subtype: 54,
rareflag: 0,
rating: 85,
amount: Some(15),
contract: None,
untradeable: CONSUMABLE_UNTRADEABLE,
}
}
#[test]
fn consumable_emits_exactly_the_keys_the_client_itself_stored() {
let it = shape_consumable_item(training_consumable());
// Verbatim from the real profile import (persona 33068179):
// {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3,
// "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85,
// "itemState":"free","owners":1,"untradeable":true,"amount":15}
assert_eq!(
it,
json!({
"id": 100000239,
"resourceId": 5_003_012,
"assetId": 5_003_012,
"cardassetid": 3,
"cardsubtypeid": 54,
"itemType": "player",
"rareflag": 0,
"rating": 85,
"itemState": "free",
"owners": 1,
"untradeable": true,
"amount": 15,
})
);
// The three "extras" that crashed the client on 2026-08-05, and the
// player-only keys that would make a consumable look like a footballer.
for forbidden in [
"teamid",
"leagueid",
"leagueId",
"value",
"attributeList",
"preferredPosition",
"nation",
"playStyle",
"fitness",
"definitionId",
"discardValue",
"pile",
] {
assert!(
it.get(forbidden).is_none(),
"a consumable must not carry `{forbidden}`"
);
}
}
#[test]
fn consumable_art_id_is_never_the_resource_id() {
// The green "NOT FOUND" box: the client resolves artwork by cardassetid,
// which is a SMALL fcc_ art id, not the carddbid.
let it = shape_consumable_item(training_consumable());
assert_eq!(it["cardassetid"], 3);
assert_ne!(it["cardassetid"], it["resourceId"]);
}
/// EVERY `itemState` this crate can put on the wire must be one of the twelve
/// tokens recovered from the client's own table. An unrecovered token decodes
/// to `0xffffffff` through `FUN_180166660` and the client then acts on an
/// unrecognised state.
#[test]
fn every_emitted_item_state_is_in_the_recovered_table() {
let ent = entities();
let mut emitted: Vec<String> = Vec::new();
let player = shape_item(
&item("oc1", "card_ch_1", 86, "CDM"),
Fifa17Identity {
item_id: 1,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
&ent,
legacy_discard_value(86),
);
emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item(
Fifa17StaffIdentity {
item_id: 2,
resource_id: 1_000_509,
subtype: MANAGER_SUBTYPE,
nation: 45,
league_id: 53,
team_id: 241,
},
STAFF_CONTRACT,
);
emitted.push(staff["itemState"].as_str().unwrap().to_string());
emitted.push(
shape_consumable_item(training_consumable())["itemState"]
.as_str()
.unwrap()
.to_string(),
);
// Every state `/club` can hand a kit, including both equipped roles.
let kit = Fifa17KitIdentity {
item_id: 3,
asset_id: 6_300_006,
resource_id: 6_300_006,
card_asset_id: 35,
subtype: 9,
team_id: 21,
};
for state in [
item_state::FREE,
item_state::ACTIVE_HOME_KIT,
item_state::ACTIVE_AWAY_KIT,
] {
let it = shape_club_item(kit, state);
emitted.push(it["itemState"].as_str().unwrap().to_string());
}
for state in &emitted {
assert!(
item_state::is_recovered(state),
"{state:?} is not one of the twelve recovered itemState tokens"
);
}
assert!(
!emitted.iter().any(|s| s == item_state::INVALID),
"omitting itemState yields `invalid` (0) and fails the squad builder; \
no shaper may emit it deliberately either"
);
}
/// Kit, badge and stadium share ONE cardtype-7 record, but their captions do
/// not read the same field: kit and badge resolve
/// `TeamName_Abbr15_<teamid>`, while a stadium resolves
/// `StadiumName_<assetId>` and its resolver never reads teamid. Sending a
/// field the resolver does not read is how this project earned a client
/// freeze, so the record carries exactly what each family consumes.
#[test]
fn a_club_item_carries_only_the_field_its_caption_resolves() {
let ident = |subtype| Fifa17KitIdentity {
item_id: 100_000_500,
asset_id: 6_000_005,
resource_id: 6_000_005,
card_asset_id: 39,
subtype,
team_id: 21,
};
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
let it = shape_club_item(ident(subtype), item_state::FREE);
assert_eq!(
it["teamid"], 21,
"subtype {subtype} resolves TeamName_Abbr15"
);
assert_eq!(it["cardsubtypeid"], subtype);
}
let stadium = shape_club_item(ident(STADIUM_SUBTYPE), item_state::FREE);
assert!(
stadium.get("teamid").is_none(),
"a stadium caption reads assetId, never teamid"
);
// The art id is the FAMILY's, never a copy of the resource id: a card
// whose card_asset_id equals its asset_id draws the notfound box.
assert_eq!(stadium["cardassetid"], 39);
assert_ne!(stadium["cardassetid"], stadium["resourceId"]);
// Every cardtype-7 family keeps the established minimal key set.
for key in [
"id",
"resourceId",
"assetId",
"cardassetid",
"cardsubtypeid",
"itemState",
"owners",
"untradeable",
] {
assert!(stadium.get(key).is_some(), "missing {key}");
}
// Never a player field: these have no rating, contract or attributes.
for key in [
"attributeList",
"contract",
"fitness",
"rating",
"discardValue",
] {
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
}
}
}
@@ -0,0 +1,111 @@
//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the
//! only place these strings are written down.
//!
//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at
//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory.
//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for
//! anything not in it, so an invented token is not a cosmetic slip: it decodes to
//! "unrecognised state" and the client acts on garbage. Every shaper in this
//! crate therefore takes its `itemState` from a constant here, and
//! [`is_recovered`] is asserted over every emitted value by the tests.
//!
//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record
//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an
//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad
//! builder's `state == 1 || state == 2` acceptance test. Always send it.
//!
//! **The casing is a CONTRACT, not a convention** — measured, not assumed. The
//! table lookup compares through a slot the host fills at runtime
//! (`FUN_180008190` is just `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`),
//! so this was long recorded as unresolvable without a live process. Resolved
//! read-only against the running client on 2026-08-21
//! (`fifa17-recon/tools/service_ptr_probe.py`): the slot forwards through two
//! FIFA17.exe thunks into `msvcr120.dll+0x3c330`, whose body is `strncmp` — a
//! plain byte compare (`cmp al,[rcx+rdx]`) with NO case folding anywhere. So a
//! mis-cased token does not "mostly work": it matches nothing, decodes to
//! [`INVALID`], and the item fails the squad builder. Emit these strings
//! verbatim.
//!
//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also
//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at
//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,
//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.)
/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it
/// fails the squad builder. Never emit it deliberately.
pub const INVALID: &str = "invalid";
/// `1` — the normal owned state: accepted by the squad builder, and what the
/// unequip path writes back.
pub const FREE: &str = "free";
/// `2` — alias of [`IN_GAME`] (both decode to 2).
pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME";
/// `2` — accepted by the squad builder.
pub const IN_GAME: &str = "inGame";
/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is
/// in the table, so it decodes; the transfer market emits it.
pub const FOR_SALE: &str = "forSale";
/// `6` — never tested anywhere in CardsDLL.
pub const OFFERED: &str = "offered";
/// `100` — equipped badge; drives the `IS_ACTIVE` tick.
pub const ACTIVE_BADGE: &str = "activeBadge";
/// `101` — equipped home kit.
pub const ACTIVE_HOME_KIT: &str = "activeHomeKit";
/// `102` — equipped away kit.
pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit";
/// `103` — equipped ball; the unequip path writes [`FREE`] back over it.
pub const ACTIVE_BALL: &str = "activeBall";
/// `104` — equipped stadium.
pub const ACTIVE_STADIUM: &str = "activeStadium";
/// `255` — no consumer found.
pub const ACTIVE: &str = "active";
/// The complete recovered vocabulary, in table order.
pub const ALL: [&str; 12] = [
INVALID,
FREE,
WAITING_FOR_GAME,
IN_GAME,
FOR_SALE,
OFFERED,
ACTIVE_BADGE,
ACTIVE_HOME_KIT,
ACTIVE_AWAY_KIT,
ACTIVE_BALL,
ACTIVE_STADIUM,
ACTIVE,
];
/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the
/// client's own lookup is a `strcmp` walk.
pub fn is_recovered(state: &str) -> bool {
ALL.contains(&state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_table_is_the_twelve_recovered_rows_and_nothing_else() {
assert_eq!(ALL.len(), 12);
for s in ALL {
assert!(is_recovered(s), "{s} must be in its own table");
}
// Tokens this project has actually seen invented or mis-cased. `listFS`
// in particular is the Python oracle's own token and appears NOWHERE in
// the client (zero occurrences in the DLL and in 4.26 GiB of live
// process memory), so it decodes to -1.
for s in [
"listFS",
"free ",
"Free",
"activehomekit",
"sold",
"won",
"equipped",
"",
] {
assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState");
}
}
}
@@ -0,0 +1,266 @@
//! FIFA 17 `/match` + `/match/end` wire ↔ Core match-economy mapping.
//!
//! This module owns the FIFA 17-specific match protocol: the `endReason` enum
//! (wire atom 260), the `PUT …/match/end` payload shape, and the reward-response
//! body. It is pure — no state, no Core calls. The host wires it to OpenFUT
//! Core's authoritative `complete_match` transaction:
//!
//! 1. [`parse_match_end`] turns the client payload into a [`MatchEnd`].
//! 2. [`MatchResult::core_token`] gives Core the canonical, game-independent
//! result string — Core never sees a FIFA `endReason`.
//! 3. Core applies the economy exactly once and returns the authoritative coin
//! numbers, which the host renders back through [`reward_response`].
//!
//! Keeping every FIFA 17 constant here (never in Core) is the layering contract:
//! a second title's adapter maps its own wire onto the same canonical tokens.
use serde_json::{json, Value};
/// Participation award added to every match reward. FIFA 17 economy parameter
/// (oracle `MATCH_PARTICIPATION`, production default `0`). Core owns the coin
/// balance; this is only the wire body's cosmetic `participationAward` field.
pub const MATCH_PARTICIPATION: i64 = 0;
/// Canonical match result. The FIFA 17 `endReason` enum (atom 260) is the
/// AUTHORITATIVE source [STATIC_REVERSED]; this is the normalized shape the host
/// forwards to Core.
///
/// * `Win` / `Draw` / `Loss` — a decided match.
/// * `Dnf` — the reporting player abandoned/quit (`DNF`/`QUIT`). Economically a
/// loss (LIVE_PROVEN: `endReason=DNF` → loss reward), tracked in its own Core
/// statistics bucket.
/// * `NoContest` — a voided match; zero economic effect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResult {
/// The canonical Core result token — the ONLY match datum the adapter hands
/// Core. Matches `openfut_core::models::match_result::MatchResultKind`'s serde
/// representation exactly (`win`/`draw`/`loss`/`dnf`/`no_contest`).
pub fn core_token(self) -> &'static str {
match self {
MatchResult::Win => "win",
MatchResult::Draw => "draw",
MatchResult::Loss => "loss",
MatchResult::Dnf => "dnf",
MatchResult::NoContest => "no_contest",
}
}
}
/// Map the FIFA 17 `endReason` enum onto a canonical [`MatchResult`].
///
/// The enum is authoritative when present [STATIC_REVERSED]. `DNF`/`QUIT` are the
/// reporting player's abandon (→ `Dnf`, loss economics, LIVE_PROVEN); the
/// `DNF_WIN`/`DNF_DRAW`/`DNF_LOSS` variants carry a decided outcome (the opponent
/// abandoned) and map to that outcome. `NO_CONTEST` voids the match. An
/// absent/unknown reason is a conservative `Draw`, matching the oracle default.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" => MatchResult::Win,
"DRAW" => MatchResult::Draw,
"LOSS" => MatchResult::Loss,
"DNF" | "QUIT" => MatchResult::Dnf,
"DNF_WIN" => MatchResult::Win,
"DNF_DRAW" => MatchResult::Draw,
"DNF_LOSS" => MatchResult::Loss,
"NO_CONTEST" => MatchResult::NoContest,
_ => MatchResult::Draw,
}
}
/// A parsed `PUT …/match/end` payload: the fields the host needs to drive Core.
/// Unknown fields are ignored.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchEnd {
/// The raw `endReason` string, if the client sent one.
pub end_reason: Option<String>,
/// Canonical result derived from `end_reason`.
pub result: MatchResult,
/// `matchReportId` from the payload (observed `0` on the live path).
pub match_report_id: i64,
/// Goals scored by the reporting player — `myMatchStats[0]` (goals is the
/// first of the 15 ints). Absent on `DNF`/`QUIT` (stats omitted) → `0`.
pub goals_for: i64,
/// Opponent goals — `opponentMatchStats[0]`. Absent on `DNF`/`QUIT` → `0`.
pub goals_against: i64,
}
/// Parse the FIFA 17 match-end body. Returns `None` for a body that is not a
/// JSON object (malformed). A well-formed object with a missing/unknown
/// `endReason` still parses — the result defaults to `Draw`.
pub fn parse_match_end(body: &[u8]) -> Option<MatchEnd> {
let v: Value = serde_json::from_slice(body).ok()?;
if !v.is_object() {
return None;
}
let end_reason = v
.get("endReason")
.and_then(Value::as_str)
.map(str::to_string);
let result = result_from_end_reason(end_reason.as_deref());
let match_report_id = v.get("matchReportId").and_then(Value::as_i64).unwrap_or(0);
Some(MatchEnd {
end_reason,
result,
match_report_id,
goals_for: first_stat(&v, "myMatchStats"),
goals_against: first_stat(&v, "opponentMatchStats"),
})
}
/// `myMatchStats`/`opponentMatchStats` are 15 ints with goals first
/// [STATIC_REVERSED]; the arrays are OMITTED on DNF/QUIT, so a missing array is
/// `0` goals, not an error.
fn first_stat(v: &Value, key: &str) -> i64 {
v.get(key)
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_i64)
.unwrap_or(0)
}
/// Build the FIFA 17 match-reward response body (the `destroy_match_body` shape,
/// [STATIC_REVERSED]).
///
/// `all_coins` is Core's AUTHORITATIVE post-credit balance; `match_coins` is the
/// amount Core granted for THIS match (mirrored into `gameModeAward.coins`, where
/// the client reads it). Emits ONLY the reversed fields — it NEVER emits
/// `bidTokens` or `qualifiedChampionEventId`, which are client freeze traps.
pub fn reward_response(all_coins: i64, match_coins: i64) -> Value {
json!({
"allCoins": all_coins,
"matchCoins": match_coins,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's misspelling (atom 96), preserved on the wire.
"participationAward": MATCH_PARTICIPATION,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": match_coins },
})
}
/// Build the FIFA 17 `POST …/match` create ack. Zero economic effect: it only
/// hands the client a match id + start time. `id` doubles as the per-match
/// identity the host later keys Core's exactly-once completion on.
pub fn create_response(id: i64, start_epoch: i64) -> Value {
json!({
"startDateTime": start_epoch,
"reportIdEnabled": false,
"id": id,
})
}
/// Build the FIFA 17 `…/match/ready` ack (FutMatchReady). Zero economic effect.
///
/// Two scalars only. The response type also has an optional nested item list,
/// which is deliberately omitted: a nested value the client half-reads is the
/// documented freeze mode, and nothing needs it here. `opponent_persona_id` is
/// echoed from the request when the client supplies one and is otherwise `0` —
/// an offline AI opponent has no persona, and it must NEVER default to the
/// player's own persona, which would claim the user is their own opponent.
pub fn ready_response(match_id: i64, opponent_persona_id: i64) -> Value {
json!({
"matchId": match_id,
"opponentPersonaId": opponent_persona_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_end_reason_maps_to_canonical_result() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DNF")), MatchResult::Dnf);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Dnf);
assert_eq!(
result_from_end_reason(Some("NO_CONTEST")),
MatchResult::NoContest
);
assert_eq!(result_from_end_reason(Some("DNF_WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DNF_DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("DNF_LOSS")), MatchResult::Loss);
// Case-insensitive.
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
// Unknown / absent → conservative draw.
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
}
#[test]
fn core_tokens_match_core_serde() {
assert_eq!(MatchResult::Win.core_token(), "win");
assert_eq!(MatchResult::Draw.core_token(), "draw");
assert_eq!(MatchResult::Loss.core_token(), "loss");
assert_eq!(MatchResult::Dnf.core_token(), "dnf");
assert_eq!(MatchResult::NoContest.core_token(), "no_contest");
}
#[test]
fn parse_match_end_reads_reason_and_goals() {
let body = br#"{"matchReportId":7,"endReason":"WIN","myMatchStats":[3,1,2,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Win);
assert_eq!(end.match_report_id, 7);
assert_eq!(end.goals_for, 3);
assert_eq!(end.goals_against, 1);
}
#[test]
fn parse_match_end_dnf_omits_stats_as_zero() {
// The live DNF payload: stats arrays omitted entirely.
let body = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchStatusFlags":0}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Dnf);
assert_eq!(end.goals_for, 0);
assert_eq!(end.goals_against, 0);
}
#[test]
fn parse_match_end_unknown_reason_defaults_draw() {
let end = parse_match_end(br#"{"endReason":"BANANA"}"#).expect("parses");
assert_eq!(end.result, MatchResult::Draw);
assert_eq!(end.end_reason.as_deref(), Some("BANANA"));
}
#[test]
fn parse_match_end_rejects_malformed() {
assert!(parse_match_end(b"not json").is_none());
assert!(parse_match_end(b"[]").is_none());
assert!(parse_match_end(b"42").is_none());
}
#[test]
fn reward_response_has_only_reversed_fields() {
let body = reward_response(29_876_876, 100);
assert_eq!(body["allCoins"], 29_876_876);
assert_eq!(body["matchCoins"], 100);
assert_eq!(body["gameModeAward"]["coins"], 100);
assert_eq!(body["seasonCoins"], 0);
assert_eq!(body["tournamentCoins"], 0);
assert_eq!(body["boostConis"], 0);
assert_eq!(body["participationAward"], 0);
assert_eq!(body["teamOfTournamentWinner"], false);
// The freeze traps must never appear.
assert!(body.get("bidTokens").is_none());
assert!(body.get("qualifiedChampionEventId").is_none());
}
#[test]
fn create_response_shape() {
let body = create_response(100_004_838, 1_700_000_000);
assert_eq!(body["id"], 100_004_838);
assert_eq!(body["reportIdEnabled"], false);
assert_eq!(body["startDateTime"], 1_700_000_000);
}
}
+5
View File
@@ -7,15 +7,20 @@
pub mod catalog;
pub mod club_response;
pub mod club_stats;
pub mod consumables;
pub mod content_taxonomy;
pub mod discard;
pub mod economy;
pub mod economy_policy;
pub mod entities;
pub mod item;
pub mod item_state;
pub mod match_wire;
pub mod non_economy;
pub mod owned_query;
pub mod pack_content;
pub mod sbc;
pub mod season_wire;
pub mod squad;
pub mod squad_ext;
pub mod squad_projection;
+103 -13
View File
@@ -24,9 +24,50 @@ pub fn accountinfo_body() -> Value {
json!({})
}
/// `GET …/settings` — production oracle returns an empty config list.
pub fn settings_body() -> Value {
json!({ "configs": [] })
/// The commerce flags the client leaves OFF unless a `configs` row turns them on.
///
/// `FutGetSettingsServerResponse` (deser `0x18013c6d0`, read end to end) has a
/// single wrapper key `configs` (0xa2) holding an array of
/// `{ type (0x354), value (0x377) }`, and nothing else. `type` is the setting
/// NAME, not an index.
///
/// These matter because the struct's defaults are NOT uniform: several fields
/// default to 1, but `tradingEnabled` defaults to **0**. It is not a flag we have
/// been overwriting — it is a flag nobody has ever sent. It gates the service
/// half of the `TO_TRADE_PILE` predicate (vtable slot `+0x270` =
/// `FUN_18011c670`, reading gate byte `0x1fd2e`, measured 0 in the live client),
/// which is one of the two reasons "Place on Transfer List" is greyed. The other
/// reason — `untradeable` — this crate already handles: [`crate::fut::item`]
/// emits `false` for owned copies.
const COMMERCE_SETTINGS: [&str; 8] = [
"storeEnabled",
"storeEnabled_JP",
"coinEnabled",
"coinEnabled_JP",
"cardPackStoreEnabled",
"cardPackStoreEnabled_JP",
"pointsPackStoreEnabled",
"tradingEnabled",
];
/// `GET …/settings` — an empty config list by default, matching the production
/// oracle and the live-proven behaviour.
///
/// `enable_commerce` opts in to [`COMMERCE_SETTINGS`]. It defaults OFF because
/// the flags are RECOVERED BUT UNTESTED: the schema is high-confidence and this
/// is the exact row shape the oracle would emit, but no launch has yet confirmed
/// what the client does with them. Turning it on is the server half of the
/// transfer-list fix; it explains why the menu entry is greyed and does NOT
/// promise that the transfer market behind it works.
pub fn settings_body(enable_commerce: bool) -> Value {
if !enable_commerce {
return json!({ "configs": [] });
}
let rows: Vec<Value> = COMMERCE_SETTINGS
.iter()
.map(|name| json!({ "type": name, "value": 1 }))
.collect();
json!({ "configs": rows })
}
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
@@ -67,12 +108,19 @@ pub fn feature_off_body() -> Value {
pub fn item_def(resource_id: i64) -> Value {
let asset = resource_id & 0xff_ffff;
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let (name, rating, pos, nation, league, team, attrs): (
&str,
i64,
&str,
i64,
i64,
i64,
[i64; 6],
) = if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let attribute_list: Vec<Value> = attrs
.iter()
.enumerate()
@@ -365,7 +413,11 @@ pub fn user_mass_info_body(
pub fn format_utc_datetime(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_euclid(86_400);
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
let (hour, min, sec) = (
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
);
// civil_from_days: days is a count of days since 1970-01-01.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
@@ -415,12 +467,44 @@ mod tests {
#[test]
fn static_bodies_match_oracle() {
assert_eq!(accountinfo_body(), json!({}));
assert_eq!(settings_body(), json!({ "configs": [] }));
assert_eq!(settings_body(false), json!({ "configs": [] }));
assert_eq!(leaderboard_options_body(), json!({}));
assert_eq!(match_reset_body(), json!({}));
assert_eq!(club_stats_staff_body(), json!({}));
}
/// The default MUST stay the live-proven empty list, and the opt-in body must
/// match the schema exactly: `configs` holding `{type, value}` rows where
/// `type` is the setting NAME. `tradingEnabled` is the one that matters — the
/// client defaults it to 0 and it gates the transfer-list menu entry.
#[test]
fn commerce_settings_are_opt_in_and_shaped_to_the_schema() {
assert_eq!(
settings_body(false),
json!({ "configs": [] }),
"default must remain the live-proven body"
);
let on = settings_body(true);
let rows = on["configs"].as_array().expect("configs is an array");
assert_eq!(rows.len(), COMMERCE_SETTINGS.len());
for row in rows {
let obj = row.as_object().expect("each config row is an OBJECT");
assert_eq!(
obj.keys().collect::<Vec<_>>(),
vec!["type", "value"],
"the deserializer knows exactly two keys; an extra one is skipped \
at best and a type desync at worst"
);
assert!(obj["type"].is_string(), "type is the setting NAME");
assert_eq!(obj["value"], 1);
}
assert!(
rows.iter().any(|r| r["type"] == "tradingEnabled"),
"the whole point of the opt-in"
);
}
#[test]
fn action_parse() {
assert_eq!(
@@ -628,8 +712,14 @@ mod tests {
});
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
// Flat top-level envelope.
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
assert_eq!(
body["pileSizeClientData"]["entries"][0],
json!({"key": 2, "value": 100})
);
assert_eq!(
body["pileSizeClientData"]["entries"][1],
json!({"key": 4, "value": 50})
);
assert_eq!(body["settings"], json!({"configs": []}));
assert_eq!(body["userData"], json!({}));
// userInfo economy + club identity.
@@ -48,6 +48,9 @@ use std::collections::HashMap;
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
/// no FIFA content taxonomy, so the host applies this filter locally.
pub item_type: Option<String>,
/// Quality filter: `any` (default, always present) or `gold`.
pub level: Option<String>,
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
@@ -66,6 +69,18 @@ pub struct Fifa17ClubQuery {
pub start: Option<u32>,
/// Pagination page size.
pub count: Option<u32>,
/// `defId=` — a comma-joined list of DEFINITION ids. The documented club
/// grammar says the client sends EITHER the filter block above OR this list,
/// never both.
///
/// CAPTURED BUT NOT APPLIED, deliberately. The grammar is single-source (one
/// decompile plus one live log line, and that log line carried no `defId`),
/// so the exact semantics — definition id as `resourceId`, presumably — are
/// not confirmed against an observed request. Filtering on a wrong reading
/// would turn "too many items" into "zero items", which is the worse failure.
/// The host logs it instead, so the first real occurrence is visible and the
/// filter can be written against evidence rather than a guess.
pub def_ids: Vec<i64>,
}
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
@@ -115,6 +130,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
None => (pair, String::new()),
};
match k {
"type" => out.item_type = Some(v),
"level" => out.level = Some(v),
"rare" => out.rare = Some(v),
"position" => out.position = Some(v),
@@ -124,6 +140,14 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
"sort" => out.sort = Some(v),
"start" => out.start = v.parse().ok(),
"count" => out.count = v.parse().ok(),
// Comma-joined, digits only — the same reading the item-definition
// routes already use for this parameter.
"defId" => {
out.def_ids = v
.split(',')
.filter_map(|d| d.trim().parse::<i64>().ok())
.collect()
}
_ => {}
}
}
@@ -325,6 +349,7 @@ mod tests {
assert_eq!(
q,
Fifa17ClubQuery {
item_type: Some("player".into()),
level: Some("gold".into()),
rare: None,
position: Some("ST".into()),
@@ -334,6 +359,7 @@ mod tests {
sort: Some("desc".into()),
start: Some(10),
count: Some(11),
def_ids: Vec::new(),
}
);
}
@@ -523,3 +549,25 @@ mod tests {
);
}
}
#[cfg(test)]
mod def_id_tests {
use super::*;
/// `defId=` is CAPTURED so the host can report it, and deliberately does NOT
/// participate in the Core query — narrowing on an unconfirmed reading of the
/// parameter would answer "zero items" where today we answer "too many".
#[test]
fn def_id_list_is_captured_but_never_narrows_the_core_query() {
let q = parse_club_query("?year=2017&type=player&defId=20801,117617092,84044103");
assert_eq!(q.def_ids, vec![20801, 117_617_092, 84_044_103]);
assert_eq!(q.item_type.as_deref(), Some("player"));
// Non-numeric entries are dropped rather than poisoning the list.
let q = parse_club_query("?defId=20801,,notanid,42");
assert_eq!(q.def_ids, vec![20801, 42]);
// Absent means empty, never a phantom filter.
assert!(parse_club_query("?type=player").def_ids.is_empty());
}
}
+87 -49
View File
@@ -7,20 +7,16 @@
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
//! mints the drawn cards into Core, and shapes them onto the wire.
//!
//! ## Parity note — Python `open_pack` / `_pack_body`
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
//! 3. Each drawn player becomes a special with probability `special_chance`
//! (`random.random() < special_chance`).
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
//! ## Policy (real FUT 17 composition, DESIGNED odds)
//!
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
//! is biased toward a special version with probability `special_chance` (a DESIGNED
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
//! the whole pool so a draw is always possible even when the pool lacks that tier.
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
//! candidates are player defs.
use rand::Rng;
@@ -77,50 +73,83 @@ pub struct GeneratedCard {
pub attributes: [u8; 6],
}
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
/// the pool is empty or the pack awards no cards.
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
/// or the pack awards no cards.
///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
/// the pack's tier (`gold`), biasing each draw toward a special card with
/// probability `special_chance`. An empty tier or partition falls back to the
/// next-wider set so a draw is always possible when the pool is non-empty.
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
/// An empty tier falls back to the whole pool (so a draw is always possible).
pub fn generate_pack_contents(
pack: &PackDef,
rng: &mut impl Rng,
pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 {
if pool.is_empty() || pack.count() == 0 {
return Vec::new();
}
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
pool.iter().collect()
} else {
tier
};
// Partition the tier by special so `special_chance` can bias a draw; either
// partition falls back to the whole tier when empty.
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
let chance = pack.special_chance.clamp(0.0, 1.0);
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let silver: Vec<&GeneratedCandidate> = pool
.iter()
.filter(|c| (65..75).contains(&c.rating))
.collect();
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
let mut out = Vec::with_capacity(pack.count as usize);
for _ in 0..pack.count {
let mut out = Vec::with_capacity(pack.count() as usize);
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
draw_tier(
&mut out,
&silver,
&all,
pack.n_silver,
pack.special_chance,
rng,
);
draw_tier(
&mut out,
&bronze,
&all,
pack.n_bronze,
pack.special_chance,
rng,
);
out
}
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
/// biasing each pick toward a special version with probability `chance`. Either the
/// special or normal partition falls back to the tier when empty.
fn draw_tier(
out: &mut Vec<GeneratedCard>,
tier: &[&GeneratedCandidate],
fallback: &[&GeneratedCandidate],
n: u64,
chance: f64,
rng: &mut impl Rng,
) {
if n == 0 {
return;
}
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
if src.is_empty() {
return;
}
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
let chance = chance.clamp(0.0, 1.0);
for _ in 0..n {
let want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special
} else if !want_special && !normal.is_empty() {
&normal
} else {
&tier
src
};
let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card());
}
out
}
#[cfg(test)]
@@ -156,13 +185,22 @@ mod tests {
]
}
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
PackDef {
id,
name: "Test Pack",
price: 1000,
count,
gold,
n_bronze,
n_silver,
n_gold,
rares: 0,
category: if n_gold > 0 {
"gold"
} else if n_silver > 0 {
"silver"
} else {
"bronze"
},
special_chance,
owned_only: false,
}
@@ -171,7 +209,7 @@ mod tests {
#[test]
fn same_seed_same_output() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let mut a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42);
assert_eq!(
@@ -183,7 +221,7 @@ mod tests {
#[test]
fn different_seeds_can_diverge() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
// Not a hard guarantee, but with this pool/count the two seeds differ.
@@ -196,7 +234,7 @@ mod tests {
let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] {
let p = pack(6, n, true, 0.08);
let p = pack(6, 0, 0, n, 0.08);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n);
for c in &cards {
@@ -212,7 +250,7 @@ mod tests {
#[test]
fn gold_pack_draws_only_gold_tier() {
let pool = pool();
let p = pack(5, 20, true, 0.03);
let p = pack(5, 0, 0, 20, 0.03);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating >= 75),
@@ -223,7 +261,7 @@ mod tests {
#[test]
fn bronze_pack_draws_only_bronze_tier() {
let pool = pool();
let p = pack(1, 20, false, 0.005);
let p = pack(1, 20, 0, 0, 0.005);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating < 75),
@@ -239,7 +277,7 @@ mod tests {
.filter(|c| c.special)
.map(|c| c.card_id.as_str())
.collect();
let p = pack(7, 11, true, 1.0);
let p = pack(7, 0, 0, 11, 1.0);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards
.iter()
@@ -248,7 +286,7 @@ mod tests {
#[test]
fn empty_pool_fails_closed() {
let p = pack(5, 7, true, 0.03);
let p = pack(5, 0, 0, 7, 0.03);
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
}
}
+131 -5
View File
@@ -66,7 +66,7 @@ pub fn sets_body(challenges: &[ChallengeView]) -> Value {
"description": challenge.description,
"priority": challenge.identity.priority,
"challengesCount": 1,
"challengesCompletedCount": i64::from(challenge.times_completed > 0),
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
"awards": [],
"hidden": false,
"endTime": 4_102_444_800_i64
@@ -89,6 +89,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
.iter()
.filter(|challenge| challenge.identity.set_id == set_id)
.map(|challenge| {
// A repeatable challenge is always available to enter again. The FIFA 17
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
// repeatable challenge never reports itself as terminally completed here.
// Core keeps the true completion record (economy authority); this is
// presentation only. Live-proven on the retail client 2026-08-18.
let times_completed = if challenge.repeatable {
0
} else {
challenge.times_completed
};
json!({
"challengeId": challenge.identity.challenge_id,
"setId": challenge.identity.set_id,
@@ -103,8 +114,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
"repeatable": challenge.repeatable,
"trophyId": 0,
"status": "OPEN",
"timesCompleted": challenge.times_completed,
"timesCompleted": times_completed,
"awards": [],
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
// text — it does NOT validate on them. The ordinal->string map is not
// recoverable from any asset we have, so any value we emit would show the
// WRONG requirement to the player. Submission is validated server-side by
// Core regardless; leaving this empty is display-only, never a correctness
// gap. Populate ONLY once the locale ordinal map is captured.
"elgReq": []
})
})
@@ -178,9 +198,15 @@ impl std::error::Error for SbcWireError {}
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
///
/// Retail request captures for this body are unavailable. The parser therefore accepts
/// only the two already-proven FIFA 17 squad containers: a normal `players` array or the
/// SBC `squad` array. Within either, only `itemData.id` is interpreted.
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
/// the manager, chemistry, rating, formation, dream, and index fields are
/// presentation/validation hints and are never consumed. The `squad` array
/// container remains accepted for the alternate proven shape. Within either,
/// only non-zero `itemData.id` values are interpreted.
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
let root: Value =
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
@@ -240,6 +266,53 @@ mod tests {
assert!(submit["grantedSetAwards"].is_array());
}
#[test]
fn repeatable_completed_challenge_stays_enterable() {
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
// a repeatable challenge must always project as not-yet-completed while a
// non-repeatable one keeps its true count. Core holds the real record.
let repeatable = ChallengeView {
identity: CHALLENGES[0],
name: "Bronze Upgrade".into(),
description: "Submit players".into(),
repeatable: true,
times_completed: 3,
};
let once = ChallengeView {
identity: CHALLENGES[1],
name: "Hybrid Nations".into(),
description: "Submit a hybrid squad".into(),
repeatable: false,
times_completed: 1,
};
let repeatable_view =
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
let sets = sets_body(&[repeatable, once]);
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
let repeatable_set = sets_arr
.iter()
.find(|set| set["setId"] == CHALLENGES[0].set_id)
.unwrap();
let once_set = sets_arr
.iter()
.find(|set| set["setId"] == CHALLENGES[1].set_id)
.unwrap();
assert_eq!(
repeatable_set["challengesCompletedCount"], 0,
"a repeatable set never reports itself terminally completed"
);
assert_eq!(
once_set["challengesCompletedCount"], 1,
"a one-shot set counts its single completion"
);
}
#[test]
fn parser_accepts_only_known_squad_containers_and_item_ids() {
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
@@ -259,4 +332,57 @@ mod tests {
Err(SbcWireError::Json(_))
));
}
#[test]
fn parser_matches_captured_retail_challenge_squad_body() {
// Verbatim shape from the 2026-08-18 Gate C retail capture of
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
// non-zero player itemData.id values are consumed, in wire order; the
// manager and all presentation fields are ignored.
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
"manager":[{"id":100000427,"dream":false}],
"players":[
{"index":0,"itemData":{"id":100004227,"dream":false}},
{"index":1,"itemData":{"id":100004233,"dream":false}},
{"index":2,"itemData":{"id":100001317,"dream":false}},
{"index":3,"itemData":{"id":100001531,"dream":false}},
{"index":4,"itemData":{"id":100000966,"dream":false}},
{"index":5,"itemData":{"id":100001947,"dream":false}},
{"index":6,"itemData":{"id":100000169,"dream":false}},
{"index":7,"itemData":{"id":100002017,"dream":false}},
{"index":8,"itemData":{"id":100002765,"dream":false}},
{"index":9,"itemData":{"id":100000147,"dream":false}},
{"index":10,"itemData":{"id":100000311,"dream":false}},
{"index":11,"itemData":{"id":0,"dream":false}},
{"index":12,"itemData":{"id":0,"dream":false}},
{"index":13,"itemData":{"id":0,"dream":false}},
{"index":14,"itemData":{"id":0,"dream":false}},
{"index":15,"itemData":{"id":0,"dream":false}},
{"index":16,"itemData":{"id":0,"dream":false}},
{"index":17,"itemData":{"id":0,"dream":false}},
{"index":18,"itemData":{"id":0,"dream":false}},
{"index":19,"itemData":{"id":0,"dream":false}},
{"index":20,"itemData":{"id":0,"dream":false}},
{"index":21,"itemData":{"id":0,"dream":false}},
{"index":22,"itemData":{"id":0,"dream":false}}
]}"#;
assert_eq!(
parse_wire_item_ids(retail).unwrap(),
[
100_004_227,
100_004_233,
100_001_317,
100_001_531,
100_000_966,
100_001_947,
100_000_169,
100_002_017,
100_002_765,
100_000_147,
100_000_311
],
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
);
}
}
@@ -0,0 +1,209 @@
//! FIFA 17 offline-Seasons wire shapes.
//!
//! Reversed from `CardsDLL_Win64_retail.dll`, not guessed. The `season/list`
//! per-element parser is `FUN_180167740` (element stride 0x318) and the response
//! deserialiser root is `FUN_1801683f0` (an object with the single key
//! `seasons`). Element fields land at:
//!
//! | wire key | atom | element offset |
//! |--------------|-------|--------------------------------|
//! | `id` | 0x15c | +0x1b0 |
//! | `divisionId` | 0x0dc | +0x1f8, as `(0xb - value)` |
//! | `type` | — | +0x1b4 (int, switch) |
//! | `matches` | 0x1b8 | vector at +0x2e8/+0x2f0/+0x2f8 |
//!
//! Each `matches` element is 16 bytes, parsed by `FUN_180167fb0`:
//! `teamId`(0x305) int@+0x0, `difficulty`(0xd4) byte@+0x4, `roundId`(0x291)
//! byte@+0x5, `rewardMult`(0x28b) int@+0x8, `coins`(0x95) int@+0xc.
//!
//! WHY `matches` MUST BE NON-EMPTY: `StartSeason` (`FUN_1800fc500`) reads
//! `matches[*(x+0x70)].teamId` through `*(elem+0x2e8 + index*0x10)`. With an
//! empty vector `elem+0x2e8` is NULL and the client dereferences address 0 —
//! a hard crash at `CardsDLL+0xfc5b5`. Emitting a full round set is therefore a
//! correctness requirement, not a nicety.
//!
//! WHY STRUCTS AND NOT `json!`: `type` must precede `divisionId` — the element
//! parser binds the competition type before it maps the division. `serde_json`'s
//! `Value` is a `BTreeMap` without the `preserve_order` feature, so `json!`
//! silently reorders keys ALPHABETICALLY and would emit `divisionId` first.
//! A `#[derive(Serialize)]` struct serialises in declaration order, so these
//! types ARE the wire contract. For the same reason every body here is rendered
//! straight to a `String` and never round-tripped through `Value`.
use serde::Serialize;
/// Rounds in one FIFA 17 offline season. The division ladder is ten matches.
pub const SEASON_ROUNDS: i64 = 10;
/// Opponent team ids used for the round schedule.
///
/// These are real team ids observed in this client's own database (they appear
/// as the `teamid` of club players in the live kit-item trace), so every round
/// resolves to a team the client can actually render. They are cycled rather
/// than randomised so a season's schedule is stable across reloads — the client
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
/// One scheduled offline-season round.
#[derive(Debug, Serialize)]
pub struct SeasonMatch {
#[serde(rename = "teamId")]
pub team_id: i64,
pub difficulty: i64,
#[serde(rename = "roundId")]
pub round_id: i64,
#[serde(rename = "rewardMult")]
pub reward_mult: i64,
pub coins: i64,
}
/// One offline competition. FIELD ORDER IS THE WIRE CONTRACT — `type` first.
#[derive(Debug, Serialize)]
pub struct SeasonElement {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: i64,
#[serde(rename = "divisionId")]
pub division_id: i64,
pub matches: Vec<SeasonMatch>,
}
#[derive(Debug, Serialize)]
pub struct SeasonList {
pub seasons: Vec<SeasonElement>,
}
/// The club's position in its current season.
#[derive(Debug, Serialize)]
pub struct SeasonUser {
#[serde(rename = "seasonId")]
pub season_id: i64,
#[serde(rename = "divisionId")]
pub division_id: i64,
pub round: i64,
#[serde(rename = "userPoints")]
pub user_points: i64,
/// Opaque client blob; the client round-trips it and never requires server
/// interpretation.
#[serde(rename = "dataVersion")]
pub data_version: &'static str,
pub data: &'static str,
}
fn round(index: i64) -> SeasonMatch {
SeasonMatch {
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
// Difficulty and reward multiplier are per-round bytes; a flat schedule
// is the honest default until the retail ladder is captured.
difficulty: 1,
round_id: index,
reward_mult: 1,
coins: 400,
}
}
/// `GET …/season/list` — the offline competitions the club can enter, as wire
/// text (see the module note on key order).
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
let list = SeasonList {
seasons: vec![SeasonElement {
kind: "OFFLINE",
id: season_id,
division_id,
matches: (0..SEASON_ROUNDS).map(round).collect(),
}],
};
serde_json::to_string(&list).expect("season list serialises")
}
/// `GET …/season/user` — where the club currently is in its season.
pub fn season_user_body(season_id: i64, division_id: i64, round: i64, user_points: i64) -> String {
let user = SeasonUser {
season_id,
division_id,
round,
user_points,
data_version: "1",
data: "",
};
serde_json::to_string(&user).expect("season user serialises")
}
/// `GET …/season/user/history` — completed seasons. Empty until a season ends;
/// the client renders an empty history without complaint.
pub fn season_history_body() -> String {
String::from(r#"{"seasons":[]}"#)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn parsed(text: &str) -> Value {
serde_json::from_str(text).expect("valid json")
}
#[test]
fn list_emits_a_full_round_schedule() {
let body = parsed(&season_list_body(1, 10));
let season = &body["seasons"][0];
assert_eq!(season["type"], "OFFLINE");
assert_eq!(season["id"], 1);
assert_eq!(season["divisionId"], 10);
assert_eq!(
season["matches"].as_array().unwrap().len(),
SEASON_ROUNDS as usize
);
}
/// An empty `matches` vector makes StartSeason dereference NULL
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
#[test]
fn matches_are_never_empty_and_every_round_has_a_team() {
let body = parsed(&season_list_body(3, 7));
let matches = body["seasons"][0]["matches"].as_array().unwrap();
assert!(!matches.is_empty());
for (i, m) in matches.iter().enumerate() {
assert_eq!(m["roundId"], i as i64, "rounds are 0..n and in order");
assert!(
m["teamId"].as_i64().is_some_and(|t| t > 0),
"round {i} must name a real opponent team: {m}"
);
assert!(m["coins"].as_i64().is_some());
assert!(m["rewardMult"].as_i64().is_some());
assert!(m["difficulty"].as_i64().is_some());
}
}
/// The element parser binds the competition type before mapping the
/// division, so `type` MUST serialise before `divisionId`. `json!` would
/// order them alphabetically and break this.
#[test]
fn type_is_serialised_before_division_id() {
let text = season_list_body(1, 10);
let type_at = text.find("\"type\"").expect("type key");
let division_at = text.find("\"divisionId\"").expect("divisionId key");
assert!(
type_at < division_at,
"type must precede divisionId on the wire: {text}"
);
}
#[test]
fn user_state_carries_the_season_position() {
let body = parsed(&season_user_body(1, 10, 3, 6));
assert_eq!(body["seasonId"], 1);
assert_eq!(body["divisionId"], 10);
assert_eq!(body["round"], 3);
assert_eq!(body["userPoints"], 6);
assert_eq!(body["dataVersion"], "1");
assert_eq!(body["data"], "");
}
#[test]
fn history_is_an_empty_season_list() {
let body = parsed(&season_history_body());
assert_eq!(body["seasons"].as_array().unwrap().len(), 0);
}
}
+52 -10
View File
@@ -141,9 +141,31 @@ pub struct ProposedSquad {
/// never used to derive slot layout.
pub formation: Option<String>,
pub slots: Vec<ProposedSlot>,
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
/// replacement if this is non-empty — a save must never silently drop an
/// owned player it failed to identify.
/// The owned instance assigned as the squad's **manager**, reverse-resolved
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
/// is ownership-backed, never a dangling wire id). `None` when the save
/// carries no manager, or when its manager ref does not resolve — see
/// [`Self::unresolved_manager_wire_id`].
pub manager_owned_card_id: Option<String>,
/// A non-zero manager ref the resolver could not map, if any.
///
/// This does NOT refuse the save. FIFA 17 sends a manager ref that is not an
/// owned club item: production's own squad points at instance 100000427,
/// which is absent from production's `/club/staff` listing (1975 items,
/// 100000001..100004826), and the client accepts that squad back unchanged —
/// so the client does not validate the manager against the club, and refusing
/// the save would break EVERY real squad save for a field that was not even
/// ownership-backed before migration 0023.
///
/// An unresolvable ref therefore means "no ownership-backed manager": the
/// assignment is cleared, exactly as a full replacement should, and the id is
/// reported so the host can log what it could not map. An occupied PLAYER
/// slot is different and still refuses the save — dropping one would silently
/// lose an owned card from the club.
pub unresolved_manager_wire_id: Option<i64>,
/// Occupied PLAYER wire item ids the resolver could not map. A caller MUST
/// refuse the replacement if this is non-empty — a save must never silently
/// drop an owned player it failed to identify.
pub unresolved_wire_ids: Vec<i64>,
}
@@ -180,12 +202,14 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
/// squadType) and client-reported evaluation are NOT canonical — they are built
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
/// are reported, never guessed or dropped.
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
/// 23-slot array, and reverse-resolve the manager ref to an owned instance
/// (the manager assignment is ownership-backed canonical state, migration 0023).
/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType)
/// and client-reported evaluation are NOT canonical — they are built separately
/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is
/// carried verbatim (never mapped). Unresolvable occupied ids are reported,
/// never guessed or dropped.
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
let captain = put.captain.unwrap_or(0);
let mut slots = Vec::new();
@@ -205,11 +229,25 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
None => unresolved.push(p.item_data.id),
}
}
// Manager: the first non-zero manager ref, reverse-resolved to an owned
// instance. Unresolvable is NOT fatal (see `unresolved_manager_wire_id`) --
// the real client always sends a dangling ref, so refusing would break every
// squad save.
let mut manager_owned_card_id = None;
let mut unresolved_manager_wire_id = None;
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
match resolver.owned_id_for_wire(wire) {
Some(owned) => manager_owned_card_id = Some(owned),
None => unresolved_manager_wire_id = Some(wire),
}
}
ProposedSquad {
squad_id: put.id,
name: put.squad_name.clone(),
formation: put.formation.clone(),
slots,
manager_owned_card_id,
unresolved_manager_wire_id,
unresolved_wire_ids: unresolved,
}
}
@@ -237,9 +275,11 @@ mod tests {
}
fn full_resolver() -> MapResolver {
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
// 100000427 is the fixture's manager ref — the host resolves it like any
// other owned instance, so the manager assignment is ownership-backed.
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
100000001, 100000002, 100000025, 100000427,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
@@ -283,6 +323,8 @@ mod tests {
assert_eq!(caps[0].owned_card_id, "oc-100000001");
assert_eq!(caps[0].index, 8);
assert_eq!(caps[0].kit_number, 8);
// The manager ref is reverse-resolved to an owned instance (canonical).
assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427"));
}
#[test]
+36 -24
View File
@@ -12,7 +12,7 @@
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
//!
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
pub const EXT_NAMESPACE: &str = "fifa17.squad";
@@ -49,15 +49,6 @@ pub struct WireItemRef {
pub dream: bool,
}
impl From<&SquadEntityRef> for WireItemRef {
fn from(r: &SquadEntityRef) -> Self {
WireItemRef {
id: r.id,
dream: r.dream,
}
}
}
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
/// `item` is the referenced FIFA wire item. The role→player meaning and any
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
@@ -83,9 +74,9 @@ pub struct Fifa17SquadExtensionV1 {
/// proves the kit number follows the player across swaps and formation change.
#[serde(default)]
pub kit_numbers: BTreeMap<String, i64>,
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
#[serde(default)]
pub manager: Vec<WireItemRef>,
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
// canonical Core state (migration 0023 `squad_managers`), resolved to an
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
#[serde(default)]
pub kicktakers: Vec<KicktakerRef>,
@@ -132,7 +123,6 @@ impl Fifa17SquadExtensionV1 {
custom: put.custom.clone(),
squad_type: put.squad_type.clone(),
kit_numbers,
manager: put.manager.iter().map(WireItemRef::from).collect(),
kicktakers: put
.kicktakers
.iter()
@@ -256,6 +246,9 @@ mod tests {
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
// The f442 fixture's manager ref — the host resolves it like any other
// owned instance, so the ownership-backed manager assignment is present.
100000427,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
@@ -308,22 +301,41 @@ mod tests {
}
#[test]
fn manager_and_kicktakers_preserved_opaquely() {
let ext = built().extension;
fn manager_is_canonical_and_kicktakers_stay_opaque() {
let build = built();
// Manager is now ownership-backed canonical state: the wire ref resolved
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
assert_eq!(
ext.manager,
vec![WireItemRef {
id: 100000427,
dream: false
}]
build.canonical.manager_owned_card_id.as_deref(),
Some("oc-100000427")
);
// Kicktakers remain opaque in the extension.
let ext = build.extension;
assert_eq!(ext.kicktakers.len(), 5);
// All five reference the same wire id in this capture; carried verbatim,
// NEVER normalized to the captain even though they coincide here.
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
assert_eq!(ext.kicktakers[0].index, 0);
}
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
/// sends one, and on a real profile it is dangling (production points at
/// 100000427, absent from its own /club/staff). The save commits with no
/// ownership-backed manager and reports the id it could not map.
#[test]
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let mut ids = full_resolver().0;
ids.remove(&100000427);
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
assert_eq!(build.canonical.manager_owned_card_id, None);
assert_eq!(
build.canonical.unresolved_manager_wire_id,
Some(100000427),
"the ref we could not map is reported, not swallowed"
);
// The starting XI is untouched -- only the manager assignment is dropped.
assert_eq!(build.canonical.slots.len(), 11);
}
#[test]
fn unknown_schema_version_is_rejected_not_coerced() {
let payload = built().extension.to_payload();
@@ -36,7 +36,9 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use crate::fut::item::{
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
@@ -77,6 +79,11 @@ pub struct SquadProjectionInput<'a> {
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
/// the host in one batch — the projector only reads from it.
pub owned: &'a HashMap<String, CoreOwnedItem>,
/// The owned instance assigned as this squad's **manager** (Core's
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
/// dangling wire id, and never fabricated when absent.
pub manager: Option<CoreOwnedItem>,
}
/// Result of a projection, with the extension-freshness verdict surfaced.
@@ -159,7 +166,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.unwrap_or(0);
players.push(json!({
"index": index,
"itemData": shape_item(item, id, ent),
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
"kitNumber": kit,
}));
}
@@ -171,6 +178,35 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves 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` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
//
// An owned manager with no resolvable FIFA staff identity is omitted
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
// with a fabricated id.
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
Some(id) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, STAFF_CONTRACT),
"dream": false,
}]),
None => json!([]),
};
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
@@ -180,7 +216,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
"starRating": ext.client_reported.star_rating,
"rating": ext.client_reported.rating,
"captain": captain_wire,
"manager": ext.manager,
"manager": manager,
"custom": ext.custom,
"players": players,
"kicktakers": ext.kicktakers,
@@ -219,14 +255,22 @@ pub fn squad_list(projected: &Value) -> Value {
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use crate::fut::item::Fifa17Identity;
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
// A resolver that mints a distinct wire id per owned item and a fixed asset.
struct TableIdentity(HashMap<String, Fifa17Identity>);
// `staff` is separate because a manager resolves through the STAFF identity,
// which carries the chemistry fields a player identity has no room for.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
@@ -262,6 +306,7 @@ mod tests {
}],
ext,
owned,
manager: None,
}
}
@@ -272,7 +317,6 @@ mod tests {
custom: Some("[1,2,3]".into()),
squad_type: Some("REGULAR_SQUAD".into()),
kit_numbers: kit,
manager: vec![],
kicktakers: vec![],
client_reported: Default::default(),
}
@@ -282,15 +326,18 @@ mod tests {
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
@@ -318,7 +365,7 @@ mod tests {
let owned = HashMap::new();
// A stale extension IS carried (host may log it) but must not be applied.
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
let ident = TableIdentity(HashMap::new());
let ident = TableIdentity(HashMap::new(), HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Stale
@@ -329,7 +376,7 @@ mod tests {
fn missing_is_explicit_never_fabricated() {
let owned = HashMap::new();
let input = one_slot_input(&owned, SquadExtInput::Missing);
let ident = TableIdentity(HashMap::new());
let ident = TableIdentity(HashMap::new(), HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Missing
@@ -340,7 +387,7 @@ mod tests {
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::new()); // resolves nothing
let ident = TableIdentity(HashMap::new(), HashMap::new()); // resolves nothing
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
assert_eq!(
project_squad(&input, &ident, &ent()),
@@ -355,26 +402,29 @@ mod tests {
let mut owned = HashMap::new();
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
let ident = TableIdentity(HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]));
let ident = TableIdentity(
HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]),
HashMap::new(),
);
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
@@ -403,6 +453,7 @@ mod tests {
],
ext: SquadExtInput::Fresh(ext),
owned: owned_ref,
manager: None,
};
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!();
@@ -423,4 +474,89 @@ mod tests {
"kit stays with the instance"
);
}
#[test]
fn manager_projected_from_ownership_as_wire_ref() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::from([(
"oc-mgr".to_string(),
Fifa17StaffIdentity {
item_id: 100000427,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
);
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
assert_eq!(
v["manager"],
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": STAFF_CONTRACT,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
);
}
#[test]
fn absent_manager_projects_empty_array_never_fabricated() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
// one_slot_input leaves manager: None.
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(
v["manager"],
json!([]),
"no manager assignment => empty array, nothing fabricated"
);
}
}
+355 -86
View File
@@ -1,92 +1,217 @@
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
//! owns coins + unopened packs), so there is no Python dependency and no
//! dual-write/split-brain.
//!
//! ## Scope / split-brain safety
//! ## Relationship to the Python oracle
//!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
//! before that economy authority exists would create a dual-write/split-brain. See
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
//! The wire *shape* was RE'd from the client and cross-checked against the Python
//! oracle's `_pack_body`/`store_catalog`
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
//! Python oracle stays the rollback baseline and is never modified; the
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
//!
//! ## Economy-parameter provenance
//!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
//! EA-authentic (the overnight audit established the store economy is invented). The
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
//! Pack prices and tier composition are the real always-available FUT 17
//! regular-store packs (community-documented on fifauteam). Pack ODDS
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
use serde_json::{json, Value};
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
/// per-tier composition are the real always-available FUT 17 regular-store packs,
/// with DESIGNED (not EA-authentic) `special_chance` odds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef {
pub id: u64,
pub name: &'static str,
pub price: u64,
pub count: u64,
pub gold: bool,
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
/// pack's displayed composition matches what its generator draws.
pub n_bronze: u64,
pub n_silver: u64,
pub n_gold: u64,
/// `rareQuantity` shown on the tile (wire display only).
pub rares: u64,
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
pub category: &'static str,
/// Per-draw probability the awarded card is a special version (DESIGNED
/// placeholder; FUT 17 odds are unrecoverable).
pub special_chance: f64,
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
/// rendered only when owned (in `unopenedPackIds`).
pub owned_only: bool,
}
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
/// compatibility shim, never a catalogue pack (never purchasable/openable).
impl PackDef {
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
pub fn count(&self) -> u64 {
self.n_bronze + self.n_silver + self.n_gold
}
}
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
/// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[
// ── Bronze category ──
PackDef {
id: 1,
name: "Bronze Pack",
price: 400,
count: 5,
gold: false,
special_chance: 0.005,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: false,
},
PackDef {
id: 2,
name: "Premium Bronze Pack",
price: 750,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 3,
category: "bronze",
special_chance: 0.02,
owned_only: false,
},
// ── Silver category ──
PackDef {
id: 3,
name: "Silver Pack",
price: 2500,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.015,
owned_only: false,
},
PackDef {
id: 4,
name: "Premium Silver Pack",
price: 3750,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 3,
category: "silver",
special_chance: 0.03,
owned_only: false,
},
// ── Gold category ──
PackDef {
id: 5,
name: "Gold Pack",
price: 5000,
count: 7,
gold: true,
special_chance: 0.03,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.04,
owned_only: false,
},
PackDef {
id: 6,
name: "Premium Gold",
price: 15000,
count: 11,
gold: true,
special_chance: 0.08,
owned_only: false,
},
PackDef {
id: 7,
name: "Special Players Pack",
price: 25000,
count: 11,
gold: true,
special_chance: 1.0,
name: "Premium Gold Pack",
price: 7500,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.06,
owned_only: false,
},
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
PackDef {
id: 70,
name: "Reward Special Players Pack",
name: "Reward Gold Pack",
price: 0,
count: 11,
gold: true,
n_bronze: 0,
n_silver: 0,
n_gold: 11,
rares: 11,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
PackDef {
id: 71,
name: "Bronze Pack",
price: 0,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: true,
},
PackDef {
id: 72,
name: "Silver Pack",
price: 0,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.02,
owned_only: true,
},
PackDef {
id: 73,
name: "Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.05,
owned_only: true,
},
PackDef {
id: 74,
name: "Rare Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.10,
owned_only: true,
},
PackDef {
id: 75,
name: "Icon Pack",
price: 0,
n_bronze: 0,
n_silver: 0,
n_gold: 12,
rares: 12,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
@@ -97,27 +222,88 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
PACK_CATALOG.iter().find(|p| p.id == id)
}
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
/// one of the six hard-coded tokens the client resolves.
fn category(p: &PackDef) -> &'static str {
if p.special_chance >= 1.0 {
"special"
} else if p.gold {
"gold"
} else {
"bronze"
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
/// `None` and is simply not shown as an openable pack rather than faked.
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
if let Ok(numeric) = definition_id.parse::<u64>() {
return pack_by_id(numeric)
.filter(|pack| pack.owned_only)
.map(|pack| pack.id);
}
let id = match definition_id {
"bronze_pack" => 71,
"silver_pack" => 72,
"gold_pack" => 73,
"rare_gold_pack" => 74,
"icon_pack" => 75,
_ => return None,
};
Some(id)
}
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
/// carries its own token; owned/reward packs take `mypacks` instead (see
/// [`pack_body`]).
fn category(p: &PackDef) -> &'static str {
p.category
}
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
let mtx = std::cmp::max(1, p.price / 100);
let pack_type = match p.category {
"gold" => "GOLD",
"silver" => "SILVER",
_ => "BRONZE",
};
// Group background texture: FIFA 17's all-groups landing (shown on first store
// entry) renders one tile per group whose art is the client-bundled
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
// category and that landing shows native art instead of blank shields. The
// persistent tabbed store draws its pack art from the packs themselves and
// does not depend on this.
let group_bg: u64 = if owned {
3
} else {
match p.category {
"gold" => 3,
"silver" => 2,
_ => 1,
}
};
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
// three store tabs and two reward tiles:
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
// catalogue asset (1-6) makes art appear.
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
// unchanged and only rotated the featured player, which proves this.)
// So a reward tile automatically shows the same art as the equivalent
// purchasable pack; we only need a valid asset, and we use the tier's own store
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
let art_asset: u64 = if owned {
match p.category {
"gold" => 5,
"silver" => 3,
_ => 1,
}
} else {
p.id
};
let mut body = json!({
"assetId": p.id,
"assetId": art_asset,
"id": p.id,
"packType": if p.gold { "GOLD" } else { "BRONZE" },
"packType": pack_type,
"description": p.name,
"state": "active",
"saleType": "promo",
@@ -127,26 +313,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
"purchaseCount": 0,
"isPremium": false,
"sortPriority": idx,
"displayGroupAssetId": group_bg,
// The tile renders `finalFunds` as its coin price; the HUD balance reads
// `funds` from the /credits currencies array. We keep the pair equal.
//
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
// a real-money price" and switches on the broken `or %1s` label — the
// Origin/Dime commerce catalogue that would fill it no longer exists
// offline, so every string stays at its constructor default. Omitting the
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
// experiment #4): it strictly reduces executed client code and leaves every
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
"extPrice": {
"finalPrice": { "amount": mtx, "currency": "mtx" },
"originalPrice": { "amount": mtx, "currency": "mtx" },
},
"packContentInfo": {
"bronzeQuantity": if p.gold { 0 } else { p.count },
"silverQuantity": 0,
"goldQuantity": if p.gold { p.count } else { 0 },
"rareQuantity": if p.gold { p.count } else { 0 },
"itemQuantity": p.count,
"bronzeQuantity": p.n_bronze,
"silverQuantity": p.n_silver,
"goldQuantity": p.n_gold,
"rareQuantity": p.rares,
"itemQuantity": p.count(),
},
"unopened": owned,
});
let obj = body.as_object_mut().expect("pack body is a JSON object");
if owned {
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
// objects makes the client render the price label as literal "undefined".
obj.remove("currencies");
obj.remove("extPrice");
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
// reward packs). My Packs opens through the store purchase flow, so a tile
// with no currency row is not actionable — clicking navigates instead of
// opening. With a free coin row the client sends POST /purchased and the
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
// mtx bug), so the free coin row formats as "0", not an unavailable label.
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
obj.insert(
"displayGroup".into(),
json!({ "value": "mypacks", "priority": idx }),
@@ -165,8 +362,11 @@ pub fn sentinel_body(idx: u64) -> Value {
id: SENTINEL_PACK_ID,
name: "",
price: 0,
count: 0,
gold: true,
n_bronze: 0,
n_silver: 0,
n_gold: 0,
rares: 0,
category: "gold",
special_chance: 0.0,
owned_only: true,
};
@@ -176,13 +376,19 @@ pub fn sentinel_body(idx: u64) -> Value {
.expect("sentinel body is a JSON object");
obj.insert("state".into(), json!("active"));
obj.insert("unopened".into(), json!(false));
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
// for an empty My Packs), so it keeps no purchase path.
obj.remove("currencies");
// Keep the sentinel's own id as its asset: it must render as an inert blank
// placeholder, never borrow a real pack's cover.
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
body
}
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
let mut packs: Vec<Value> = PACK_CATALOG
.iter()
@@ -203,10 +409,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
#[cfg(test)]
mod tests {
//! Differential parity against the Python oracle. The fixtures under
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
//! module header).
use super::*;
fn parse(s: &str) -> Value {
@@ -214,7 +421,7 @@ mod tests {
}
#[test]
fn purchasegroup_zero_sentinel_matches_oracle() {
fn purchasegroup_zero_sentinel_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::Sentinel);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
@@ -223,7 +430,7 @@ mod tests {
}
#[test]
fn purchasegroup_zero_clean_matches_oracle() {
fn purchasegroup_zero_clean_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_clean.json"
@@ -232,7 +439,7 @@ mod tests {
}
#[test]
fn purchasegroup_pack70_matches_oracle() {
fn purchasegroup_pack70_matches_golden() {
// Owned pack present -> no sentinel regardless of mode.
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
let want = parse(include_str!(
@@ -247,6 +454,68 @@ mod tests {
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
}
#[test]
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
// Core reward services grant symbolic pack names; each must resolve to an
// owned-only catalogue pack so it renders as an openable My Packs tile.
for (def, want) in [
("bronze_pack", 71),
("silver_pack", 72),
("gold_pack", 73),
("rare_gold_pack", 74),
("icon_pack", 75),
] {
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
assert_eq!(id, want);
assert!(
pack_by_id(id).unwrap().owned_only,
"a reward pack must be owned-only"
);
}
// Imported numeric owned-pack ids resolve to themselves.
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
// Purchasable (non-owned) numeric ids and unknown names never resolve.
assert_eq!(owned_pack_id_for_definition("5"), None);
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
}
#[test]
fn reward_tiles_carry_a_renderable_cover_asset() {
// A reward pack's own id is not a client art asset, so its My Packs tile
// renders blank. Each reward tile must therefore carry a valid catalogue
// assetId (its tier's store pack) while `id` stays the open packId.
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
let tile = pack_body(pack, 1, true);
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
assert_eq!(
tile["assetId"], want_asset,
"reward tile borrows its tier's store-pack cover asset"
);
assert!(
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
"the cover asset must be a real purchasable catalogue pack"
);
}
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
}
#[test]
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
// A granted silver reward pack (resolved to id 72) must appear as an owned
// My Packs tile, not the non-openable sentinel shim.
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
let packs = got["purchase"].as_array().unwrap();
let reward = packs
.iter()
.find(|p| p["id"] == 72)
.expect("reward pack tile present");
assert_eq!(reward["displayGroup"]["value"], "mypacks");
assert!(reward["unopened"].as_bool().unwrap());
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
}
#[test]
fn clean_v1_empty_emits_no_mypacks_group() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
@@ -256,13 +525,13 @@ mod tests {
.iter()
.map(|e| e["id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![1, 5, 6, 7]);
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn category_tokens_are_canonical() {
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
assert_eq!(category(pack_by_id(7).unwrap()), "special");
}
}
+188 -150
View File
@@ -2,197 +2,235 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
}
},
{
"assetId": 7,
"assetId": 5,
"id": 70,
"packType": "GOLD",
"description": "Reward Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
"name": "coins",
"funds": 0,
"finalFunds": 0
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
"itemQuantity": 11
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
},
{
"assetId": 70,
"description": "Reward Special Players Pack",
"unopened": true,
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 70,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": true
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
@@ -2,171 +2,201 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
}
}
],
"timestamp": 1596326400
@@ -2,197 +2,228 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
}
},
{
"assetId": 65534,
"description": "",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 65534,
"isPremium": false,
"packType": "GOLD",
"description": "",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0,
"itemQuantity": 0,
"rareQuantity": 0,
"silverQuantity": 0
"itemQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
@@ -11,8 +11,9 @@
//! ```
//!
//! Fidelity is asserted by ownership class:
//! CANONICAL player instance per index, formation, captain, bench split
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
//! CANONICAL player instance per index, formation, captain, bench split, and
//! the ownership-backed manager assignment (migration 0023)
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
//!
@@ -23,7 +24,9 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
@@ -49,11 +52,19 @@ impl SquadWireResolver for OcResolver {
}
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
struct TableIdentity(HashMap<String, Fifa17Identity>);
/// `staff` is a second table because a manager resolves through the STAFF
/// identity, which carries the chemistry fields a player identity cannot hold.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
@@ -115,7 +126,7 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
},
);
}
(owned, TableIdentity(ident))
(owned, TableIdentity(ident, HashMap::new()))
}
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
@@ -146,6 +157,9 @@ fn project_put(
slots,
ext: SquadExtInput::Fresh(extension),
owned,
// This stand-in supplies no owned manager; the manager is projected from
// the ownership-backed assignment, exercised in persisted_read below.
manager: None,
};
match project_squad(&input, ident, &NoEntities).unwrap() {
SquadProjection::Projected(v) => v,
@@ -194,9 +208,11 @@ fn baseline_projects_the_known_squad_round_trip() {
projected["captain"], 100000001,
"captain is the player's WIRE id"
);
// EXTENSION: custom byte-identical, manager + squadType preserved.
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
// an ownership-backed assignment (not projected from the PUT/ext); with none
// supplied to this stand-in it projects empty.
assert_eq!(projected["custom"], put_v["custom"]);
assert_eq!(projected["manager"], put_v["manager"]);
assert!(projected["manager"].as_array().unwrap().is_empty());
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
assert_eq!(projected["chemistry"], 52);
@@ -268,13 +284,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
// evidence, then project and require the read back — the strongest fidelity
// check across all four ownership classes.
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
use openfut_adapter_fifa17::fut::squad_ext::{
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
};
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
use std::collections::BTreeMap;
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
let (owned, ident) = oracle_tables();
let (owned, mut ident) = oracle_tables();
let captain = oracle["captain"].as_i64().unwrap();
let mut slots = Vec::new();
@@ -294,14 +308,40 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
is_on_bench: index >= 11,
});
}
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
// The manager is ownership-backed: register its owned instance + STAFF
// identity and pass it as the assignment, not as an opaque extension field.
// A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga
// 53, Barcelona 241) so the projected item is a shape the client could
// actually merge.
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
let mgr_oc = format!("oc-{mgr_wire}");
ident.1.insert(
mgr_oc.clone(),
Fifa17StaffIdentity {
item_id: mgr_wire as u32,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
);
let manager_item = CoreOwnedItem {
owned_card_id: mgr_oc,
card_id: "def-manager".to_string(),
rating: 0,
position: String::new(),
nation: String::new(),
league: String::new(),
club: String::new(),
attributes: [0; 6],
};
let kicktakers: Vec<KicktakerRef> =
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
let ext = Fifa17SquadExtensionV1 {
custom: oracle["custom"].as_str().map(str::to_string),
squad_type: oracle["squadType"].as_str().map(str::to_string),
kit_numbers,
manager,
kicktakers,
client_reported: ClientReportedSquadEval {
chemistry: oracle["chemistry"].as_i64(),
@@ -316,6 +356,7 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
slots,
ext: SquadExtInput::Fresh(ext),
owned: &owned,
manager: Some(manager_item),
};
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
else {
@@ -352,7 +393,23 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
}
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
assert_eq!(projected["custom"], oracle["custom"]);
assert_eq!(projected["manager"], oracle["manager"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
assert_eq!(
projected["manager"][0]["id"], oracle["manager"][0]["id"],
"the manager wire ref itself must still round-trip"
);
assert_eq!(
projected["manager"][0]["dream"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
assert_eq!(mgr_item["cardsubtypeid"], 4);
assert_eq!(mgr_item["resourceId"], 1_000_509);
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
assert_eq!(projected["squadType"], oracle["squadType"]);
assert_eq!(projected["chemistry"], oracle["chemistry"]);
+16 -1
View File
@@ -70,6 +70,11 @@ pub struct GenericClub {
pub struct GenericOwned {
pub owned_item_id: String,
pub card_id: String,
/// Generic classification for Core's ownership row. FIFA17 is the only side
/// that can map `cardsubtypeid` onto this, and Core defaults to `player`, so
/// leaving it off would durably record a coach or a contract card as a
/// player — wrong in the ownership authority even when the wire looks right.
pub content_kind: &'static str,
}
#[derive(Debug, Serialize)]
@@ -174,6 +179,7 @@ pub fn plan_apply(
owned.push(GenericOwned {
owned_item_id: core_id.clone(),
card_id: def.card_id.clone(),
content_kind: "player",
});
mappings.push(IdentityMapping {
core_id,
@@ -191,6 +197,7 @@ pub fn plan_apply(
owned.push(GenericOwned {
owned_item_id: core_id.clone(),
card_id: def.card_id.clone(),
content_kind: def.kind.as_str(),
});
mappings.push(IdentityMapping {
core_id,
@@ -208,7 +215,15 @@ pub fn plan_apply(
.and_then(|s| s.get(0))
.context("report says a squad is present but profile has no squads[0]")?;
let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
let mut put =
parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
// A HISTORICAL profile may reference a manager whose owned instance is
// not imported (unsupported/deferred, or a dangling id with no owned
// item at all). Drop such a manager ref here rather than failing the
// whole import — the manager assignment is only imported when its owned
// instance is. (A LIVE squad PUT still refuses an unresolved manager,
// because the client is actively assigning one it must own.)
put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
.map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
let formation = build
+296 -27
View File
@@ -87,16 +87,37 @@ pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
// --------------------------------------------------------------- entities
/// Forward numeric-id -> name resolution for the committed FIFA 17 tables
/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump).
/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`).
/// Forward lookups over the committed FIFA 17 tables: numeric id -> name for
/// `leagues.json`/`nations.json`/`teams.json` (`{schema, rows:[…]}` dump, mirrors
/// `scripts/seed_fifa17_cards.py`'s `_table_map`), plus EA's authored rating and
/// rare flag for the five STAFF families.
#[derive(Default)]
pub struct Entities {
leagues: BTreeMap<i64, String>,
nations: BTreeMap<i64, String>,
teams: BTreeMap<i64, String>,
/// `(cardsubtypeid, carddbid)` -> `(value, rare)` for the five staff tables.
///
/// `value` is the RATING the client re-rates a staff card to, and `rare`
/// selects its discard price column. Both are LIVE-VERIFIED: reading the
/// running client's own card records back out of memory
/// (`tools/coach_probe.py`, 4/4 HIT) shows record `+0xb4` == this `value`
/// and `+0x58` == this `rare`, and the discard value the client computes at
/// record `+0x3c` (`tools/discard_probe.py`) matches the price those two
/// select — 36 for the `value`-66 GK coach, 282 for the `rare`-1,
/// `value`-88 manager.
staff: BTreeMap<(i64, i64), (i64, i64)>,
}
/// `cardsubtypeid` -> staff table basename (`FUN_1800d8330`'s family selector).
const STAFF_TABLES: [(i64, &str); 5] = [
(4, "managercards"),
(5, "headcoachcards"),
(6, "gkcoachcards"),
(7, "physiocards"),
(8, "fitnesscoachcards"),
];
fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64, String>> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading table {}", path.display()))?;
@@ -118,17 +139,48 @@ fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64,
Ok(map)
}
/// `carddbid -> (value, rare)` from one staff table dump.
///
/// `value` is EA's authored rating for the card and `rare` its rare flag; both
/// are read verbatim, never defaulted, and a row missing either is skipped so a
/// gap stays a gap.
fn load_staff_table(path: &Path) -> Result<BTreeMap<i64, (i64, i64)>> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading staff table {}", path.display()))?;
let doc: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing staff table {}", path.display()))?;
let rows = doc
.get("rows")
.and_then(|r| r.as_array())
.with_context(|| format!("staff table {} has no rows[]", path.display()))?;
let mut map = BTreeMap::new();
for row in rows {
let get = |k: &str| row.get(k).and_then(serde_json::Value::as_i64);
if let (Some(id), Some(value), Some(rare)) = (get("carddbid"), get("value"), get("rare")) {
map.insert(id, (value, rare));
}
}
Ok(map)
}
impl Entities {
pub fn from_tables_dir(dir: impl AsRef<Path>) -> Result<Self> {
let dir = dir.as_ref();
let mut staff = BTreeMap::new();
for (subtype, table) in STAFF_TABLES {
for (carddbid, row) in load_staff_table(&dir.join(format!("{table}.json")))? {
staff.insert((subtype, carddbid), row);
}
}
Ok(Entities {
leagues: load_table(&dir.join("leagues.json"), "leagueid", "leaguename")?,
nations: load_table(&dir.join("nations.json"), "nationid", "nationname")?,
teams: load_table(&dir.join("teams.json"), "teamid", "teamname")?,
staff,
})
}
/// Build directly from id->name maps (tests).
/// Build directly from id->name maps (tests). Carries no staff table.
pub fn from_maps(
leagues: BTreeMap<i64, String>,
nations: BTreeMap<i64, String>,
@@ -138,6 +190,32 @@ impl Entities {
leagues,
nations,
teams,
staff: BTreeMap::new(),
}
}
/// EA's `(rating, rare)` for one staff definition, or `None` when the id is
/// absent from its family's table (the client would draw its own miss-fill).
pub fn staff_stats(&self, subtype: i64, carddbid: i64) -> Option<(i64, i64)> {
self.staff.get(&(subtype, carddbid)).copied()
}
/// Fill in the rating and rare flag the client re-rates STAFF cards to.
///
/// The wire never sends either (measured: a staff record arrives with no
/// `rating`, no `rareflag` and no `discardValue`), so without this a staff
/// definition reaches the catalog with `rating: None` and the server cannot
/// price a quick sell to match what the client displays. Players and
/// consumables are untouched — their wire values are authoritative.
pub fn enrich_staff(&self, plan: &mut NonPlayerPlan) {
for def in &mut plan.supported {
if def.kind != ContentKind::Staff && def.kind != ContentKind::Manager {
continue;
}
if let Some((value, rare)) = self.staff_stats(def.subtype, def.resource_id) {
def.rating.get_or_insert(value);
def.rareflag.get_or_insert(rare);
}
}
}
@@ -160,16 +238,61 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
// ------------------------------------------------------------- classification
/// A club item's family, settled from the CardsDLL club-item resolver
/// (`FUN_180119bd0`, `plan-2026-08-06-card-subsystem.md`) and cross-checked
/// against the shipped definition tables. Each family's `cardassetid` is
/// CONSTANT across every row it ships, which is what makes it a usable gate:
/// kit 35 (1482 rows), stadium 36 (78), badge 39 (656), ball 37 (42),
/// league logo 40 (44).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClubFamily {
pub kind: ContentKind,
pub label: &'static str,
/// The `cardassetid` every definition in this family carries.
pub card_asset_id: i64,
}
/// Club-item `cardsubtypeid` -> family. These subtypes cannot collide with the
/// other classes: consumables occupy 51..=341 and staff 4/6/8.
pub fn club_family(subtype: i64) -> Option<ClubFamily> {
let f = |kind, label, card_asset_id| {
Some(ClubFamily {
kind,
label,
card_asset_id,
})
};
match subtype {
9 => f(ContentKind::Kit, "Kit", 35),
10 => f(ContentKind::Stadium, "Stadium", 36),
11 => f(ContentKind::Badge, "Club Badge", 39),
30 => f(ContentKind::Ball, "Ball", 37),
// League logos have no equipped slot of their own, so Core holds them
// as generic owned content rather than inventing a designation.
31 => f(ContentKind::Misc, "League Logo", 40),
_ => None,
}
}
/// The disjoint source item classes. Every source item is exactly one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemClass {
PlayerCard,
Consumable,
Staff,
/// A club item (kit / stadium / badge / ball / league logo).
Club(ClubFamily),
Other,
}
pub fn classify(item: &Item) -> ItemClass {
// Subtype is the settled discriminator, so a club item is recognised by what
// it IS rather than by the id range it happens to occupy — kits used to be
// matched on 6_300_000..=6_400_654, which classified every other club family
// as `Other` and silently dropped it from the import.
if let Some(family) = item.cardsubtypeid.and_then(club_family) {
return ItemClass::Club(family);
}
match item.item_type.as_str() {
"staff" => ItemClass::Staff,
"player" => {
@@ -189,12 +312,15 @@ pub struct ItemCounts {
pub player_cards: usize,
pub consumables: usize,
pub staff: usize,
/// Club items: kits, stadiums, badges, balls, league logos.
pub club_items: usize,
pub other: usize,
}
impl ItemCounts {
pub fn balances(&self) -> bool {
self.player_cards + self.consumables + self.staff + self.other == self.total
self.player_cards + self.consumables + self.staff + self.club_items + self.other
== self.total
}
}
@@ -208,6 +334,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
ItemClass::PlayerCard => c.player_cards += 1,
ItemClass::Consumable => c.consumables += 1,
ItemClass::Staff => c.staff += 1,
ItemClass::Club(_) => c.club_items += 1,
ItemClass::Other => c.other += 1,
}
}
@@ -524,21 +651,43 @@ pub fn plan_definitions(
// ------------------------------------------------------- non-player content
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
/// emit time; this carries only the identity + honest functional `name` (the
/// taxonomy label, never a marketing name).
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
/// render metadata stays here and in the adapter catalog, never generic Core.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonPlayerDefinition {
pub card_id: String,
pub resource_id: i64,
/// Base asset id when the source carries one (consumables: `== resource_id`);
/// staff carry no `assetId`, so this is `None`.
/// Base asset id when the source carries one.
pub asset_id: Option<i64>,
pub kind: ContentKind,
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
pub subtype: i64,
/// Honest functional label (e.g. "Player Contract", "GK Coach").
/// Non-player card-art id (`35` for kits), when present.
pub card_asset_id: Option<i64>,
/// Source team id for a kit definition, when present.
pub team_id: Option<i64>,
/// Consumable effect magnitude (`amount`), when the source carries one.
///
/// Definition-level, and measured to be so: across every owned consumable in
/// the real profile the observed `amount` equals its `fcc_*` table row
/// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds
/// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068
/// arrive as two separate instances, each with the same amount.
pub amount: Option<i64>,
/// Contract-card payload (`contract`), when the source carries one. Present
/// on exactly the contract families (subtypes 201/202) and absent from every
/// other owned consumable, so it is the card's own field rather than the
/// generic per-item contract atom that players and staff carry.
pub contract: Option<i64>,
/// Card rating, when the source carries one (matches the `fcc_*` row).
pub rating: Option<i64>,
/// EA's authored `rare` flag for a STAFF definition, from the same table row
/// as [`Self::rating`]. The wire never carries it (the client re-rates staff
/// from its own DB), and it is NOT cosmetic: it selects the discard price
/// column, so a `rare`-1 manager and a `rare`-0 coach price differently.
pub rareflag: Option<i64>,
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
pub name: String,
/// Wire ids of every owned copy of this resourceId (preserved).
pub wire_ids: Vec<i64>,
@@ -563,6 +712,8 @@ pub struct NonPlayerPlan {
pub consumables: usize,
/// Count of SUPPORTED staff definitions.
pub staff: usize,
/// Count of SUPPORTED kit definitions.
pub kits: usize,
}
impl NonPlayerPlan {
@@ -572,15 +723,16 @@ impl NonPlayerPlan {
}
}
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
/// the family (consumable) or role (staff) via the adapter's evidence-based
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
/// on their definition-level metadata across every owned copy; disagreement or
/// missing required metadata defers the whole group, never fabricates a value.
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items {
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
if matches!(
classify(it),
ItemClass::Consumable | ItemClass::Staff | ItemClass::Club(_)
) {
groups.entry(it.resource_id).or_default().push(it);
}
}
@@ -622,6 +774,31 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
continue;
};
let card_asset_id = items[0].cardassetid;
let team_id = items[0].teamid;
// These are definition-level, so every owned copy must agree. Two copies
// of one consumable that disagreed would mean the field is really
// per-instance, and silently taking the first copy's value would bake a
// guess into the catalog — so defer the whole group instead.
let amount = items[0].amount;
let contract = items[0].contract;
let rating = items[0].rating;
if items.iter().any(|item| {
item.cardassetid != card_asset_id
|| item.teamid != team_id
|| item.amount != amount
|| item.contract != contract
|| item.rating != rating
}) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "render_metadata_conflict".to_string(),
});
continue;
}
let (kind, label) = match class {
ItemClass::Consumable => match consumable_family(subtype) {
Some((_family, label)) => (ContentKind::Consumable, label),
@@ -647,15 +824,55 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
continue;
}
},
_ => unreachable!("only Consumable/Staff were grouped"),
ItemClass::Club(family) => {
// Every definition in a club family ships the SAME cardassetid,
// so a copy carrying anything else is not the item it claims to
// be and is deferred rather than rendered as the wrong art.
if card_asset_id != Some(family.card_asset_id) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "missing_club_item_render_metadata".to_string(),
});
continue;
}
// A kit additionally needs its team: the kit identity resolver
// keys on it, and this requirement is observed on real owned
// kits. The other families ship no teamid at all, so demanding
// one would defer every legitimate badge, ball and stadium.
if family.kind == ContentKind::Kit && team_id.is_none() {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "missing_kit_render_metadata".to_string(),
});
continue;
}
(family.kind, family.label)
}
_ => unreachable!("only Consumable/Staff/Club were grouped"),
};
plan.supported.push(NonPlayerDefinition {
card_id: format!("fifa17_{resource_id}"),
resource_id,
asset_id: items[0].asset_id,
// A club item's carddbid IS its asset id; the source may not repeat
// it in an `assetId` field.
asset_id: if matches!(class, ItemClass::Club(_)) {
Some(resource_id)
} else {
items[0].asset_id
},
kind,
subtype,
card_asset_id,
team_id,
amount,
contract,
rating,
rareflag: None,
name: label.to_string(),
wire_ids,
});
@@ -670,6 +887,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
.iter()
.filter(|d| d.kind == ContentKind::Staff)
.count();
plan.kits = plan
.supported
.iter()
.filter(|definition| definition.kind == ContentKind::Kit)
.count();
plan
}
@@ -815,10 +1037,11 @@ impl Report {
let mut b = Vec::new();
if !self.counts.balances() {
b.push(format!(
"item-type accounting does not balance ({}+{}+{}+{} != {})",
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.club_items,
self.counts.other,
self.counts.total
));
@@ -874,7 +1097,10 @@ pub fn analyze(
let identity = plan_identity(profile, &supported_rids);
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
let squad = plan_squad(profile, &supported_wire);
let non_player = plan_non_player_definitions(profile);
let mut non_player = plan_non_player_definitions(profile);
// Staff carry no rating or rare flag on the wire; fill them from the tables
// the client itself re-rates from, so a quick sell can be priced to match.
entities.enrich_staff(&mut non_player);
Report {
game: "fifa17".to_string(),
persona_id: profile.persona_id,
@@ -911,11 +1137,12 @@ impl std::fmt::Display for Report {
)?;
writeln!(
f,
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} club_items={} other={} balances={}",
self.counts.total,
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.club_items,
self.counts.other,
self.counts.balances()
)?;
@@ -976,10 +1203,11 @@ impl std::fmt::Display for Report {
}
writeln!(
f,
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
np.supported.len(),
np.consumables,
np.staff,
np.kits,
np.deferred.len(),
np.deferred_instances()
)?;
@@ -1033,6 +1261,9 @@ pub struct EmitSummary {
pub content_pack: std::path::PathBuf,
pub host_catalog: std::path::PathBuf,
pub manifest: std::path::PathBuf,
/// Core `reclassify` request correcting the content_kind of rows imported
/// before the taxonomy existed.
pub reclassify: std::path::PathBuf,
pub definitions: usize,
pub catalog_entries: usize,
pub supported_instances: usize,
@@ -1126,13 +1357,26 @@ pub fn emit_content(
}
for d in &report.non_player.supported {
// asset_id falls back to resource_id (staff carry no assetId); version 0,
// rareflag 0 — a consumable/staff never renders as a special card.
// because a consumable/staff never renders as a versioned special card.
cards.insert(
d.card_id.clone(),
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
// A consumable is unrenderable without these: the adapter refuses
// to emit a card whose art id it does not know, and the families
// that read `amount`/`contract` draw "-1" or grant nothing when
// the field is missing. Omitting them here is what kept every
// owned consumable off the wire.
"amount": d.amount,
"contract": d.contract,
"rating": d.rating,
"asset_id": d.asset_id.unwrap_or(d.resource_id),
"version": 0,
"rareflag": 0,
// NOT cosmetic: `rareflag` selects the discard price column, and
// a staff card's is EA's own `rare`, filled from its family table
// by `Entities::enrich_staff`. 0 for everything else, as before.
"rareflag": d.rareflag.unwrap_or(0),
"kind": d.kind.as_str(),
"subtype": d.subtype,
}),
@@ -1152,6 +1396,28 @@ pub fn emit_content(
let host_catalog = content_dir.join("fifa17-production-catalog.json");
write_json_pretty(&host_catalog, &catalog)?;
// ---- Core reclassify request (content_kind of already-imported rows) ----
// A profile import is once-only, so a club imported before the taxonomy
// existed still records its coaches, kits and consumables as players. Core
// cannot derive the right kind (it is FIFA17 taxonomy), so hand it the
// mapping. Players are included too: a definition that was mis-set stays
// correctable, and a rerun changes nothing.
let assignments: Vec<serde_json::Value> =
report
.definitions
.supported
.iter()
.map(|d| serde_json::json!({ "card_id": d.card_id, "content_kind": "player" }))
.chain(report.non_player.supported.iter().map(
|d| serde_json::json!({ "card_id": d.card_id, "content_kind": d.kind.as_str() }),
))
.collect();
let reclassify = content_dir.join("fifa17-reclassify.json");
write_json_pretty(
&reclassify,
&serde_json::json!({ "game_id": "fifa17", "assignments": assignments }),
)?;
// ---- PRIVATE: import manifest (wire ids + deferred set) ----
let supported: Vec<serde_json::Value> = report
.definitions
@@ -1195,6 +1461,8 @@ pub fn emit_content(
.iter()
.map(|d| {
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
"card_id": d.card_id,
"resource_id": d.resource_id,
"asset_id": d.asset_id,
@@ -1259,6 +1527,7 @@ pub fn emit_content(
Ok(EmitSummary {
content_pack,
host_catalog,
reclassify,
manifest: manifest_path,
definitions: report.definitions.supported.len(),
catalog_entries: report.definitions.supported.len() + non_player_definitions,
+5
View File
@@ -123,6 +123,11 @@ fn run() -> Result<ExitCode> {
sum.supported_instances,
sum.deferred_instances
);
println!(
" reclassify : {} (run `openfut-core reclassify <file>` to correct \
content_kind on a club imported before the taxonomy existed)",
sum.reclassify.display()
);
println!(
" non-player : {} definition(s), {} instance(s) (consumable/staff)",
sum.non_player_definitions, sum.non_player_instances
+6 -1
View File
@@ -70,12 +70,17 @@ pub struct Item {
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
#[serde(default)]
pub cardassetid: Option<i64>,
/// Consumable stack size (`amount`). Permissive.
/// Consumable effect magnitude (`amount`) — NOT a stack size: every owned
/// copy is its own instance and carries its definition's value, which is
/// exactly the `fcc_*` table's `amount` column. Permissive.
#[serde(default)]
pub amount: Option<i64>,
/// Staff/contract `contract` count. Permissive.
#[serde(default)]
pub contract: Option<i64>,
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
#[serde(rename = "itemState", default)]
pub item_state: String,
}
#[derive(Debug, Clone, Deserialize)]
+273 -14
View File
@@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() {
player(100000002, VER5_176580, 176580, 92),
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
r#"{"id":100000500,"resourceId":6300006,"assetId":6300006,
"cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"#
.to_string(),
];
let c = count_items(&profile(&items, "[]", 100000500));
let c = count_items(&profile(&items, "[]", 100000501));
assert_eq!(
(c.total, c.player_cards, c.consumables, c.staff, c.other),
(4, 2, 1, 1, 0)
(
c.total,
c.player_cards,
c.consumables,
c.staff,
c.club_items,
c.other
),
(5, 2, 1, 1, 1, 0)
);
assert!(c.balances());
}
@@ -649,6 +659,14 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String {
)
}
fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String {
format!(
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
"cardsubtypeid":9,"cardassetid":35,"teamid":{team_id},
"itemState":"{item_state}","owners":1,"untradeable":false}}"#
)
}
#[test]
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
// The exact record set from the ticket: distinct resourceIds, so each is its
@@ -676,6 +694,7 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
);
assert_eq!(plan.consumables, 17);
assert_eq!(plan.staff, 3);
assert_eq!(plan.kits, 0);
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
// Honest labels + kinds resolve from the taxonomy (spot checks).
@@ -695,6 +714,138 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
}
/// A consumable the adapter cannot render is a consumable the club cannot use:
/// the shaper drops any card whose art id it does not know, and the families
/// that read `amount`/`contract` would draw "-1" or grant nothing. Emitting the
/// definition without these fields is exactly what kept all 17 owned
/// consumables off the wire while club/stats still counted them.
#[test]
fn consumable_definitions_carry_the_fields_the_client_renders() {
let items = vec![
// training +15: carries `amount`, no `contract`
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rating":85,"rareflag":0}"#
.to_string(),
// player contract: carries `contract`, no `amount`
r#"{"id":100000202,"resourceId":5001004,"assetId":5001004,"itemType":"player",
"cardsubtypeid":201,"cardassetid":7,"contract":7,"rating":60,"rareflag":0}"#
.to_string(),
];
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
let training = by_id("fifa17_5003012");
assert_eq!(training.card_asset_id, Some(3), "card art id");
assert_eq!(training.amount, Some(15), "effect magnitude");
assert_eq!(training.contract, None);
assert_eq!(training.rating, Some(85));
let contract = by_id("fifa17_5001004");
assert_eq!(contract.card_asset_id, Some(7));
assert_eq!(contract.contract, Some(7));
assert_eq!(contract.amount, None, "contract families ignore amount");
}
/// `amount` is definition-level (every owned copy of a card carries the same
/// value), so two copies that DISAGREE mean the field is really per-instance.
/// Taking the first copy's value would silently bake a guess into the catalog.
#[test]
fn disagreeing_render_metadata_defers_rather_than_guessing() {
let items = vec![
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rareflag":0}"#
.to_string(),
r#"{"id":100000202,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":10,"rareflag":0}"#
.to_string(),
];
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
assert!(plan.supported.is_empty(), "must not pick a winner");
assert_eq!(plan.deferred.len(), 1);
assert_eq!(plan.deferred[0].reason, "render_metadata_conflict");
}
#[test]
fn club_item_missing_render_metadata_defers() {
// No cardassetid at all: it cannot be drawn, whatever family it claims.
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
"cardsubtypeid":9,"itemState":"activeHomeKit"}"#
.to_string();
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
assert!(plan.supported.is_empty());
assert_eq!(plan.deferred.len(), 1);
assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata");
// Correct kit art, but no team: the kit identity resolver keys on teamid.
let item = r#"{"id":100000502,"resourceId":6300007,"assetId":6300007,
"cardsubtypeid":9,"cardassetid":35,"itemState":"activeHomeKit"}"#
.to_string();
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
assert!(plan.supported.is_empty());
assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata");
// A kit carrying another family's art is not the item it claims to be.
let item = r#"{"id":100000503,"resourceId":6300008,"assetId":6300008,
"cardsubtypeid":9,"cardassetid":39,"teamid":21}"#
.to_string();
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
assert!(plan.supported.is_empty());
assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata");
}
/// Club items are settled by `cardsubtypeid`, not by the id range they occupy.
/// Keying kits off 6_300_000..=6_400_654 classified every OTHER club family as
/// `Other`, so a badge, ball, stadium or league logo was silently dropped from
/// the import even though its definition table ships with the game.
#[test]
fn every_club_family_classifies_and_imports() {
let club = |id: i64, resource: i64, subtype: i64, art: i64| {
format!(
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
"cardsubtypeid":{subtype},"cardassetid":{art},"teamid":21}}"#
)
};
let items = vec![
club(100000501, 6_300_006, 9, 35), // kit
club(100000502, 6_200_001, 10, 36), // stadium
club(100000503, 6_000_012, 11, 39), // badge
club(100000504, 8_120_194, 30, 37), // ball
club(100000505, 8_010_001, 31, 40), // league logo
];
let counts = count_items(&profile(&items, "[]", 100000600));
assert_eq!(
counts.club_items, 5,
"no club family falls through to Other"
);
assert_eq!(counts.other, 0);
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000600));
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
let kind = |cid: &str| {
plan.supported
.iter()
.find(|d| d.card_id == cid)
.unwrap_or_else(|| panic!("{cid} not imported"))
.kind
};
assert_eq!(kind("fifa17_6300006"), ContentKind::Kit);
assert_eq!(kind("fifa17_6200001"), ContentKind::Stadium);
assert_eq!(kind("fifa17_6000012"), ContentKind::Badge);
assert_eq!(kind("fifa17_8120194"), ContentKind::Ball);
// A league logo has no equipped slot, so it is generic owned content.
assert_eq!(kind("fifa17_8010001"), ContentKind::Misc);
// A club item's carddbid IS its asset id.
assert_eq!(
plan.supported
.iter()
.find(|d| d.card_id == "fifa17_6000012")
.unwrap()
.asset_id,
Some(6_000_012)
);
}
#[test]
fn unknown_subtype_consumable_defers_never_fabricated() {
let plan = plan_non_player_definitions(&profile(
@@ -763,6 +914,7 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
player(100000001, 20801, 20801, 94),
consumable(100000201, 5003012, 201), // Player Contract
staff(100000427, 3000083, 8), // Fitness Coach
kit(100000500, 6300006, 21, "activeHomeKit"),
];
let rep = analyze(
&profile(&items, "[]", 100000500),
@@ -771,16 +923,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
&none(),
);
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
assert_eq!(rep.non_player.supported.len(), 2);
assert_eq!(rep.non_player.supported.len(), 3);
let dir = tempfile::tempdir().unwrap();
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
assert_eq!(sum.definitions, 1, "one player definition");
assert_eq!(sum.non_player_definitions, 2);
assert_eq!(sum.non_player_instances, 2);
assert_eq!(sum.non_player_definitions, 3);
assert_eq!(sum.non_player_instances, 3);
assert_eq!(
sum.catalog_entries, 3,
"player + 2 non-player catalog entries"
sum.catalog_entries, 4,
"player + 3 non-player catalog entries"
);
// Content pack: neutral non-player CardDefinition with honest name.
@@ -807,21 +959,29 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit");
assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9);
assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35);
assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21);
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit);
let loaded_kit = loaded.lookup("fifa17_6300006").unwrap();
assert_eq!(loaded_kit.card_asset_id, 35);
assert_eq!(loaded_kit.team_id, 21);
// Manifest: private non_player section with preserved wire ids.
let man: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
assert_eq!(man["non_player"]["supported_instances"], 2);
assert_eq!(man["non_player"]["supported_instances"], 3);
let np = man["non_player"]["supported_definitions"]
.as_array()
.unwrap();
assert_eq!(np.len(), 2);
assert_eq!(np.len(), 3);
let cons_man = np
.iter()
.find(|d| d["card_id"] == "fifa17_5003012")
@@ -836,13 +996,14 @@ fn plan_apply_mints_non_player_owned_instances() {
player(100000001, 20801, 20801, 94),
consumable(100000201, 5003012, 201),
staff(100000427, 3000083, 8),
kit(100000500, 6300006, 21, "activeHomeKit"),
];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
// 1 player + 2 non-player owned instances, minted via the identical path.
assert_eq!(plan.request.owned.len(), 3);
assert_eq!(plan.mappings.len(), 3);
assert_eq!(plan.supported_instances, 3);
// Player, consumable, staff, and kit instances mint through one generic path.
assert_eq!(plan.request.owned.len(), 4);
assert_eq!(plan.mappings.len(), 4);
assert_eq!(plan.supported_instances, 4);
assert_eq!(plan.deferred_instances, 0);
let cards: BTreeSet<&str> = plan
.request
@@ -852,6 +1013,31 @@ fn plan_apply_mints_non_player_owned_instances() {
.collect();
assert!(cards.contains("fifa17_5003012"), "consumable minted");
assert!(cards.contains("fifa17_3000083"), "staff minted");
assert!(cards.contains("fifa17_6300006"), "kit minted");
// Core is the ownership authority, and it defaults an unstated row to
// `player`. A coach or a contract card durably recorded as a player is wrong
// in the authority even while the catalog-driven wire still looks right.
let kind_of = |card: &str| {
plan.request
.owned
.iter()
.find(|o| o.card_id == card)
.unwrap()
.content_kind
};
assert_eq!(kind_of("fifa17_20801"), "player");
assert_eq!(kind_of("fifa17_5003012"), "consumable");
assert_eq!(kind_of("fifa17_3000083"), "staff");
assert_eq!(kind_of("fifa17_6300006"), "kit");
// `amount` is an effect magnitude, not a stack count: two copies of one
// consumable are two rows, never one row of quantity 2. The importer states
// no quantity at all, and Core's default for an absent quantity is "not a
// stack" — so the guarantee is that the key never appears in the request.
let wire = serde_json::to_string(&plan.request).unwrap();
assert!(
!wire.contains("quantity"),
"no instance may claim to be a stack: {wire}"
);
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
let m = plan
.mappings
@@ -882,3 +1068,76 @@ fn deferred_non_player_instances_gate_a_production_apply() {
assert!(gate_staging(&plan, false).is_err(), "production blocks");
assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows");
}
/// The staff rating and rare flag come from the client's OWN tables, and these
/// exact values were read back out of the RUNNING client's memory:
/// `tools/coach_probe.py` graded all four resident staff records HIT (record
/// `+0xb4` == `value`, `+0x58` == `rare`), and `tools/discard_probe.py` read the
/// discard value the client computed for itself at record `+0x3c` — 36 for both
/// `value`-66 coaches and 282 for the `rare`-1, `value`-88 manager.
///
/// So this is not a table-parsing test. It pins the importer to numbers the live
/// client demonstrably uses.
#[test]
fn staff_stats_are_the_values_the_live_client_re_rates_to() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
// (subtype, carddbid) -> (value, rare), verified live.
assert_eq!(ent.staff_stats(6, 9000081), Some((66, 0)), "GK coach");
assert_eq!(ent.staff_stats(8, 3000083), Some((66, 0)), "fitness coach");
assert_eq!(ent.staff_stats(4, 1000509), Some((88, 1)), "manager");
// Keyed per family: a coach id must not resolve through another's table.
assert_eq!(
ent.staff_stats(4, 9000081),
None,
"gkcoach id is not a manager"
);
assert_eq!(ent.staff_stats(6, 12345678), None, "absent id stays absent");
}
/// `enrich_staff` fills ONLY staff, and only where the wire left a gap.
#[test]
fn enrich_staff_fills_staff_and_leaves_everything_else_alone() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
let items = vec![
// A GK coach (subtype 6) and a contract consumable, which carries its own
// rating on the wire and must not be touched.
staff(100000280, 9000081, 6),
consumable(100000300, 5001004, 201),
];
let mut plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
let before: Vec<Option<i64>> = plan.supported.iter().map(|d| d.rating).collect();
ent.enrich_staff(&mut plan);
let coach = plan
.supported
.iter()
.find(|d| d.resource_id == 9000081)
.expect("coach planned");
assert_eq!(
coach.rating,
Some(66),
"rating filled from gkcoachcards.value"
);
assert_eq!(coach.rareflag, Some(0), "rare filled from the same row");
let cons = plan
.supported
.iter()
.find(|d| d.resource_id == 5001004)
.expect("consumable planned");
assert_eq!(cons.rareflag, None, "a consumable gets no staff rare flag");
let cons_before = before[plan
.supported
.iter()
.position(|d| d.resource_id == 5001004)
.unwrap()];
assert_eq!(
cons.rating, cons_before,
"the wire rating is left untouched"
);
}
+11 -4
View File
@@ -31,7 +31,9 @@ S2 live-staging defect where the v2 Store BUY escaped to Python.
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
| Move | `PUT …/item` | `MoveItems` |
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` |
| Match ready | `…/match/ready` (any verb) | `MatchReady` |
| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
| Market buy | `…/trade/<id>` | `MarketBuy` |
@@ -76,7 +78,9 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO):
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` |
| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) |
| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` |
| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` |
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
@@ -177,8 +181,11 @@ Landed (Core authority + transport + several handlers; classifier NOT yet flippe
full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo`
economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one
Core state.
- **Writer handler**: `/match` reward (`handle_match_end` → Core `grant_reward`,
oracle `destroy_match_body` shape).
- **Writer handler**: `/match/end` reward (`handle_match_end` → Core
`complete_match`, the single exactly-once transaction — NOT `grant_reward`).
The per-match identity is the id minted by `POST …/match`, which is what makes
two abandoned matches (whose bodies are byte-identical) both payable while a
replay of either is refused.
- **Adapter policy mappers** (`181bd94`): match reward, pack price.
- All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested.
- **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy`
+142 -32
View File
@@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{
generate_pack_contents, GeneratedCandidate, GeneratedCard,
};
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
use openfut_adapter_fifa17::fut::store_catalog::{pack_by_id, PackDef};
use openfut_adapter_fifa17::fut::store_catalog::{
owned_pack_id_for_definition, pack_by_id, PackDef,
};
use crate::{
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
@@ -160,7 +162,12 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
.filter_map(|m| {
let item = core_owned(m);
let id = deps.assets.resolve(&item)?;
Some(shape_item(&item, id, deps.entities))
Some(shape_item(
&item,
id,
deps.entities,
deps.assets.discard_value(&item),
))
})
.collect()
}
@@ -244,7 +251,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
"firstPartyStoreId": 0,
"groupName": "fifa17",
"productId": pid.to_string(),
"purchasePackType": if pack.gold { "GOLD" } else { "BRONZE" },
"purchasePackType": match pack.category {
"gold" => "GOLD",
"silver" => "SILVER",
_ => "BRONZE",
},
}))
}
@@ -269,11 +280,12 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -
Ok(e) => e,
Err(_) => return error_response(503, "core_unavailable"),
};
// The unopened pack instance is an entitlement whose definition id is the
// pack id. Absent → already consumed / never granted: honest empty reveal.
// The unopened pack instance is an entitlement whose definition id resolves
// to this owned-only pack id (a numeric id or a symbolic reward-pack name).
// Absent → already consumed / never granted: honest empty reveal.
let ent = match ents
.into_iter()
.find(|e| e.definition_id.parse::<u64>().ok() == Some(pid))
.find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
{
Some(e) => e,
None => return json_response(&json!({ "itemData": [] })),
@@ -364,22 +376,13 @@ pub struct QuickSellDeps<'a> {
/// uses). Identity only; ownership is authorized by [`OwnedItemLookup`].
pub reverse: &'a dyn SquadWireResolver,
pub items: &'a dyn OwnedItemLookup,
}
/// OPENFUT CURRENT quick-sell value by rating (PLACEHOLDER, not EA-authentic).
/// Mirrors the on-wire `discardValue` that
/// `openfut_adapter_fifa17::fut::item` stamps, so the coins credited equal the
/// value the client displayed. (The Python oracle used a *different* invented
/// fallback — 600/300/150/50, `fut_store.py:505` — which disagreed with the wire
/// `discardValue`; crediting the displayed figure keeps them consistent.)
fn quick_sell_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
/// Prices the sale. This is the SAME resolver, and the same method, that
/// stamps `discardValue` onto the shaped card, so the coins credited are by
/// construction the number the client displayed — there is no second ladder
/// to drift. (The Python oracle had exactly that bug: an invented
/// 600/300/150/50 fallback in `fut_store.py:505` that disagreed with the
/// wire `discardValue`.)
pub assets: &'a dyn ItemIdentityResolver,
}
/// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do
@@ -402,7 +405,10 @@ pub fn handle_quick_sell(wire_ids: &[i64], deps: &QuickSellDeps<'_>) -> WireResp
Some(i) => i,
None => continue, // resolvable id, but not owned: never sold
};
match deps.econ.sell_item(&core_id, quick_sell_value(item.rating)) {
match deps
.econ
.sell_item(&core_id, deps.assets.discard_value(&item))
{
Ok(balance) => {
last_balance = Some(balance);
sold_ids.push(wire);
@@ -457,7 +463,10 @@ mod tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
@@ -592,6 +601,13 @@ mod tests {
// Sale settlement is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
@@ -695,7 +711,7 @@ mod tests {
// ── Store BUY ──────────────────────────────────────────────────────────
#[test]
fn buy_pack1_debits_mints_and_reveals_five_cards() {
fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
@@ -705,23 +721,23 @@ mod tests {
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"], 5); // pack 1 count
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5);
assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
assert_eq!(cpr["purchasedPackId"], 1);
assert_eq!(cpr["duplicateItemIdList"], json!([]));
// Exactly one atomic debit of the pack price (400) minting 5 items.
// Exactly one atomic debit of the pack price (400) minting 12 items.
assert_eq!(econ.coins(), 10_000 - 400);
let purchased = econ.purchased.lock();
assert_eq!(purchased.len(), 1);
assert_eq!(purchased[0].0, 400);
assert_eq!(purchased[0].1.len(), 5);
assert_eq!(purchased[0].1.len(), 12);
for g in &purchased[0].1 {
assert!(pool.iter().any(|c| c.card_id == g.card_id));
}
}
#[test]
fn buy_pack5_debits_gold_price_and_mints_seven() {
fn buy_pack5_debits_gold_price_and_mints_twelve() {
let econ = RecEcon::new(10_000);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
@@ -729,7 +745,7 @@ mod tests {
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["createPackResponse"]["numberItems"], 7); // pack 5 count
assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
assert_eq!(econ.coins(), 10_000 - 5000);
}
@@ -860,7 +876,7 @@ mod tests {
assert_eq!(b["firstPartyStoreId"], 0);
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
assert_eq!(econ.coins(), 10_000 - 5000);
assert_eq!(econ.purchased.lock()[0].1.len(), 7);
assert_eq!(econ.purchased.lock()[0].1.len(), 12);
}
#[test]
@@ -886,6 +902,31 @@ mod tests {
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count
}
#[test]
fn open_symbolic_silver_reward_redeems_entitlement_without_debit() {
// A Core reward grant ("silver_pack") resolves to owned-only pack 72 and
// opens for free by consuming its entitlement — the SBC reward-pack fix.
let econ = RecEcon::with_entitlements(4600, &["silver_pack"]);
let pool = pool();
let assets = FakeAssets::for_pool(&pool);
let ent = Fifa17Entities::default();
let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12));
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["packId"], 72);
assert_eq!(b["purchasePackType"], "SILVER");
assert_eq!(econ.coins(), 4600, "a reward pack opens for free");
assert!(
econ.entitlements.lock().is_empty(),
"the reward entitlement is consumed once"
);
let redeemed = econ.redeemed.lock();
assert_eq!(redeemed.len(), 1);
assert_eq!(redeemed[0].0, "e0");
assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver
}
#[test]
fn open_owned_70_twice_is_consume_once() {
let econ = RecEcon::with_entitlements(4600, &["70"]);
@@ -948,6 +989,16 @@ mod tests {
}
}
/// Prices sales with the trait's DEFAULT (legacy ladder) implementation, so
/// these tests pin the deployed behaviour: no catalog, no table.
struct LadderAssets;
impl ItemIdentityResolver for LadderAssets {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
}
static LADDER_ASSETS: LadderAssets = LadderAssets;
fn qs_deps<'a>(
econ: &'a RecEcon,
reverse: &'a FakeReverse,
@@ -957,6 +1008,7 @@ mod tests {
econ,
reverse,
items,
assets: &LADDER_ASSETS,
}
}
@@ -977,6 +1029,64 @@ mod tests {
assert_eq!(sold[0], ("c1".to_string(), 1500));
}
/// THE INVARIANT: the coins credited are whatever
/// [`ItemIdentityResolver::discard_value`] says — the SAME method, on the
/// SAME resolver, that stamps `discardValue` onto the shaped card. A second
/// pricing ladder living in this module is exactly the drift this pins
/// against, so the double returns a value no ladder could produce.
#[test]
fn the_sale_credits_whatever_priced_the_card_on_the_wire() {
struct OddPriced;
impl ItemIdentityResolver for OddPriced {
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
fn discard_value(&self, _item: &CoreOwnedItem) -> i64 {
10_980
}
}
let econ = RecEcon::new(1000);
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
// Rating 88 -> the legacy ladder would pay 1500. The resolver must win.
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
let deps = QuickSellDeps {
econ: &econ,
reverse: &reverse,
items: &items,
assets: &OddPriced,
};
let resp = handle_quick_sell_path(100_000_001, &deps);
assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(b["totalCredits"], 11_980, "1000 + the card's own price");
assert_eq!(econ.sold.lock()[0], ("c1".to_string(), 10_980));
}
/// The trait default MUST stay the deployed ladder, so a resolver with no
/// catalog behind it prices exactly as it did before the table existed.
#[test]
fn the_default_price_is_still_the_legacy_ladder() {
for (rating, expected) in [
(94u8, 1500i64),
(88, 1500),
(82, 900),
(77, 600),
(66, 300),
(50, 150),
(0, 150),
] {
assert_eq!(
LADDER_ASSETS.discard_value(&owned("c", rating)),
expected,
"rating {rating}"
);
assert_eq!(
openfut_adapter_fifa17::fut::item::legacy_discard_value(rating),
expected
);
}
}
#[test]
fn quick_sell_body_form_and_alias_match_path_form() {
for req in [
+1555 -182
View File
File diff suppressed because it is too large Load Diff
+27 -14
View File
@@ -28,6 +28,7 @@ use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item_state;
use openfut_adapter_fifa17::fut::non_economy;
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
@@ -81,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// snapshot persisted at listing time; a row written before snapshots existed
/// degrades to the stub (honest, not fabricated).
///
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the
/// `state` overrides the card's `itemState`. FIFA 17's vocabulary is the
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
/// value for an item offered for sale. The Python oracle stamps `listFS` on the
/// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero
@@ -89,8 +90,8 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
/// oracle, the binary wins.
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
auction_record_tuned(l, item_state, None, 0)
fn auction_record_as(l: &Listing, state: &str) -> Value {
auction_record_tuned(l, state, None, 0)
}
/// [`auction_record_as`] with the two fields the staging sold experiment varies.
@@ -105,7 +106,7 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
/// what makes the client's reaction attributable to the token.
fn auction_record_tuned(
l: &Listing,
item_state: &str,
state: &str,
sold_bid_state: Option<&str>,
coins_processed: i64,
) -> Value {
@@ -152,7 +153,7 @@ fn auction_record_tuned(
.map(|mut card| {
// Keep the wire identity and presentation state authoritative here.
card["id"] = json!(item_id);
card["itemState"] = json!(item_state);
card["itemState"] = json!(state);
card["untradeable"] = json!(false);
card
})
@@ -160,7 +161,7 @@ fn auction_record_tuned(
json!({
"id": item_id,
"resourceId": resource,
"itemState": item_state,
"itemState": state,
"untradeable": false,
})
});
@@ -204,9 +205,9 @@ fn auction_record_tuned(
/// closed/sold echoes the buy path returns.
fn auction_record(l: &Listing) -> Value {
let state = if l.state == "active" {
"forSale"
item_state::FOR_SALE
} else {
"free"
item_state::FREE
};
auction_record_as(l, state)
}
@@ -283,7 +284,7 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
let identity = resolver.resolve(&owned);
let resource_id = identity.map(|id| id.resource_id as i64);
let item_json = identity
.map(|id| shape_item(&owned, id, ent))
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
.and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing {
item_id,
@@ -444,7 +445,7 @@ pub async fn handle_market_query(
};
let mut auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_as(l, "forSale"))
.map(|l| auction_record_as(l, item_state::FOR_SALE))
.collect();
// STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if
// sold rows persist in the seller's pile until acknowledged, so the experiment
@@ -456,7 +457,7 @@ pub async fn handle_market_query(
for l in &sold {
auctions.push(auction_record_tuned(
l,
"forSale",
item_state::FOR_SALE,
exp.bid_state,
exp.coins_processed,
));
@@ -607,7 +608,7 @@ pub async fn handle_market_status(
};
let auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed))
.map(|l| auction_record_tuned(l, item_state::FOR_SALE, exp.bid_state, exp.coins_processed))
.collect();
eprintln!(
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
@@ -723,7 +724,7 @@ pub async fn handle_market_buy(
rec["tradeState"] = json!("closed");
rec["bidState"] = json!("highest");
rec["currentBid"] = json!(price);
rec["itemData"]["itemState"] = json!("free");
rec["itemData"]["itemState"] = json!(item_state::FREE);
ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance }))
}
// Insufficient funds surfaced by Core (concurrent debit) -> 461.
@@ -805,7 +806,8 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -959,6 +961,13 @@ mod tests {
squad_slots_freed: 0,
})
}
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
@@ -1147,6 +1156,10 @@ mod tests {
let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
let rec = parse(&pile)["auctionInfo"][0].clone();
assert_eq!(rec["itemData"]["itemState"], "forSale");
assert!(
item_state::is_recovered(rec["itemData"]["itemState"].as_str().unwrap()),
"every emitted itemState must be in FIFA 17's own 12-row table"
);
assert_eq!(rec["itemData"]["rating"], 84);
assert_eq!(rec["itemData"]["id"], 100004617i64);
assert_eq!(rec["itemData"]["resourceId"], 169193);
+41 -23
View File
@@ -226,16 +226,11 @@ fn set_balance(client: &HttpCoreClient, target: i64) {
assert_eq!(client.balance().unwrap(), target, "balance set");
}
/// The on-wire quick-sell `discardValue` tiers (host `economy_store::quick_sell_value`),
/// replicated to assert the EXACT credit — the client-visible contract.
/// The on-wire quick-sell `discardValue`, taken from the ONE implementation the
/// server uses rather than replicated here: a local copy silently passes while
/// the real price changes underneath it.
fn qs_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
openfut_adapter_fifa17::fut::item::legacy_discard_value(rating)
}
fn owns(client: &HttpCoreClient, core_id: &str) -> bool {
@@ -311,7 +306,7 @@ fn case_a_two_buys(h: &Harness) -> String {
refs, 1,
"exactly one BUY refused 461 (statuses {statuses:?})"
);
// The winner minted 5 cards; the loser minted nothing.
// The winner minted 12 cards (real Bronze Pack); the loser minted nothing.
for r in &rs {
if r.status == 200 {
assert_eq!(
@@ -319,7 +314,7 @@ fn case_a_two_buys(h: &Harness) -> String {
.as_array()
.unwrap()
.len(),
5
12
);
}
}
@@ -436,8 +431,10 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
"POST",
"/ut/game/fifa17/auctionhouse",
&[],
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
.as_bytes(),
format!(
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
)
.as_bytes(),
None,
)
.expect("list routed");
@@ -484,20 +481,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
)
}
/// E: match REWARD + Store BUY concurrently → final balance is one legal
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
/// final balance must equal the start exactly.
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
/// update). The two commute, so the final balance must equal exactly one serial
/// outcome: the match's reported post-credit balance (`allCoins`), or that minus
/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic,
/// so it holds even when a WIN also triggers an XP level-up bonus.
fn case_e_reward_and_buy(h: &Harness) -> String {
// Measure the store BUY's deterministic debit once.
set_balance(&h.client, 50_000);
let pre_probe = h.client.balance().unwrap();
let probe = fire(
&h.server,
vec![(
"PUT",
"/ut/game/fifa17/store/transaction".into(),
b"{\"packId\":1}".to_vec(),
)],
);
assert_eq!(probe[0].status, 200, "probe buy ok");
let buy_debit = pre_probe - h.client.balance().unwrap();
assert!(buy_debit > 0, "store buy must debit a positive price");
let start = 8_000i64;
for _ in 0..ITERS {
for i in 0..ITERS {
set_balance(&h.client, start);
// A DISTINCT match per iteration (unique matchReportId) so the
// exactly-once reward applies every time.
let rs = fire(
&h.server,
vec![
(
"POST",
"/ut/delete/game/fifa17/match".into(),
b"{\"endReason\":\"WIN\"}".to_vec(),
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
),
(
"PUT",
@@ -507,13 +523,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
],
);
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
assert_eq!(
h.client.balance().unwrap(),
start,
"reward(+400) and buy(-400) both applied: no lost update"
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
let after = h.client.balance().unwrap();
// Both writers serialized: `after` is one of the two legal orderings.
assert!(
after == all || after == all - buy_debit,
"no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}"
);
}
format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)")
format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})")
}
/// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
+98 -30
View File
@@ -26,15 +26,15 @@
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
//! | | | across the credits & massinfo surfaces on each side. |
//! | purchasegroup pack70 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. |
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, |
//! | | | sentinel `state:"active"`. |
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, |
//! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
//! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
//! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. |
//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};|
//! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
//! | | | machine (register_capability+open_session+freeze) exactly as |
//! | | | the oracle's launcher/auth handshake does. |
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, |
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
//! | | | machine (register_capability+open_session+freeze). |
//! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
//! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal |
@@ -432,6 +432,36 @@ fn json_shape(value: &Value) -> Value {
}
}
/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's
/// own report.
///
/// Core's progression system (objectives + achievements) has no counterpart in
/// the Python oracle, and `complete_match` unlocks achievements in the SAME
/// transaction that pays the match reward. So a raw balance delta around a match
/// is `match reward + newly unlocked achievements`, and the differential has to
/// account for the second term instead of pretending it does not exist.
///
/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so
/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes
/// any already-satisfied unlock and leaves the measured window attributable to the
/// op under test.
fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 {
let body: Value = http
.get(format!("{core_base}/achievements"))
.header("X-OpenFUT-Game", "fifa17")
.send()
.expect("GET /achievements")
.json()
.expect("achievements json");
body["achievements"]
.as_array()
.expect("achievements array")
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.map(|a| a["reward_coins"].as_i64().unwrap_or(0))
.sum()
}
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
/// Core client + `reqwest::blocking` require this), exactly like the
/// thread-per-connection server — the bridge takes its direct `block_on` path.
@@ -550,29 +580,43 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
None,
)
.1;
// STORE DIVERGED: Rust is the authoritative store owner and serves the real
// 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback
// baseline, never modified) still serves the old placeholder set {1,5,6,7,70},
// so this is Rust-authoritative, NOT oracle parity.
assert_eq!(
pack_ids(&opg),
vec![1, 5, 6, 7, 70],
"oracle owned pack70 id set"
"oracle (rollback) placeholder id set"
);
assert_eq!(
pack_ids(&rpg),
vec![1, 5, 6, 7, 70],
"rust owned pack70 id set"
vec![1, 2, 3, 4, 5, 6, 70],
"rust authoritative real-catalogue id set"
);
for b in [&opg, &rpg] {
assert!(
!pack_ids(b).contains(&SENTINEL_PACK_ID),
"no 65534 sentinel while a pack is owned"
);
// packType parity per id (BRONZE for 1, GOLD for the rest).
for p in b["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "packType parity for pack {id}");
}
}
matrix.push(("purchasegroup pack70", "PARITY"));
// packType by side: oracle BRONZE for 1 else GOLD; rust by real category
// (1,2 bronze / 3,4 silver / 5,6,70 gold).
for p in opg["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "oracle packType for pack {id}");
}
for p in rpg["purchase"].as_array().unwrap() {
let id = p["id"].as_u64().unwrap();
let want = match id {
1 | 2 => "BRONZE",
3 | 4 => "SILVER",
_ => "GOLD",
};
assert_eq!(p["packType"], want, "rust packType for pack {id}");
}
matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
let o_bal0 = oracle.coins();
@@ -600,11 +644,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.as_array()
.expect("rust itemList")
.clone();
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards");
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards");
// STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
// oracle placeholder awards 5. Both debit the same 400-coin price.
assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards");
assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards");
assert_eq!(
o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
5,
"oracle numberItems==5"
);
assert_eq!(
r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
12,
"rust numberItems==12"
);
for b in [&o_buy, &r_buy] {
let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
assert_eq!(
cpr["purchasedPackId"].as_i64().unwrap(),
1,
@@ -619,9 +674,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
assert_eq!(
client.balance().unwrap() - r_bal0,
-400,
"rust BUY debits 400"
"rust BUY debits 400 (price parity)"
);
matrix.push(("Store BUY (pack1)", "PARITY"));
matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN"));
// Minted wire ids for the item ops that follow (both sides put them in the
// pending purchased pile).
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
@@ -807,6 +862,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
matrix.push(("move-items PUT /item", "PARITY"));
// ── OP 10: match reward (WIN = +400) ───────────────────────────────────
// The oracle pays the match reward and nothing else. Core pays the same match
// reward and, in the same transaction, any achievement the match unlocks — a
// deliberate Rust-authority feature the oracle never had. Flush pending
// unlocks first so the measured window belongs to this match.
let r_ach_before = achievement_coins_granted(&http, core_base);
let o_mbal = oracle.coins();
let (o_mms, o_mm) = oracle.req(
"POST",
@@ -864,7 +924,15 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
);
}
assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400");
assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400");
// Same match reward on both sides; Core additionally credits exactly the
// achievements this match unlocked, and nothing unexplained.
let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before;
assert_eq!(
client.balance().unwrap() - r_mbal,
400 + r_ach_awarded,
"rust WIN credits +400 plus exactly the achievements it unlocked \
(achievement coins = {r_ach_awarded})"
);
matrix.push(("match reward (WIN)", "PARITY"));
// ── OP 11: market list (POST /auctionhouse) ────────────────────────────
@@ -1176,8 +1244,8 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
);
assert_eq!(
pack_ids(&r_pg_s),
vec![1, 5, 6, 7, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> sentinel"
vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> real catalogue + sentinel"
);
for b in [&o_pg_s, &r_pg_s] {
let s = b["purchase"]
@@ -1188,7 +1256,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.unwrap();
assert_eq!(s["state"], "active", "sentinel state active");
}
matrix.push(("purchasegroup sentinel", "PARITY"));
matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN"));
// ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
// Oracle: register the launcher capability, open a session (auth), present the
@@ -1240,11 +1308,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
assert_eq!(
pack_ids(&r_pg_c),
vec![1, 5, 6, 7],
"rust clean-v1 strips the sentinel"
vec![1, 2, 3, 4, 5, 6],
"rust clean-v1 real catalogue, sentinel stripped"
);
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID));
matrix.push(("purchasegroup clean-v1", "PARITY"));
matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN"));
// ── Emit the matrix for the run log. ────────────────────────────────────
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
+9 -3
View File
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server, WireResponse,
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
}
self.inner.settle_sale(sale)
}
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
if self.trip("complete_match") {
return Err(Self::injected());
}
self.inner.complete_match(m)
}
}
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
+153 -21
View File
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
}
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
/// Returns nothing; panics on any mismatch.
fn seed_and_exercise(base: &str) {
/// Returns the final Core balance so the restart phase can assert persistence.
fn seed_and_exercise(base: &str) -> i64 {
wait_ready(base);
let http = reqwest::blocking::Client::new();
@@ -155,12 +155,43 @@ fn seed_and_exercise(base: &str) {
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
// exactly-once complete_match transaction, end to end. The reward is at least
// the match coins; Core may also grant XP-driven level-up and first-win
// achievement coins, so assert the flat match coins + a relative delta.
let before_match = client.balance().unwrap();
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(m.status, 200);
let mb: Value = serde_json::from_slice(&m.body).unwrap();
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
assert_eq!(client.balance().unwrap(), 5400);
assert_eq!(mb["matchCoins"], 400, "flat match coins");
let after_match = client.balance().unwrap();
assert!(
after_match >= before_match + 400,
"match credited at least +400"
);
assert_eq!(
mb["allCoins"].as_i64().unwrap(),
after_match,
"response echoes the authoritative Core balance"
);
// Idempotent replay: the SAME match session does NOT double-credit.
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(replay.status, 200);
assert_eq!(
client.balance().unwrap(),
after_match,
"replay must not re-credit"
);
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
// must credit again. Keyed on the body alone it would not, which is the
// silent under-credit every abandoned match would have hit.
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
assert_eq!(second.status, 200);
let after_second = client.balance().unwrap();
assert!(
after_second >= after_match + 400,
"a second, distinct match must credit: {after_second} vs {after_match}"
);
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
post(
@@ -169,11 +200,16 @@ fn seed_and_exercise(base: &str) {
"/economy/purchase-entitlement",
json!({ "cost": 600, "definition_id": "70" }),
);
assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically");
let after_buy = after_second - 600;
assert_eq!(
client.balance().unwrap(),
after_buy,
"debit applied atomically"
);
// credits reflects the debit through the same Core state.
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits2["currencies"][0]["funds"], 4800);
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
let pg: Value =
@@ -194,25 +230,27 @@ fn seed_and_exercise(base: &str) {
client.balance().unwrap(),
client.entitlements().unwrap().len(),
);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
// Invariant: credits coins == userMassInfo coins == Core balance.
assert_eq!(
credits2["currencies"][0]["funds"],
mass["userInfo"]["currencies"][0]["funds"]
);
after_buy
}
/// After a Core restart from the same DB file, all economy state persists.
fn verify_after_restart(base: &str) {
fn verify_after_restart(base: &str, expected_balance: i64) {
wait_ready(base);
let client = HttpCoreClient::new(base, "fifa17");
assert_eq!(
client.balance().unwrap(),
4800,
expected_balance,
"coins persisted across restart"
);
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], 4800);
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
assert!(
@@ -239,12 +277,12 @@ async fn economy_end_to_end_and_restart_persistence() {
let b1 = base1.clone();
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
h1.abort();
r.expect("exercise phase");
let expected_balance = r.expect("exercise phase");
// --- Core instance #2: same on-disk DB, prove persistence ---
let (h2, base2) = start_core(&db_url).await;
let b2 = base2.clone();
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await;
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
h2.abort();
r2.expect("restart phase");
@@ -373,8 +411,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.as_array()
.expect("itemList")
.clone();
assert_eq!(items.len(), 5, "pack 1 awards 5 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 5);
assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 12);
assert!(
items[0]["id"].as_i64().unwrap() >= 100_000_000,
"minted wire id above the FIFA floor"
@@ -422,7 +460,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
"totalCredits == absolute Core balance"
);
// 4) Match END reward through dispatch (WIN = +400).
// 4) Match END reward through dispatch. The WIN credits at least the flat
// match coins (Core may also add XP level-up / first-win achievement coins).
let before_match = client.balance().unwrap();
let mm = server
.try_handle_economy(
@@ -434,10 +473,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
)
.expect("match routed");
assert_eq!(mm.status, 200);
assert_eq!(
client.balance().unwrap(),
before_match + 400,
"WIN credited +400 via Core"
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
assert!(
client.balance().unwrap() >= before_match + 400,
"WIN credited at least +400 via Core"
);
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
@@ -629,6 +669,98 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.owned_id_for_wire(move_wire)
.expect("moved item reverses to a Core id");
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
// `POST …/match` and `PUT …/match/end` were not classified at all and were
// proxied to Python, which the client reported as "There was an error
// creating your game session".
let created = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
None,
)
.expect("match create must be claimed by the economy dispatch, never proxied");
assert_eq!(created.status, 200);
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
let match_id = created_body["id"].as_i64().expect("a match id is minted");
assert!(match_id > 0, "the client needs a non-zero session id");
assert_eq!(created_body["reportIdEnabled"], false);
assert!(
created_body.get("squad").is_none(),
"`squad` is nested and a documented freeze risk — it must be omitted"
);
// The match id lives in its OWN identity scope: it must not reverse-map to
// an owned card, or quick-sell and move would resolve a match as an item.
assert!(
resolver.owned_id_for_wire(match_id).is_none(),
"a match id must never appear in the owned-item reverse map"
);
// FutPlayGame reuses the create path, discriminated by an integer matchId.
// It must ack without minting a second session.
let play = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
None,
)
.expect("play routed");
assert_eq!(
serde_json::from_slice::<Value>(&play.body).unwrap(),
serde_json::json!({}),
"FutPlayGame parses no fields"
);
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
let before_dnf = client.balance().unwrap();
let ended = server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("match end must be claimed, never proxied");
assert_eq!(ended.status, 200);
let after_dnf = client.balance().unwrap();
assert!(after_dnf > before_dnf, "the abandoned match credited");
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
// id is what makes the replay reuse one identity; taking it sent the replay
// down the body-fingerprint path, a different identity, and Core paid twice.
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("replay routed");
assert_eq!(
client.balance().unwrap(),
after_dnf,
"a replayed match end must NOT credit again"
);
// …and a NEW session with the identical body is a different match, which
// must credit. Keyed on the body alone every abandoned match after the
// first would silently pay nothing.
let created2 = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE"}"#,
None,
)
.expect("second create routed");
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
.as_i64()
.unwrap();
assert_ne!(second_id, match_id, "each match gets its own id");
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("second end routed");
assert!(
client.balance().unwrap() > after_dnf,
"a second, distinct abandoned match must credit"
);
SeqResult {
final_balance: client.balance().unwrap(),
sold_listing: trade_id.to_string(),
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
Why this script exists at all
----------------------------
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
production state directory `/home/alex/openfut-promotion/state`. That directory is
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
any path underneath it. That guard is deliberate and stays absolute: the staging
scripts must not be able to reach production state even by accident.
So the club cannot enter staging directly. This script is the ONE place allowed to
read production state, it is read-only by construction, and its only output is a
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
installs from the snapshot and never learns where it came from.
Read-only by construction
-------------------------
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
API, so the copy is transactionally consistent and the source is never written
(a plain file copy of a database with a hot WAL can tear).
* Every destination is asserted to be outside the production state directory
before anything is opened for writing.
* The sha256 of every source file is taken before and after the copy and compared.
A mismatch aborts loudly -- that would mean this script, or something racing it,
modified production state.
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sqlite3
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ProductionError, production_state # noqa: E402
# --- fixed facts -------------------------------------------------------------------
# The production state directory. This script READS these files and writes nothing
# back. It is the same path both staging scripts refuse outright.
PROD_STATE = "/home/alex/openfut-promotion/state"
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
SNAP_CORE_DB = "core.db"
SNAP_IDENTITY = "identity.json"
SNAP_CLIENTDATA = "clientdata.json"
SNAP_MANIFEST = "snapshot.json"
GAME = "fifa17"
def banner(title: str) -> None:
print()
print("=" * 78)
print(f" {title}")
print("=" * 78)
def step(msg: str) -> None:
print(f" {msg}")
def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
class Fatal(ProductionError):
"""Anything that must abort the snapshot loudly rather than degrade."""
def sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def assert_outside_production(path: str, what: str) -> str:
"""Every write target goes through here. The snapshot must never be able to
land inside the directory this script is reading."""
resolved = os.path.abspath(path)
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
raise Fatal(
f"REFUSING: {what} {resolved!r} is inside the production state "
f"directory {PROD_STATE!r} -- this script never writes there"
)
return resolved
def read_club_facts(db_path: str) -> dict:
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
record what was taken) and on the copy (to prove the copy is faithful)."""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
profiles = conn.execute(
"SELECT id, username, game_id, import_fingerprint FROM profiles "
"WHERE game_id = ?", (GAME,)
).fetchall()
if len(profiles) != 1:
raise Fatal(
f"expected exactly one {GAME} profile in {db_path}, found "
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
)
profile_id, username, game_id, fingerprint = profiles[0]
club = conn.execute(
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
).fetchone()
if club is None:
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
club_id, club_name, coins = club
owned, distinct = conn.execute(
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
(club_id,),
).fetchone()
squads = conn.execute(
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
).fetchall()
squad_players = conn.execute(
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
"WHERE s.club_id = ?", (club_id,)
).fetchone()[0]
migration = conn.execute(
"SELECT MAX(version) FROM _sqlx_migrations"
).fetchone()[0]
return {
"profile_id": profile_id,
"username": username,
"game_id": game_id,
"import_fingerprint": fingerprint,
"club_id": club_id,
"club_name": club_name,
"coins": coins,
"owned_cards": owned,
"distinct_card_ids": distinct,
"squads": [
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
],
"squad_players": squad_players,
"schema_version": migration,
}
finally:
conn.close()
def copy_database(src: str, dst: str) -> None:
"""Online-backup copy. The source is opened read-only, so this cannot write to
production state even if the backup API wanted to."""
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
try:
if os.path.exists(dst):
os.remove(dst)
target = sqlite3.connect(dst)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
DEFAULT_SNAPSHOT_DIR),
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
args = ap.parse_args()
try:
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
dest = assert_outside_production(args.dir, "snapshot directory")
step(f"source (read-only): {PROD_STATE}")
step(f"destination : {dest}")
# Production is not involved in a file copy -- its Core runs from a docker
# volume, not from this directory -- but proving the freeze held across the
# operation costs nothing and keeps the evidence uniform with the other
# lifecycle scripts.
before_state = production_state()
ok("production before: " + ", ".join(before_state.describe()))
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
if not os.path.isfile(p)]
if missing:
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
for path, digest in before.items():
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
f"sha256 {digest[:16]}")
facts = read_club_facts(SRC_CORE_DB)
ok(
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
)
banner("COPY")
os.makedirs(dest, exist_ok=True)
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
copy_database(SRC_CORE_DB, snap_db)
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
dst = assert_outside_production(os.path.join(dest, name), name)
shutil.copy2(src, dst)
# The source is root-owned; the copy must be writable by the staging user
# that installs it.
os.chmod(dst, 0o644)
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
after = {p: sha256(p) for p in before}
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
if changed:
raise Fatal(
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
f"{changed} -- this must never happen"
)
ok("every source file byte-identical before and after (sha256)")
copy_facts = read_club_facts(snap_db)
differences = {
key: (facts[key], copy_facts[key])
for key in facts
if facts[key] != copy_facts[key]
}
if differences:
raise Fatal(f"snapshot does not match the source: {differences}")
ok(
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
f"cards, {copy_facts['coins']:,} coins, "
f"{copy_facts['squad_players']} squad players"
)
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
rows = identity.get("rows", [])
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
try:
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
finally:
conn.close()
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
if unmapped:
raise Fatal(
f"{len(unmapped)} owned cards have no wire id in the identity store; "
"the client would see them appear under freshly minted ids and its "
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
)
ok(
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
)
manifest = {
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"source_dir": PROD_STATE,
"sources": {
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
for p in before
},
"members": {
"core_db": SNAP_CORE_DB,
"identity": SNAP_IDENTITY,
"clientdata": SNAP_CLIENTDATA,
},
"club": copy_facts,
"identity_rows": len(rows),
"watermarks": identity.get("watermarks"),
}
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
after_state = production_state()
ok("production after : " + ", ".join(after_state.describe()))
banner("SNAPSHOT READY")
print(f" {dest}")
print()
print(" Install it into staging with:")
print(" python3 scripts/sold-staging-up.py --club real "
"--variant highest \\")
print(" --roster-host winter15.gosredirector.ea.com:8081")
return 0
except ProductionError as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Authoritative Core state snapshot for FIFA17 FUT loop verification.
Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the
host projection, NOT Python) and emits a machine-readable JSON snapshot with the
integrity fields needed to detect, across any economy operation or a full stack
restart:
* coin drift (balance vs club.coins cross-check + absolute value)
* duplicate ownership (repeated owned_card_id)
* missing ownership (squad references an owned_card_id not in the collection)
* resurrection (owned-set hash changes when it must not)
* entitlement dup/count (unopened pack entitlements)
* stale squad mappings (squad player ids absent from ownership)
This is verification/oracle tooling, not production behavior. It reads only.
Usage:
core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad]
[--out FILE] [--label NAME]
Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS.
Exit code 0 always for a successful read; integrity problems are reported IN the
snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert.
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
def fetch(core, game, path):
req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game})
try:
with urllib.request.urlopen(req, timeout=15) as f:
return f.status, f.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot
return None, str(e).encode()
def fetch_json(core, game, path):
st, body = fetch(core, game, path)
if st != 200:
raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}")
return json.loads(body)
def sha(items):
h = hashlib.sha256()
for it in items:
h.update(str(it).encode())
h.update(b"\x00")
return h.hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101"))
ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17"))
ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad"))
ap.add_argument("--out")
ap.add_argument("--label", default="")
args = ap.parse_args()
balance = fetch_json(args.core, args.game, "/economy/balance")
entitlements = fetch_json(args.core, args.game, "/economy/entitlements")
profile = fetch_json(args.core, args.game, "/profile")
club = fetch_json(args.core, args.game, "/club")
collection = fetch_json(args.core, args.game, "/collection")["collection"]
squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}")
coins_balance = balance.get("balance")
coins_club = club.get("coins")
owned_ids = sorted(x["owned_card_id"] for x in collection)
card_ids = sorted(x["card"]["id"] for x in collection)
dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else []
loans = [x["owned_card_id"] for x in collection if x.get("is_loan")]
# Squad player ownership references (from the opaque extension payload).
squad_player_ids = []
ext = squad.get("extension") or {}
payload_raw = ext.get("payload")
kit_numbers = {}
if payload_raw:
try:
payload = json.loads(payload_raw)
kit_numbers = payload.get("kit_numbers", {}) or {}
squad_player_ids = sorted(kit_numbers.keys())
except (json.JSONDecodeError, TypeError):
pass
owned_set = set(owned_ids)
squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set)
problems = []
if coins_balance != coins_club:
problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}")
if dup_owned:
problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)")
if squad_missing:
problems.append(f"squad references non-owned ids: {squad_missing}")
snap = {
"label": args.label,
"captured_at": datetime.now(timezone.utc).isoformat(),
"core": args.core,
"game": args.game,
"coins": {"balance": coins_balance, "club": coins_club},
"profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")},
"entitlements": {"count": len(entitlements), "ids": entitlements},
"collection": {
"owned": len(collection),
"distinct_owned_card_id": len(set(owned_ids)),
"distinct_card_id": len(set(card_ids)),
"loans": len(loans),
"duplicate_owned_card_id": dup_owned,
"owned_set_sha256": sha(owned_ids),
"card_multiset_sha256": sha(card_ids),
},
"squad": {
"namespace": args.squad_ns,
"verdict": squad.get("verdict"),
"player_count": len(squad_player_ids),
"player_ids": squad_player_ids,
"missing_from_ownership": squad_missing,
"ext_schema_version": ext.get("schema_version"),
},
"integrity": {"ok": not problems, "problems": problems},
}
text = json.dumps(snap, indent=2, sort_keys=True)
if args.out:
with open(args.out, "w") as f:
f.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+830
View File
@@ -0,0 +1,830 @@
#!/usr/bin/env python3
"""FIFA 17 definition-coverage guard.
Classifies EVERY table in the FIFA 17 resident-database dump
(`fifa17-recon/data/tables/*.json`, produced by `fifa17-recon/tools/db_dump.py`)
into exactly one coverage bucket, and every definition id those tables ship into
exactly one of them too. A table with no rule lands in UNKNOWN and the run
FAILS: that is the whole point. Adding a newly dumped table, or renaming one,
must force a human to classify it rather than letting it silently disappear into
"presentation".
This is OFFLINE ANALYSIS TOOLING (an RE/coverage oracle). It is not authoritative
for anything at runtime: the authoritative reader/writer/owner of FUT items is
Rust Core plus the FIFA 17 adapter.
Authority for the taxonomy below, in precedence order:
1. `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` (SETTLED; outranks
`fifa17-recon/docs/CARD_SYSTEM.md` wherever the two disagree)
2. the shipped tables themselves (`--tables`) and
`fifa17-recon/data/consumables.json` (`--consumables`)
3. the Rust taxonomy that consumes them:
`openfut-adapter-fifa17/src/fut/content_taxonomy.rs` and
`openfut-import-fifa17/src/lib.rs::club_family`
Buckets
-------
KNOWN_OWNABLE A FUT club can hold an inventory instance of these
definitions, and the class is inside OpenFUT's item
vocabulary (player / staff / consumable / kit / badge /
ball / stadium / league logo).
KNOWN_PRESENTATION_ONLY Shipped and rendered, but never owned as an inventory
instance: match/career content, asset registries, pure
lookup and localisation tables, empty scratch mirrors.
Trophies belong to this class conceptually but ship NO
definition table at all (see TROPHY_NOTE).
KNOWN_UNSUPPORTED A real card-definition class, ownable in the retail
game, that OpenFUT deliberately does not support yet.
Every entry carries a one-line stated reason.
UNKNOWN Cannot be placed from evidence. MUST be zero.
Usage
-----
python3 scripts/fifa17-definition-coverage.py [--tables DIR] [--json] [-v]
Exits 0 only when UNKNOWN == 0 and every invariant check passes.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import NamedTuple
OWNABLE = "KNOWN_OWNABLE"
PRESENTATION = "KNOWN_PRESENTATION_ONLY"
UNSUPPORTED = "KNOWN_UNSUPPORTED"
UNKNOWN = "UNKNOWN"
BUCKET_ORDER = (OWNABLE, UNSUPPORTED, PRESENTATION, UNKNOWN)
class Rule(NamedTuple):
"""One table's classification.
bucket -- which coverage bucket the table belongs to.
id_col -- the column holding the CARD DEFINITION id, or None when the
table ships no definition id of its own (a table may still hold
foreign keys into somebody else's id space; those are not
definition ids and are never counted here).
id_space -- the shared id-space key. Two tables with the same key are
counted as a UNION, never summed (see ID_SPACES).
reason -- why this table is in this bucket. Evidence, not opinion.
"""
bucket: str
id_col: str | None
id_space: str | None
reason: str
def ownable(id_col: str, id_space: str, reason: str) -> Rule:
return Rule(OWNABLE, id_col, id_space, reason)
def presentation(reason: str) -> Rule:
# Presentation tables ship no definition id of their own by construction:
# if one did, it would be a card class and would not be in this bucket.
return Rule(PRESENTATION, None, None, reason)
def unsupported(id_col: str, id_space: str, reason: str) -> Rule:
return Rule(UNSUPPORTED, id_col, id_space, reason)
# ---------------------------------------------------------------------------
# THE ONE CLASSIFICATION TABLE. Audit this dict and you have audited the tool.
# One entry per table in the dump; a table absent from here becomes UNKNOWN.
# ---------------------------------------------------------------------------
RULES: dict[str, Rule] = {
# -- KNOWN_OWNABLE: the 16 real card-definition tables ---------------------
# (15 here + fcc_misccards, which is the 16th definition table but sits in
# KNOWN_UNSUPPORTED; the 15 below + misccards are the settled 16.)
"players": ownable(
"playerid", "player.playerid",
"cardtype 1 player definitions; playerid IS the FUT assetId. The "
"reference ownable class — read/write/ownership already in Core.",
),
"managercards": ownable(
"carddbid", "staff.manager.carddbid",
"staff cardsubtypeid 4 (manager); cardtype 2 merge FUN_1801356c0. "
"Ownable staff card (carries value/rare/nation/formationid).",
),
"headcoachcards": ownable(
"carddbid", "staff.headcoach.carddbid",
"staff cardsubtypeid 5 (head coach) in the FUN_1800d8330 family "
"selector; ownable staff card.",
),
"gkcoachcards": ownable(
"carddbid", "staff.gkcoach.carddbid",
"staff cardsubtypeid 6 (GK coach) in the FUN_1800d8330 family "
"selector; ownable staff card.",
),
"physiocards": ownable(
"carddbid", "staff.physio.carddbid",
"staff cardsubtypeid 7 (physio) in the FUN_1800d8330 family selector; "
"ownable staff card.",
),
"fitnesscoachcards": ownable(
"carddbid", "staff.fitnesscoach.carddbid",
"staff cardsubtypeid 8 (fitness coach) in the FUN_1800d8330 family "
"selector; ownable staff card.",
),
"fcc_contractcards": ownable(
"carddbid", "consumable.contract.carddbid",
"consumable definitions, cardsubtype 201 (player contract) / 202 "
"(manager contract); both in data/consumables.json.",
),
"fcc_healingcards": ownable(
"carddbid", "consumable.healing.carddbid",
"consumable definitions, cardsubtype 211..218 (healing) + 219/220 "
"(player/squad fitness); all in data/consumables.json.",
),
"fcc_trainingcards": ownable(
"carddbid", "consumable.training.carddbid",
"consumable definitions: GK/player training 51..67, position 91..110, "
"formation 121..136, chemistry styles 250..273, manager league "
"300..341; all in data/consumables.json.",
),
"fcc_kitcards": ownable(
"carddbid", "club.kit.carddbid",
"cardtype 7 club item, cardsubtypeid 9 (kit) via resolver FUN_180119bd0; "
"constant cardassetid 35 on all rows. Ownable club customisation.",
),
"fcc_stadium": ownable(
"carddbid", "club.stadium.carddbid",
"cardtype 7 club item, cardsubtypeid 10 (stadium); constant cardassetid "
"36. Ownable club customisation (stadiumid is a foreign key into the "
"match-venue registry, not this table's identity).",
),
"fcc_badgecards": ownable(
"carddbid", "club.badge.carddbid",
"cardtype 7 club item, cardsubtypeid 11 (club badge); constant "
"cardassetid 39. Ownable club customisation.",
),
"fcc_balls": ownable(
"carddbid", "club.ball.carddbid",
"cardtype 9 club item, cardsubtypeid 30 (ball, FUT_UC_BALL caption); "
"constant cardassetid 37. Ownable club customisation.",
),
"fcc_leaguelogos": ownable(
"carddbid", "club.leaguelogo.carddbid",
"cardtype 9 club item, cardsubtypeid 31 (league logo, by elimination "
"over FUN_1800d8330's cardtype-9 set); constant cardassetid 40. "
"SHARES its carddbid space with fcc_leaguelogostickers — see "
"ID_SPACES/collision report.",
),
"fcc_leaguelogostickers": ownable(
"carddbid", "club.leaguelogo.carddbid",
"same cardsubtypeid 31 / cardassetid 40 league-logo class; its 39 "
"carddbids ALL collide with fcc_leaguelogos' ids (shipped data bug), so "
"it contributes 0 new definition ids and is counted as a union.",
),
# -- KNOWN_UNSUPPORTED: real card classes OpenFUT deliberately defers ------
"fcc_misccards": unsupported(
"carddbid", "misc.carddbid",
"cardtype 9 cardsubtype {231,232,233,236} = FUN_1800d8330's "
"0xe7..0xe9/0xec block, matched exactly by this table's cardsubtype "
"column. These ARE owned items (the client's TO_TRADE_PILE gate "
"excludes exactly {0xe7,0xe8,0xe9,0xec}, which only means anything for "
"owned inventory), but the per-subtype semantics (cardassetid 43..46, "
"meaning of amount/rating) are NOT reverse-engineered, the subtypes are "
"absent from data/consumables.json's 172, and neither "
"content_taxonomy::consumable_family nor import::club_family maps them. "
"UNSUPPORTED rather than guessed.",
),
"fut_storymodehero": unsupported(
"carddbid", "storymodehero.carddbid",
"a distinct carddbid space (22800..22879) for The Journey story-mode "
"hero cards, but the shipped row is only {carddbid, teamid}: no name, "
"rating, value, cardassetid or cardsubtypeid, so no FUT item can be "
"shaped from client data without fabricating it. OpenFUT does not "
"support story-mode hero items.",
),
# -- KNOWN_PRESENTATION_ONLY ----------------------------------------------
# Crowd / atmosphere / match-engine lookups.
"BigAttendance": presentation(
"crowd-emotion band lookup (min/max/emotion) for a full stadium; "
"match presentation, not a card definition."),
"NoAttendance": presentation(
"crowd-emotion band lookup for an empty stadium; match presentation."),
"MatchIntensity": presentation(
"score-difference x match-minute AI intensity curve; match engine "
"lookup, no ids."),
"fcc_GrandStandPlayers": presentation(
"single playerid column choosing which player heads populate the FUT "
"hub grandstand. Foreign keys into the player id space (several do not "
"even resolve in the shipped players table); rendered, never owned."),
"celebrations": presentation(
"celebration animation ids; match presentation."),
"videos": presentation(
"per-locale video asset ids; front-end presentation."),
"eatrax": presentation(
"licensed soundtrack rows (song/artist/album); audio presentation."),
"songplaylistlinks": presentation(
"song-to-playlist links; audio presentation."),
"audionation": presentation(
"per-nation commentary/crowd audio bank indices; audio lookup."),
"audiostadium": presentation(
"per-stadium commentary language index; audio lookup."),
"dynamicimages": presentation(
"ad/sponsor dynamic image descriptors; stadium dressing presentation."),
"sponsors": presentation(
"ad sponsor registry (adsponserid); stadium dressing presentation."),
"teamsponsorlinks": presentation("team-to-sponsor links; presentation."),
"competitionsponsorlinks": presentation(
"competition-to-sponsor links; presentation."),
"modeadboardlinks": presentation("mode-to-adboard links; presentation."),
"presentationcompsettings": presentation(
"per-competition presentation feature flags; presentation."),
"presentationmodesettings": presentation(
"per-mode presentation feature flags; presentation."),
"assetcryptokeys": presentation(
"asset decryption keys (empty in this dump); build plumbing, not a "
"card definition."),
# Asset registries the FUT card tables POINT AT. The ownable definition is
# the fcc_* card; these registries are the rendered asset behind it.
"teams": presentation(
"club/crest asset registry (assetid = team asset). fcc_badgecards' "
"teamid points here; the ownable badge definition is the fcc_ row."),
"teamkits": presentation(
"in-match kit asset registry. fcc_kitcards is the ownable FUT kit "
"definition; these rows are the geometry/colour asset it renders."),
"stadiums": presentation(
"match-venue registry (stadiumid). fcc_stadium is the ownable FUT "
"stadium definition; these rows are the venue it renders."),
"teamballs": presentation(
"in-match ball asset registry (ballid, isavailableinstore). fcc_balls "
"is the ownable FUT ball definition."),
"teamballremapping": presentation(
"ballid remap table (empty in this dump); asset plumbing."),
"dlcballs": presentation(
"DLC ball asset names (empty in this dump); asset registry, and FIFA 17 "
"FUT owns balls through fcc_balls, not through DLC asset rows."),
"dlcboots": presentation(
"DLC boot asset names (empty in this dump). FIFA 17 FUT has no boot "
"item class at all — boots are rendered on players, never owned."),
"playerboots": presentation(
"boot asset registry (shoetype/colour/licensing). Rendered on players; "
"FIFA 17 FUT ships no boot card class."),
"playerbootremapping": presentation(
"shoetype remap table (empty in this dump); asset plumbing."),
"shoecolors": presentation("boot colour palette; asset lookup."),
"manager": presentation(
"career-mode manager PERSONS (managerid, head/suit appearance). The "
"ownable FUT manager card is managercards."),
"physio": presentation(
"career-mode physio PERSONS (physioid, appearance). The ownable FUT "
"physio card is physiocards."),
"referee": presentation(
"referee persons/appearance (refereeid); match presentation, never "
"owned."),
"leaguerefereelinks": presentation("league-to-referee links; lookup."),
# FUT front-end lookup / calculator tables (fcc_* that are NOT card tables).
"fcc_bonusvalues": presentation(
"chemistry/boost bonus value lookup (bonusid, bonuslevel); pure "
"calculator input, not a card definition."),
"fcc_managerbonusvalues": presentation(
"manager bonus value lookup; pure calculator input."),
"fcc_chemlinkcalc": presentation(
"formation x position chemistry-link matrix; pure calculator input."),
"fcc_nationcalc": presentation(
"nation grouping for chemistry (nationid -> groupid); lookup."),
"fcc_leagues": presentation(
"FUT league name/country lookup (leagueid -> futcountryid). "
"fcc_leaguelogos' leagueid points here; the ownable definition is the "
"fcc_leaguelogos row."),
"fcc_coinrewards": presentation(
"leaderboard objective coin reward lookup; economy table, not a card."),
"fcc_discardcoins": presentation(
"quick-sell price lookup keyed by (cardtype, level, rare). Prices for "
"cards, not card definitions."),
"fcc_myclubs": presentation("MY CLUB tab id/name lookup; UI structure."),
"fcc_myclubscategories": presentation(
"MY CLUB category rows (categoryid, isteamcategory); UI structure."),
"fcc_preferredpositioncalc": presentation(
"preferred-position calculator matrix; calculator input."),
"fcc_preferredformationcalcgk": presentation(
"preferred-formation calculator matrix (GK); calculator input."),
"fcc_preferredformationcalcback": presentation(
"preferred-formation calculator matrix (defence); calculator input."),
"fcc_preferredformationcalcmid": presentation(
"preferred-formation calculator matrix (midfield); calculator input."),
"fcc_preferredformationcalcst": presentation(
"preferred-formation calculator matrix (attack); calculator input."),
"fcc_formationcardspositions_hd": presentation(
"on-screen card X/Y coordinates per formation (HD layout); UI layout."),
"fcc_formationcardspositions_kc": presentation(
"on-screen card X/Y coordinates per formation (KC layout); UI layout."),
"fcc_textposvalues_hd": presentation(
"on-screen text offsets per formation (HD layout); UI layout."),
"fcc_textposvalues_kc": presentation(
"on-screen text offsets per formation (KC layout); UI layout."),
"fcc_navcoords_hd": presentation(
"D-pad navigation graph between squad slots (HD). Its `playerid` column "
"is a 0..11 SLOT INDEX, not a player id; UI navigation."),
"fcc_navcoords_kc": presentation(
"D-pad navigation graph between squad slots (KC); UI navigation."),
# Squad / formation / tactics data.
"formations": presentation(
"formation definitions (positions, offsets, instructions); tactics "
"data, never owned."),
"formationlayout": presentation("formation-to-formation slot remap; lookup."),
"defformation": presentation("default formation rows; tactics data."),
"customformations": presentation(
"user custom formations (empty in this dump); tactics data."),
"customteamstyles": presentation(
"user custom team styles (empty in this dump); tactics data."),
"teamformationteamstylelinks": presentation(
"team-to-formation/style links (empty in this dump); tactics lookup."),
"teamsheets": presentation("team sheet rows; tactics data."),
"default_teamsheets": presentation("default team sheet rows; tactics data."),
"defaultteamdata": presentation("default per-team tactics; tactics data."),
"teamsheetanalysis": presentation(
"playerid x recorded position analysis rows; tactics lookup."),
"fieldpositionboundingboxes": presentation(
"pitch bounding boxes per position id; match engine geometry."),
"playerpositionzones": presentation(
"position zone polygons; match engine geometry."),
"attributeprefpositionformula": presentation(
"attribute weighting per position; rating calculator input."),
"playerattributesmapping": presentation(
"raw-attribute to displayed-attribute mapping; calculator input."),
"playerattributesmapping_g4": presentation(
"gen-4 variant of the attribute mapping; calculator input."),
"starratingboundaries": presentation(
"overall-rating to star-rating bands; UI lookup."),
# Reference / geography / competition structure.
"nations": presentation("nation registry (nationid, iso, confederation); lookup."),
"leagues": presentation("league registry (leagueid, level); lookup."),
"leagueteamlinks": presentation("league table state per team; lookup/state."),
"teamnationlinks": presentation("international team-to-nation links; lookup."),
"rowteamnationlinks": presentation("rest-of-world team-to-nation links; lookup."),
"teamstadiumlinks": presentation("team-to-stadium links; lookup."),
"teamstadiumlinkscache": presentation(
"cached team-to-stadium links (empty in this dump); lookup."),
"stadiumassignments": presentation(
"custom stadium name assignments (empty in this dump); lookup."),
"competition": presentation(
"competition definitions and presentation flags (competitionid); "
"match/competition structure, never owned."),
"rivals": presentation("team rivalry pairs; match presentation."),
"fixtures": presentation("fixture rows (empty in this dump); schedule state."),
"factory_teams": presentation(
"single factory/template team row; default data, not a card."),
"fifaGameDefaults": presentation(
"default player/team/ball/referee ids per settings context; defaults "
"lookup."),
"fifaGameSettings": presentation("game settings rows; user settings."),
"version": presentation("database schema/version metadata."),
"server_db_version": presentation("server database version metadata."),
# Player-adjacent state and career tables (state about players, not new
# definitions; every playerid here is a foreign key into players.playerid).
"teamplayerlinks": presentation(
"player-to-team squad links plus season form/stats; state over "
"players.playerid, not a new definition id."),
"previousteam": presentation(
"player's previous team ids; state over players.playerid."),
"playerloans": presentation(
"loan rows (playerid, teamidloanedfrom); career state."),
"playersuspensions": presentation(
"suspension rows (empty in this dump); career state."),
"restrictedplayers": presentation(
"restricted/replacement player rows (empty in this dump); career state."),
"playerformdiff": presentation(
"player rating deltas (empty in this dump); career state."),
"teamformdiff": presentation(
"team rating deltas (empty in this dump); career state."),
"transfers": presentation("transfer rows (empty in this dump); career state."),
"transactionhistory": presentation(
"transfer history rows (empty in this dump); career state."),
"player_grudgelove": presentation(
"player-to-team emotional affinity; career/story lookup."),
"dna": presentation("player DNA rows (empty in this dump); career data."),
"stories": presentation("career story engine rows (empty in this dump)."),
"career_calendar": presentation("career calendar dates; career data."),
"career_clinchedobjectives": presentation(
"career objective flags (empty in this dump); career state."),
"career_squadranking": presentation(
"career squad ranking (empty in this dump); career state."),
"career_playerlastmatchhistory": presentation(
"career last-match history (empty in this dump); career state."),
"career_playermatchratinghistory": presentation(
"career match rating history (empty in this dump); career state."),
# Localisation / name string tables.
"playernames": presentation(
"nameid -> player name strings; localisation, not a card definition."),
"dcplayernames": presentation(
"DLC player name strings (empty in this dump); localisation."),
"editedplayernames": presentation(
"user-edited player names (empty in this dump); localisation."),
"commentarynames": presentation("commentary name strings; localisation."),
"clubcommentarynames": presentation("club commentary name strings; localisation."),
"createclubnames": presentation("create-a-club name affixes; localisation."),
"career_commonnames": presentation(
"career common-name strings (empty in this dump); localisation."),
"career_firstnames": presentation(
"career first-name strings (empty in this dump); localisation."),
"career_lastnames": presentation(
"career last-name strings (empty in this dump); localisation."),
"trainingteamplayernames": presentation(
"training/arena team player name strings; localisation."),
# Create-a-player / customisation input tables.
"createplayer": presentation(
"create-a-player morph sliders (empty in this dump); customisation "
"input, not a card definition."),
"temp_createplayer": presentation(
"scratch mirror of createplayer (empty in this dump)."),
"createplayerpositiontemplates": presentation(
"create-a-player attribute templates per position; customisation input."),
"createplayerviews": presentation(
"create-a-player UI view/attribute mapping; UI structure."),
# Skill-games / arena / training rosters (fixed non-FUT rosters).
"smplayers": presentation(
"skill-games roster player rows; a fixed non-FUT roster, never owned."),
"smrivals": presentation("skill-games rivalry pairs; presentation."),
"trainingteamplayers": presentation(
"training/arena roster player rows; a fixed non-FUT roster."),
"trainingteamplayerlinks": presentation(
"training/arena roster squad links; lookup."),
# Empty scratch/staging mirrors (0 rows in this dump; the game copies live
# tables into them). No definition ids of their own by construction.
"temp_players": presentation("empty scratch mirror of players."),
"temp_teams": presentation("empty scratch mirror of teams."),
"temp_teamplayerlinks": presentation("empty scratch mirror of teamplayerlinks."),
"temp_formations": presentation("empty scratch mirror of formations."),
"temp_arenaplayer": presentation("empty scratch arena player table."),
"temp_arenaplayername": presentation("empty scratch arena player name table."),
"temp_arenateam": presentation("empty scratch arena team table."),
"temp_arenateamplayerlinks": presentation("empty scratch arena squad links."),
# Companion-app / cloud ("cz_") staging tables, all empty in this dump.
"cz_players": presentation(
"companion/cloud staging copy of players (empty in this dump)."),
"cz_teams": presentation(
"companion/cloud staging copy of teams (empty in this dump)."),
"cz_teamkits": presentation(
"companion/cloud staging copy of teamkits (empty in this dump)."),
"cz_leagues": presentation(
"companion/cloud staging copy of leagues (empty in this dump)."),
"cz_assets": presentation(
"companion/cloud asset descriptors (empty in this dump)."),
}
# Tables that share ONE carddbid space and must therefore be counted as a
# UNION. Each entry is (id_space, expected_union, note) — the note is printed so
# the bug cannot regress silently.
ID_SPACES: dict[str, tuple[int, str]] = {
"club.leaguelogo.carddbid": (
44,
"SHIPPED DATA BUG: fcc_leaguelogos (44 rows) and "
"fcc_leaguelogostickers (39 rows) both start their carddbid at "
"8010000, so all 39 sticker ids collide with logo ids. The league-logo "
"definition count is the UNION = 44, never the naive sum 83.",
),
}
# The SETTLED inventory: 16 real definition tables holding 20918 distinct ids
# (league logos counted as their union of 44). Checked as an invariant so a
# re-dump that gains or loses definitions is caught here.
SETTLED_DEFINITION_TABLES = (
"players", "managercards", "headcoachcards", "gkcoachcards",
"fitnesscoachcards", "physiocards", "fcc_contractcards",
"fcc_healingcards", "fcc_trainingcards", "fcc_misccards", "fcc_kitcards",
"fcc_badgecards", "fcc_balls", "fcc_stadium", "fcc_leaguelogos",
"fcc_leaguelogostickers",
)
SETTLED_DEFINITION_ID_TOTAL = 20918
# Each club family ships a CONSTANT cardassetid, verified across all shipped
# rows; it is the deserializer's own per-subtype default.
CLUB_CARD_ASSET_IDS = {
"fcc_kitcards": 35,
"fcc_stadium": 36,
"fcc_balls": 37,
"fcc_badgecards": 39,
"fcc_leaguelogos": 40,
"fcc_leaguelogostickers": 40,
}
# Consumable definition tables whose every cardsubtype must appear in
# data/consumables.json. fcc_misccards is deliberately absent: its subtypes
# {231,232,233,236} are NOT consumables and NOT in that file — that is exactly
# why it is KNOWN_UNSUPPORTED.
CONSUMABLE_DEFINITION_TABLES = (
"fcc_contractcards", "fcc_healingcards", "fcc_trainingcards",
)
TROPHY_NOTE = (
"TROPHIES (cardsubtypeid 0x91..0x96): FUN_180108c00 computes "
"subtype = tournamentType + 0x91 and FUN_1800fed90 is the only function "
"whose case set is exactly {0x91..0x96}. They are rendered from wire "
"FUT::TournamentInfo data and ship NO definition table in this dump, so "
"they contribute 0 definition ids. Presentation-only by evidence, not by "
"convenience."
)
def load_table(path: Path) -> dict:
with path.open(encoding="utf-8") as fh:
return json.load(fh)
def classify(tables_dir: Path, consumables_path: Path) -> dict:
files = sorted(tables_dir.glob("*.json"))
if not files:
raise SystemExit(f"no *.json tables under {tables_dir}")
per_table: list[dict] = []
ids_by_space: dict[str, set[int]] = {}
space_bucket: dict[str, str] = {}
problems: list[str] = []
for path in files:
name = path.stem
data = load_table(path)
rows = data.get("rows") or []
columns = {c["name"] for c in data.get("schema", [])}
rule = RULES.get(name)
if rule is None:
per_table.append({
"table": name,
"bucket": UNKNOWN,
"rows": len(rows),
"definition_ids": 0,
"id_space": None,
"reason": "NO CLASSIFICATION RULE — classify it in RULES before "
"this dump can be trusted.",
})
continue
ids: set[int] = set()
if rule.id_col is not None:
if rule.id_col not in columns:
problems.append(
f"{name}: declared id column {rule.id_col!r} is absent from "
f"the shipped schema"
)
ids = {r[rule.id_col] for r in rows if rule.id_col in r}
space = rule.id_space
assert space is not None, f"{name}: id column without id space"
prior = space_bucket.setdefault(space, rule.bucket)
if prior != rule.bucket:
problems.append(
f"id space {space!r} spans two buckets ({prior} and "
f"{rule.bucket}); a shared id space must be one class"
)
ids_by_space.setdefault(space, set()).update(ids)
per_table.append({
"table": name,
"bucket": rule.bucket,
"rows": len(rows),
"definition_ids": len(ids),
"id_space": rule.id_space,
"reason": rule.reason,
})
# Definition ids per bucket, deduplicated across shared id spaces.
bucket_ids: dict[str, set[int]] = {b: set() for b in BUCKET_ORDER}
for space, ids in ids_by_space.items():
bucket_ids[space_bucket[space]].update(ids)
totals = {}
for bucket in BUCKET_ORDER:
entries = [e for e in per_table if e["bucket"] == bucket]
totals[bucket] = {
"tables": len(entries),
"rows": sum(e["rows"] for e in entries),
"definition_ids": len(bucket_ids[bucket]),
}
collisions = []
for space, (expected, note) in ID_SPACES.items():
members = [e["table"] for e in per_table if e["id_space"] == space]
counts = {
e["table"]: e["definition_ids"]
for e in per_table if e["id_space"] == space
}
union = len(ids_by_space.get(space, set()))
collisions.append({
"id_space": space,
"tables": members,
"per_table_ids": counts,
"naive_sum": sum(counts.values()),
"union": union,
"expected_union": expected,
"ok": union == expected,
"note": note,
})
checks = [{
"check": "leaguelogo carddbid union",
"ok": all(c["ok"] for c in collisions),
"detail": "; ".join(
f"{c['id_space']}: union={c['union']} expected={c['expected_union']} "
f"naive_sum={c['naive_sum']}" for c in collisions
),
}]
settled_spaces = {
RULES[t].id_space for t in SETTLED_DEFINITION_TABLES if t in RULES
}
settled_total = sum(len(ids_by_space.get(s, set())) for s in settled_spaces)
checks.append({
"check": "settled 16 definition tables total",
"ok": settled_total == SETTLED_DEFINITION_ID_TOTAL,
"detail": f"{settled_total} distinct ids across "
f"{len(SETTLED_DEFINITION_TABLES)} tables "
f"({len(settled_spaces)} id spaces); "
f"expected {SETTLED_DEFINITION_ID_TOTAL}",
})
asset_detail = []
asset_ok = True
for table, expected in CLUB_CARD_ASSET_IDS.items():
path = tables_dir / f"{table}.json"
if not path.exists():
asset_ok = False
asset_detail.append(f"{table}=MISSING")
continue
seen = {r.get("cardassetid") for r in load_table(path).get("rows") or []}
ok = seen == {expected}
asset_ok = asset_ok and ok
asset_detail.append(
f"{table}={expected}" if ok else f"{table}=UNEXPECTED{sorted(seen)}"
)
checks.append({
"check": "constant cardassetid per club family",
"ok": asset_ok,
"detail": ", ".join(asset_detail),
})
cons_ok = True
if not consumables_path.exists():
cons_ok = False
cons_detail = f"{consumables_path} missing — cannot verify"
else:
with consumables_path.open(encoding="utf-8") as fh:
known = {e["cardsubtypeid"] for e in json.load(fh)["subtypes"]}
unlisted: set[int] = set()
for table in CONSUMABLE_DEFINITION_TABLES:
path = tables_dir / f"{table}.json"
if not path.exists():
cons_ok = False
continue
unlisted |= {
r["cardsubtype"] for r in load_table(path).get("rows") or []
} - known
cons_ok = cons_ok and not unlisted
cons_detail = (
f"{len(known)} documented subtypes; unlisted in consumable "
f"definition tables: {sorted(unlisted) if unlisted else 'none'}"
)
checks.append({
"check": "consumable subtypes all documented",
"ok": cons_ok,
"detail": cons_detail,
})
for problem in problems:
checks.append({"check": "schema/id-space integrity", "ok": False,
"detail": problem})
rules_without_table = sorted(
set(RULES) - {e["table"] for e in per_table}
)
all_ids = set()
for ids in ids_by_space.values():
all_ids |= ids
return {
"tables_dir": str(tables_dir),
"table_count": len(per_table),
"per_table": per_table,
"totals": totals,
"definition_ids_total": len(all_ids),
"id_space_collisions": collisions,
"checks": checks,
"rules_without_shipped_table": rules_without_table,
"trophy_note": TROPHY_NOTE,
"unknown": totals[UNKNOWN]["tables"],
"ok": totals[UNKNOWN]["tables"] == 0 and all(c["ok"] for c in checks),
}
def render(report: dict, verbose: bool) -> None:
print(f"FIFA 17 definition coverage — {report['tables_dir']}")
print(f"{report['table_count']} shipped tables\n")
for bucket in BUCKET_ORDER:
entries = [e for e in report["per_table"] if e["bucket"] == bucket]
if not entries:
print(f"{bucket}: (none)\n")
continue
t = report["totals"][bucket]
print(f"{bucket} — {t['tables']} tables, {t['rows']} rows, "
f"{t['definition_ids']} distinct definition ids")
detailed = bucket != PRESENTATION or verbose
for e in sorted(entries, key=lambda e: (-e["definition_ids"], e["table"])):
print(f" {e['table']:<32} rows={e['rows']:>6} "
f"ids={e['definition_ids']:>6}"
+ (f" space={e['id_space']}" if e["id_space"] else ""))
if detailed:
for line in wrap(e["reason"], 72):
print(f" {line}")
print()
print("ID-SPACE COLLISIONS")
for c in report["id_space_collisions"]:
per = ", ".join(f"{k}={v}" for k, v in sorted(c["per_table_ids"].items()))
print(f" {c['id_space']}: {per}")
print(f" naive sum={c['naive_sum']} UNION={c['union']} "
f"expected={c['expected_union']} "
f"{'OK' if c['ok'] else 'MISMATCH'}")
for line in wrap(c["note"], 72):
print(f" {line}")
print()
print("TROPHIES")
for line in wrap(report["trophy_note"], 74):
print(f" {line}")
print()
print("INVARIANT CHECKS")
for c in report["checks"]:
print(f" [{'PASS' if c['ok'] else 'FAIL'}] {c['check']}: {c['detail']}")
print()
if report["rules_without_shipped_table"]:
print("RULES WITH NO SHIPPED TABLE (informational — stale or "
"guard entries)")
for name in report["rules_without_shipped_table"]:
print(f" {name}")
print()
print("SUMMARY")
for bucket in BUCKET_ORDER:
t = report["totals"][bucket]
print(f" {bucket:<24} {t['tables']:>4} tables "
f"{t['definition_ids']:>6} definition ids")
print(f" {'TOTAL':<24} {report['table_count']:>4} tables "
f"{report['definition_ids_total']:>6} definition ids")
print(f" UNKNOWN = {report['unknown']}")
print(" RESULT: " + ("OK" if report["ok"] else "FAIL"))
def wrap(text: str, width: int) -> list[str]:
words = text.split()
lines: list[str] = []
cur = ""
for w in words:
if cur and len(cur) + 1 + len(w) > width:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
if cur:
lines.append(cur)
return lines
def main(argv: list[str] | None = None) -> int:
repo = Path(__file__).resolve().parent.parent
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--tables", type=Path,
default=repo / "fifa17-recon/data/tables",
help="directory of dumped *.json definition tables")
ap.add_argument("--consumables", type=Path,
default=repo / "fifa17-recon/data/consumables.json",
help="consumable subtype evidence file")
ap.add_argument("--json", action="store_true",
help="emit the machine-readable report on stdout")
ap.add_argument("-v", "--verbose", action="store_true",
help="also print the reason for every presentation table")
args = ap.parse_args(argv)
report = classify(args.tables, args.consumables)
if args.json:
json.dump(report, sys.stdout, indent=2, sort_keys=False)
sys.stdout.write("\n")
else:
render(report, args.verbose)
return 0 if report["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
"""Runtime identity of the PRODUCTION FIFA-17 stack, shared by the staging
lifecycle scripts.
This exists because both `sold-staging-up.py` and `sold-staging-down.py` need the
same answer to the same safety question -- "what is production right now, and is
it still healthy?" -- and two hand-maintained copies of a safety gate is two
chances to rot.
Production runs in containers, so its pids are NOT stable facts: every pid changes
when a container is restarted. A hardcoded pid list decays into the worst of both
worlds -- a kill-refusal gate that guards nothing (the real production pids are no
longer in it) and a liveness gate that fails a perfectly good teardown (the pids it
does list are long dead). So pids and published ports are both resolved from the
container runtime at the moment they are needed.
"""
from __future__ import annotations
import subprocess
# Production containers. Anything running inside one of these is production.
PROD_CONTAINERS = ("openfut-fut-backend", "openfut-bridge-1", "openfut-core-1")
# Reserved ports: staging may never bind one of these, whether or not it is
# currently published. 8199 (Python oracle) and 18080 (Core) belonged to the
# retired host-process deployment and are kept so an old port map cannot be
# silently reused by staging.
PROD_PORTS = frozenset(
{8080, 8081, 8085, 8094, 8099, 8199, 8443, 4216, 18080, 42127, 42130, 42131}
)
class ProductionError(RuntimeError):
"""Production could not be observed, or is not healthy."""
def _inspect(container: str, template: str) -> str:
"""One `docker inspect -f` field.
Any failure is fatal by design: a script that cannot see production must
refuse to signal anything rather than assume the best.
"""
try:
result = subprocess.run(
["docker", "inspect", "-f", template, container],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ProductionError(
f"cannot inspect production container {container!r}: {exc}"
) from exc
if result.returncode != 0:
detail = result.stderr.strip() or f"docker exited {result.returncode}"
raise ProductionError(
f"cannot inspect production container {container!r}: {detail}"
)
return result.stdout.strip()
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table.
A trial bind() would report EADDRINUSE for a stopped server's TIME_WAIT
sockets and so wrongly claim a port is still served.
"""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
class ProductionState:
"""A snapshot of production as the container runtime reports it."""
__slots__ = ("pids", "published")
def __init__(self, pids: dict[int, str], published: dict[int, str]) -> None:
self.pids = pids
self.published = published
def describe(self) -> list[str]:
return [f"{what} pid {pid}" for pid, what in sorted(self.pids.items())]
def assert_serving(self) -> None:
"""Every port production publishes must actually be listening."""
listening = listening_ports()
silent = sorted(port for port in self.published if port not in listening)
if silent:
raise ProductionError(
"production port(s) no longer listening: "
+ ", ".join(f"{port} ({self.published[port]})" for port in silent)
)
def assert_unchanged(self, before: "ProductionState") -> None:
"""Production must be the same processes serving the same ports."""
if self.pids != before.pids:
raise ProductionError(
f"production pids CHANGED: before={before.pids}, after={self.pids}"
)
if set(self.published) != set(before.published):
raise ProductionError(
"production published ports CHANGED: "
f"before={sorted(before.published)}, after={sorted(self.published)}"
)
def production_state() -> ProductionState:
"""Resolve production's current pids and published ports, proving every
production container is running."""
pids: dict[int, str] = {}
published: dict[int, str] = {}
for container in PROD_CONTAINERS:
status = _inspect(container, "{{.State.Status}}")
if status != "running":
raise ProductionError(
f"production container {container} is {status!r}, not running"
)
pid = int(_inspect(container, "{{.State.Pid}}") or 0)
if pid <= 0:
raise ProductionError(
f"production container {container} is running but reports no pid"
)
pids[pid] = f"prod {container}"
ports = _inspect(
container,
"{{range $port, $bindings := .NetworkSettings.Ports}}"
"{{range $bindings}}{{.HostPort}} {{end}}{{end}}",
)
for field in ports.split():
published[int(field)] = container
return ProductionState(pids, published)
+31 -46
View File
@@ -10,10 +10,12 @@ ones. So there is no pattern matching here at all:
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
directory -- production's cmdline never can, because staging runs binaries
copied into that directory;
* the known production pids are refused explicitly, as a second gate;
* production's CURRENT pids, resolved from the container runtime, are refused
explicitly as a second gate;
* only the process GROUP the up script created (pgid == pid, via
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
* afterwards every staging port is proven free and production is proven alive.
* afterwards every staging port is proven free, and production is proven to be
the same running containers serving the same ports as before.
python3 scripts/sold-staging-down.py
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
@@ -29,20 +31,20 @@ import signal
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ( # noqa: E402
PROD_PORTS,
ProductionError,
listening_ports,
production_state,
)
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
# Production processes that MUST be alive before and after this script runs. These
# two are the ones the batch contract names, and they live in the host pid view.
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
# Reported but not gated: container pids change when the operator restarts the
# container, and a stale entry here would turn a successful teardown into a FATAL.
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
2090888: "prod pow"}
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
class Fatal(RuntimeError):
class Fatal(ProductionError):
pass
@@ -87,37 +89,17 @@ def cmdline_of(pid: int) -> str:
return ""
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
teardown failed."""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
def port_free(port: int) -> bool:
return port not in listening_ports()
def stop_one(rec: dict, staging_dir: str) -> str:
def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str:
"""Stop exactly one recorded process. Returns a human-readable outcome."""
name, pid = rec["name"], int(rec["pid"])
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
if pid in known_prod:
if pid in prod_pids:
raise Fatal(
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). "
"REFUSING to signal anything from this manifest."
)
if not pid_alive(pid):
@@ -192,8 +174,14 @@ def main() -> int:
)
step(f"variant : {manifest.get('variant')}")
# Resolved BEFORE anything is signalled: the refusal gate below is only
# meaningful if it knows production's pids as they are right now.
before = production_state()
for line in before.describe():
step(f"production : {line}")
for rec in manifest.get("processes", []):
ok(stop_one(rec, staging_dir))
ok(stop_one(rec, staging_dir, before.pids))
banner("PROVE STAGING IS GONE")
ports = manifest.get("ports", {})
@@ -214,16 +202,13 @@ def main() -> int:
ok("no recorded staging process is alive")
banner("PROVE PRODUCTION IS STILL UP")
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
if not pid_alive(pid)]
for pid, what in PROD_PIDS.items():
if pid_alive(pid):
ok(f"{what} pid {pid} alive")
if dead:
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
for pid, what in PROD_PIDS_INFO.items():
state = "alive" if pid_alive(pid) else "not found (informational only)"
step(f"{what} pid {pid} {state}")
after = production_state()
for line in after.describe():
ok(f"{line} alive")
after.assert_unchanged(before)
after.assert_serving()
ok(f"all {len(after.published)} published production ports still listening: "
+ ", ".join(str(port) for port in sorted(after.published)))
if args.purge:
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
@@ -243,7 +228,7 @@ def main() -> int:
print()
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
return 0
except Fatal as exc:
except ProductionError as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
+35
View File
@@ -21,11 +21,20 @@ answers `{"id": 0}` and does NOT echo the squad.
python3 scripts/sold-staging-seed-squad.py # seed, then verify
python3 scripts/sold-staging-seed-squad.py --show # read-only
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
the operator's real imported club would overwrite that club's own squad with an
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
therefore refuses to run when the staging manifest says the real club is installed,
unless `--force` is given. `--show` stays available in every mode.
"""
import http.client
import json
import os
import sys
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
HOST, PORT = "127.0.0.1", 8299
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
@@ -153,11 +162,37 @@ def show():
return len(filled)
def refuse_on_real_club():
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
the operator's own lineup, so the fixture seeder must not be runnable there by
accident -- the staging manifest records which club is installed."""
if "--force" in sys.argv:
print(" --force: seeding over the installed club as instructed")
return
try:
with open(STAGING_MANIFEST) as fh:
manifest = json.load(fh)
except FileNotFoundError:
return # no manifest: nothing claims a real club is installed
if manifest.get("club") != "real":
return
club = manifest.get("real_club") or {}
raise SystemExit(
"REFUSING: staging is running the operator's REAL club "
f"({club.get('username')}, {club.get('owned_cards')} items) and "
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
" Override only if you mean it: --force"
)
def main():
print("=== staging squad, before ===")
before = show()
if "--show" in sys.argv:
return 0
refuse_on_real_club()
players = club_players()
print(f"=== owned players available: {len(players)} ===")
chosen, missing = pick_xi(players)
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Attribute captured UTAS requests to labelled UI actions, and diff them.
utas-filter-diff.py --session <dir> [--baseline NO_FILTER]
The My Squad filter investigation needs to answer "which wire field changed
when I changed exactly one thing in the UI". That is a diff between labelled
groups of requests, so this tool needs both halves:
raw/utas.ofcap what the client sent
labels.txt MARKER <label> <HH:MM:SS> UTC lines, written when the human
said a search was done
Requests are attributed to the label whose marker most recently PRECEDES them.
Anything before the first marker is 'boot'.
Why a separate tool: the observer must stay a dumb, byte-faithful tee. Anything
that interprets traffic belongs outside it, so a mistake here can never affect
what was recorded.
"""
import argparse
import base64
import collections
import hashlib
import json
import os
import re
import sys
from urllib.parse import parse_qsl
def load(session):
"""Rebuild per-connection streams, keeping the wall time of each chunk."""
path = os.path.join(session, "raw", "utas.ofcap")
conns = collections.defaultdict(
lambda: {"c2s": bytearray(), "s2c": bytearray(), "marks": []}
)
with open(path) as f:
for line in f:
r = json.loads(line)
if "b64" not in r:
continue
c = conns[r["conn"]]
data = base64.b64decode(r["b64"])
if r["dir"] == "c2s":
c["marks"].append((len(c["c2s"]), r["unix"]))
c[r["dir"]].extend(data)
return conns
def labels(session):
out = []
p = os.path.join(session, "labels.txt")
if not os.path.exists(p):
return out
import datetime
for line in open(p):
m = re.match(r"MARKER\s+(\S+)\s+(\d\d):(\d\d):(\d\d)\s+UTC", line.strip())
if m:
name, h, mi, s = m.group(1), *map(int, m.groups()[1:])
out.append((name, h * 3600 + mi * 60 + s))
return out
def split_requests(buf):
"""(offset, method, target, headers, body) per request in a stream."""
out, i = [], 0
while True:
sep = buf.find(b"\r\n\r\n", i)
if sep < 0:
break
head = bytes(buf[i:sep]).decode("latin1")
line0 = head.split("\r\n")[0]
m = re.match(r"(\S+)\s+(\S+)\s+HTTP/", line0)
if not m:
break
hdrs = [h.split(":", 1) for h in head.split("\r\n")[1:] if ":" in h]
hdrs = [(k.strip(), v.strip()) for k, v in hdrs]
cl = next((int(v) for k, v in hdrs if k.lower() == "content-length" and v.isdigit()), 0)
start = sep + 4
body = bytes(buf[start:start + cl])
out.append((i, m.group(1), m.group(2), hdrs, body))
i = start + cl
return out
def split_responses(buf):
out, i = [], 0
while True:
sep = buf.find(b"\r\n\r\n", i)
if sep < 0:
break
head = bytes(buf[i:sep]).decode("latin1")
st = re.match(r"HTTP/\d\.\d\s+(\d+)", head)
cl = re.search(r"(?im)^content-length:\s*(\d+)\s*$", head)
te = re.search(r"(?im)^transfer-encoding:.*chunked", head)
start = sep + 4
if te:
j, body = start, bytearray()
while True:
nl = buf.find(b"\r\n", j)
if nl < 0:
return out
try:
n = int(bytes(buf[j:nl]).split(b";")[0], 16)
except ValueError:
return out
j = nl + 2
if n == 0:
j = buf.find(b"\r\n", j)
j = j + 2 if j >= 0 else len(buf)
break
body.extend(buf[j:j + n])
j += n + 2
out.append((int(st.group(1)) if st else None, bytes(body)))
i = j
elif cl:
n = int(cl.group(1))
out.append((int(st.group(1)) if st else None, bytes(buf[start:start + n])))
i = start + n
else:
out.append((int(st.group(1)) if st else None, b""))
i = start
return out
def time_at(marks, off):
t = marks[0][1] if marks else 0
for pos, unix in marks:
if pos > off:
break
t = unix
return t
def item_ids(body):
"""Item ids in a response, if it looks like an item list."""
try:
d = json.loads(body)
except Exception:
return None
ids = []
def walk(o):
if isinstance(o, dict):
if "id" in o and isinstance(o["id"], int):
ids.append(o["id"])
for v in o.values():
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
walk(d)
return ids
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--session", required=True)
ap.add_argument("--baseline", default="NO_FILTER")
a = ap.parse_args()
import datetime
conns = load(a.session)
marks = labels(a.session)
rows = []
for cid in sorted(conns):
c = conns[cid]
reqs = split_requests(c["c2s"])
resps = split_responses(c["s2c"])
for i, (off, method, target, hdrs, body) in enumerate(reqs):
unix = time_at(c["marks"], off)
secs = datetime.datetime.utcfromtimestamp(unix)
secs = secs.hour * 3600 + secs.minute * 60 + secs.second
label = "boot"
for name, at in marks:
if at <= secs:
label = name
status, rbody = resps[i] if i < len(resps) else (None, b"")
path, _, query = target.partition("?")
ids = item_ids(rbody)
rows.append({
"label": label,
"time": datetime.datetime.utcfromtimestamp(unix).strftime("%H:%M:%S"),
"conn": cid, "method": method, "path": path,
"query": dict(parse_qsl(query, keep_blank_values=True)) if query else {},
"query_raw": query,
"req_body": body.decode("latin1") if len(body) < 2000 else "<%dB>" % len(body),
"status": status,
"resp_len": len(rbody),
"resp_sha": hashlib.sha256(rbody).hexdigest()[:12],
"item_count": len(ids) if ids is not None else None,
"item_ids": ids[:40] if ids else None,
})
print("=== requests by label ===")
for r in rows:
if r["label"] == "boot":
continue
print("%-18s %s conn%-3d %-5s %-46s %s %5dB sha=%s items=%s"
% (r["label"], r["time"], r["conn"], r["method"], r["path"][:46],
r["status"], r["resp_len"], r["resp_sha"],
r["item_count"] if r["item_count"] is not None else "-"))
if r["query"]:
print("%-18s query: %s" % ("", r["query"]))
if r["req_body"].strip():
print("%-18s body : %s" % ("", r["req_body"][:200]))
# Field-level diff against the baseline label.
base = [r for r in rows if r["label"] == a.baseline]
if not base:
print("\n(no %s rows yet; skipping diff)" % a.baseline)
return 0
print("\n=== query-field diff vs %s ===" % a.baseline)
bq = base[-1]["query"]
bpath = base[-1]["path"]
seen = set()
for r in rows:
if r["label"] in ("boot", a.baseline) or r["label"] in seen:
continue
seen.add(r["label"])
added = {k: v for k, v in r["query"].items() if bq.get(k) != v}
removed = {k: v for k, v in bq.items() if k not in r["query"]}
note = "" if r["path"] == bpath else " PATH DIFFERS: %s" % r["path"]
print(" %-18s +%s -%s%s" % (r["label"], added or "{}", removed or "{}", note))
return 0
if __name__ == "__main__":
sys.exit(main())
+13 -1
View File
@@ -1,5 +1,12 @@
#!/usr/bin/env bash
# OpenFUT Setup Script
# OpenFUT Setup Script — ⚠️ LEGACY (FIFA 23 core+bridge lineage)
#
# SUPERSEDED. The working target is FIFA 17. This script builds/starts the old
# FIFA 23 core+bridge stack and redirects EA domains for FIFA 23. Do NOT use it
# for the current server. Canonical server bring-up (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md
#
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23.
# Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
@@ -407,6 +414,11 @@ EOF
}
# ── Dispatch ───────────────────────────────────────────────────────────────────
# --- Legacy guard: this is the superseded FIFA 23 flow (see header banner). ---
echo "[LEGACY] setup.sh drives the superseded FIFA 23 core+bridge stack." >&2
echo "[LEGACY] Canonical FIFA 17 server: cd fifa17-recon/docker/fifa17-python && docker compose up -d" >&2
echo "[LEGACY] Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md" >&2
case "${1:-help}" in
quickstart) cmd_quickstart ;;
build) cmd_build ;;
+107
View File
@@ -0,0 +1,107 @@
# OpenFUT FIFA 17 - native Windows client
The FIFA 17 client host (`10.10.0.105`, Windows 11 Pro) runs FIFA 17 **natively**
(no Wine/Proton/umu). This directory holds the read-only preflight verifier and
documents the native launch/routing/rollback model.
## Install layout (`C:\FIFA 17`)
| File | Role |
|---|---|
| `FIFA17.exe` | retail game exe (sha256 `29C31CEF…`). **Never modify/patch.** ImageBase `0x140000000`. |
| `_fifa17.exe` | native crack loader (Chemicalflood). This is what you launch. `asInvoker` manifest -> must be elevated externally. |
| `version.dll` | **OpenFUT hook** (in-process via the version.dll load-order hijack). ImageBase `0x180000000`. |
| `version.dll.stale-849k.bak` | **rollback** copy of the previous hook. |
| `CardsDLL_Win64_retail.dll` | FUT card/SBC/kit logic. ImageBase `0x180000000`. |
| `powdll_Win64_retail.dll` | Pack-Opening-World (EASFC store). ImageBase `0x180000000`. |
| `sysdll_Win64_retail.dll` | EA networking / ProtoSSL (cert, ea.com). |
| `stp-origin_emu.dll` + `stp-origin_emu.ini` | Origin/LSX login emulator (in-process; opens LSX `:4216` locally at runtime). Persona configured in the `.ini`. |
| `stp-selector.exe` | ssl/LSX selector companion. |
| `openfut.cfg` | operator-facing routing override (see below). |
## Launch (native - there is NO launcher script by design)
Run `C:\FIFA 17\_fifa17.exe` **as Administrator**. The correct, reproducible way:
- Double-click the **"FIFA 17 (OpenFUT)"** shortcut (Desktop and Start Menu).
It targets `_fifa17.exe`, working dir `C:\FIFA 17`, with the RunAsAdmin bit set.
- `_fifa17.exe` is also flagged `RUNASADMIN` in
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`,
so any launch (Explorer double-click included) elevates via UAC.
On launch the Windows loader maps `version.dll` from the game directory (hijack),
`stp-origin_emu.dll` emulates Origin login for the configured persona, and the
hook redirects EA endpoints to the OpenFUT backend.
> Do **not** wrap the launch in a script. The elevation + shortcut is the
> supported mechanism. FIFA under native Windows also ignores synthetic input,
> so in-game steps are performed by the operator one at a time.
### OpenFUT Launcher (GUI)
The `openfut-launcher` egui app runs natively on Windows (built for
`x86_64-pc-windows-gnu`; the Linux Proton path is `cfg`-gated out). It is the
one-button front end: it checks the backend, reconciles `openfut.cfg` from its
settings, and starts the game.
- Binary: `C:\OpenFUT\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
- Launch it from the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). The
launcher itself is flagged `RUNASADMIN`, so it elevates once at start; the game
it spawns inherits that elevation (no second UAC prompt, no crack loader
"run as admin" failure).
- On Windows the launcher does NOT spawn LSX/autopatch (they are in-process:
`stp-origin_emu.dll` + the `version.dll` hook) and does NOT arm the host
(routing is purely `openfut.cfg`, which it writes into `C:\FIFA 17`).
- Rebuild from Linux: `cargo build -p openfut-launcher --release --target x86_64-pc-windows-gnu`.
## Routing (`openfut.cfg`)
```
host=10.10.0.120
https_port=8443
blaze_redirector_port=42127
blaze_main_port=42130
```
The hook carries `10.10.0.120` as its baked-in default; `openfut.cfg` is the
override. `10.10.0.120` hosts **both** production and staging.
> **Production safety:** `blaze_main_port=42130` is the **production** Blaze.
> Before any match/economy exercise, repoint `blaze_main_port` (and the matching
> UTAS/HTTPS route) to the staging port so no traffic reaches the prod container.
> The preflight raises a WARN whenever `42130` is configured.
## Rollback
The hook is a single file swap; no installer state.
```powershell
# disable OpenFUT hook (restore previous DLL)
Copy-Item 'C:\FIFA 17\version.dll' 'C:\FIFA 17\version.dll.disabled.bak' -Force
Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Force
# re-arm: copy the desired hook build over version.dll again
```
Always keep a `*.bak` of the live hook before redeploying (the preflight checks
that a rollback backup exists and differs from the live DLL).
## Preflight
`openfut-client-preflight.ps1` is **read-only**: it never launches the game,
never elevates, never writes game files, never mutates economy state. It verifies
the retail exe hash, companion DLLs, hook + rollback, routing + backend
reachability, login persona, launcher elevation, and the RE toolchain
(x64dbg, cargo). Exit 0 = OK, 1 = blocking failure.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\openfut-client-preflight.ps1
```
## Runtime RE (x64dbg)
See the Vault note **`02 Reverse Engineering/FIFA 17/Windows Client Runtime & x64dbg.md`**
for the attach workflow and the RVA<->VA (ASLR) math. In short: these modules'
preferred ImageBase is `0x180000000` (`0x140000000` for `FIFA17.exe`); in x64dbg
a module name evaluates to its runtime (ASLR) base, so a Ghidra address maps to a
breakpoint as `bp CardsDLL_Win64_retail.dll+<RVA>` where
`RVA = ghidra_addr - 0x180000000`.
+129
View File
@@ -0,0 +1,129 @@
<#
.SYNOPSIS
OpenFUT FIFA 17 Windows client preflight - READ ONLY.
.DESCRIPTION
Non-destructive verification of the native Windows FIFA 17 OpenFUT client on
this machine. It NEVER launches the game, never elevates, never writes to
game files, and never mutates any economy state. It only reads files,
registry, and performs TCP connect probes to the configured backend.
Exit code 0 = all PASS/WARN, 1 = one or more FAIL.
Native launch model (there is NO launcher script by design):
run C:\FIFA 17\_fifa17.exe as Administrator
(use the "FIFA 17 (OpenFUT)" shortcut, which carries the RunAsAdmin bit).
#>
[CmdletBinding()]
param(
[string]$FifaRoot = 'C:\FIFA 17'
)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'SilentlyContinue'
# --- known-good fingerprints -------------------------------------------------
# Retail executable MUST NOT be modified/patched (Denuvo + anti-cheat sensitive).
$EXPECT_FIFA17_EXE_SHA256 = '29C31CEF12B0C3C2A7305220617C7B4FA139AB76B8C857851BDBE88987962899'
# Currently deployed OpenFUT hook (base-supply + VEH build). Update on redeploy.
$EXPECT_HOOK_SHA256 = '8844A6BCEE7BE37DD55B989246B312AF52FA711CCBC1220D25FEC64CBAD077D8'
$ROLLBACK_BAK = Join-Path $FifaRoot 'version.dll.stale-849k.bak'
$script:fail = 0
function Say([string]$level, [string]$msg) {
switch ($level) {
'PASS' { $c = 'Green' }
'WARN' { $c = 'Yellow' }
'FAIL' { $c = 'Red'; $script:fail++ }
default { $c = 'Gray' }
}
Write-Host ('[{0}] {1}' -f $level, $msg) -ForegroundColor $c
}
function Sha([string]$p) { if (Test-Path $p) { (Get-FileHash $p -Algorithm SHA256).Hash } else { $null } }
Write-Host '=== OpenFUT FIFA 17 Windows client preflight (read-only) ===' -ForegroundColor Cyan
Say 'INFO' ("host={0} user={1} {2}" -f $env:COMPUTERNAME, $env:USERNAME, (Get-CimInstance Win32_OperatingSystem).Caption)
# --- 1. FIFA install + retail exe integrity ---------------------------------
$exe = Join-Path $FifaRoot 'FIFA17.exe'
$loader = Join-Path $FifaRoot '_fifa17.exe'
if (Test-Path $exe) {
$h = Sha $exe
if ($h -eq $EXPECT_FIFA17_EXE_SHA256) { Say 'PASS' "FIFA17.exe present and unmodified ($($h.Substring(0,16))...)" }
else { Say 'FAIL' "FIFA17.exe hash MISMATCH - retail exe changed! got $($h.Substring(0,16))... expected $($EXPECT_FIFA17_EXE_SHA256.Substring(0,16))..." }
} else { Say 'FAIL' "FIFA17.exe missing at $exe" }
if (Test-Path $loader) { Say 'PASS' "native loader _fifa17.exe present ($((Sha $loader).Substring(0,16))...)" }
else { Say 'FAIL' "_fifa17.exe (native loader) missing - cannot launch" }
# --- 2. companion DLLs -------------------------------------------------------
$companions = 'CardsDLL_Win64_retail.dll','powdll_Win64_retail.dll','sysdll_Win64_retail.dll',
'FootballCompEng_Win64_retail.dll','stp-origin_emu.dll','stp-selector.exe'
foreach ($c in $companions) {
$p = Join-Path $FifaRoot $c
if (Test-Path $p) { Say 'PASS' "companion present: $c" } else { Say 'FAIL' "companion MISSING: $c" }
}
# --- 3. OpenFUT hook (version.dll) + rollback backup ------------------------
$hook = Join-Path $FifaRoot 'version.dll'
if (Test-Path $hook) {
$hh = Sha $hook
if ($hh -eq $EXPECT_HOOK_SHA256) { Say 'PASS' "hook version.dll deployed (expected build $($hh.Substring(0,16))...)" }
else { Say 'WARN' "hook version.dll present but hash differs from recorded build ($($hh.Substring(0,16))...) - may be a newer/older hook" }
$bytes = [IO.File]::ReadAllBytes($hook); $ascii = [Text.Encoding]::ASCII.GetString($bytes)
$markers = @('OpenFUT','fifa17','CardsDLL','SBC_DISPATCH') | Where-Object { $ascii -match [regex]::Escape($_) }
if ($markers.Count -ge 3) { Say 'PASS' "hook markers found: $($markers -join ', ')" }
else { Say 'WARN' "hook markers thin: $($markers -join ', ') - is this the OpenFUT hook?" }
} else { Say 'FAIL' "hook version.dll NOT deployed - client will run vanilla (no OpenFUT)" }
if (Test-Path $ROLLBACK_BAK) {
if ((Sha $ROLLBACK_BAK) -ne (Sha $hook)) { Say 'PASS' "rollback backup present and differs from live: $(Split-Path $ROLLBACK_BAK -Leaf)" }
else { Say 'WARN' "rollback backup equals live version.dll - rollback would be a no-op" }
} else { Say 'WARN' "no rollback backup ($(Split-Path $ROLLBACK_BAK -Leaf)) - keep one before redeploying the hook" }
# --- 4. routing config + backend reachability -------------------------------
$cfg = Join-Path $FifaRoot 'openfut.cfg'
$host120 = $null; $ports = @()
if (Test-Path $cfg) {
$kv = @{}; foreach ($l in Get-Content $cfg) { if ($l -match '^\s*([^=#]+)=(.+)$') { $kv[$matches[1].Trim()] = $matches[2].Trim() } }
$host120 = $kv['host']
Say 'PASS' "openfut.cfg routing: host=$($kv['host']) https=$($kv['https_port']) redirector=$($kv['blaze_redirector_port']) blaze=$($kv['blaze_main_port'])"
foreach ($k in 'https_port','blaze_redirector_port','blaze_main_port') { if ($kv[$k]) { $ports += [int]$kv[$k] } }
if ($kv['blaze_main_port'] -eq '42130') { Say 'WARN' 'blaze_main_port=42130 targets PRODUCTION - repoint to a staging port before match/economy testing' }
} else { Say 'WARN' "openfut.cfg absent - hook uses its baked-in default host" }
if ($host120) {
foreach ($p in $ports) {
$t = New-Object Net.Sockets.TcpClient
try {
$ar = $t.BeginConnect($host120, $p, $null, $null)
if ($ar.AsyncWaitHandle.WaitOne(2500) -and $t.Connected) { Say 'PASS' "backend reachable ${host120}:$p" }
else { Say 'FAIL' "backend UNREACHABLE ${host120}:$p" }
} catch { Say 'FAIL' "backend probe error ${host120}:$p - $($_.Exception.Message)" } finally { $t.Close() }
}
}
# --- 5. login persona (stp origin emulator) ---------------------------------
$ini = Join-Path $FifaRoot 'stp-origin_emu.ini'
if (Test-Path $ini) {
$persona = (Get-Content $ini | Select-String 'PersonaId|PersonaName') -join ' '
Say 'PASS' "login emulator config: $persona"
} else { Say 'FAIL' "stp-origin_emu.ini missing - no login persona" }
# --- 6. native launcher elevation setup -------------------------------------
$lk = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers'
$layer = (Get-ItemProperty $lk).$loader
if ($layer -match 'RUNASADMIN') { Say 'PASS' "_fifa17.exe RUNASADMIN flag set ($layer)" }
else { Say 'WARN' '_fifa17.exe has no RUNASADMIN flag - launch must be manual "Run as administrator"' }
$sc = Join-Path $env:USERPROFILE 'Desktop\FIFA 17 (OpenFUT).lnk'
if (Test-Path $sc) { Say 'PASS' "desktop launcher shortcut present: $(Split-Path $sc -Leaf)" }
else { Say 'WARN' 'no desktop launcher shortcut' }
# --- 7. RE toolchain ---------------------------------------------------------
$x = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Depth 4 -Include x64dbg.exe -Attributes !ReparsePoint -EA SilentlyContinue | Select-Object -First 1 -Expand FullName
if (-not $x) { $x = (Get-Command x64dbg.exe -EA SilentlyContinue).Source }
if ($x) { Say 'PASS' "x64dbg present: $x (v$((Get-Item $x).VersionInfo.FileVersion))" } else { Say 'WARN' 'x64dbg not located - install for runtime RE' }
$cargo = (Get-Command cargo -EA SilentlyContinue).Source
if ($cargo) { Say 'PASS' "rust toolchain: $cargo" } else { Say 'WARN' 'cargo not found - needed to rebuild the hook natively' }
# --- summary -----------------------------------------------------------------
Write-Host ''
if ($script:fail -eq 0) { Write-Host 'PREFLIGHT: PASS (no blocking failures)' -ForegroundColor Green; exit 0 }
else { Write-Host "PREFLIGHT: FAIL ($script:fail blocking issue(s))" -ForegroundColor Red; exit 1 }