136 Commits

Author SHA1 Message Date
funman300 704482be84 tools(re): watch resident club-item population for route correlation
Read-only watcher that waits for FIFA17.exe, re-resolves the club-item store
each tick (the manager is reallocated per login), and emits one timestamped line
per CHANGE. Pair it with

    journalctl -u openfut-staging-host -o short-iso

to answer "after which response does a resident club item first appear?" by wall
clock, without having to reverse the constructor first.

Re-resolving per tick matters: the store is reached through
[CardsDLL+0x2e6398] -> vtable[0x4e8], and that getter is a `lea`, so the manager
is an embedded subobject whose address moves with the owner. The tool also
re-checks the module base against a known immediate every time it attaches and
refuses to report from a wrong base.

Verified against the currently live client: club=0/5 players=18/23.

Staging can host the session: every bootstrap route probed answers 200
(userMassInfo, squad/active, squad/list, club/stats/*, hub, user, club arms,
item idList, clubUser, season/list, watchList, purchased/items, settings,
clientdata) with the single exception of /statistics/tournament, which 502s
because staging's Python upstream is deliberately dead and which is not on the
club bootstrap path.
2026-08-24 16:13:36 +00:00
funman300 0997dd3b60 tools(re): census FIFA 17's resident club-item vector
Read-only /proc/PID/mem census of the client's resident club-item store, which
is what the pre-match kit selector actually consumes. No writes.

Resolves the whole chain from static RE rather than guessing offsets:

  [CardsDLL+0x2e6398]        -> owner object       (FUN_18011a830 is a plain
                                                    global read)
  owner->vtable[0x4e8]       -> lea rax,[rcx+0x1f9d8]; ret, i.e. mgr is an
                                EMBEDDED subobject, not a pointer
  mgr+0x108 .. mgr+0x110     -> club-item vector, stride 24
  element+0x10               -> the item record    (FUN_1800d73d0)

CardsDLL is located by its NEAREST PRECEDING NAMED mapping, because Wine maps PE
sections anonymously and the Wine heap is also rwx, so permissions do not
discriminate code from heap. The base is then sanity-checked against a known
immediate (mov edx,0x7575 at 0x180026fea) and the tool aborts rather than
reporting from a wrong base.

Field offsets are the ones already proven, and nothing else is interpreted:
+0x4c cardtype, +0x50 cardsubtypeid, +0x5c itemState, +0x60 category,
+0x94 teamid, +0xba teamkittypetechid (u16).

FIRST RESULT, live on the client parked at the pre-match kit selector:

  players    mgr+0x0d8: 23 slots, 18 non-null, all (cardtype 1, subtype 0)
  club items mgr+0x108:  5 slots,  0 non-null

Five slots, every item pointer NULL. Five is the club's active club-item set --
home kit, away kit, badge, stadium, ball -- so the client knows it should hold
five and holds none. That is why FUN_1800d73d0 returns the 0x1802c2a28 sentinel
whose +0x10 is NULL, and why the tiles are untextured.
2026-08-24 16:08:02 +00:00
funman300 743b20b00b kits: 0x757a is the command "useSavedMatchKits" - the gate was never the bug
Recovered from the live retail client (pid 44405, parked on the pre-match kit
selector) by read-only /proc/PID/mem. No writes, no debugger, no detours, no
client modification. Denuvo decrypts FIFA17.exe in memory, so the immediate that
is absent on disk is present at runtime.

At live 0x147de80e8 FIFA17.exe runs a name-registration loop through the same
string-setter vtable slot (+0x20) CardsDLL uses, so each id can be given its
name:

    0x7579  useSavedMatchData        -> movb $1, ctx+0x151
    0x757a  useSavedMatchKits        -> movb $1, ctx+0x152  == KITS_AVAILABLE
    0x7587  setFUTServerEnvironment

271 pairs recovered and committed. This is a DIFFERENT namespace from CardsDLL's
DataProvider id table -- the same numeric id has a different name in each --
matching the APT's own split between game.uif.UIFDataProviderList and the
action/command list.

KITS_AVAILABLE therefore does not mean "the server sent kits". It means "use the
previously SAVED match kits", and the APT writes those via ACTION_SAVE_MATCH_KIT
in SaveKitsForMatch. Live, both flags read 0 on a fresh match at the exact
moment the tiles are blank. So 0 is CORRECT on first entry and the client is
designed to take the fallback path. The gate was never the defect, which is what
the 2026-08-23 measured negative was already indicating when better item data
changed nothing.

The fallback is GetKitArrayForFUT -> ION_Uniform.GetIDs(teamId), which natively
gates on team id 130000 (0x1800d8ab0) or 130001 (0x1800d8ad0) -- the synthetic
FUT home/away pair -- and otherwise defers to the generic engine catalogue. For
a matching team it packs the two active kit records and admits them through a
check that resolves at runtime to "cmp edx,0x189a1003 / setne al", i.e. merely
"not the invalid sentinel".

Still no OpenFUT change: whether the selector requests 130000/130001, and
whether the active kit records resolve, are both unanswered and both live
questions. Full write-up in the Vault under Kit Selector APT Decode section 8.
2026-08-24 05:29:21 +00:00
funman300 e48d659bce host: fix stale test that still asserted equippables was withheld
d146f9c flipped `club?type=equippables` from withheld to answering the KIT
family — the change that made kits render in My Club, operator-confirmed — but
it only touched src/lib.rs. `withheld_club_type_arms_are_empty_with_a_recorded_reason`
still listed that arm, so the suite asserted the exact behaviour the fix
removed. It failed on `cargo test --workspace`; the "1241 passed" figure carried
in the notes was measured before the flip and was stale.

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

`OPENFUT_FIFA17_EQUIPPABLES=0` is deliberately not exercised: it is read through
a OnceLock, so flipping the process-wide env in one test would leak into every
other test in the binary.
2026-08-23 23:08:24 +00:00
funman300 a86d21ec79 kits: decode the FIFA 17 APT bytecode; KITS_AVAILABLE is not a server field
Adds fifa17-recon/tools/apt_decode.py, a clean-room decoder for the EA APT
compiled-ActionScript format as FIFA 17 ships it, and deletes avm1_disasm.py,
which assumed SWF framing and could not decode this artifact.

FORMAT. FIFA 17 uses a 64-bit variant of the format: constant-pool entries are
16-byte {u64 type, u64 value} in a separate "Apt1" container member, container
pointers and counts are u64, DefineFunction2's operand block is 48 bytes rather
than 28, its 0x1234567898765432 trailer is stored as two u64 halves, and
parameterised instructions align their operand block to EIGHT bytes, not four.
The alignment is the fact that made the stream decodable: the ConstantPool at
0xd38 yields garbage at align-4 and count=401 at align-8, with an index array
that terminates exactly on the parameter-list region.

The three opcodes that blocked the previous attempt are all unaligned 2-byte
records: 0xAF EA_GetNamedMember (u8 constant-pool index, pop object push member),
0xB9 EA_PushRegister (u8 register index), 0xA2 EA_PushConstantByte (u8
constant-pool index). Byte-wide indices only reach pool entries 0-255, which is
why CheckIsKitLocked at index 293 is emitted as 0xA3 PushConstantWord.

Format facts came from a written specification derived from OpenSAGE
(588ac477367a0022adf29f20a084e8873014e6ce, GPL-3.0 with EA section 7 additional
terms). No code was copied or transliterated; only the interface specification
was used. Provenance is recorded in the module docstring.

VALIDATION. The whole artifact decodes: 5251 instructions, 24 functions, action
stream 0xd38..0x3e75 with 12605 of 12605 bytes covered and zero interior gaps,
zero unresolved opcodes, zero invalid branch targets, zero unresolved strings.
Every byte of the 20630-byte member is accounted for by region. --selftest
asserts all of it, plus operand fixtures and fail-closed behaviour on truncated
records, out-of-range pool indices and unknown opcodes. Unknown opcodes still
refuse to guess a length rather than resynchronising.

RESULT. KITS_AVAILABLE is a BOOLEAN in the DataProvider header, not a count, so
the logged Some(0) means false. futSelectTeam::Publish reads
publishObject.header.KITS_AVAILABLE == true and only then fills m_arrKitPanelData
from data[side].LENGTH and data[side]["KIT_"+i]; CardsDLL's builder writes
exactly that shape ("LENGTH" and "KIT_%d" confirmed in .rdata). The flag comes
from ctx+0x152, whose only setter is internal message 0x757a. That message is
never constructed in ActionScript (the asset contains zero integer literals
above 255) and never sent by CardsDLL (247 of 247 send sites pass an immediate,
none of them 0x757a; four sites in the same family are the positive control).

So no HTTP response can open this gate, and no OpenFUT change is made here.
CheckIsKitLocked is called at 0x2cc3 but defined on the mcSelectTeam child clip
in another asset, so its body is deliberately NOT reconstructed rather than
guessed. Full findings in the Vault under Kit Selector APT Decode.
2026-08-23 22:44:14 +00:00
funman300 f40e8587ac kits: futSelectTeam APT extracted; bytecode is EA-extended AVM1, not plain SWF
Operator exported futSelectTeam.BIG (88272 B, BIGF, 11 members). Confirmed the
right screen: FUT_GET_MATCH_KITS_DP, CheckIsKitLocked, mcLockHome and
KITS_AVAILABLE all present. Members split out and committed:

  futSelectTeam_Apt1.bin     14526 B  magic Apt1
  futSelectTeam_AptData.bin  20630 B  magic "Apt Data:1:7:8"

STRUCTURE, measured rather than assumed:
  * Apt1 is header + a u32 STRING POINTER TABLE + an 8-byte-aligned string table.
    Every symbol has exactly one u32 reference and the refs run in the same order
    as the strings, so they are pointers, not code references.
  * Apt Data holds the actions. Opcode frequencies are AVM1-shaped - GetMember
    0x4e x309, If 0x9d x172, CallMethod 0x52 x160, DefineFunction2 0x8e x33,
    Jump 0x99 x74 - but two non-standard opcodes dominate (0xaf x1081,
    0xb9 x1064), so this is EA's extended dialect and strings are referenced by
    table offset instead of a SWF ActionConstantPool.

Adds avm1_disasm.py, which reads the standard subset and prints unknown opcodes
with their length rather than skipping them. It is NOT sufficient for this file:
decoding 0xaf/0xb9 is the remaining work, and OpenSAGE apt-toolkit is the
reference implementation for EA APT actions.

So CheckIsKitLocked is located but not yet READ. What is known stays known: the
native side only ever writes LOCKED = 0, so the predicate lives here.
2026-08-23 22:09:17 +00:00
funman300 5bf2c7ddc1 kits: the pre-match kit screen is futSelectTeam, found without Frosty
The Frostbite .cas chunks holding APT ActionScript are greppable, so screens can
be identified and their whole symbol table recovered without driving the GUI.
Control: KitAssignmentPopup (a string from an already-exported BIG) hits 43 times
across the 52 cas files, so a miss would have been meaningful.

FUT_GET_MATCH_KITS_DP hits 10 times. The binding screen is
external.ion_fut.screens.futSelectTeam
(fifa_installpackage_01/cas_01.cas @ 0x3707ecd7), which no exported BIG contained
after 33 attempts at guessing names.

It binds FUT_GET_MATCH_KITS_DP, KitSelectDP and TeamSetupDP, and carries exactly
the vocabulary the native side implies:

  panels/locks  mcKitHome mcKitAway mcLockHome mcLockAway m_arrKitPanels
  sides         HOME_SIDE AWAY_SIDE NEUTRAL_SIDE SIDE_HOME SIDE_AWAY
  DP fields     KITS_AVAILABLE  KIT_  HOME_KIT_ID  AWAY_KIT_ID
  flow          InitializeKitConfig GetKitArrayForFUT InitializeKitsFromArray
                EnterKitSelect IsKitSelectCreated ExitKitSelect SaveKitsForMatch
  lock          CheckIsKitLocked  RemoveKitLocks  SetKitReady  SetKitUnReady
  uniform       SetUniform ION_Uniform GetNonConflictingUniformID

CheckIsKitLocked is the lock predicate the native side does not own — recall
sub_180033430 only ever writes LOCKED = 0, so the "kit is currently locked" dialog
is raised here.

Adds find_apt_in_cas.py (control-guarded) and the recovered 934-symbol table.
2026-08-23 21:52:36 +00:00
funman300 d146f9c3fc host: answer club?type=equippables by default — kits now render in My Club
OPERATOR-CONFIRMED on the retail client: the My Club kit tab shows both kits, the
first time kits have ever rendered in this project.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Vault: "Kit Selector Data Path.md". cargo test 429 passed 0 failed across the two
crates; clippy -D warnings clean; fmt clean.
2026-08-23 20:08:08 +00:00
funman300 eefa98c961 kits: canonical table-proven kit map, and category is the home/away key (not the id band)
Answers from the extracted client tables, before touching a binary. Every number
is a count over the full table.

  fcc_kitcards 1482 rows, teamkits 2576 rows.

CATEGORY -> ENGINE KIT TYPE, with a test that can actually fail. Asserting
"category 3 means away" because away kits usually exist is not evidence: types
0/1/2 are present for most teams, so it is true by construction. The
discriminating cases are the teams that LACK a type.

  category 2 -> type 0 HOME    54 keys lack type 0, 0 counterexamples
  category 3 -> type 1 AWAY   166 keys lack type 1, 0 counterexamples
  category 5 -> type 2 THIRD  145 keys lack type 2, 0 counterexamples

and there is never more than one card per (team, year, category).

THE ID BAND IS NOT HOME/AWAY. Band 6300000 holds 740 HOME cards AND 88 THIRD
cards; band 6400000 holds the 654 AWAY cards. assetid is fully determined by the
band (14 for 828/828 of 63xxxxx, 15 for 654/654 of 64xxxxx), so it carries no
information the band does not. cardassetid is 35 on all 1482 rows - it is the FUT
card frame, not the kit art.

This matters for openfut-adapter-fifa17: KIT_AWAY_FLOOR splits home from away at
6_400_000, which is right for home vs away but silently classifies all 88 THIRD
kits as HOME. Recorded here, not yet fixed - third kits are not currently
ownable, so nothing observable depends on it.

teamkits.islocked is 0 on all 2576 rows, so the DB lock flag is NOT what makes
the pre-match selector call a kit locked. 6 rows are embargoed.

Team 21, the staging club, resolves exactly:
  6300006 cat 2 HOME  year 0    assetid 14 -> teamkitid 1376
  6400003 cat 3 AWAY  year 0    assetid 15 -> teamkitid 1377
  6300007 cat 2 HOME  year 1972 assetid 14 -> teamkitid 5126
  6300008 cat 5 THIRD year 0    assetid 14 -> teamkitid 1378

so the two kits OpenFUT serves are the correct home/away pair.

NOT recoverable from data/tables: the kit's own name string. fcc_kitcards
name/header/description/biodescription are byte OFFSETS into the table's string
blob (583, 597, 608, 619 on one row), and that blob is not among the extracted
tables.

Adds audit_fifa17_kits.py (the tool, with the discriminating test inline) and
fifa17-kit-map.json (its output) so this is reusable data rather than terminal
scrollback.
2026-08-23 19:34:06 +00:00
funman300 88ae754b0f launcher: record the detour revert (d764117)
The submodule pointer still referenced the rate-limited engine-provider build.
d764117 removes those detours entirely; they froze the client once and crashed
it once, and the fault was the detours themselves rather than their logging.
2026-08-23 19:30:23 +00:00
funman300 e0f1e379b7 kit probe: settle the ingest question in one restart; itemType alone did NOT fix it
RESULT OF THE PREVIOUS COMMIT, recorded before anything else: sending itemType on
cardtype-7 club items did NOT make them ingest. Client relaunched, ?type=kit
answered total=2 emitted=2 with itemType="kit" on both, and afterwards there is
still no cardtype-7 record resident and the hook still traces
KITS_AVAILABLE = 0. The player/staff-vs-kit/badge/stadium correlation was real
but it is NOT the cause. The field is kept because every real EA item in the
capture corpus carries it and the two ingesting families already did, but it is
now labelled wire fidelity, not a fix.

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

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

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

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

Both ids are real team-21 carddbids, served free so they cannot disturb the
active-kit assignment, with instance ids outside Core's range. Two items in one
family: the response that crashed this client on 2026-08-05 was thirty across
five.
2026-08-23 19:29:33 +00:00
funman300 6d89d62e41 club items: send itemType, which every ingested family already carries
Measured live 2026-08-23 against the client parked on the pre-match kit selector
(pid 8793, read-only /proc/PID/mem). Whether a served club item becomes a
resident item record correlates perfectly with whether we send itemType:

    family    itemType sent   resident record?
    player    "player"        yes
    staff     "staff"         yes
    kit       (absent)        NO
    badge     (absent)        NO
    stadium   (absent)        NO

Two of two families that carry it are ingested; none of the three that omit it
is. With no cardtype-7 record resident the club scan FUN_1800d73d0 matches
nothing, the FUT match-kit DataProvider is built empty (traced: KITS_AVAILABLE
= 0), and the selector falls back to catalogue-gated engine kits - which is the
"This kit is currently locked. To unlock and use it, please go to the Football
Club Catalogue." dialog the operator sees. That string lives in the Flash UI, and
Blaze receives no catalogue or unlock request at any point, so the lock is
decided client-side from data the client already holds.

CARD_SYSTEM.md records that itemType "is parsed into a heap string and never
stored". That stays true of the RECORD; it does not follow that the string is
unused, and the correlation is evidence it is consulted before the record is
kept.

Tokens come from the ?type= vocabulary decoded from the FUN_18012ec50 jump table
(kit 12, stadium 13, badge 11) - the same vocabulary the two working families
use. INFERRED, not proven: no real EA club item exists anywhere in the capture
corpus, so the exact token is taken from the atom vocabulary, not observed wire.

Near miss worth recording: STADIUM_SUBTYPE was not imported at module scope, so
match treated it as a fresh binding instead of a constant, made that arm
irrefutable and typed badges as "stadium". It compiled with only an
unused-variable warning. every_cardtype7_family_carries_its_own_item_type
asserts three distinct tokens, which is what catches that.

cargo test --workspace: 1240 passed, 0 failed.
2026-08-23 19:15:38 +00:00
funman300 40e53ed02c kit selector: withdraw the "client dead end" verdict, measure what is actually resident
The 2026-08-21 entry concluded the pre-match kit selector "is a client dead end,
not a missing wire field" because nothing stores 4 into item +0x60. Withdrawn.
It rested on two mistakes:

  1. +0x60 == 4 DOES occur live - a record with +0x4c == 2 and +0x60 == 4 reached
     the art-clone driver FUN_1801c3480. The immediate-store scan cannot see it,
     so "nothing can satisfy the gate" was never licensed by that evidence.
  2. It annotated `cmp [rdi+0x4c], 7` with "<- we produce this" without measuring
     it. Its own live half showed only {1: players, 0: staff}: zero cardtype-7
     records. That is the finding, and it was read as the opposite.

Measured now against the live client parked on the kit selector, read-only via
/proc/PID/mem over 3047 MiB, searching the exact u32 values the server sent:
players and staff are resident with sane fields; kit, badge and stadium are all
absent by resourceId AND by instance id. The host served ?type=kit total=2
emitted=2 at 17:50:09 this session and neither kit produced a record.

So the blocker sits upstream of the +0x60 gate: no cardtype-7 record is ever
created, so the club scan FUN_1800d73d0 has nothing to match, KIT_DESC never
fires and KITS_AVAILABLE reads 0. Cause is not yet settled - either our wire
shape (the cardtype-7 arm wants name/localizedName/description, which we do not
send) or cardtype-7 items being transient. Neither is recorded as fact.

Also: kit_gate_probe.py's live half is unreliable. On pid 8793 it reported
"CardsDb is empty" while a byte scan found 1966 resident players, so its
structural chain is stale and its record counts understate reality. Adds
club_record_residency_probe.py, which is read-only and cannot disturb the game.
2026-08-23 18:51:34 +00:00
funman300 b55dd16a46 chore: bump openfut-launcher (engine-provider kit traces) 2026-08-23 17:55:12 +00:00
funman300 11b7991d81 feat(fifa17): gated kits-only club?type=equippables, to test the locked-kit cause
A live kit_trace run on the retail client showed the kit clone driver only ever
sees PLAYERS - about 19 records, all cardtype 1 / itemState 1 / +0x60 = 1 - and
never a kit, with no KIT_DBCLONE line at all. So the locked-kit failure is
UPSTREAM of the item+0x60 == 4 gate that this arm's comment blamed.

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

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

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

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

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

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

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

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

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

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

Two things this does NOT fix, both client-owned and recorded rather than
guessed:
  - the runtime teamkits clone into FUT club 130000 (the kit ART) is gated on
    item+0x60 == 4, and +0x60 has no wire atom at all: the deserialiser
    unconditionally zeroes it, the only CMP against 4 in the whole DLL is
    0x1801c34f1, and a live probe measured it as 1 for players / 0 for staff,
    never 4. The existing lib.rs claim that a server can never produce 4 is
    CONFIRMED, though its stated reason was a live observation rather than the
    real one (no wire atom exists).
  - whether a year==0 kit needs an explicit LOCKED write is UNKNOWN: CardsDLL
    only writes LOCKED for year != 0, and the engine's default for an untouched
    descriptor is in Denuvo-packed FIFA17.exe.
2026-08-23 04:30:19 +00:00
funman300 8614acff57 chore: bump openfut-core (one-match training expiry in the match transaction) 2026-08-23 02:58:36 +00:00
funman300 3ff09ae62c chore: bump openfut-launcher (config BOM tolerance + corrupt-config quarantine) 2026-08-23 02:06:39 +00:00
funman300 34be38260d chore: bump openfut-launcher to 9ba88c7 (roster socket redirect)
Records the submodule commit carrying ea_ports::FIFA17_ROSTER, which the
version.dll build deployed to the Windows client is built from.
2026-08-23 01:58:19 +00:00
funman300 1e0124c197 hook: redirect the FIFA 17 roster dial in-process, drop the DNS workaround
The client fetches the roster from the URL our own Blaze hands it,
https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml, and
ProtoSSL verifies that certificate by dNSName only. An IP-addressed roster host
is refused even with IP Address:10.10.0.120 in the SANs (retested on Windows
2026-08-23), so the hostname has to survive into SNI while the connection lands
on us.

The hook log showed the dial was ALREADY being intercepted:

    connect_hook: call 20.51.153.159:8081   (octets logged reversed)

It simply was not rewritten, because 8081 was not in the EA port table. So this
is a table entry, not new hook surface: ea_ports::FIFA17_ROSTER makes the
existing connect/WSAConnect/ConnectEx detour rewrite the destination while
leaving the URL untouched, and the certificate still validates.

That removes the scoped DNS responder and the client NRPT rule from the normal
path. scoped-dns.py stays as the documented contingency for EA withdrawing the
public record, which is the one dependency this cannot remove: the name must
still resolve to something for connect() to be reached at all.

roster_port is accepted in openfut.cfg but written only when non-default. The
parser rejects unknown keys, so emitting it unconditionally would make every
already-deployed hook reject the file and install NO redirect, breaking the game
rather than degrading.

Also corrects two documented claims that are false on the real machine:
elevation comes from the shortcuts' RunAsAdmin bit, not from an AppCompatFlags
RUNASADMIN entry (there is none), and hook_dll_path must point at a source copy
rather than the deployed version.dll it is copied onto.
2026-08-23 01:57:46 +00:00
funman300 286a44461d tools/windows: scoped DNS resolver for the FIFA 17 roster hostname
FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so the
client must reach the roster as winter15.gosredirector.ea.com. Retested on
Windows 2026-08-23: an IP-addressed roster host is refused even though the
certificate carries IP Address:10.10.0.120 as a SAN.

Public DNS points that name at EA's dead 159.153.51.20, so the client has to
resolve it to us. scoped-dns.py pins exactly that one name and forwards every
other query upstream verbatim, so a client pointed at it cannot lose general
resolution -- verified against www.microsoft.com, github.com and
www.msftconnecttest.com.

Paired with a Windows NRPT rule rather than a hosts entry: per-name, auditable
via Get-DnsClientNrptRule, and revertible in one command. A hosts edit on this
machine had previously taken its whole internet down.
2026-08-23 01:34:13 +00:00
funman300 8c353c6c66 chore: bump openfut-core (training replace + all-six card) 2026-08-22 23:33:44 +00:00
funman300 3a038fe406 feat(fifa17): apply the rare all-six training card
Passes a null attribute slot for the rare card -- Core reads absence as
"every slot", so sending 0 would silently train pace alone -- and declares
the per-family ceiling rather than a single constant.

Tests pin the null-slot serialisation, both ceilings, and that all 36
single-attribute plus all 6 rare cards resolve.
2026-08-22 23:33:43 +00:00
funman300 3bb6b4fc9d fix(fifa17): subtypes 57/67 are the rare all-six training card
They were failing closed as "squad fitness". Four facts say otherwise: each
family holds exactly 21 rows = 7 types x 3 levels matching the published
"6 single attributes + 1 ALL" list; those six rows are the ONLY ones with
weightrare=2 while all 36 single-attribute rows are 0, and the published list
marks ALL rare; their amounts are exactly 3/6/10 against the documented ALL
card's +3/+6/+10; and bc=6 is one past the six real slots, an "all" sentinel,
with c0=0 reading as "not single-ATTRIBUTE" rather than "not single-target".

The misreading came from consumables.json's FUT_FITNESS_UC/MC label, which is
tool-authored -- build_consumables.py names the 7th element of its attribute
array -- and has no documented provenance. The bc/c0 values beside it ARE
reversed; only the name was not. The genuine squad-fitness card is subtype
220 in fcc_healingcards (10/20/30) and player fitness is 219; that family
stays unsupported.

The two families carry different authored ceilings, 15 single-attribute and
10 all-six, so ceiling_for() reports the right one per effect -- sending the
single-attribute ceiling for an all-six card would let a +15 all-six boost
through, granting 90 attribute points from a card worth 60.

Also corrects ENDPOINT_MAP row 7: ApplyCardByRes carries urlIndex 0x0e, which
resolves to ut/%s/item/resource, and the live verb is POST. The row claimed
PUT ut/%s/item for both apply RPCs, and that conflation is what kept the
"apply must ride PUT ut/%s/item" hypothesis alive until the POST capture
settled it -- every observed PUT ut/%s/item is a pile move.
2026-08-22 23:33:43 +00:00
funman300 a9bac8be8e chore: bump openfut-core to a45155e (attribute training effect) 2026-08-22 22:59:46 +00:00
funman300 3c28b0d1af feat(fifa17): apply training cards, and project trained attributes
Opens the 409 `apply_effect_unproven` gate for attribute training, the
second family after contracts to have its effect settled rather than merely
its magnitude.

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

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

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

Tests pin the verb split on `item/resource/<rid>` (POST applies, PUT stays
quick-sell, GET is not an economy route at all), the digit guard, the exact
JSON Core deserialises for both effects, and that no shipped training card
exceeds the ceiling the host declares to Core.
2026-08-22 22:59:46 +00:00
funman300 4936654f84 feat(fifa17): map training subtypes to attribute slots
Which attribute a FIFA 17 training card trains is recoverable after all --
not from a table, but from the client's own dispatch: `FUN_18013f4d0`
derives a consumable's whole presentation from `cardsubtypeid` and writes an
attribute selector to `rec+0xbc`. Paired with `fcc_trainingcards.amount`
(TABLE_PROVEN, matching the wire 8/8), that settles both halves of the
effect for all 36 attribute training cards -- 12 families x 5/10/15, 18
goalkeeper and 18 outfield.

The subtype order is NOT the slot order: 54 is SPEED at slot 4 while 56 is
REFLEXES at slot 3, and 65 is HEADING at slot 5 while 66 is DEFENDING at
slot 4. Reading them sequentially trains a different stat from the one on
the card, invisibly, so the table is explicit and a test pins those four.

The two SQUAD training cards (57, 67) share the table and the client's
training UI bucket but are the only ones whose single-target byte is 0: they
act on a squad and move fitness, not an attribute. They resolve to None and
fail closed rather than being mistaken for a +3 attribute card.

A keeper's six slots mean DIV/HAN/KIC/REF/SPD/POS and an outfielder's mean
PAC/SHO/PAS/DRI/DEF/PHY -- same numbers, different attributes -- so the
class gate is not cosmetic.
2026-08-22 22:59:46 +00:00
funman300 4156dc5810 ops(systemd): record unattended proof of post-boot recovery
The reboot-survival gate is a test no operator can stand inside: the machine
under test is the machine running the session. So the machine records its own
recovery.

openfut-boot-evidence.service polls until the anchor, Core and host agree on
a namespace (or a 180s deadline expires), then writes a JSON file with the
boot id, anchor/Core/host pids and netns inodes, unit states, restart counts,
whether the reconciler had to act this boot, mount count, four non-mutating
probes, route ownership, and the full economy snapshot — plus the journal for
the boot so ordering is read from real timestamps rather than inferred from
unit dependencies.

It observes only; it never starts, stops or repairs anything, and carries no
Requires= or ordering that anything else waits on, so it cannot affect the
boot it is measuring. If the chain is broken the file says so, which is the
point.

Polling rather than a fixed sleep means a boot-time reconcile retry is
recorded as "settled late" rather than as a failure.
2026-08-22 21:38:28 +00:00
funman300 fc1fdcc5ab ops(systemd): exact mount match in status, add detached rollback script
Two things surfaced by the production promotion.

`status` counted namespace mounts with an unanchored grep, so on production
"run/netns/openfut" also matched "openfut-staging" and reported a phantom
"2 = leaked stack" against a perfectly healthy host. A status command that
invents a fault is the same class of bug as a unit that reports active while
serving nobody, so it is fixed with an exact mount-point match. The bind and
reconcile logic is untouched; it always umounted an exact path.

openfut-rollback-detached.sh makes the documented rollback executable rather
than a paragraph in a runbook: it removes supervision, resolves the anchor's
CURRENT pid from Docker, and relaunches the incumbent detached pair with the
environment replayed from the captured env.json. --dry-run prints the exact
commands and touches nothing, which is how it was validated while production
was still being served by the processes it would restore.
2026-08-22 21:28:21 +00:00
funman300 1e8d46b258 ops(systemd): follow the anchor container's netns across recreation
Recreating the Docker anchor left the supervised Core and host stranded in
the dead namespace while systemd still reported them active — serving
nobody, invisible to any monitoring that trusts unit state. Reproduced on
staging: netns 4026539938 -> 4026540033, both pids unchanged in the old
one, both units "active", traffic ConnectionResetError.

There is no systemd-native edge signal to bind to. Containers do appear as
units, but the scope name embeds the container ID (docker-<id>.scope), which
changes on every recreate, so BindsTo= has no stable target;
NetworkNamespacePath= resolves once at start; a .path unit on /run/netns
would watch the file this tooling maintains. So: a level-triggered reconcile
on a 10s timer, comparing the namespace the services are ACTUALLY in against
the anchor's CURRENT one, acting only on a real difference. That cannot miss
an event while the watcher restarts or dockerd is down, and needs no
debounce — a burst of three recreations produced exactly one rebind. The
trigger stays separable: a docker-events unit could invoke the same script.

Anchor absent stops the dependants rather than falling back to host
networking; docker unavailable logs once and retries on the next tick.

Two defects found while testing and fixed here:
- mount --bind STACKS when the old mount is busy, silently leaking nsfs
  entries; the bind helper now drains stale mounts in a loop.
- reconcile must stop -> rebind -> start, not rebind -> restart: a running
  service holds the old namespace open and makes the umount fail busy.

Staging also gained a faithful anchor container so the reproduction is
structural rather than mocked. Economy state was byte-identical across every
lifecycle test. Production units are templates only and remain uninstalled.
2026-08-22 21:14:23 +00:00
funman300 8ae432223a ops: systemd supervision for Core and the FIFA17 host (staging-proven)
Replaces the detached `setsid nohup … nsenter …` launch, which had no restart
policy, no boot persistence and no supervisor-visible logs. Staging units are
installed and proven; production units are TEMPLATES and are not installed.

Three decisions, each measured rather than assumed:

* `Wants=`, not `Requires=`, from host to Core. With `Requires`, stopping Core
  stopped the host AND a later Core start did not bring it back -- a routine
  Core restart would leave the client with no server. With `Wants` the host
  survives a Core outage, answers 503 core_unavailable, never falls back to
  Python, and resumes the moment Core returns with no intervention. Both halves
  tested.
* Readiness is a bounded ExecStartPre TCP gate, because ordering proves nothing
  about readiness and Type=exec only proves the binary exec'd. Core binds its
  listener after migrations and content load, so "port open" is a real signal.
  The gate FAILS rather than blocking: a host that waits forever looks healthy
  while serving nobody.
* The netns is resolved by container NAME every start. The container is
  restart=unless-stopped and its netns inode CHANGES on restart (measured:
  4026539938 -> 4026540033), so a hardcoded pid is wrong by construction and
  anything left in the old namespace serves nobody. Proven equivalent to today's
  nsenter against a scratch container, never production's namespace.

`systemd-analyze verify` caught two real defects before deployment:
StartLimitIntervalSec/StartLimitBurst sat in [Service], where systemd 252
silently ignores them, so the crash-loop ceiling was not taking effect; and a
Documentation URL containing %20 parsed as a specifier. Both fixed and the
effective properties re-confirmed from the running units.

Staging evidence: Core-first ordering, host refused when Core is absent or
merely not listening, outage survival, automatic recovery, restart, graceful
stop with no strays, boot simulated via multi-user.target, 3x SIGKILL contained
at ~5s spacing, journald logs, and economy state byte-identical throughout
(integrity ok, fk 0).
2026-08-22 20:46:15 +00:00
funman300 9026220533 feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every
non-player Core definition while `d.rating` already held EA's authoritative
`value` -- and the very next block wrote that same number correctly to the
adapter catalog. So the tier existed host-side but never reached Core:
Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 ->
tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is
exactly what the 409 was protecting against, so the refusal was correct.

The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating
the production pack changes exactly 18 entries and exactly one field each
(source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a.

WHY value IS the tier source, and why the thresholds are the player ladder:
LIVE_PROVEN, not inferred. The client re-rates staff from its own
managercards/*coachcards/physiocards by carddbid and applies discard_level's
65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3,
coaches 66 -> level 2). The shipped coach tables corroborate: each family has
exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2.

Manager contracts stop refusing and now resolve the TARGET's tier from
Core-owned state. Still fail-closed everywhere it matters: a coach or physio is
`contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a
manager Core carries no source_rating for is `manager_tier_unknown` rather than
a guessed tier. Core's own content_kind token is sent as target_kind, because
Core calls the squad manager `manager` while the catalog classifies it
`staff`+subtype 4.

NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
2026-08-22 20:08:26 +00:00
funman300 f0c6dcf238 tooling: verified backup, state snapshot, retargetable apply validator
Promotion prep for the contract-apply cutover, which unlike the quick-sell
promotion moves BOTH binaries and applies a schema migration.

fifa17-promotion-backup.py uses SQLite's online backup API, not cp. Production
runs WAL with a routinely uncheckpointed WAL (515 KB at capture time); copying
the main file alone is not atomic against a live writer and carries no
guarantee the WAL holds no newer committed state. Emits a checksummed backup, a
metadata record and a RESTORE-*.sh that removes the stale -wal/-shm BEFORE
restoring -- omit that and SQLite replays the old journal over the file you just
put back, resurrecting the state you were abandoning.

fifa17-promotion-snapshot.py is read-only (mode=ro) and counts EVERY table
rather than a hand-picked list, so a delta cannot hide in a table nobody thought
to name. It also fingerprints the ownership rows, catching a row silently
rewritten when counts alone would match.

fifa17-contract-apply-validate.py gains --host/--db so one tool serves staging,
the migration rehearsal and the production acceptance run. Defaults stay
staging: there is deliberately no production default, so a bare invocation
cannot touch production.
2026-08-22 18:40:12 +00:00
funman300 6c97bc4e2b feat(fifa17): real player-contract consumable apply, replacing the probe
POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.

THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.

No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.

The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".

FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.

`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.

CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.

Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
2026-08-22 18:23:22 +00:00
funman300 3c67fea074 feat(fifa17): serve the consumable quick-sell (PUT item/resource/<rid>)
Fixing the display was only half of it. Quick-selling a consumable from the
repaired screen produced "There was a problem communicating with the FIFA
Ultimate Team servers", because the client's consumable quick-sell is a route
neither stack had ever served:

    PUT /ut/game/fifa17/item/resource/5003068     body_len=0

Live-captured on staging. That path now carries three verbs -- GET is the
definition lookup, POST applies the consumable (ApplyCardByRes), PUT quick-sells
it -- and it is keyed by the stack's RESOURCE id, not an owned instance, unlike
the player quick-sell (DELETE item/<instanceId>).

This had to be Rust-owned rather than proxied: the Python oracle maps
item/resource method-agnostically to its definition route, so on production --
where the oracle is alive -- a PUT would return 200 with a definition list and
sell nothing, and the client would show a successful sale of a card the player
still owns.

Implementation reuses the retail-proven quick-sell path verbatim
(handle_quick_sell_path), so pricing comes from the same
ItemIdentityResolver::discard_value that stamps the number on the stack. Display
and payout are the same call; they cannot drift.

Two decisions, both documented in the code as decisions rather than discoveries:

  * ONE copy per request. The request carries no quantity, and the screen prices
    a CARD, so consuming a whole stack on one keypress would pay one card's
    price for N cards. Selling one is the conservative reading.
  * The copy sold is Core's first matching owned instance -- the same one whose
    wire id the consumables screen already published as the stack's `item`, so
    the player sells the card they were shown.

Verified against the running staging host:
    displays 38 -> PUT -> coins +38, owned -1, consumables -1, stack 2 -> 1
    replay sold the one remaining copy (+38, -1), no double credit
    exhausted -> 404 not_owned, coins +0, owned +0 (no phantom payment)

123 host tests (+1 locking all three verbs on the shared path, and that the bare
`item` PUT stays the pile move), clippy -D warnings clean, fmt clean.
2026-08-22 17:02:14 +00:00
funman300 2a9507cb6a docs(re): consumable quick-sell is PUT item/resource, live-captured 2026-08-22 16:56:01 +00:00
funman300 5b8bee286c feat(ops): read-only interception preflight for OpenFUT endpoints
During the Rust production cutover four stale openfut-switch nft rules were
still redirecting production-facing traffic to staging (42127->42227,
8081->8281, 8094->18094, 8099->18106). They matched `ip daddr 10.10.0.120`, so
every server-side probe via 127.0.0.1 or the container IP passed while the
CLIENT was refused. That cost a full false-negative acceptance round: a retail
quick-sell landed on staging while production sat untouched, and the launcher
reported the server "not answering".

The failure mode is mechanical, so the check is:

  * openfut-switch.sh status
  * nft rules on OpenFUT ports, split into REDIRECT (interception) and DNAT
    (docker publishing, expected -- reporting those as problems would train the
    reader to ignore the tool)
  * the actual point: loopback vs the ADVERTISED address per port. A redirect
    keyed on the LAN IP is invisible to loopback, which is exactly why the
    cutover probes all passed.

Verdict is CLEAN / INTERCEPTION_PRESENT with exit 0/1/2. Both branches
observed: it reports CLEAN now, and reported INTERCEPTION_PRESENT on a
loopback/advertised disagreement before :4216 was excluded.

:4216 is excluded from the verdict because LSX runs on the game machine --
compose publishes the port but OPENFUT_SERVERS omits lsx, so "published but not
served" is its normal state. It is still printed, marked as expected.

READ-ONLY by design: it never deletes a rule. Clearing interception stays a
deliberate operator act via `openfut-switch.sh off --name <id>`.

Run before production acceptance, client repoints, migrations and retail
protocol tests.
2026-08-22 02:02:00 +00:00
funman300 ba19954ffb fix(fifa17): consumable stacks carry their real quick-sell value
A production club displayed "Quick sell for 0 coins" for contract cards that
Core would have paid 3/13/32 for. The consumables stack wrapper hard-coded
discardValue (atom 0xd7) to 0.

The old rationale was that the client prices the card itself, the way it does
when we omit discardValue from an item. That is true of the ITEM record and not
of the STACK, and the evidence separates them cleanly:

  * item+0x38 non-zero makes the client SKIP its local computation and show our
    number -- re-proven on the live production client, 16/16 resident cards
    "SERVER-SHOWN (local calc skipped)", including the acceptance card
    235066 -> 40.
  * We send no discardValue inside a consumable's item, so +0x38 is 0 and the
    local computation DOES run and fills +0x3c correctly -- Milestone 1 measured
    3/3/32/38 there, matching this table.
  * The screen still showed 0. So the screen is not reading the item's computed
    +0x3c; it reads the stack's atom 0xd7, which we were sending as 0.

So the number belongs on the stack, and it is the SAME
discard::value_for_definition that computes the payout -- one source, so the
screen and the wallet cannot disagree. Per CARD, not per stack: FUT prices a
card and the stack is only a quantity badge over identical copies. An
unpriceable definition stays 0 rather than inventing a number.

Verified on staging across every populated family, 16/16 stacks shown ==
recovered, none zero:
  contracts 32/3/13 · healing 32/3 · training 3/13/34 · playstyle 38/38/38
  · position 36/38/38/38/38

Two tests lock it: the payout equality (with the exact 3/13/32 the production
club would have been shortchanged on) and per-card-not-per-stack pricing for a
collapsed count=3 stack.

No payout logic changed, no taxonomy change, no ownership change, no Python.
250 adapter tests, 122 host, clippy -D warnings clean, fmt clean.
2026-08-22 02:00:40 +00:00
funman300 88b4cad780 test(fifa17): migration invariant capture and rehearsal harness
fifa17-migration-invariants.py  Pre/post invariants across every domain the
    migration authorization names: coins, ownership (+kind histogram, distinct
    definitions, chemistry styles, loans, position overrides), squads,
    managers, staff, consumables, club items, transfer state (market_listings),
    packs, SBC, match history, plus integrity_check and foreign_key_check.
    Table names are the REAL schema, not guessed: transfer state lives in
    market_listings (29 rows in production), the FIFA17 opaque squad blob in
    game_entity_ext.

fifa17-migration-rehearse.py    Serves a migrated COPY with the candidate Rust
    stack on isolated ports and validates the wire surface: club discardValue
    is table-derived, squad projects, consumable categories populate, and the
    apply probe is OFF (502 upstream-unavailable rather than a diagnostic ack).

Both are read-only against production: the rehearsal operates on a copy under
/home/alex/openfut-migration/, and nothing under openfut-promotion/state is
opened.

Evidence from the 2026-08-22 rehearsal is written up in the Vault runbook
"FIFA17 Rust Production Migration (rehearsed)".
2026-08-22 01:15:45 +00:00
funman300 a4c6aeed49 docs(re): ApplyCardByRes post-ACK protocol is outcome B, live-proven 2026-08-22 01:07:54 +00:00
funman300 97498c560e docs(re): refute the contract:7 effect source; record the competing development reading
Two corrections found while trying to close the effect boundary statically.

1. `contract: 7` IS OUR OWN PLACEHOLDER. fut_store.py:232's generic _item()
   factory -- which builds every item the oracle serves -- hardcodes
   playStyle 250 / contract 7 / fitness 99 on players and consumables alike. The
   staging GK reads back exactly those three constants. So the production
   catalog's contract:7 for resource 5001004 is an oracle placeholder
   round-tripped through an observed profile, not an EA value. Its status is not
   INFERRED, it is KNOWN-BOGUS as a source. Had the effect been implemented on
   it, it would have been a fabricated game rule wearing observed-data clothing.

2. fcc_contractcards is NOT amount-less. An earlier note here claimed it "has no
   amount column, so this value comes from observed data". It has 13 rows with
   gold/silver/bronze/rating, 6 player + 6 manager paired by rating plus a
   99/99/99 special. The sibling fcc_healingcards shares every column except
   that it carries a single `amount`, which argues the differing columns ARE the
   effect payload (per target tier). Against that: the values are non-monotonic
   across tiers, which suits weights better than amounts; and no column of
   5001004 is 7, so neither reading explains the placeholder.

   The reader that would settle amount-vs-weight is in FIFA17.exe, not CardsDLL
   (the table and column literals are absent from the DLL), so this stays
   EFFECT_UNKNOWN rather than being guessed.

Also records, in content_taxonomy.rs, the competing reading of `development`:
fut_consumables.py's TYPE_CATEGORIES groups it as card-categories {6,7,8,9,10}
(modifiers only), explicitly flagged there as inferred from UI-bucket names and
never observed on the wire. Different enum space from the CONSUMABLE_TYPE switch
that actually emits the segment, and the switch gives formation/position/
playStyle/managerLeagueModifier their own segments rather than folding them into
development -- so the unfiltered reading is better supported, but it is still a
reading and the doc now says so instead of sounding settled.

248 adapter tests, fmt clean. No behaviour change.
2026-08-22 01:01:47 +00:00
funman300 8cb2a0f9c6 test(fifa17): smoke-test the apply probe and prove the gate fails closed
Unit tests cover classification and body parsing; they do not prove the running
host behaves. These three scripts exercise the real service, and they found
nothing broken but make the two load-bearing claims checkable:

fifa17-apply-snapshot.py   Core truth around an apply: coins, owned rows, kind
                           histogram, the source stack's copy count, and the
                           target's mutable fields (contract/fitness/playStyle/
                           training/injury). Coins and ownership come from the
                           staging DB, not the wire, so the check cannot be
                           satisfied by a projection bug.

fifa17-apply-probe-smoke.py  Replays the EXACT captured request plus the edges,
                           against the live host, no client needed:
                             1. {"apply":[{"id":100000003}]} -> 200 {"itemData":[]}
                                source=Consumable subtype=201 copies=1,
                                target=fifa17_200389 rating=87
                             2. two targets            -> 400 apply_batch_unsupported
                             3. unknown target wire id -> 200 UNRESOLVED_WIRE_ID
                             4. unowned source         -> 200 NOT_OWNED
                           then re-snapshots: Core identical after all four.

fifa17-apply-gate-off.py   The production-safety claim. With APPLY_PROBE unset
                           the same request must produce the pre-probe
                           behaviour, and does: no apply-probe line, three
                           passthrough lines, 502 into the dead upstream, Core
                           unchanged. Verified by restarting staging without the
                           flag -- an assertion about failing closed is worth
                           nothing unless the closed path is executed.

Nothing here writes to production; snapshot reads the staging DB read-only.
2026-08-22 00:54:24 +00:00
funman300 9f445904a5 docs(re): record the reversed ApplyCardByRes success contract and the nine-segment consumables vocabulary 2026-08-22 00:50:47 +00:00
funman300 ce5d4204ac feat(host): staging-only consumable-apply probe; reverse the success contract
Claims POST ut/<sku>/item/resource/<resourceId> -- the consumable apply captured
live 2026-08-21 -- behind OPENFUT_FIFA17_APPLY_PROBE=1, default OFF. With the
gate off the route takes the extracted `passthrough` method, i.e. byte-for-byte
the behaviour that existed before this commit, so production cannot serve a
diagnostic even if the route is reached.

The handler is NON-AUTHORITATIVE BY CONSTRUCTION: it consumes no source card,
mutates no target, touches no contract/fitness/chemistry/training/injury state,
mints no coins and changes no ownership. It exists only to observe the client's
success path, because the EFFECT of a consumable is still unreversed and
implementing one on an inferred value is not acceptable.

RESPONSE SHAPE, from static RE rather than convenience (the brief was explicit
that `{}` must not be chosen because it is easy):

  * The apply completion handler is CardsDLL 0x180035520. It does
    `mov ecx,[rdx+0x1c]; test ecx,ecx; jne FAILURE`, raising
    EVENT_CARDS_APPLY_CARD_SUCCESS (0x1801f37f0) on zero and
    EVENT_CARDS_APPLY_CARD_FAILURE (0x1801f3810) otherwise. It tests exactly one
    field -- the transport code -- and never inspects the body.
  * That is materially different from the MOVE ack (0x180128600), which builds
    per-item verdict records and reports FAILURE when the vector is EMPTY. The
    `{}`-is-broken precedent does not transfer.
  * The response object's constructor (0x1800a4ce0) initialises its record vector
    (+0x50/+0x58/+0x60, 0x20-byte elements) EMPTY, so an empty parse result is a
    legal state here, and the destructor (0x1800682b0) frees it accordingly.
  * The legacy oracle routes `item/resource` method-agnostically to defs_route,
    so historically this path answered with an `itemData` OBJECT.

`{"itemData":[]}` is the smallest candidate consistent with all four, and it is
labelled a PROBE, not a proven contract.

`apply` is an array, but only len==1 has ever been observed, so a multi-target
request is logged and refused (400 apply_batch_unsupported) rather than given
invented batch semantics.

Operands are identified READ-ONLY for the capture: the source by Core card id
(`<sku>_<resourceId>`, no new resolver method for a probe) with a copy count, the
target by reversing the wire id through the identity store -- never a guess,
`UNRESOLVED_WIRE_ID` when unknown.

Also records the reversed protocol and the `development` finding in
CLIENT_ROUTE_SURFACE.md.

122 host tests (+2: the verb/resource-id classification boundary, and target
parsing incl. the exact captured bytes). clippy and fmt clean.
2026-08-22 00:49:23 +00:00
funman300 6ca735749e fix(fifa17): serve the development and formation consumable categories
The live client asked for `club/consumables/development` and got an empty
screen: `consumable_families_for_category` had no arm for it. Tracing that
segment recovered the client's OWN category vocabulary, and it is nine segments,
not the seven this file assumed.

CardsDLL, live 2026-08-22: the literal table at 0x1801f5a38 (under
MyClubAdapterClass / CONSUMABLE_TYPE) and the switch at 0x180048820, which
indexes by `enum + 1` through the byte table at 0x180048a90 into the case table
at 0x180048a6c:

    enum -1 (unset)      -> development
    enum 1, 2            -> contracts
    enum 3               -> healing
    enum 4               -> fitness
    enum 16              -> formation
    enum 17              -> position
    enum 23              -> playStyle
    enum 24              -> managerLeagueModifier
    enum 0, 5..15, 18..22 -> training (switch default)

Two consequences:

1. `formation` HAS a segment (enum 16). This file claimed the two formation
   modifier families "have NO group code, so no segment can reach them -- that is
   the client's own gap, not an omission here", and a test asserted it. Both were
   wrong, and wrong in the direction that hides a server bug: it was our gap.
   `formation` now maps to manager_formation_mod + formation_mod, so all
   THIRTEEN families are reachable instead of eleven.

2. `development` is the type-UNSET bucket -- index 0 of a table indexed by
   `enum + 1` -- i.e. no type filter. It is therefore the unfiltered view and
   maps to every family via ALL_CONSUMABLE_FAMILIES. That is consistent rather
   than overlapping by accident: the eight TYPED segments already reach all
   thirteen families exactly once, so there is no family for `development` to
   own privately.

The partition test now asserts the eight typed segments cover all thirteen
families with no duplicates, and that `development` is exactly their union, so a
family added to the taxonomy cannot silently vanish from the unfiltered screen.
Ownership and classification are untouched; this is projection only.

248 adapter tests, clippy and fmt clean.
2026-08-22 00:49:04 +00:00
funman300 739228efdb feat(host): capture unclaimed request bodies; record the consumable-apply wire
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.

Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.

With it, one operator apply captured the whole thing:

    POST /ut/game/fifa17/item/resource/5001004
    {"apply":[{"id":100000003}]}

  source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
  target item(s)    : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
  verb              : POST

There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.

Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.

NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.

Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.

Host 120 lib tests, fmt clean.
2026-08-22 00:23:15 +00:00
funman300 db5fb37980 feat(host): name unclaimed requests, and record the FUT task vocabulary
Milestone 2 groundwork.

The passthrough arm forwarded to Python without ever recording WHAT was asked
for, so on staging -- where the upstream is deliberately dead -- an unhandled
request produced an anonymous 502. It now logs method, path and body length
before forwarding, which is how the next unclaimed route gets identified:

  utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=0

Also records the FUT TASK vocabulary read out of the live client. The client
drives UTAS through named tasks held in a CardsDLL .rdata table of 0x20-byte
MixedCase/UPPERCASE slots, with a .data descriptor table giving each a task id:

  ApplyCard 0x0d, ApplyCardByRes 0x0e, ConsumeCard, ActivateCard, AssingCard(sic),
  MoveCard, MoveCardByRes, SwapCard, DiscardCard, DiscardCardByRes, ViewCards, ...

So consumable application IS a first-class client action even though the route
table contains no /apply endpoint -- it must ride an existing route. The
descriptor's function pointer is a `mov [rip+flag], cl; ret` setter, not a
request builder, so the request is assembled elsewhere keyed by task id; that is
cheaper to answer with one live capture than with more static tracing.

Search tooling carries mandatory positive controls (tradePile, ut/%s/item, squad
-- all FOUND), so the "no /apply route" result is a valid negative rather than a
failed scan.

Host 120 lib tests, fmt and clippy clean.
2026-08-22 00:14:11 +00:00
funman300 0a7c4e129c docs(fifa17): record discard economy validation evidence and impact
Adds scripts/fifa17-discard-impact.py (owned-instance economic impact, computed
from the shipped implementation's matrix -- informational, never a reason to
alter a value) and records the measured results.

Owned club, 1993 instances: legacy 1,820,700 -> recovered 19,128,031 = 10.51x.
Players 10.53x, manager 1.88x, consumables 0.19x (the ladder overpaid them ~5x),
staff 0.24x, club items 900 -> 0.
2026-08-21 23:59:35 +00:00
funman300 a96d06dbc0 test(fifa17): validate discard payouts, replay, concurrency and persistence
Adds two staging-only harnesses and records the results.

scripts/fifa17-discard-validate.py drives the REAL Rust/Core quick-sell path for
a fixture spanning every quick-sell-relevant category, and checks each against
the authoritative table value emitted by the discard_matrix example (i.e. the
shipped implementation, not a reimplementation). Per item it asserts the payout
is exact, the instance is removed exactly once, and a REPLAY of the same request
grants nothing and resurrects nothing.

Results with OPENFUT_FIFA17_DISCARD_TABLE=1 on the real 1993-item club:

  players     6/6 exact   752 .. 74,400 (rareflag 1,3,4,5,6,11,21,22,23,24)
  staff       2/2 exact   36 (gk coach, fitness coach)
  consumables 4/4 exact   3, 3, 32, 38
  club item   1/1 exact   0  (kit -- and 0 is what the client displays)
  TOTAL      12/12 exact, 0 replay grants

  wire discardValue == expected == actual payout for every player, so what the
  client is shown and what Core credits are the same number by construction.

Concurrency: 4 simultaneous DELETEs on one wire id -> removed exactly 1, paid
exactly once (23,280).

scripts/fifa17-restart-persistence.py restarts Core and host IN PLACE with their
own environment rather than via the bring-up script, because `up` re-seeds the
club and would mask a persistence failure. It refuses to signal any process
outside the staging root -- production runs as another user and is skipped
explicitly. Result across SIGTERM + respawn of both: coins 29,967,428, owned
1978, players 1958 -> PERSISTED EXACTLY.

Production untouched; staging only, flag set only in staging.
2026-08-21 23:56:24 +00:00
funman300 06d94bb37d fix(fifa17): club items are zero-value, not a fallback to an invented price
`value_for_definition` declined for anything non-player without a catalog rating,
which sent club items into the legacy ladder and paid an invented 150 each.

That is wrong, and the client says so. `shape_club_item` sends neither `rating`
nor `discardValue`, and cardtype 7/9 are NOT re-rated by the client (the merge
jump table sends them to the shared tail), so the client computes for itself from
record +0xb4 == 0: level 1, `0 * price / 100` == 0. It DISPLAYS 0. Paying 150
invents value the player was never shown.

Move the decline boundary onto the real distinction, which is `client_rerates`:

  * NOT re-rated (cardtypes 1, 6, 7, 8, 9) -> the server's rating is what the
    client prices with, so Core's value is authoritative even at 0.
  * RE-RATED (2, 3, 4, 5, 10 -- the staff families) -> the client substitutes its
    own database value, so without a catalog rating we genuinely cannot match it
    and must decline rather than guess.

Over the 1717-definition corpus this takes "declined -> legacy" from 6 to ZERO:
every definition is now priced by the one authoritative table and no generic
fallback is reachable in the current corpus. The six club items price at exactly
0; staff and consumables are unchanged.

Adapter 248 lib, host 120 lib, fmt and clippy clean.
2026-08-21 23:53:14 +00:00
funman300 f371349dd5 refactor(fifa17): one authoritative discard implementation + corpus matrix
The pricing DECISION (which rating to trust, when to decline) lived in the host
while the TABLE lived in the adapter, so FIFA semantics were split across two
crates and no single function could be pointed at as authoritative.

Move the decision into the adapter as `discard::value_for_definition(subtype,
rareflag, catalog_rating, core_rating) -> Option<i64>` and have the host call it.
Its three tests move with it. There is now exactly one table implementation, one
decision point (`ItemIdentityResolver::discard_value`), and one deliberately
retained rollback ladder (`legacy_discard_value`).

Add `examples/discard_matrix.rs`, which audits an entire FIFA17 corpus using the
SHIPPED implementation rather than reimplementing the formula, so the matrix
cannot drift from what the server pays. Over the current 1717-definition corpus:

  declined -> legacy : 6   (badge, ball, kit x2, misc, stadium -- no catalog rating)
  priced zero        : 0
  negative           : 0
  implausible        : 0
  rating boundaries  : OK (1/2/3 at <65 / 65..74 / >=75)

Also re-verified both numeric cores against the LIVE client rather than trusting
the earlier notes:

  level  0x180141e8a  cmp al,0x4b -> 3 ; cmp al,0x41 ; sbb/add 2 -> 2 else 1
  value  0x180141119  imul rating*price ; /100 via 0x51eb851f ; imul 0x64 ; sub ;
                      cmp remainder,0x32 ; jl/inc   == round-half-up

`(rating*price + 50)/100` is identical to that for non-negative inputs.

Adapter 247 lib, host 120 lib, fmt and clippy clean.
2026-08-21 23:49:01 +00:00
funman300 fb38ee6087 fifa17: claim five routes whose handlers were already unreachable
Read the client's COMPLETE UTAS route surface out of CardsDLL's .rdata in the
running process (new tools/url_template_probe.py) and probed every one against
staging, where the Python upstream is deliberately dead so anything the Rust host
does not own answers 502 instead of being silently proxied.

That found five routes whose handlers already existed and were dead code because
`classify` never produced their Route -- the same defect as `season/list` and
`watchList`, whose fix comments are still in the file. This is the third and
fourth time:

  captcha        -> handle_static_ack, which already returns the oracle's exact
                    {encodedImg,sequence,sizeBeforeEncode}
  tfa            -> handle_static_ack, {}
  livemessage    -> handle_static_ack, {}
  activeMessage  -> handle_static_ack, {}
  tournament/user-> FeatureOffEmpty, {} == the oracle with FUT_MODES off
                    (tools/utas_server.py:1504); the client builds this literal
                    at CardsDLL 0x18021e540 and the bare `tournament` arm never
                    matched it

Route's own doc comment already claimed the first four as "Rust-owned
UNCONDITIONAL", so the documentation was wrong rather than the intent. All five
are byte-identical to the oracle, so claiming them is parity, not new behaviour.
Invisible in production because the upstream answers there.

Two tests pin the vocabularies so a handler cannot go unreachable a fifth time;
both are mutation-checked (removing the captcha arm fails the first).

Also documents the surface in docs/CLIENT_ROUTE_SURFACE.md, including the trap
that bit me repeatedly: an .rdata literal is a FRAGMENT, not a callable path.
`clientdata`, `purchasegroup`, `sbs/challenges`, `squadBuildingSets`, `club/items`
and `item` all looked unserved and are not. Only `squad/mode` is genuinely
unserved, and correctly so -- it is Draft-only, which is out of scope.

L5 finding: there is NO consumable-apply route anywhere in the binary. The only
owned-item mutations the client can express are PUT item (move/pile), DELETE
item/<id> and POST delete/item (quick sell), and PUT squad. So applying a
consumable is not a dedicated endpoint; L5/L6 must be pursued by capturing the
PUT item payload, not by implementing a route that does not exist.

Host 123 lib + 45 host_test, fmt and clippy clean. tournament/user, livemessage
and activeMessage verified 200 on staging (were 502).
2026-08-21 23:28:29 +00:00
funman300 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
120 changed files with 48896 additions and 888 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:
Generated
-1
View File
@@ -3166,7 +3166,6 @@ dependencies = [
name = "openfut-bridge"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"axum",
"bytes",
+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:
File diff suppressed because it is too large Load Diff
+934
View File
@@ -0,0 +1,934 @@
k|J|
tz^WU
Gb\X[
,j|L
cXXXX
gzW[rp
)l``b`
c^^^^
zrbnz
r--)
&jzzx
Jl```
c^^^\
----
k|X\^_
c\Xxx
K|bjk
cxxxx
---%
{xx|
cxxxz
Frxz
{VVVW
cpxz~
gr*:
sTVUU
cxz^W
[5555
px~M
cUUU5
cUU-
gz((+
&rX`
&kVX
cUUUx
&r^\
%%%%
&kUW
f[UUW
gcE[
$gcE[
cUU%
UUWT
xxxx
FsUU^
$ecF[
icD[
T\Rb
sUW|
UUU\
VUUU
BIGF
L286
Apt Data:1:7:8
game/globalComponents/globalComponents
game.globalComponents.ImageLoader
game/components/SelectTeam
game.components.SelectTeam
Coins
TournamentData
BackingFUT
VersusFUT
external.ion_fut.screens.futSelectTeam
__Packages.external.ion_fut.screens.futSelectTeam
__Packages.ion.manager.HelpProperties
EACondBold
10.000
Screen
RtIL
RYgO
7uOO
XsOY
2sOY
<@OY
BG&Y
3NuIL
7NuI
3NuI
3NstY
7uOY
&v>Y
&v>t
3NYN
7NYN
tOYZ
BG&v
NZGO
NZGOZu
uOZu
mcCup
txtPrizeHeading
txtCoins
mcCoin
mcBacking
txtVs
mcTournamentInfo
mcSelectTeam
mcVersusFUT
publishObject
dpID
nHomeKitID
nAwayKitID
keyCode
controllerId
nSide
arrKitIDs
teamId
kitToResolve
side
isUser
arrKits
objProperties
Void
nXPos
DDS |
NVTT
DXT5
8VTTT
UUVT
TTTU
0TUVT
TTUW
$$r
%UUU
WUUU
UUUSP
UUUM
UUUNK
72Ib
*72Ib
U;8I
WWWW?>I
UIFI
WWWWLKI
WWWVQNI
VVYVI
`]IB
daIB
heIB
VlhI
vtI"I
UU%!I
*;8I
U?>I
ULKI
Apt1
_global
external
Object
ion_fut
screens
futSelectTeam
futSelectTeam::futSelectTeam()
OnExitScreen
cafe
utility
Delegate
Create
game
globalClasses
ScreenManager
SetOnExitScreenCallback
m_nFlowState
EA_ZONE
gScreenFlowManager
getFlowState
ION_Platform
IsFinal
CardNotification
eState
FUT_OFFLINE_DRAFT
FUT_OFFLINE_TOURNAMENT
FUT_OFFLINE_SEASON
m_bAllowSelectAnyTeam
FUT/ALLOW_ANY_CPU_TEAM
ION_Customization
GetAardvarkIntValue
mcPanelHome
mcPanelAway
mcReadyHome
mcReadyAway
mcKitHome
mcKitAway
mcLockHome
mcLockAway
InitComponents
InitializeScreen
Initialize
screen
BaseScreen
prototype
futSelectTeam::InitializeScreen()
_visible
HOME_SIDE
GameServices
eTeamSide
SIDE_HOME
AWAY_SIDE
SIDE_AWAY
NEUTRAL_SIDE
SIDE_NEUTRAL
m_arrPanelData
Array
m_arrKitPanelData
futSelectTeam::InitComponents()
InitializeKitConfig
InitializeTeamConfig
SetTeamAndKitConfigs
UIFDataProviderList
FUT_USER_CLUB_DATA_DP
UIFUtility
RegisterDataProvider
FUT_OPPONENT_CLUBS_LIST_DP
FUT_OPPONENTS_SQUADS_LIST_DP
FUT_OPPONENT_SQUAD_LINEUP_DP
FUT_USER_SQUAD_LINEUP_DP
FUT_CREATE_MATCH_DP
FUT_GET_MATCH_KITS_DP
SetupReadyTexts
initSideInfo
SetPanels
m_arrPanels
m_arrKitPanels
KitSelectDP
TeamSetupDP
AnimateIn
AnimateInComplete
BeginAnimateIn
futSelectTeam::AnimateInComplete()
m_bHasAnimatedIn
checkForDisconnect
gScreenNotAborted
LocalEventHandler
InputManager
AddLocalEventHandler
SetHandlerId
refreshCurrentConnectionStatus
HelpManager
Update
futSelectTeam::OnExitScreen()
AnimationManager
ClearAnimations
UnregisterDataProvider
INJURY_POPUP_ID
PopupManager
DeletePopup
TOTW_BELOW_MIN_POPUP_ID
USER_BELOW_MIN_POPUP_ID
OPP_BELOW_MIN_POPUP_ID
OPP_HAS_NO_VALID_SQUADS_ID
Shutdown
ClearSavedOpponentData
SQUAD_ID
UUID_UPPER
UUID_LOWER
UIFActionList
ACTION_SAVE_OPPONENT_DATA
SendActionObj
Publish
futSelectTeam::Publish()
header
USER_CLUB_DATA
SetUserClubData
initVersusFUTComponents
OPPONENT_CLUBS
m_arrOpponentClubs
data
MATCH_CREATED
FUT_PAFC_GAME
GetCurrentCountryIndex
SQUADS
GetCurrentLeagueIndex
ACTION_ADVANCE
SendAction
eSoundEvent
PRIMARY_SELECT
playSound
m_bShouldWaitForPublish
OPP_SQUADS_LIST
SetOpponentSquadListData
SQUAD_LINEUP_LOADED
IS_USER
SetSquadLineup
KITS_AVAILABLE
LENGTH
KIT_
push
futSelectTeam::InitializeKitConfig()
SetupTeamsInfo
GetHomeTeamId
ACTION_MATCHDAY_HOME_TEAM_CHANGE
GetAwayTeamId
ACTION_MATCHDAY_AWAY_TEAM_CHANGE
ACTION_MATCHDAY_ADVANCE_KIT_SETUP
SetReadyStatus
FadeOut
GetKitArrayForFUT
HOME_KIT_ID
AWAY_KIT_ID
ION_Uniform
IsKitSelectCreated
EnterKitSelect
IsAlternatingMode
GetUnhighlightedSide
SetKitUnReady
InitializeKitsFromArray
Unhighlight
SetDisabled
SetHighlightedSide
GetHighlightedSide
Highlight
futSelectTeam::InitializeTeamConfig()
LEAGUE_ID
components
TeamSetupControl
TEAM_TOGGLE
GetUserSideForFUT
m_isInFUT
InitData
GetToggleValue
UpdateTeamInfo
m_bOpponentTeamInvalid
m_OppHasSquads
SetChemistryValue
ResetTeamInfo
FadeIn
SetupMouseSupport
SetWomenTeamsOnlyFilter
SetMenTeamsOnlyFilter
DeactivateReady
futSelectTeam::SetupTeamsInfo()
USER_TEAM_ID
ION_GameSetup
GetTeam
SetHomeTeamId
SetAwayTeamId
setCustomSelectionArray
Team
eAttribute
ION_Team
GetAttributes
futSelectTeam::LocalEventHandler()
WARNING: Preventing the user to move until a Publish occurs.
IsInTransition
Stop spamming buttons, the team select screen is in a transition.
GetUserControllerSide
GetScreenState
DataProviders
STATE_TEAM
InputCodes
LEFT
RIGHT
GetReadyStatus
DOWN
BACK
ADVANCE
OPTION_TOP
OPTION_LEFT
IsSwitchSidesActive
STATE_KIT
SetUniform
ExitKitSelect
RemoveKitLocks
ACTION_BACKOUT
CANCEL
SetKit
SaveKitsForMatch
FUT_TOTW_GAME
SetGoingToKickoffHub
SetHomeKitId
SetAwayKitId
GetHomeKitId
GetAwayKitId
ACTION_CREATE_MATCH
SetReady
SetKitReady
FUT_OPP_HAS_NO_VALID_SQUADS
PopupData
Okay_abbr2
AddButton
ShowPopup
ValidateFullLineUp
m_sInjuryOrSuspendedWarning
m_bConceptPlayersInSquad
FUT_DB_Players_Not_Playable
FUT_TOTW_BELOW_MIN_PLAYERS
FUT_BELOW_MIN_PLAYERS
FUT_OPP_BELOW_MIN_PLAYERS
COUNTRY_TOGGLE
LEAGUE_TOGGLE
ACTION_GET_USER_SQUAD_LINEUP
ACTION_GET_OPPONENT_SQUAD_LINEUP
GoToViewSquad
PlatformManager
IsMicrosoft
USER_NAME
length
gEaso
showGamercard
getHelpContext
futSelectTeam::getHelpContext()
STATE_INVALID
FUT_VIEW_SQUAD_HOME
ltxt
manager
HelpItem
CreateHelpItem
FUT_VIEW_SQUAD_AWAY
ViewGamerCard
CreateHelpTickerItem
futSelectTeam::InitializeKitsFromArray()
GetAllAttributes
TYPE_UPPER
ITEM_NAME
ITEM_ID
ASSET_ID
StyleManager
FONT_TILE_HS
SetTitleTextFormat
SetToggleOffset
globalComponents
BasePanel
STYLE_FIFTEEN
SetBasePanelStyle
KIT_SCALE
kits
ToggleWithImage
STYLE_TOGGLE
SetStrokeVisibility
CheckIsKitLocked
futSelectTeam::GetKitArrayForFUT()
GetNonConflictingUniformID
eSortType
SORT_ASCENDING
Uniform
eSortColumn
SORT_NONE
eFilter
FILTER_UNFILTERED
GetIDs
LOCKED
NAME
shift
futSelectTeam::initVersusFUTComponents()
text
Versus_abbr
_height
FUT_Tournament
GetOfflineActiveTournamentId
GetOfflineTournamentInfo
TROPHY_ID
trophy
getArtAssetPath
SCALE_ASPECT_CENTER
setScaling
setSize
setImage
FUT_UC_TOURNAMENT_BONUS
PRIZE_FINAL
ION_Localization
LocalizeInteger
_width
textWidth
FUT_COINS_OFFSET
futSelectTeam::GoToViewSquad()
isUserTeam
CLUB_NAME
BADGE_TEAM_ID
SQUAD_NAME
RATING
SQUAD_RATING
CHEMISTRY
SQUAD_CHEMISTRY
SHOW_CHEM_LINE
SCREEN
VIEW_SQUADS
setContextDataObject
loadOverlayScreen
futSelectTeam::SetUserClubData()
m_arrUserClubs
PUBLIC
CLUB_ABBR
EST_DATE
ACTIVE_SQUAD_ID
SIDE_NAME
Away_Side
Home_Side
futSelectTeam::SetOpponentSquadListData()
split
FUT_NO_VALID_SQUADS
futSelectTeam::SetSquadLineup()
SetTeam
futSelectTeam::GetCurrentCountryIndex()
futSelectTeam::GetCurrentLeagueIndex()
futSelectTeam::GetUserSideForFUT()
bIsDemo
GetLockRules
SIDE_LOCK
futSelectTeam::ValidateFullLineUp()
FUT_SquadManagement
GetOpponentSquadLineup
GetSquadLineup
FUT_NUM_PLAYERS_IN_SQUAD
CARD_ID
ION_Card
GetPlayerCardInfo
IS_DREAM_PLAYER
FUT_NUM_PLAYERS_IN_SQUAD_EXTENDED
gFutHelpers
GetInjuryOrSuspendedSquadWarning
futSelectTeam::SaveKitsForMatch()
SIDE
NUM_KITS
ACTION_SAVE_MATCH_KIT
FUT_TOURNAMENT_CUP_SCALE
INJURY_OR_SUSPENDED_POPUP
TOTW_NUM_PLAYERS_BELOW_MIN_POPUP
USER_NUM_PLAYERS_BELOW_MIN_POPUP
OPP_NUM_PLAYERS_BELOW_MIN_POPUP
OPP_HAS_NO_VALID_SQUADS
SCALE_NONE
SCALE_ASPECT
SCALE_ABSOLUTE
ASSetPropFlags
HelpProperties
mXPos
GetXPos
SetXPos
registerClass
hj\W
hj/U
UUUV
'Z`XV
j`XVW
b$9^
UW^}
hb>x
DA__\X
UWW^
HbCA
hjCA/
+{dA
b#9X7*
I^^|x
(Z``pP
Zxp`
\j~X
&j\xp
jXXXX
Fn%Vb=
xxxp
xh``
WWWW
r\XXX
xxz_
{XPpr
Hb__^\
`x|x
``xX
]{jp
xxhh
X\\\
U\T_
`pz~
_^^^
cq,6
-/+*+
jjjj
jJJj
_^Xp
WV\p
TTWU
```h
pWUU
\UUU
$I">
x^UU
/UUU
$IR`m
$IL#
cAxW
dI/U
&b%W
|UWV\
j_uyQ
|UUVT
|WT\\
j`ppp
|\\\\
bpppp
)---
||x||
bzzzz
s"#)5
K{&R
D(tFR```
j5555
){zxh`
hlUU_`
$Ithd
Ithd
hlUWVT
s(ljj
HR{O
IBww
@Bbb
Hl\\Xx
zzhh
@`pP
Htxxxx
hlHd
Ht%%%5
ZZ\\
H|xxxx
Hthd
xxxX
TV_]
H|hd
Xxx`
_\|p
pppP
H|xxxz
i|%%%5
pX\T
H|xzzz
(dHt
H|`xz_
UU^p
$G|(t
G|(t
(tG\
VTTT
kUUU5
(pXxx
XXXX
KOKK
'cUU^
hs'c
$Gk(c
Gk(c
~ZZX
GkUWx
GkUUU\
'Gk(c
p``H
zUU~
X`pxZ
sUWx
c```
@@@@
WVT\
Vw~U
_^_j
\XPp
^|~^
c``pX\
````
UUUU
\\\\
????
p~UU
Ib'b
X\WU
A*++
UUUX
VWUU
z^VW
W^x
\\\\j
TWVV
\^xx
W^~
VWVt
cI^xxp
k$)WWVT
)W_VT
$1VTVT
(\\\\
\\\\"
$1\\XX
pr`z
AXPp`
yU^r^
$I2,r
PZrC
U{Bz
{||Z
kkki
c`p^
cx6l
\\\\]
dIb`@@
pvv]
'z@@
XVUU
e9`p
UVVV
(.-5
JJJJ
cQxxx
(%-)+
VVVV
#9ZZxx
i-)-
1U_|
#9=*
VVTT
T\\X
X^__
5-)+
$I"'r
rrbJ
8)-%5
ZZZZ
jjjk
1^UUU
g1G)^
!XX\V
BIGF0
Apt Data:1:5:8
U555
Urpp
~B'j
5555
m*((
;RRRR
m***
:RRRR
`15555
sZPPP
`95555
Apppp
95555
{PPPR
RRRR
rrp_
&j2'
pppp
Ns%!U
%)%%%%
f)%!
` 6dC.kE!
Z%)70
1E!W
1E!U
f1E!
F1Xp*
9f)U
xUU\
JV~No
(n{$!
iJPPpp
<W\^
AqUW
{Cq_
U]P\
Pppp
TTTT
PPPp
,(;k
z^\x
\^VT
???/
btTVV
M{-/75
x~_^
TUWW
%555
(^xp`
UUWV
jR\T\\
1xp``
b\\\X
b557/
jZ'5
WWWh
^XPZ
zxxxx
1UWVT
h4Vb%
\^U?
\\\X
I*.$
Hb'A
9UU\
i--+
Wka@
P|WWW
UW^x
@PW^
czXX
++-5
`x@p
brp`
U%%%
\VUW
XPXX
WTTV
PPXX
zc9~^z
I"1-+
!*+*
TTVT
----Y
73 &
Av|z
`^UJ
xx~p
&jB1_
Y444$
xWU0
$_nO
G1BBBB
xxx^
xxz~
---=
***J
O"'@
7 '>
U`X\Y
h035^
Lw!f
&T@a
]8RR
[OAq
D/Oz%F
+1.^-
_,_Y
..^O
CG|!@
;}>|
nHT*
a8Nj
?'Un70
^[zM2Bj @
6nd[N
Z)MBc
wY=A
8p(a
:m"D
[dbt
E'0S
nT+bJuZ
V-:t
v)(n
(*s?p
cc?r
B%{r
-4Yi
sci,Iy
|3;=
<KB6
cCFVJ
J|jg
4VvVV6
p$$e&
4%1{
~%ew==_.
EFGFFED
[FFB}9
$"dOz%^-
mw77
ct3r
ecGB
*\JV
c&WV
w6GMq
13aB
$X~_
mMx%
;11sZR
'&'n
q&##>o
+:Z/Y
]:AY
$(+~
,^:(,
kp>C
luqYql
wf_q
XYX\
@p77
--X&q
{ cf
waF,
znrn;
VRwCE
5=#&
/J"}
_A4Z
gnB7%
q`Y
Q|!+
[MMA
**rV
)~U(w*,)m
Z*SVd
$#&qi
qmUW=
F"MN
HaA%e%
(T!]5(\
IK#k
v wQ
C(\M
];%P
7f&=kJ
%(oRtK
gRr?
+rq_
/==7
,IUk
D?t(zC,
\}oe
'^YY
nwzu
jdf,
i5hFc
z@xOrFp
aqgv
y^oT+
dZ13
d{g~
ttzi
p,@B
upqH
1{pl0
J4)~
&W<;@
p77Es
:aPw
`>(^&
lnW|
~+w8
@@ -0,0 +1,278 @@
# FIFA17.exe runtime command/event id -> name registry
# Recovered 2026-08-24 from live pid 44405 (Denuvo-decrypted, /proc/PID/mem, read-only).
# CardsDLL live base 0x6ffffc0f0000; registration loop at live 0x147dd0000-0x147df8000.
# NOTE: this is a DIFFERENT namespace from CardsDLL's DataProvider id table.
# the same numeric id has a different name in each, matching the APT's split
# between game.uif.UIFDataProviderList and the action/command list.
#
0x0207 %d
0x0bb9 back
0x0bbb preScreenSucceeded
0x0bbc preScreenFailed
0x0bc0 clearTeamSheets
0x0be7 selectTab
0x0c15 optionSelected
0x0c2a leaveGameGroup
0x0c2c quitToHub
0x0dac UpdateStadiumCrests
0x0dac startStoryMode
0x2713 matchdayFixtureChange
0x271a evt_set_matchDay_offline_fixture
0x271b evt_team_setup_state
0x271c advanceDefault
0x271d advanceDefaultWithTeam
0x271e advancePran
0x271f feInitialized
0x2720 skipBootflow
0x2721 startBootflow
0x2722 bootflowStarted
0x2723 bootflowFinished
0x2724 bootflowSaveLoadFailed
0x2725 returnToPressStart
0x2726 showPressStart
0x2727 evt_load_personal_settings
0x2728 evt_settings_load_complete
0x2729 assetUpdate
0x272a pranUpload
0x272b pranDownload
0x272c controllerConfig
0x272d activateGameModeIntro
0x272e ActivateFullGame
0x272f startIntroFlow
0x2730 offlineEulaProfileSuccess
0x2731 offlineEulaProfileFail
0x2732 startIntroMatch
0x2733 abortIntroMatch
0x2735 setCareerType
0x2736 exitTitle
0x2737 evt_set_fullscreen
0x273e enterSubPanel
0x273f exitSubPanel
0x2742 evt_invite_accepted
0x2743 profileSignOut
0x2744 profilePrepareForSave
0x2745 logTelemetry
0x2746 enterPracticeArena
0x2748 navigationBackoutStart
0x2749 navigationBackoutContinue
0x274a navigationBackoutComplete
0x274b checkSpeechData
0x274c newsSharingSettings
0x274d leaveBootFlow
0x274e mainMenuProfileCreationDone
0x274f nonLeadProfileCreation
0x2750 nonLeadProfileLoad
0x2755 teamSheetAction
0x2758 evt_set_lead_profile
0x2759 evt_sign_out
0x275a notifySignOut
0x275b notifySignOutReady
0x275c notifySignOutTitleScreen
0x275d evt_sign_out_flow_ready
0x275e evt_sign_out_flow_not_ready
0x275f showSignOutPopup
0x2760 showSignOutTitleScreenPopup
0x2761 evt_dismiss_sign_out_popup
0x2762 evt_show_account_picker
0x2763 evt_lead_profile_recovered
0x2764 triggerSignOut
0x2765 checkLeadProfilePairing
0x2766 evt_lead_profile_paired
0x2767 evt_lead_profile_unpaired
0x2768 evt_lead_profile_controller_changed
0x2769 beginProfileCheck
0x276a endProfileCheck
0x276b evt_controller_disconnect
0x276c evt_notify_controller_disconnect
0x276d evt_controller_disconnect_flow_ready
0x276e evt_controller_disconnect_flow_not_ready
0x276f showLoadPersonalSettingsPopup
0x2770 showSavePersonalSettingsPopup
0x2771 feRenderInGame
0x2772 pvProfilerStart
0x2773 pvProfilerStop
0x2775 enterMatchDayTab
0x2776 exitMatchDayTab
0x2777 restartWithNewTeams
0x2778 playSecondLegFixture
0x2779 setupSecondLegFixture
0x277a welcomeToMatchDayLive
0x277b exitMatchDayLivePanel
0x277c enableAardvark
0x277d disableAardvark
0x277e conditionAardvark
0x2780 adaptiveDifficultyDetectedPopup
0x2781 adaptiveDifficultyUpPopup
0x2782 adaptiveDifficultyDownPopup
0x2783 adaptiveDifficultyDetected
0x2784 adaptiveDifficultyUp
0x2785 adaptiveDifficultyDown
0x2786 adaptiveDifficultyDisable
0x2787 adaptiveDifficultyReset
0x2788 adaptiveDifficultyKeep
0x2789 adaptiveDifficultyOverride
0x278c evt_countdown_done
0x278d evt_countdown_restart
0x278e evt_start_stadium_change
0x278f evt_wait_for_stadium_change
0x2790 evt_wait_for_stadium_change_bootflow
0x2791 evt_advance_to_wait_popup
0x2792 evt_advance_to_wait
0x2793 evt_stadium_background_loaded
0x2795 setupTournament
0x2796 createTournament
0x2797 createWomenTournament
0x2799 setWomenTournament
0x279a evt_sl_operation_started
0x279b evt_sl_operation_complete
0x279c evt_sl_operation_load
0x279d evt_sl_operation_boot_load
0x279e evt_sl_operation_save
0x279f evt_sl_operation_delete
0x27a0 FUTLoginComplete
0x27a1 requestDownload
0x27a2 backendEnter
0x27a3 backendExit
0x27a4 onlineLoginToEaPopup
0x27a5 onlineBootLoginToEaPopup
0x27a6 evt_onlineAlertPopup
0x27a7 evt_onlineBootLoginFailurePopup
0x27a8 evt_onlineLoginFailurePopup
0x27a9 onlineLoginPopupHide
0x27aa onlineLoginPopupShow
0x27ab evt_invite_flow_ready
0x27ac evt_invite_flow_not_ready
0x27ad inviteFlowAbortSaveLoad
0x27ae evt_verify_invite_nav_cleanup
0x27af downloadComplete
0x27b0 downloadFailed
0x27b1 spevnetNotAvailable
0x27b2 spevnetNotRegistered
0x27b3 spevnetNotRegisteredBeta
0x27b4 userBanned
0x27b5 showExitConfirmPopup
0x27b6 hideExitConfirmPopup
0x27b7 confirmExit
0x27b8 showRegisterConfirmPopup
0x27b9 hideRegisterConfirmPopup
0x27ba setStadiumPosition
0x27bb liveCompCountryDecision
0x27bc liveCompAllCountriesSelect
0x27bd liveCompLimitedCountriesSelect
0x27be liveCompAdvanceToTeamSelect
0x27bf liveCompRegistrationConfirm
0x27c0 liveCompEventListSuccess
0x27c1 liveCompEventListFail
0x27c2 postMatchHighlightExit
0x27c3 postMatchHighlightComplete
0x27c4 postMatchHighlightSelect
0x27c5 postMatchHighlightReelSelect
0x27c6 postMatchHighlightIRSelect
0x27cf leaveUpsell
0x27d0 purchase
0x27d1 advanceFromPMA
0x27d2 evt_transitionToPMADone
0x27d3 cutSceneCommand
0x27d4 cutScenePlay
0x27d5 loadCutScenesSubLevel
0x27d6 unloadCutScenesSubLevel
0x27d7 evt_enable_skip_cutscene
0x27d8 gmCutSceneStarted
0x27d9 gmCutSceneEnded
0x27da gmCutScenesSublevelLoaded
0x27db gmCutScenesSublevelUnloaded
0x27dc gmAirlockToGameplayEnded
0x27dd gmAirlockLoadComplete
0x27de evt_quit_to_training_hub
0x27e0 evt_training_allow_advance_to_game
0x27e1 checkOriginConnected
0x27e2 OriginIsOnline
0x27e3 OriginIsOffline
0x27e4 OIGOpened
0x27e5 OIGClosed
0x27e6 overrideOnlineStadium
0x27e7 smLoadFEStadium
0x27e8 smActivateFreeRoam
0x27e9 smGameOver
0x27ea smScenePrime
0x27eb smScenePrimeAndPrep
0x27ec smScenePause
0x27ed smSceneResume
0x27ee smMoment
0x27ef smMomentRepeat
0x27f0 smMomentComplete
0x27f1 smExitMomentState
0x27f2 smOnPlayScene
0x27f3 smConversation
0x27f4 smConversationComplete
0x27f5 smConversationNotification
0x27f6 smConversationNotificationComplete
0x27f7 smGameplayStartLoad
0x27f8 smGameplayLoadOver
0x27f9 smGameplayStart
0x27fa smGameplayOver
0x27fb smGameplayPause
0x27fc smGameplayResume
0x27ff smTweetConsume
0x2800 smHeroLoanedOut
0x2801 smSetupAcademyMatch
0x2802 smSetupAcademyTeams
0x2803 smStartIntroFlow
0x2804 smStartSeason
0x2805 smPlayMatch
0x2806 smEndMatch
0x2807 smGetTrainingSet
0x2808 smEnterTrainingTeamHub
0x2809 smEnterTraining
0x280a smPlayTrainingSessionVO
0x280b smPrepareTraining
0x280c smPlayTraining
0x280d smStopTraining
0x280e smStartSkillGame
0x280f smSimTraining
0x2810 smEndTraining
0x2811 smSave
0x2812 smAutoSave
0x2814 smLoad
0x2815 smSetScreenFlowLocation
0x2816 smGetScreenFlowLocation
0x2817 smGetHomeHubLocation
0x2818 smGetHeroLeague
0x2819 smCompleteMatchday
0x281a smEndInterviewPeriod
0x281b smHeroRemovedFromMatch
0x281c smEpisodicUploadCheck
0x281d smRetryEpisodicUpload
0x281e smNotifyMatchNotPlayed
0x281f matchFlowStart
0x2820 matchFlowHalftime
0x2821 matchFlowPostgame
0x2822 matchFlowEnd
0x2823 enterGameplay
0x2824 leaveGameplay
0x2825 forfeitMatch
0x2826 matchSetType
0x2827 simMatch
0x2828 simStarted
0x2829 simStopped
0x282a fbStartFlowEvent
0x282b stopSavedInput
0x282c changeSonyStoreBrowseMode
0x282d trialCheck
0x282e gotoTrialUpsell
0x754d retrieveManagerQuestData
0x7560 futWidgetShow
0x7561 futWidgetHide
0x7562 futWidgetLoad
0x7563 futWidgetUnload
0x7567 inviteAcceptedFUT
0x7568 futAddCriticalSection
0x7569 futRemoveCriticalSection
0x7572 exitDraftMode
0x7579 useSavedMatchData
0x757a useSavedMatchKits
0x7580 exitSbcMode
0x7587 setFUTServerEnvironment
0x9cc1 discardTeamSheet
0x9cc1 resetReady
0x9cd0 showKeyboard
+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…"
+153 -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,95 @@ 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. The pre-match kit selector gates on `+0x60 == 4` at `0x1801c34f2`, and no
instruction in CardsDLL stores that constant immediately (29 stores, constants
`{-2,0,1,908,0x3f800000}`), nor does FIFA17.exe across 79 MB.
Tool: `fifa17-recon/tools/kit_gate_probe.py`.
**CORRECTED 2026-08-23 (live, pid 8793, read-only `/proc/PID/mem`).** The
2026-08-21 entry went on to call the kit selector "a client dead end, not a
missing wire field", on the grounds that "every OTHER input to that gate is
already served". That conclusion is WITHDRAWN. It rested on two mistakes.
1. **`+0x60 == 4` does occur.** A live record reached the art-clone driver
`FUN_1801c3480` holding `+0x4c == 2`, `+0x60 == 4`. So the value arrives by
some path the immediate-store scan cannot see (register copy or computed),
and "nothing can ever satisfy the gate" is false. What the static scan
actually licenses is the narrower claim above.
2. **cardtype 7 was never verified to be produced at all.** The probe annotates
`cmp [rdi+0x4c], 7` with "<- we produce this". Nothing measured that. Its own
live half showed `{1: players, 0: staff}` -- i.e. zero cardtype-7 records --
and that was read as "the only thing missing is +0x60".
**What is actually measured now.** With the client parked on the kit selector,
scanning all 3047 MiB of readable process memory for the exact u32 values the
server sent:
```
resident (record-shaped, sane fields):
player resourceId 83906881 -> cardtype 1, itemState 1, teamid 243, +0x60 1
staff resourceId 9000081 -> cardtype 2
staff resourceId 3000083 -> cardtype 4, subtype 8
staff resourceId 1000509 -> cardtype 2, subtype 4, teamid 241
NOT resident, by resourceId AND by instance id, zero hits each:
kit 6300006 / 100004874 (cardsubtypeid 9)
kit 6400003 / 100004873 (cardsubtypeid 9)
badge 6000005 / 100004875 (cardsubtypeid 11)
stadium 6200000 / 100004876 (cardsubtypeid 10)
```
The client fetched `?type=kit` at 17:50:09 this session and the host logged
`total=2 emitted=2`. Both kits were delivered and NEITHER produced a record.
Every cardtype-7 family is absent while cardtype 1/2/4 are resident.
So the blocker is upstream of the `+0x60` gate: no cardtype-7 record is ever
created, therefore the club scan `FUN_1800d73d0` (`+0x4c==7 && +0x50==9 &&
`+0x5c in {101,102}`) has nothing to match, `KIT_DESC` never fires, and
`KITS_AVAILABLE` reads 0. Whether that is a bad wire shape (the cardtype-7 parse
arm wants `name`/`localizedName`/`description`, which OpenFUT does not send) or
cardtype-7 items being transient by design is NOT yet settled -- do not record
either as fact.
**Method note.** `kit_gate_probe.py`'s live half is unreliable as written: on
pid 8793 it printed "CardsDb is empty (no FUT session loaded)" while a byte scan
found 1966 resident players. Its structural chain is stale, so its record counts
(including the original "27 resident records") understate reality. Prefer the
value scan until the chain is re-derived.
**`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.
+406
View File
@@ -0,0 +1,406 @@
# 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.
## FUT task vocabulary (2026-08-21, live)
The client drives UTAS through named TASKS, not just URLs. The task-name table
lives in CardsDLL `.rdata` as 0x20-byte inline slots holding MixedCase/UPPERCASE
pairs (`tools/apply_route_search.py`, controls `tradePile`/`ut/%s/item`/`squad`
all FOUND):
```
ViewCards AssingCard(sic) ApplyCard ApplyCardByRes
ActivateCard ConsumeCard DiscardCard DiscardCardByRes
DiscardACard MoveCard MoveCardByRes SwapCard
CreateMatch MatchReady DestroyMatch PlayGame ResetMatch KeepAlive
LoadCategoryDetails LoadSetChallenges StartChallenge LoadSquadChallenge
SaveSquadChallenge SubmitChallenge TagSets SetSbcData
TournamentList TournamentTeams SetUserInfo GetHistorical SetTutData ...
```
A descriptor table in `.data` pairs each name with a task id and a small setter
thunk, e.g. `ApplyCard` id **0x0d** at `0x1802cb170`, `ApplyCardByRes` id **0x0e**
at `0x1802cb1a0`. The thunks are `mov [rip+flag], cl; ret` (a per-task flag), NOT
request builders, so the request is assembled elsewhere keyed by task id.
**So consumable application IS a first-class client action (`ApplyCard` /
`ApplyCardByRes` / `ConsumeCard`), even though no `/apply` URL exists.** It
therefore rides an existing route. Which one is a one-capture question, and the
host now names every unclaimed request:
```
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
```
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
Captured end to end on staging, operator applying a bronze player contract:
```
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
```
| element | value | where |
|---|---|---|
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
| verb | `POST` | |
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
we already serve for **GET** (item-definition lookup). The **POST** verb on that
path is the mutation, and nothing claimed it, so it fell through to Python. This
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
res**ource" -- which is why the source is a definition id rather than an instance
id.
`apply` is an ARRAY, so one consumable resource can name several targets in a
single request. Whether the client ever batches is unobserved.
Corroborating UI evidence from the same session: applying to a PLAYER offered
only the subtype-201 card and withheld both subtype-202 manager contracts,
independently confirming the `201 = player_contract / 202 = manager_contract`
split.
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
EXACTLY unchanged (coins, owned count, and the source card all identical).
### Not yet known
* the **response shape** the client expects on success;
* the **effect** -- how many matches a contract grants. Our own catalog carries
`contract: 7` for `5001004`, documented as "the number of matches the card
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
effect is implemented on that basis.
## Consumables category `development` is unmapped (client really asks)
The new passthrough/route logging caught the client requesting
```
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
```
`consumable_families_for_category` has no `development` arm, so the screen is
served empty. The client demonstrably asks for it, which is exactly the condition
that function's own doc says should add an arm. Which families it should map to
is NOT guessed here.
### Success contract — STATIC_REVERSED (2026-08-22)
The apply completion handler is `0x180035520`:
```asm
0x180035529 mov ecx,DWORD PTR [rdx+0x1c] ; the ONLY field tested
0x18003552c test ecx,ecx
0x18003552e jne 0x18003555c ; nonzero -> FAILURE
0x18003553c lea rdx,[EVENT_CARDS_APPLY_CARD_SUCCESS] ; 0x1801f37f0
0x180035569 lea rdx,[EVENT_CARDS_APPLY_CARD_FAILURE] ; 0x1801f3810
```
It tests exactly one 32-bit field — the transport code — and **never inspects
the body**. `EVENT_CARDS_APPLY_CARD_SUCCESS` has precisely one reference in the
module, so this is the whole verdict path.
This does NOT resemble the move ack (`0x180128600`), which builds per-item
verdict records and reports FAILURE on an EMPTY vector. The "`{}` is
known-broken" precedent is specific to that route and does not transfer here.
Supporting structure: the response object's constructor `0x1800a4ce0` installs
vtable `0x1801fb5b0` and initialises its record vector at `+0x50`/`+0x58`/`+0x60`
EMPTY (0x20-byte elements); `0x1800682b0` is the matching destructor, freeing
that range with a 0x20 stride. An empty result is therefore a legal parsed state
for this response, unlike the move.
Registration site: `0x1800357da` installs the completion handler and
`0x1800357e5` the response factory, back to back.
**Probe response**: `{"itemData":[]}` — an object root (matching how the oracle's
method-agnostic `item/resource` route answers this path) containing an empty
vector (legal per the constructor). Labelled a PROBE. The client's SUCCESS only
requires transport code 0.
## Consumables categories — nine, not seven (2026-08-22)
Correcting the earlier claim that the two formation-modifier families "have no
group code, so no segment can reach them — the client's own gap". The client's
own switch says otherwise. Literal table at `0x1801f5a38` (under
`MyClubAdapterClass` / `CONSUMABLE_TYPE`); switch at `0x180048820` indexing by
`enum + 1` through the byte table at `0x180048a90` into the case table at
`0x180048a6c`:
| CONSUMABLE_TYPE | segment |
|---|---|
| **-1 (unset)** | `development` |
| 1, 2 | `contracts` |
| 3 | `healing` |
| 4 | `fitness` |
| **16** | `formation` |
| 17 | `position` |
| 23 | `playStyle` |
| 24 | `managerLeagueModifier` |
| 0, 5..15, 18..22 | `training` (switch default) |
`formation` was a SERVER gap, not a client one. `development` is the type-unset
bucket — index 0 of an `enum + 1` table — i.e. the unfiltered view; the eight
typed segments already reach all thirteen families exactly once, so it owns no
family privately and maps to their union.
## Contract effect — the `contract: 7` inference is REFUTED at the source
Do not implement a contract effect from the catalog's `contract: 7`.
`fifa17-recon/tools/fut_store.py:232` — the generic `_item()` factory that builds
EVERY item the oracle serves — hardcodes:
```python
"playStyle": 250,
"contract": 7,
"fitness": 99,
```
These are blanket placeholders on every item, players and consumables alike. The
staging squad's GK reads back `contract 7 / fitness 99 / playStyle 250`: the same
three constants. So the `contract: 7` carried in the production catalog for
resource 5001004 is **our own oracle placeholder round-tripped through an
observed profile**, not an EA value. Its evidence level is not INFERRED; it is
KNOWN-BOGUS as a source of the effect.
### What the client's own table does say
`fcc_contractcards` (13 rows) is NOT amount-less, contrary to an earlier note
here. Columns: `carddbid, cardsubtype, weightrare, cardassetid, gold, rating,
bronze, silver`.
| rating | player (201) | manager (202) | gold | silver | bronze |
|---|---|---|---|---|---|
| 50 | 5001001 | 5001007 | 1 | 2 | 8 |
| 65 | 5001002 | 5001008 | 8 | 10 | 10 / 8 |
| 80 | 5001003 | 5001009 | 13 | 11 | 15 / 11 |
| 60 | 5001004 | 5001010 | 3 | 6 | 15 |
| 70 | 5001005 | 5001011 | 18 | 24 | 20 / 18 |
| 90 | 5001006 | 5001012 | 28 | 24 | 28 / 24 |
| 90 | 5001013 | — | 99 | 99 | 99 |
Compare the sibling `fcc_healingcards`, which shares `carddbid, cardsubtype,
weightrare, cardassetid, rating` and differs only by carrying a single `amount`.
So `weightrare` is the drop weight and the differing column(s) are the effect
payload — which would make gold/silver/bronze a per-target-tier amount.
AGAINST that reading: the values are not monotonic across tiers (5001005 is gold
18, silver 24, bronze 20; 5001003 is gold 13, silver 11, bronze 15), which is
odd for an amount and unremarkable for a weight. Note also that **no column of
5001004 equals 7**, so nothing here explains the placeholder either way.
Unresolved, and NOT to be guessed: the fcc tables are loaded by `FIFA17.exe`, not
CardsDLL (the table-name and column literals are absent from the DLL), so the
reader that would settle amount-vs-weight lives in the EXE. Status stays
**EFFECT_UNKNOWN**.
## Post-ACK behaviour — OUTCOME B, LIVE_PROVEN (2026-08-22)
Captured with the staging probe answering `200 {"itemData":[]}` and mutating
nothing:
```
T0 POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]}
T1 200 {"itemData":[]}
T2 callback -> SUCCESS (no failure event; ZERO ut/delete/auth; session alive)
T4 GET club/consumables/contracts <- refresh of the SOURCE list
T5 GET club/consumables/development
T6 GET squad/active <- refresh of the TARGET
T7 no second mutation of any kind
```
So of the candidate protocols:
```
B) POST resource -> ACK -> client performs GET refresh
-> the SERVER is expected to have mutated state
```
Ruled out by observation: (A) the response carries the modified state — the body
was empty and the client was satisfied; (C) a follow-up generic PUT/item — none
was sent; (D) another route performs the mutation — nothing else was called.
Three consequences.
1. **The success verdict is transport-only, confirmed live.** The static read of
`0x180035520` said the body is never inspected; an empty `itemData` produced a
clean success and a surviving session, which is that prediction holding.
2. **The server owns the effect entirely.** The client does not compute one; it
re-reads. This is the good failure mode: a wrong server-side effect cannot be
masked by client-side optimism, and the refresh will always show server truth.
Here the refresh correctly showed `contracts copies=3` and an unchanged squad,
because the probe consumed nothing.
3. **There is no client-side amount to harvest.** Since the client never renders
an optimistic "+N games" of its own, the live path cannot reveal the grant
size. The number the client DISPLAYS on a contract card comes from the wire
`contract` atom (0xb8 -> record+0x8c; see `fut_consumables.py`, which notes
categories 2 and 3 ignore `amount` and read `contract`) — i.e. the server
tells the client what the card is worth.
That last point matters for honesty: our oracle has been sending the placeholder
`7` for that atom, so every contract card this project has ever shown a player
said "7" because WE said 7. Recovering EA's real value is not reachable from the
client's behaviour; it needs the `FIFA17.exe` reader of `fcc_contractcards`, or
it becomes an explicit design decision. Status: **EFFECT_UNKNOWN**.
### Boundary status
| aspect | status |
|---|---|
| route, method, source encoding, target encoding | LIVE_PROVEN |
| success condition (`[obj+0x1c] == 0`, body ignored) | STATIC_REVERSED + LIVE_CONFIRMED |
| response shape accepted by the client | LIVE_PROVEN (`{"itemData":[]}`, session survived) |
| post-ACK protocol | LIVE_PROVEN — outcome B |
| batching | UNPROVEN — refused, never guessed |
| contract effect / grant size | UNKNOWN (placeholder source refuted) |
| source instance selection with multiple copies | UNDETERMINED (only 1 copy owned) |
## Consumable QUICK-SELL is PUT item/resource — LIVE_PROVEN (2026-08-22)
Captured on staging when the operator quick-sold a Position Modifier from the
consumables screen:
```
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
```
So `ut/<sku>/item/resource/<resourceId>` carries THREE verbs, and this is the
third:
| verb | meaning |
|---|---|
| `GET` | item-definition lookup (`defs_route` parity) |
| `POST` | apply the consumable (`ApplyCardByRes`, body `{"apply":[{"id":N}]}`) |
| `PUT` | **quick-sell the consumable**, EMPTY body |
Note it is keyed by **resourceId**, i.e. the STACK, not by an owned instance
id — unlike the player quick-sell, which is `DELETE ut/<sku>/item/<instanceId>`
and is retail-proven in production. That asymmetry follows the consumables
screen's own model: the UI entity there is a stack, not a card.
Neither stack has ever served this route. The Python oracle maps
`item/resource` method-agnostically to `defs_route`, so a PUT would get a
definition list and HTTP 200 while nothing was sold — the client would believe
the sale succeeded. On staging the oracle is deliberately dead, so it 502'd and
Core was left untouched (coins 29843976, owned 1993, consumables 17).
### Consequence for production
Production's oracle IS alive, so today a consumable quick-sell there would reach
Python, return 200 from `defs_route`, and mutate nothing — the client would show
a successful sale that never happened. That is a second, independent reason not
to quick-sell consumables in production until this route is implemented in Rust.
### UNKNOWN, not to be guessed
* Does an empty-body PUT sell ONE copy or the WHOLE stack? The request carries no
quantity, and both readings fit. A stack of 2 at 38 is either +38 or +76.
* Which owned instance is consumed when several share the resourceId.
* What response the client requires (the player path's ack shape may not apply).
+103 -4
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.**
@@ -920,16 +939,96 @@ freezes any of these — GAPs are "feature missing", not "crash".
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/<rid>` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH |
> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex
> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item`
> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and
> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture:
> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`.
> This row previously read `PUT ut/%s/item` for both, and that conflation is what
> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive
> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every
> observed `PUT ut/%s/item` is a pile MOVE, never an apply.
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
| 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.
+697
View File
@@ -0,0 +1,697 @@
#!/usr/bin/env python3
"""Decoder for EA APT (compiled ActionScript) as shipped in FIFA 17.
Clean-room implementation. The byte-level format facts (opcode numbers, operand
widths, alignment rule, branch base, DefineFunction2 field order) were taken from
a written specification derived from OpenSAGE, which is GPL-3.0 with EA
additional terms. No OpenSAGE code was copied or transliterated; only the format
description -- an interface specification -- was used. Reference read at
OpenSAGE/OpenSAGE commit 588ac477367a0022adf29f20a084e8873014e6ce and
OpenSAGE/AptEditor commit 09f73c655c45a781f883b623a93d2e8f5b065a6c.
FIFA 17 ships a 64-BIT variant of the format. Differences from the 32-bit SAGE
layout described by the reference, all established by measurement against
futSelectTeam and asserted by --selftest:
* Container pointers and counts are u64, not u32.
* Parameterised instructions align their operand block to 8 bytes, not 4.
Proven by the ConstantPool at 0xd38: aligning to 4 yields garbage, aligning
to 8 yields count=401 with an index array that ends exactly on the
parameter-list region.
* The constant pool lives in a separate "Apt1" container member rather than a
".const" sibling file. Entries are 16 bytes: {u64 type, u64 value}; type 1
is a string whose value is an absolute offset inside that same member.
* DefineFunction2's operand block is 48 bytes rather than 28, and the
0x1234567898765432 trailer is stored as two u64 halves.
* Branch displacements remain i32 and remain relative to the end of the
branch record, exactly as in the 32-bit format.
"""
from __future__ import annotations
import argparse
import struct
import sys
from dataclasses import dataclass, field
APT1_MAGIC = b"Apt1"
APTDATA_MAGIC = b"Apt Data:1:7:8\x1a\x00"
# Trailer sentinel on DefineFunction/DefineFunction2, stored as two u64 halves.
FUNC_SENTINEL_LO = 0x98765432
FUNC_SENTINEL_HI = 0x12345678
ALIGN = 8
# Operand kinds.
NONE = "none" # no operand block
U8REG = "u8reg" # 1 raw byte, register index
U8CONST = "u8const" # 1 raw byte, constant-pool index
U16CONST = "u16const" # 2 raw bytes, constant-pool index
U8LIT = "u8lit" # 1 raw byte, literal integer
U16LIT = "u16lit" # 2 raw bytes, literal integer
BRANCH = "branch" # aligned i32, relative to end of record
U32 = "u32" # aligned u32
F32 = "f32" # aligned f32
STR64 = "str64" # aligned u64 absolute offset to NUL-terminated string
POOL = "pool" # aligned u64 count + u64 array offset (array of u64 ids)
FUNC2 = "func2" # aligned DefineFunction2 record
FUNC1 = "func1" # aligned DefineFunction record
# opcode -> (mnemonic, operand kind)
OPCODES: dict[int, tuple[str, str]] = {
0x00: ("End", NONE),
0x04: ("NextFrame", NONE),
0x06: ("Play", NONE),
0x07: ("Stop", NONE),
0x0A: ("Add", NONE),
0x0B: ("Subtract", NONE),
0x0C: ("Multiply", NONE),
0x0D: ("Divide", NONE),
0x12: ("Not", NONE),
0x13: ("StringEquals", NONE),
0x17: ("Pop", NONE),
0x18: ("ToInteger", NONE),
0x1C: ("GetVariable", NONE),
0x1D: ("SetVariable", NONE),
0x21: ("StringConcat", NONE),
0x22: ("GetProperty", NONE),
0x23: ("SetProperty", NONE),
0x26: ("Trace", NONE),
0x30: ("Random", NONE),
0x3A: ("Delete", NONE),
0x3B: ("Delete2", NONE),
0x3C: ("DefineLocal", NONE),
0x3D: ("CallFunction", NONE),
0x3E: ("Return", NONE),
0x3F: ("Modulo", NONE),
0x40: ("NewObject", NONE),
0x41: ("Var", NONE),
0x42: ("InitArray", NONE),
0x43: ("InitObject", NONE),
0x44: ("TypeOf", NONE),
0x47: ("Add2", NONE),
0x48: ("LessThan2", NONE),
0x49: ("Equals2", NONE),
0x4A: ("ToNumber", NONE),
0x4B: ("ToString", NONE),
0x4C: ("PushDuplicate", NONE),
0x4E: ("GetMember", NONE),
0x4F: ("SetMember", NONE),
0x50: ("Increment", NONE),
0x51: ("Decrement", NONE),
0x52: ("CallMethod", NONE),
# 0x53 appears in the reference enum as NewMethod but the reference never
# parses it. Standard AVM1 ActionNewMethod carries no operand block;
# decoding it as zero-length keeps this artifact synchronised with every
# branch still landing on an instruction boundary, which is the check that
# would break first if the width were wrong.
0x53: ("NewMethod", NONE),
0x54: ("InstanceOf", NONE),
0x55: ("Enumerate2", NONE),
0x56: ("PushThis", NONE),
0x59: ("PushZero", NONE),
0x5A: ("PushOne", NONE),
0x5B: ("CallFuncPop", NONE),
0x5C: ("CallFunc", NONE),
0x5D: ("CallMethodPop", NONE),
0x62: ("BitwiseXOr", NONE),
0x66: ("StrictEqual", NONE),
0x67: ("Greater", NONE),
0x69: ("Extends", NONE),
0x70: ("PushThisVar", NONE),
0x71: ("PushGlobalVar", NONE),
0x72: ("ZeroVar", NONE),
0x73: ("PushTrue", NONE),
0x74: ("PushFalse", NONE),
0x75: ("PushNull", NONE),
0x76: ("PushUndefined", NONE),
0x87: ("SetRegister", U32),
0x88: ("ConstantPool", POOL),
0x8C: ("GotoLabel", STR64),
0x8E: ("DefineFunction2", FUNC2),
0x96: ("PushData", POOL),
0x99: ("BranchAlways", BRANCH),
0x9B: ("DefineFunction", FUNC1),
0x9D: ("BranchIfTrue", BRANCH),
0x9F: ("GotoFrame2", U32),
0xA1: ("PushString", STR64),
0xA2: ("PushConstantByte", U8CONST),
0xA3: ("PushConstantWord", U16CONST),
0xA4: ("GetStringVar", STR64),
0xA5: ("GetStringMember", STR64),
0xA6: ("SetStringVar", STR64),
0xA7: ("SetStringMember", STR64),
0xAE: ("PushValueOfVar", U8CONST),
0xAF: ("GetNamedMember", U8CONST),
0xB0: ("CallNamedFuncPop", U8CONST),
0xB1: ("CallNamedFunc", U8CONST),
0xB2: ("CallNamedMethodPop", U8CONST),
0xB3: ("CallNamedMethod", U8CONST),
0xB4: ("PushFloat", F32),
0xB5: ("PushByte", U8LIT),
0xB6: ("PushShort", U16LIT),
0xB8: ("BranchIfFalse", BRANCH),
0xB9: ("PushRegister", U8REG),
}
ALIGNED_KINDS = {BRANCH, U32, F32, STR64, POOL, FUNC2, FUNC1}
class DecodeError(Exception):
"""Raised when the stream cannot be decoded without guessing."""
@dataclass
class Instr:
offset: int
opcode: int
mnemonic: str
length: int # opcode byte through end of operand block, incl. padding
operands: dict
raw: bytes
target: int | None = None # resolved branch destination
comment: str = ""
def render(self, width: int = 22) -> str:
ops = self.comment or ""
return f" {self.offset:#07x} {self.mnemonic:<{width}} {ops}"
@dataclass
class Function:
name: str
record_offset: int # offset of the DefineFunction* opcode byte
body_start: int
body_end: int
n_params: int
n_registers: int
flags: int
params: list = field(default_factory=list)
@property
def anonymous(self) -> bool:
return not self.name
PRELOAD_FLAGS = [
(0x010000, "PreloadExtern"),
(0x008000, "PreloadParent"),
(0x004000, "PreloadRoot"),
(0x002000, "SupressSuper"),
(0x001000, "PreloadSuper"),
(0x000800, "SupressArguments"),
(0x000400, "PreloadArguments"),
(0x000200, "SupressThis"),
(0x000100, "PreloadThis"),
(0x000001, "PreloadGlobal"),
]
# Registers preloaded by the VM, in flag order, starting at index 1.
PRELOAD_ORDER = [
(0x000100, "this"),
(0x000400, "arguments"),
(0x001000, "super"),
(0x004000, "_root"),
(0x008000, "_parent"),
(0x000001, "_global"),
(0x010000, "extern"),
]
def flag_names(flags: int) -> str:
got = [n for bit, n in PRELOAD_FLAGS if flags & bit]
return "|".join(got) if got else "0"
def register_map(fn: Function) -> dict[int, str]:
"""Reproduce the VM's register preload order, then bound parameters."""
regs: dict[int, str] = {}
idx = 1
for bit, name in PRELOAD_ORDER:
if fn.flags & bit:
regs[idx] = name
idx += 1
for reg, pname in fn.params:
if reg:
regs[reg] = pname
return regs
class ConstPool:
"""The 'Apt1' container member: header, 16-byte entries, string table."""
def __init__(self, data: bytes):
if data[:4] != APT1_MAGIC:
raise DecodeError(f"not an Apt1 member: {data[:4]!r}")
self.data = data
self.count = struct.unpack_from("<Q", data, 0x20)[0]
self.first = struct.unpack_from("<Q", data, 0x28)[0]
self.entries: list[tuple[int, int, str | None]] = []
for i in range(self.count):
off = self.first + i * 16
if off + 16 > len(data):
raise DecodeError(f"const entry {i} at {off:#x} runs past end")
etype, value = struct.unpack_from("<QQ", data, off)
text = None
if etype == 1:
if not (0 < value < len(data)):
raise DecodeError(
f"const entry {i}: string offset {value:#x} outside member"
)
end = data.find(b"\0", value)
if end < 0:
raise DecodeError(f"const entry {i}: unterminated string")
text = data[value:end].decode("latin1")
self.entries.append((etype, value, text))
def string(self, index: int) -> str:
if not (0 <= index < len(self.entries)):
raise DecodeError(f"const index {index} out of range (0..{len(self.entries)-1})")
etype, _, text = self.entries[index]
if etype != 1 or text is None:
raise DecodeError(f"const index {index} is type {etype}, not a string")
return text
def find(self, needle: str) -> list[int]:
return [i for i, (_, _, t) in enumerate(self.entries) if t == needle]
class AptData:
"""The 'Apt Data' container member: movie structures plus action streams."""
def __init__(self, data: bytes, pool: ConstPool):
if not data.startswith(APTDATA_MAGIC[:8]):
raise DecodeError(f"not an Apt Data member: {data[:16]!r}")
self.data = data
self.pool = pool
self.scope: list[str] = [] # installed by ConstantPool
self.functions: list[Function] = []
# -- helpers ---------------------------------------------------------
def cstr(self, off: int) -> str:
if not (0 <= off < len(self.data)):
raise DecodeError(f"string offset {off:#x} outside Apt Data")
end = self.data.find(b"\0", off)
if end < 0:
raise DecodeError(f"unterminated string at {off:#x}")
return self.data[off:end].decode("latin1")
def const(self, index: int) -> str:
"""Resolve through the scope pool installed by the most recent 0x88."""
if self.scope:
if not (0 <= index < len(self.scope)):
raise DecodeError(
f"scope-pool index {index} out of range (0..{len(self.scope)-1})"
)
return self.scope[index]
return self.pool.string(index)
def install_pool(self, ids: list[int]) -> None:
self.scope = [self.pool.string(i) for i in ids]
# -- instruction decoding --------------------------------------------
def decode_one(self, pos: int) -> Instr:
d = self.data
if pos >= len(d):
raise DecodeError(f"position {pos:#x} past end of stream")
op = d[pos]
entry = OPCODES.get(op)
if entry is None:
raise DecodeError(
f"unknown opcode {op:#04x} at {pos:#07x} "
f"(raw {d[pos:pos+8].hex(' ')}) - refusing to guess its length"
)
mnem, kind = entry
p = pos + 1
if kind in ALIGNED_KINDS:
p = (p + ALIGN - 1) & ~(ALIGN - 1)
ops: dict = {}
comment = ""
target = None
def need(n: int) -> None:
if p + n > len(d):
raise DecodeError(f"{mnem} at {pos:#07x} truncated: needs {n} bytes")
if kind == NONE:
pass
elif kind in (U8REG, U8LIT):
need(1)
ops["value"] = d[p]
p += 1
comment = f"r{ops['value']}" if kind == U8REG else str(ops["value"])
elif kind == U8CONST:
need(1)
ops["index"] = d[p]
p += 1
comment = f"{ops['index']:#04x} -> {self.const(ops['index'])!r}"
elif kind == U16CONST:
need(2)
ops["index"] = struct.unpack_from("<H", d, p)[0]
p += 2
comment = f"{ops['index']:#06x} -> {self.const(ops['index'])!r}"
elif kind == U16LIT:
need(2)
ops["value"] = struct.unpack_from("<H", d, p)[0]
p += 2
comment = str(ops["value"])
elif kind == U32:
need(4)
ops["value"] = struct.unpack_from("<I", d, p)[0]
p += 4
comment = str(ops["value"])
elif kind == F32:
need(4)
ops["value"] = struct.unpack_from("<f", d, p)[0]
p += 4
comment = repr(ops["value"])
elif kind == BRANCH:
need(4)
disp = struct.unpack_from("<i", d, p)[0]
p += 4
ops["displacement"] = disp
target = p + disp # base = end of record
comment = f"{disp:+d} -> {target:#07x}"
elif kind == STR64:
need(8)
off = struct.unpack_from("<Q", d, p)[0]
p += 8
ops["offset"] = off
ops["text"] = self.cstr(off)
comment = f"{ops['text']!r}"
elif kind == POOL:
need(16)
count, arr = struct.unpack_from("<QQ", d, p)
p += 16
if arr + count * 8 > len(d):
raise DecodeError(f"{mnem} at {pos:#07x}: array {arr:#x}[{count}] overruns")
ids = list(struct.unpack_from(f"<{count}Q", d, arr))
ops["count"], ops["array"], ops["ids"] = count, arr, ids
comment = f"count={count} array={arr:#x}"
elif kind in (FUNC2, FUNC1):
if kind == FUNC2:
need(48)
name_off, n_params = struct.unpack_from("<QI", d, p)
n_reg = d[p + 12]
flags = int.from_bytes(d[p + 13:p + 16], "little")
plist, body = struct.unpack_from("<QQ", d, p + 16)
lo, hi = struct.unpack_from("<QQ", d, p + 32)
p += 48
else:
need(40)
name_off, n_params, plist, body = struct.unpack_from("<QQQQ", d, p)
n_reg, flags = 4, 0
lo, hi = struct.unpack_from("<QQ", d, p + 32)
p += 40
if (lo, hi) != (FUNC_SENTINEL_LO, FUNC_SENTINEL_HI):
raise DecodeError(
f"{mnem} at {pos:#07x}: bad trailer {lo:#x}/{hi:#x}, "
"record layout is wrong"
)
name = self.cstr(name_off)
params = []
for i in range(n_params):
e = plist + i * 16
if e + 16 > len(d):
raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns")
reg, pn = struct.unpack_from("<QQ", d, e)
params.append((reg, self.cstr(pn)))
ops.update(name=name, n_params=n_params, n_registers=n_reg,
flags=flags, params=params, body_size=body)
comment = (f"{name or '<anonymous>'}({', '.join(n for _, n in params)}) "
f"nRegs={n_reg} flags={flag_names(flags)} bodySize={body}")
ops["body_start"] = p
ops["body_end"] = p + body
else:
raise DecodeError(f"internal: unhandled kind {kind}")
return Instr(pos, op, mnem, p - pos, ops, d[pos:p], target, comment)
def decode_stream(self, start: int, limit: int | None = None) -> list[Instr]:
"""Linear decode using the reference termination rule.
Stops when the last instruction was End AND we are past every branch
destination seen so far. A stream may legitimately continue past an End.
"""
out: list[Instr] = []
pos = start
furthest = start
while True:
if limit is not None and pos >= limit:
break
ins = self.decode_one(pos)
out.append(ins)
if ins.target is not None:
furthest = max(furthest, ins.target)
if ins.mnemonic == "ConstantPool":
self.install_pool(ins.operands["ids"])
if ins.mnemonic in ("DefineFunction2", "DefineFunction"):
fn = Function(
name=ins.operands["name"],
record_offset=ins.offset,
body_start=ins.operands["body_start"],
body_end=ins.operands["body_end"],
n_params=ins.operands["n_params"],
n_registers=ins.operands["n_registers"],
flags=ins.operands["flags"],
params=ins.operands["params"],
)
self.functions.append(fn)
furthest = max(furthest, fn.body_end)
pos = ins.offset + ins.length
if ins.mnemonic == "End" and pos > furthest:
break
return out
def load(apt1_path: str, aptdata_path: str) -> tuple[ConstPool, AptData]:
pool = ConstPool(open(apt1_path, "rb").read())
movie = AptData(open(aptdata_path, "rb").read(), pool)
return pool, movie
def find_streams(movie: AptData) -> list[int]:
"""Seed stream starts: every ConstantPool record that validates."""
seeds = []
d = movie.data
for p in range(len(d)):
if d[p] != 0x88:
continue
try:
ins = movie.decode_one(p)
except DecodeError:
continue
if ins.operands.get("count", 0) and ins.operands["ids"] == list(
range(ins.operands["count"])
):
seeds.append(p)
return seeds
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apt1", default="fifa17-recon/data/apt/futSelectTeam_Apt1.bin")
ap.add_argument("--aptdata", default="fifa17-recon/data/apt/futSelectTeam_AptData.bin")
ap.add_argument("--stream", type=lambda s: int(s, 0), help="decode one stream at offset")
ap.add_argument("--function", help="decode the named function's body")
ap.add_argument("--list-functions", action="store_true")
ap.add_argument("--report", action="store_true", help="structural validation report")
ap.add_argument("--strings", action="store_true", help="dump the constant pool")
ap.add_argument("--selftest", action="store_true")
args = ap.parse_args(argv)
pool, movie = load(args.apt1, args.aptdata)
if args.selftest:
return selftest(pool, movie)
if args.strings:
for i, (t, v, s) in enumerate(pool.entries):
print(f" #{i:3d} type={t} @{v:#07x} {s!r}")
return 0
seeds = find_streams(movie)
if args.stream is not None:
seeds = [args.stream]
all_instrs: list[Instr] = []
for s in seeds:
all_instrs.extend(movie.decode_stream(s))
if args.list_functions:
for fn in movie.functions:
regs = register_map(fn)
rs = " ".join(f"r{k}={v}" for k, v in sorted(regs.items()))
print(f" {fn.body_start:#07x}-{fn.body_end:#07x} "
f"{fn.name or '<anonymous>':<34} {rs}")
return 0
if args.function:
for fn in movie.functions:
if fn.name == args.function:
print(f"; {fn.name} body {fn.body_start:#x}..{fn.body_end:#x} "
f"flags={flag_names(fn.flags)} nRegs={fn.n_registers}")
regs = register_map(fn)
for k, v in sorted(regs.items()):
print(f"; r{k} = {v}")
for ins in movie.decode_stream(fn.body_start, fn.body_end):
print(ins.render())
return 0
print(f"function {args.function!r} not found", file=sys.stderr)
return 1
if args.report:
return report(movie, seeds, all_instrs)
for ins in all_instrs:
print(ins.render())
return 0
def report(movie: AptData, seeds: list[int], instrs: list[Instr]) -> int:
import collections
hist = collections.Counter(i.mnemonic for i in instrs)
covered = set()
for i in instrs:
covered.update(range(i.offset, i.offset + i.length))
branches = [i for i in instrs if i.target is not None]
boundaries = {i.offset for i in instrs}
bad = [i for i in branches if i.target not in boundaries]
print(f" streams decoded : {len(seeds)} {[hex(s) for s in seeds]}")
print(f" instructions : {len(instrs)}")
print(f" bytes covered : {len(covered)} of {len(movie.data)}")
print(f" functions : {len(movie.functions)}")
print(f" branches : {len(branches)}")
print(f" invalid branch targets: {len(bad)}")
for i in bad[:10]:
print(f" {i.offset:#07x} {i.mnemonic} -> {i.target:#07x}")
print(f" distinct opcodes : {len(hist)}")
for m, n in hist.most_common():
print(f" {m:<22} {n}")
return 1 if bad else 0
def selftest(pool: ConstPool, movie: AptData) -> int:
"""Assertions that pin the measured format facts."""
ok = True
def check(label: str, cond: bool, detail: str = "") -> None:
nonlocal ok
print(f" [{'PASS' if cond else 'FAIL'}] {label}{(' - ' + detail) if detail else ''}")
ok = ok and cond
check("Apt1 entry count", pool.count == 414, f"{pool.count}")
check("Apt1 all entries are strings",
all(t == 1 for t, _, _ in pool.entries))
check("Apt1 entry array abuts string table",
pool.first + pool.count * 16 == min(v for t, v, _ in pool.entries if t == 1))
# Phase 3: exact pointer -> string resolution for known symbols.
for name in ("CheckIsKitLocked", "KITS_AVAILABLE", "FUT_GET_MATCH_KITS_DP",
"mcLockHome"):
idx = pool.find(name)
check(f"string resolves: {name}", len(idx) == 1 and pool.string(idx[0]) == name,
f"index {idx}")
# Bad pointers must raise, not fuzzy-match.
for bad in (-1, 10 ** 6):
try:
pool.string(bad)
check(f"bad const index {bad} rejected", False)
except DecodeError:
check(f"bad const index {bad} rejected", True)
# Phase 4 fixtures for the two EA opcodes.
movie.scope = ["alpha", "beta"] + [f"c{i}" for i in range(2, 300)]
fixtures = [
(bytes([0xB9, 0x00]), "PushRegister", 2, "r0"),
(bytes([0xB9, 0x05]), "PushRegister", 2, "r5"),
(bytes([0xB9, 0xFF]), "PushRegister", 2, "r255"),
(bytes([0xAF, 0x00]), "GetNamedMember", 2, "'alpha'"),
(bytes([0xAF, 0x01]), "GetNamedMember", 2, "'beta'"),
(bytes([0xA2, 0x01]), "PushConstantByte", 2, "'beta'"),
]
for raw, mnem, length, needle in fixtures:
probe = AptData(APTDATA_MAGIC + raw.ljust(16, b"\0"), pool)
probe.scope = movie.scope
ins = probe.decode_one(16)
check(f"fixture {raw.hex()} -> {mnem}",
ins.mnemonic == mnem and ins.length == length and needle in ins.comment,
f"{ins.mnemonic} len={ins.length} {ins.comment}")
# Truncated records must fail closed.
for raw in (bytes([0xB9]), bytes([0xAF]), bytes([0xA3, 0x01])):
probe = AptData(APTDATA_MAGIC + raw, pool)
probe.scope = movie.scope
try:
probe.decode_one(16)
check(f"truncated {raw.hex()} fails closed", False)
except DecodeError:
check(f"truncated {raw.hex()} fails closed", True)
# Out-of-range pool index must fail closed, not silently clamp.
probe = AptData(APTDATA_MAGIC + bytes([0xAF, 0x10]), pool)
probe.scope = ["only-one"]
try:
probe.decode_one(16)
check("out-of-range scope index rejected", False)
except DecodeError:
check("out-of-range scope index rejected", True)
# Unknown opcode must refuse rather than resynchronise.
probe = AptData(APTDATA_MAGIC + bytes([0xEE, 0x00]), pool)
try:
probe.decode_one(16)
check("unknown opcode refuses to guess length", False)
except DecodeError as e:
check("unknown opcode refuses to guess length", "refusing to guess" in str(e))
# Whole-artifact decode.
movie.scope = []
movie.functions = []
seeds = find_streams(movie)
instrs: list[Instr] = []
try:
for s in seeds:
instrs.extend(movie.decode_stream(s))
check("whole artifact decodes", True, f"{len(instrs)} instructions")
except DecodeError as e:
check("whole artifact decodes", False, str(e))
return 1
boundaries = {i.offset for i in instrs}
bad = [i for i in instrs if i.target is not None and i.target not in boundaries]
check("every branch lands on an instruction boundary", not bad,
f"{len(bad)} bad")
# CheckIsKitLocked is CALLED here, never defined here: it is a method on the
# mcSelectTeam child clip, whose class lives in another asset. Assert the
# call site is bound exactly, and that this asset defines no such function.
called = [i for i in instrs if i.comment and "CheckIsKitLocked" in i.comment]
check("CheckIsKitLocked referenced exactly once", len(called) == 1,
f"{[hex(i.offset) for i in called]}")
check("CheckIsKitLocked reference is PushConstantWord (pool index > u8)",
bool(called) and called[0].mnemonic == "PushConstantWord")
check("CheckIsKitLocked is not defined in this asset",
"CheckIsKitLocked" not in {f.name for f in movie.functions})
# The gate contract the native DP builder must satisfy.
gate = [i for i in instrs if i.comment and "KITS_AVAILABLE" in i.comment]
check("KITS_AVAILABLE read exactly once", len(gate) == 1)
check("KITS_AVAILABLE read via GetNamedMember on the DP header",
bool(gate) and gate[0].mnemonic == "GetNamedMember")
# 8-byte alignment is load-bearing: prove 4 would break the pool record.
p4 = (0xD38 + 1 + 3) & ~3
c4 = struct.unpack_from("<Q", movie.data, p4)[0]
check("alignment is 8 not 4", c4 != 401, f"align4 count would be {c4:#x}")
print(f"\n {'ALL PASS' if ok else 'FAILURES PRESENT'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Canonical FIFA 17 kit map, joined from the extracted client tables.
Authority for every kit question that a table can answer, so nobody has to
reverse a binary for a fact that is sitting in a JSON row. Reads only:
fifa17-recon/data/tables/fcc_kitcards.json the FUT KIT CARD definitions
fifa17-recon/data/tables/teamkits.json the ENGINE kit rows
Everything printed is TABLE_PROVEN unless the line says otherwise: it is a
direct count over the full table, not a sample.
Usage:
python3 audit_fifa17_kits.py human report
python3 audit_fifa17_kits.py --json machine-readable, for tests/tools
python3 audit_fifa17_kits.py --team 21 drill into one team
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
# TABLE_PROVEN, established by this script's own discriminating test (see
# category_type_evidence): a kit CARD's `category` selects the engine kit ROW's
# `teamkittypetechid` at the same (team, year).
CATEGORY_TO_KIT_TYPE = {2: 0, 3: 1, 5: 2}
KIT_TYPE_NAME = {0: "HOME", 1: "AWAY", 2: "THIRD", 3: "FOURTH", 5: "GK", 6: "SPECIAL6", 7: "SPECIAL7"}
def load(name):
with open(os.path.join(TABLES, name), "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, list) else data.get("rows", data)
def band(carddbid: int) -> int:
"""The 6_300_000 / 6_400_000 id band."""
return (carddbid // 100_000) * 100_000
def category_type_evidence(cards, kits):
"""The DISCRIMINATING test behind CATEGORY_TO_KIT_TYPE.
Asserting "category 3 means away" because away kits usually exist is not
evidence -- types 0/1/2 are present for most teams, so the claim is true by
construction. What discriminates is the teams that LACK a type: if category 3
really means type 1, then no category-3 card may exist for a (team, year)
that has no type-1 row. Same for category 5 and type 2.
"""
kits_by = defaultdict(set)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].add(r["teamkittypetechid"])
cards_by = defaultdict(list)
for r in cards:
cards_by[(r["teamid"], r["year"])].append(r)
out = {}
for cat, want in CATEGORY_TO_KIT_TYPE.items():
# keys that HAVE teamkits rows but not the wanted type
lacking = [k for k, t in kits_by.items() if t and want not in t]
counterexamples = [
r["carddbid"] for k in lacking for r in cards_by.get(k, []) if r["category"] == cat
]
out[cat] = {
"kit_type": want,
"name": KIT_TYPE_NAME[want],
"keys_lacking_type": len(lacking),
"counterexamples": counterexamples,
}
return out
def audit():
cards = load("fcc_kitcards.json")
kits = load("teamkits.json")
kits_by = defaultdict(list)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].append(r)
rows = []
for c in cards:
key = (c["teamid"], c["year"])
want = CATEGORY_TO_KIT_TYPE.get(c["category"])
match = next((k for k in kits_by.get(key, []) if k["teamkittypetechid"] == want), None)
rows.append(
{
"carddbid": c["carddbid"],
"band": band(c["carddbid"]),
"teamid": c["teamid"],
"year": c["year"],
"category": c["category"],
"kit_type": want,
"kit_type_name": KIT_TYPE_NAME.get(want, "?"),
"assetid": c["assetid"],
"cardassetid": c["cardassetid"],
"value": c["value"],
"weightrare": c["weightrare"],
# These are BYTE OFFSETS into the table's string blob, not ids.
# The blob is not among the extracted tables, so a kit's own
# name string is NOT recoverable from data/tables alone.
"name_offset": c["name"],
"header_offset": c["header"],
"description_offset": c["description"],
"teamkitid": match["teamkitid"] if match else None,
"teamkit_islocked": match["islocked"] if match else None,
"teamkit_embargoed": match["isembargoed"] if match else None,
}
)
dupes = [k for k, n in Counter((r["teamid"], r["year"], r["category"]) for r in rows).items() if n > 1]
return {
"counts": {"fcc_kitcards": len(cards), "teamkits": len(kits)},
"bands": dict(sorted(Counter(r["band"] for r in rows).items())),
"band_x_assetid": {f"{b}/{a}": n for (b, a), n in
sorted(Counter((r["band"], r["assetid"]) for r in rows).items())},
"band_x_category": {f"{b}/{c}": n for (b, c), n in
sorted(Counter((r["band"], r["category"]) for r in rows).items())},
"category_counts": dict(sorted(Counter(r["category"] for r in rows).items())),
"cardassetid": sorted({r["cardassetid"] for r in rows}),
"category_type_evidence": category_type_evidence(cards, kits),
"unmatched": [r["carddbid"] for r in rows if r["teamkitid"] is None],
"duplicate_team_year_category": dupes,
"teamkits_islocked": dict(Counter(r["islocked"] for r in kits)),
"teamkits_embargoed": dict(Counter(r["isembargoed"] for r in kits)),
"teamkits_types": dict(sorted(Counter(r["teamkittypetechid"] for r in kits).items())),
"rows": rows,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--team", type=int)
args = ap.parse_args()
a = audit()
if args.json:
json.dump(a, sys.stdout, indent=2)
return
print("FIFA 17 kit map — TABLE_PROVEN from the extracted client tables")
print(f" fcc_kitcards rows : {a['counts']['fcc_kitcards']}")
print(f" teamkits rows : {a['counts']['teamkits']}")
print("\nid bands")
for b, n in a["bands"].items():
print(f" {b}: {n}")
print("\nband/assetid (assetid is fully determined by band)")
for k, n in a["band_x_assetid"].items():
print(f" {k}: {n}")
print("\nband/category")
for k, n in a["band_x_category"].items():
print(f" {k}: {n}")
print(f"\ncardassetid values: {a['cardassetid']} (the FUT card frame, not the kit art)")
print("\ncategory -> engine kit type, with the discriminating test")
for cat, ev in a["category_type_evidence"].items():
verdict = "HOLDS" if not ev["counterexamples"] else f"FAILS ({len(ev['counterexamples'])})"
print(f" category {cat} -> type {ev['kit_type']} {ev['name']:6s} "
f"| {ev['keys_lacking_type']:4d} (team,year) keys lack that type, "
f"{len(ev['counterexamples'])} counterexample(s) -> {verdict}")
print("\nengine kit types present in teamkits")
for t, n in a["teamkits_types"].items():
print(f" type {t} {KIT_TYPE_NAME.get(t,'?'):8s}: {n}")
print(f"\nteamkits islocked : {a['teamkits_islocked']} <- every row, so NOT the selector lock")
print(f"teamkits embargoed : {a['teamkits_embargoed']}")
print(f"\nanomalies")
print(f" cards with no matching teamkits row : {len(a['unmatched'])}")
print(f" duplicate (team,year,category) : {len(a['duplicate_team_year_category'])}")
if args.team is not None:
print(f"\n=== team {args.team} ===")
print(f" {'carddbid':10s} {'cat':4s} {'type':7s} {'year':6s} {'assetid':8s} {'teamkitid':10s} locked")
for r in sorted((r for r in a["rows"] if r["teamid"] == args.team), key=lambda r: r["carddbid"]):
print(f" {r['carddbid']:<10} {r['category']:<4} {r['kit_type_name']:<7} {r['year']:<6} "
f"{r['assetid']:<8} {str(r['teamkitid']):<10} {r['teamkit_islocked']}")
if __name__ == "__main__":
main()
+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),
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Recover the kit caption/localisation vocabulary from the UNPACKED CardsDLL.
Why CardsDLL and not FIFA17.exe: CardsDLL is not packed, so a MISS here is
meaningful. FIFA17.exe is Denuvo-packed and only partially readable -- a hit
there is useful, a miss proves nothing. Every run therefore prints a positive
control first; if the control fails, the run is void and no negative may be
quoted from it.
Usage: python3 cardsdll_kit_strings.py [path-to-CardsDLL]
"""
from __future__ import annotations
import os
import re
import sys
DEFAULT = os.path.expanduser(
"~/.cache/openfut-investigation/bin/CardsDLL_Win64_retail.dll"
)
# Strings that MUST be present. If any is missing the search is broken.
CONTROLS = [b"activeHomeKit", b"cardsubtypeid", b"resourceId", b"activeAwayKit"]
# The kit caption vocabulary this project has referred to, plus neighbours worth
# knowing about either way.
PROBES = [
b"FUT_UC_KITS", b"TeamName_Abbr15_", b"TeamName_Abbr15", b"TeamName_",
b"FUT_UC_", b"StadiumName_", b"Badge", b"Stadium",
b"activeBadge", b"activeBall", b"activeStadium",
b"kit", b"Kit", b"KIT",
b"home", b"Home", b"HOME", b"away", b"Away", b"AWAY",
b"locked", b"Locked", b"LOCKED", b"unlock",
b"category", b"year", b"teamid", b"teamId",
b"DataProvider", b"itemData", b"itemType", b"itemState",
]
def ascii_strings(data, minlen=4):
for m in re.finditer(rb"[ -~]{%d,}" % minlen, data):
yield m.start(), m.group()
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
data = open(path, "rb").read()
print(f"{os.path.basename(path)} {len(data)} bytes")
print("\n-- positive control (a miss voids every negative below) --")
ok = True
for c in CONTROLS:
n = data.count(c)
print(f" {c.decode():16s} {n}")
if n == 0:
ok = False
if not ok:
print(" CONTROL FAILED — do not quote negatives from this run.")
return 1
print("\n-- probe counts --")
for p in PROBES:
print(f" {p.decode():18s} {data.count(p)}")
# Whole-string table: every standalone string containing kit-ish substrings.
print("\n-- standalone strings matching kit/team/caption vocabulary --")
pat = re.compile(rb"(?i)(kit|teamname|abbr|stadiumname|fut_uc|locked|unlock)")
seen = set()
for off, s in ascii_strings(data, 5):
if pat.search(s) and s not in seen:
seen.add(s)
print(f" @{off:#08x} {s.decode('latin1')[:110]}")
print(f" ({len(seen)} distinct)")
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Read-only probe v3: discriminate "kits never ingested" from "ingested then freed".
Staff was refetched by the client at 18:40:38, four minutes before the scan, and
players are resident. If staff/badge/stadium records are resident but the two
kits are not, the kits are being dropped specifically.
"""
import re
import struct
import subprocess
import sys
NEEDLES = {
"PLAYER resourceId 83906881 (control, resident)": 83906881,
"STAFF resourceId 9000081 (headcoach-ish)": 9000081,
"STAFF resourceId 3000083 (x2)": 3000083,
"STAFF resourceId 1000509": 1000509,
"STAFF instance 100004870": 100004870,
"BADGE resourceId 6000005": 6000005,
"BADGE instance 100004875": 100004875,
"STADIUM resourceId 6200000": 6200000,
"STADIUM instance 100004876": 100004876,
"KIT resourceId 6300006 (home)": 6300006,
"KIT resourceId 6400003 (away)": 6400003,
"KIT instance 100004874 (home)": 100004874,
"KIT instance 100004873 (away)": 100004873,
"KIT cardassetid 35": 35,
}
def find_pid():
out = subprocess.run(["pgrep", "-f", "FIFA17.exe"], capture_output=True, text=True).stdout.split()
for p in out:
try:
with open(f"/proc/{p}/maps") as fh:
if "CardsDLL" in fh.read():
return int(p)
except OSError:
continue
return int(out[0]) if out else None
def main():
pid = find_pid()
if not pid:
sys.exit("FIFA17.exe not running")
print(f"pid={pid}")
regs = []
with open(f"/proc/{pid}/maps") as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
if not m:
continue
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
if "r" in perms and not path.startswith("/dev/") and (hi - lo) <= (512 << 20):
regs.append((lo, hi))
hits = {k: [] for k in NEEDLES}
pats = {k: struct.pack("<I", v) for k, v in NEEDLES.items()}
mib = 0
with open(f"/proc/{pid}/mem", "rb", buffering=0) as mem:
for lo, hi in regs:
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
if not buf:
continue
mib += len(buf)
for k, needle in pats.items():
start = 0
while len(hits[k]) < 5000:
i = buf.find(needle, start)
if i < 0:
break
hits[k].append(lo + i)
start = i + 4
print(f"read {mib/(1<<20):.0f} MiB\n" + "=" * 66)
def rd(base, off, size=4):
try:
mem.seek(base + off)
raw = mem.read(size)
return int.from_bytes(raw, "little") if len(raw) == size else None
except (OSError, ValueError, OverflowError):
return None
for k in NEEDLES:
addrs = hits[k]
# count how many look like real item records (plausible cardtype)
recs = []
for a in addrs[:3000]:
base = a - 0x18
ct = rd(base, 0x4C)
if ct in (1, 2, 3, 4, 5, 6, 7, 9):
recs.append((base, ct))
flag = "" if addrs else " <-- ZERO"
print(f" {len(addrs):6d} raw / {len(recs):4d} record-shaped {k}{flag}")
for base, ct in recs[:3]:
print(f" @{base:#x} cardtype={ct} subtype={rd(base,0x50)} "
f"itemState={rd(base,0x5c)} +0x60={rd(base,0x60)} "
f"teamid={rd(base,0x94)} cat={rd(base,0xb8)} year={rd(base,0xba,2)}")
print("=" * 66)
if __name__ == "__main__":
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
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Find an APT/ActionScript symbol inside the FIFA 17 Frostbite .cas archives.
Frosty is a GUI-only tool and its Legacy Explorer is the documented way to reach
these assets, but the chunks holding APT ActionScript are stored plainly enough to
grep — so a screen can be identified, and its whole symbol table recovered,
without driving the GUI at all.
ALWAYS passes a control first: `KitAssignmentPopup` is a string from an
already-exported BIG, so if it misses, the archives are packed differently than
assumed and no negative from this tool may be quoted.
python3 find_apt_in_cas.py FUT_GET_MATCH_KITS_DP
python3 find_apt_in_cas.py --dump 0x3707ecd7 fifa_installpackage_01/cas_01.cas
"""
import argparse
import glob
import os
import re
import sys
ROOT = "/mnt/games/FIFA 17"
CONTROL = b"KitAssignmentPopup"
def cas_files():
return sorted(glob.glob(os.path.join(ROOT, "**", "*.cas"), recursive=True))
def find(needle: bytes):
control_total = 0
hits = []
for p in cas_files():
d = open(p, "rb").read()
control_total += d.count(CONTROL)
start = 0
while True:
i = d.find(needle, start)
if i < 0:
break
hits.append((p, i))
start = i + 1
return control_total, hits
def dump(path, off, span=90000):
with open(path, "rb") as f:
f.seek(max(0, off - span // 2))
d = f.read(span)
seen = []
for m in re.finditer(rb"[ -~]{4,}", d):
t = m.group().decode("latin1")
if t not in seen:
seen.append(t)
return seen
def main():
ap = argparse.ArgumentParser()
ap.add_argument("needle", nargs="?")
ap.add_argument("--dump", metavar="OFFSET")
ap.add_argument("--file")
args = ap.parse_args()
if args.dump:
path = args.file if os.path.isabs(args.file or "") else os.path.join(
ROOT, "Data/Win32/superbundlelayout", args.file or "")
for s in dump(path, int(args.dump, 0)):
print(s)
return 0
if not args.needle:
ap.error("needle required")
ctl, hits = find(args.needle.encode())
print(f"control {CONTROL.decode()}: {ctl} hit(s)")
if ctl == 0:
print("CONTROL FAILED — archives not greppable this way; no negative is valid.")
return 1
print(f"{args.needle}: {len(hits)} hit(s)")
for p, i in hits[:20]:
print(f" {os.path.relpath(p, ROOT)} @ {i:#x}")
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Read-only census of FIFA 17's RESIDENT club-item vector.
Chain, every link from CardsDLL static RE:
[CardsDLL+0x2e6398] -> owner object (FUN_18011a830)
owner->vtable[0x4e8] -> getter returning mgr (call *0x4e8(%rdx))
mgr+0x108 .. mgr+0x110 -> club-item vector, stride 24
element+0x10 -> the item record pointer (FUN_1800d73d0)
record+0x4c cardtype (derived from cardsubtypeid by FUN_1800d8330: 9/10/11 -> 7)
record+0x50 cardsubtypeid
record+0x5c itemState (101 activeHomeKit, 102 activeAwayKit)
record+0x60 category (clone driver FUN_1801c3480 requires 4)
record+0x94 teamid
record+0xba teamkittypetechid (u16)
Offsets not in that list are labelled UNVERIFIED and only dumped raw.
No writes. Ever.
"""
import re, struct, sys, collections
PID = int(sys.argv[1]) if len(sys.argv) > 1 else 44405
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
def i32(b, o):
return struct.unpack_from("<i", b, o)[0]
# locate CardsDLL by its NEAREST PRECEDING NAMED mapping (Wine maps PE sections anon)
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m: named.append((int(m.group(1),16), m.group(3).strip()))
named.sort()
base = None
for s, p in named:
if p.endswith("CardsDLL_Win64_retail.dll"):
base = s; break
if base is None:
print(" CardsDLL mapping not found"); sys.exit(1)
print(f" CardsDLL base = {base:#x}")
def live(static): return base + (static - 0x180000000)
# sanity: the 0x7575 sender immediate must be where static RE says
probe = rd(live(0x180026fea), 6)
print(f" sanity @0x180026fea: {probe.hex(' ')} (expect ba 75 75 00 00)")
if probe[:5] != bytes.fromhex("ba75750000"):
print(" SANITY FAILED - base wrong, aborting"); sys.exit(1)
owner = q(live(0x1802e6398))
print(f" owner object = {owner:#x}")
vt = q(owner)
getter = q(vt + 0x4e8)
print(f" vtable = {vt:#x}")
print(f" vtable[0x4e8] = {getter:#x} bytes: {rd(getter,12).hex(' ')}")
# expect: mov rax,[rcx+off] ; ret -> 48 8b 81 off32 c3 or 48 8b 41 off8 c3
b = rd(getter, 12)
mgr = None
if b[0:3] == bytes.fromhex("488d81"):
off = struct.unpack_from("<I", b, 3)[0]; mgr = owner + off
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488d41"):
off = b[3]; mgr = owner + off
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488b81"):
off = struct.unpack_from("<I", b, 3)[0]; mgr = q(owner + off)
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488b41"):
off = b[3]; mgr = q(owner + off)
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
elif b[0:2] == bytes.fromhex("488b") and b[2] == 0xc1:
mgr = owner; print(" getter returns owner itself")
else:
print(" getter shape unrecognised; trying owner as mgr")
mgr = owner
for label, mgr_try in (("resolved", mgr), ("owner", owner)):
try:
beg, end = q(mgr_try + 0x108), q(mgr_try + 0x110)
except OSError:
print(f" [{label}] +0x108/0x110 unreadable"); continue
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24*100000:
print(f" [{label}] vector implausible: {beg:#x}..{end:#x}")
continue
n = (end - beg) // 24
print(f"\n === club-item vector via {label}: {beg:#x}..{end:#x} {n} slot(s) ===")
hist = collections.Counter(); rows = []
for k in range(n):
try:
rec = q(beg + k*24 + 0x10)
except OSError:
continue
if not rec:
hist[("<null slot>", None)] += 1; continue
try:
r = rd(rec, 0xC0)
except OSError:
continue
if len(r) < 0xC0: continue
ct, sub, st, cat = i32(r,0x4c), i32(r,0x50), i32(r,0x5c), i32(r,0x60)
team = i32(r,0x94); kt = struct.unpack_from("<H", r, 0xba)[0]
hist[(ct, sub)] += 1
rows.append((rec, ct, sub, st, cat, team, kt))
print(f" (cardtype, cardsubtypeid) histogram:")
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
tag = " <== KIT (selector needs this)" if key == (7, 9) else ""
print(f" {str(key):<18} x{c}{tag}")
print(f" cardtype 7 records: {sum(c for (ct,_),c in hist.items() if ct==7)}")
print(f"\n first 12 records:")
print(f" {'ptr':>14} {'ctype':>5} {'subtype':>7} {'state':>5} {'cat':>4} {'team':>5} {'kittype':>7}")
for rec, ct, sub, st, cat, team, kt in rows[:12]:
print(f" {rec:#14x} {ct:>5} {sub:>7} {st:>5} {cat:>4} {team:>5} {kt:>7}")
break
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
manager is reallocated across logins), and appends one line per CHANGE so the
output can be aligned against the staging host's route log by wall clock.
Purpose: answer "after which response does a resident club item first appear?"
without reversing the constructor first. Pair with
journalctl -u openfut-staging-host --since <start> -o short-iso
and compare timestamps.
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
"""
from __future__ import annotations
import argparse
import collections
import os
import re
import struct
import sys
import time
CARDS_DLL = "CardsDLL_Win64_retail.dll"
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
SANITY_VA = 0x180026FEA # mov edx,0x7575
SANITY_BYTES = bytes.fromhex("ba75750000")
IMAGE_BASE = 0x180000000
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
"category": 0x60, "teamid": 0x94}
OFF_KITTYPE_U16 = 0xBA
class Target:
"""One live FIFA17.exe, with the store chain resolved."""
def __init__(self, pid: int):
self.pid = pid
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
self.base = self._cards_base()
if self.base is None:
raise RuntimeError("CardsDLL mapping not found")
probe = self.rd(self.live(SANITY_VA), 5)
if probe != SANITY_BYTES:
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
owner = self.q(self.live(OWNER_GLOBAL))
if not owner:
raise RuntimeError("owner object is null (not logged in yet)")
vt = self.q(owner)
getter = self.q(vt + 0x4E8)
b = self.rd(getter, 8)
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
if b[0:3] == bytes.fromhex("488d81"):
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
elif b[0:3] == bytes.fromhex("488d41"):
self.mgr = owner + b[3]
elif b[0:3] == bytes.fromhex("488b81"):
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
else:
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
# -- raw access ------------------------------------------------------
def rd(self, a: int, n: int) -> bytes:
self.mem.seek(a)
return self.mem.read(n)
def q(self, a: int) -> int:
return struct.unpack("<Q", self.rd(a, 8))[0]
def live(self, static: int) -> int:
return self.base + (static - IMAGE_BASE)
def _cards_base(self):
named = []
for ln in open(f"/proc/{self.pid}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
# the Wine heap is also rwx, so permissions cannot identify a module.
for start, path in sorted(named):
if path.endswith(CARDS_DLL):
return start
return None
# -- the store -------------------------------------------------------
def vector(self, off_begin: int):
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
return None, 0
return beg, (end - beg) // 24
def records(self, off_begin: int):
beg, n = self.vector(off_begin)
out = []
if beg is None:
return out
for k in range(n):
try:
rec = self.q(beg + k * 24 + 0x10)
except OSError:
continue
if not rec:
out.append(None)
continue
try:
r = self.rd(rec, 0xC0)
except OSError:
out.append(None)
continue
if len(r) < 0xC0:
out.append(None)
continue
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
f["ptr"] = rec
out.append(f)
return out
def snapshot(self) -> dict:
club = self.records(0x108)
players = self.records(0xD8)
hist = collections.Counter(
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
)
return {
"club_slots": len(club),
"club_filled": sum(1 for r in club if r),
"club_hist": dict(hist),
"club_records": [r for r in club if r],
"player_slots": len(players),
"player_filled": sum(1 for r in players if r),
}
def find_pid() -> int | None:
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
with open(f"/proc/{d}/comm") as f:
if f.read().strip() == "FIFA17.exe":
return int(d)
except OSError:
continue
return None
def fmt(snap: dict) -> str:
parts = [
f"club={snap['club_filled']}/{snap['club_slots']}",
f"players={snap['player_filled']}/{snap['player_slots']}",
]
if snap["club_hist"]:
parts.append("hist=" + ",".join(
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
for r in snap["club_records"]:
parts.append(
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
f"state={r['itemState']} cat={r['category']} "
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
return " ".join(parts)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--interval", type=float, default=1.0)
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
ap.add_argument("--once", action="store_true")
a = ap.parse_args()
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
def emit(msg: str) -> None:
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
print(line, file=sink)
if sink is not sys.stdout:
print(line, flush=True)
emit("watch: start")
target = None
last = None
while True:
if target is None:
pid = find_pid()
if pid is None:
if a.once:
emit("watch: no FIFA17.exe"); return 1
time.sleep(a.interval); continue
try:
target = Target(pid)
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
f"mgr={target.mgr:#x}")
last = None
except (OSError, RuntimeError) as e:
# not logged in yet, or the process died mid-resolve
if a.once:
emit(f"watch: not ready: {e}"); return 1
target = None
time.sleep(a.interval); continue
try:
snap = target.snapshot()
except (OSError, struct.error) as e:
emit(f"watch: detached ({e})")
target = None
if a.once:
return 1
continue
key = fmt(snap)
if key != last:
emit(key)
last = key
if a.once:
return 0
time.sleep(a.interval)
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())
@@ -0,0 +1,167 @@
//! Emit the discard-pricing matrix for an entire FIFA 17 corpus, and audit it.
//!
//! Uses the SHIPPED implementation (`fut::discard::value_for_definition`) rather
//! than reimplementing the formula, so the matrix cannot drift from what the
//! server actually pays.
//!
//! ```text
//! cargo run -p openfut-adapter-fifa17 --example discard_matrix -- \
//! <catalog.json> <cards.json> [--csv out.csv]
//! ```
//!
//! Prints an audit summary and, with `--csv`, the full per-definition matrix.
use std::collections::{BTreeMap, HashMap};
use openfut_adapter_fifa17::fut::discard;
use openfut_adapter_fifa17::fut::item::legacy_discard_value;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: discard_matrix <catalog.json> <cards.json> [--csv <path>]");
std::process::exit(2);
}
let catalog: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&args[1]).expect("read catalog"))
.expect("parse catalog");
let cards: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&args[2]).expect("read cards"))
.expect("parse cards");
let csv_path = args
.iter()
.position(|a| a == "--csv")
.map(|i| args[i + 1].clone());
// Core's rating per definition id (non-players are 0, which is exactly why
// the catalog rating matters).
let mut core_rating: HashMap<String, u8> = HashMap::new();
if let Some(arr) = cards.as_array() {
for c in arr {
let id = c["id"].as_str().unwrap_or_default().to_string();
let r = c["overall"].as_i64().unwrap_or(0).clamp(0, 255) as u8;
core_rating.insert(id, r);
}
}
let entries = catalog
.get("cards")
.and_then(|c| c.as_object())
.expect("catalog has cards{}");
let mut rows: Vec<String> = Vec::new();
rows.push("definition,kind,subtype,cardtype,rareflag,rating_src,rating,level,legacy,recovered,verdict".into());
let mut by_kind: BTreeMap<String, (usize, usize, i64, i64)> = BTreeMap::new(); // n, declined, legacy, recovered
let (mut negatives, mut zero_priced, mut declined_total, mut overflow) =
(0usize, 0usize, 0usize, 0usize);
let mut boundary_probe_failures = Vec::new();
for (id, e) in entries {
let kind = e["kind"].as_str().unwrap_or("player").to_string();
let subtype = e["subtype"].as_i64().unwrap_or(0);
let rareflag = e["rareflag"].as_i64().unwrap_or(0);
let cat_rating = e["rating"].as_i64().map(|r| r.clamp(0, 255) as u8);
let core = *core_rating.get(id).unwrap_or(&0);
let cardtype = discard::cardtype_for_subtype(subtype);
let recovered = discard::value_for_definition(subtype, rareflag, cat_rating, core);
let effective_rating = cat_rating.unwrap_or(core);
let level = discard::discard_level(effective_rating);
let legacy = legacy_discard_value(core);
let verdict = match recovered {
None => {
declined_total += 1;
"DECLINES->legacy"
}
Some(v) if v < 0 => {
negatives += 1;
"NEGATIVE"
}
Some(0) => {
zero_priced += 1;
"ZERO"
}
Some(v) if v > 1_000_000 => {
overflow += 1;
"IMPLAUSIBLE"
}
Some(_) => "ok",
};
let ent = by_kind.entry(kind.clone()).or_insert((0, 0, 0, 0));
ent.0 += 1;
ent.2 += legacy;
match recovered {
Some(v) => ent.3 += v,
None => {
ent.1 += 1;
ent.3 += legacy; // declining means the legacy ladder is what pays
}
}
rows.push(format!(
"{id},{kind},{subtype},{cardtype},{rareflag},{},{effective_rating},{level},{legacy},{},{verdict}",
if cat_rating.is_some() { "catalog" } else { "core" },
recovered.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
));
}
// Rating-boundary audit against the client's own ladder (cmp 0x4b / 0x41).
for (rating, want) in [(0u8, 1u8), (64, 1), (65, 2), (74, 2), (75, 3), (99, 3)] {
let got = discard::discard_level(rating);
if got != want {
boundary_probe_failures.push(format!("rating {rating}: level {got}, expected {want}"));
}
}
println!("== DISCARD MATRIX AUDIT ==");
println!("definitions : {}", entries.len());
println!("declined -> legacy : {declined_total}");
println!("priced zero : {zero_priced}");
println!("negative : {negatives}");
println!("implausible (>1e6) : {overflow}");
println!(
"rating boundaries : {}",
if boundary_probe_failures.is_empty() {
"OK (1/2/3 at <65 / 65..74 / >=75)".to_string()
} else {
boundary_probe_failures.join("; ")
}
);
println!();
println!(
"{:<12} {:>6} {:>9} {:>14} {:>14}",
"kind", "n", "declined", "legacy", "recovered"
);
let (mut tl, mut tr) = (0i64, 0i64);
for (kind, (n, dec, legacy, rec)) in &by_kind {
println!("{kind:<12} {n:>6} {dec:>9} {legacy:>14} {rec:>14}");
tl += legacy;
tr += rec;
}
println!(
"{:<12} {:>6} {:>9} {:>14} {:>14}",
"TOTAL",
entries.len(),
declined_total,
tl,
tr
);
if tl > 0 {
println!("ratio recovered/legacy : {:.2}x", tr as f64 / tl as f64);
}
if let Some(path) = csv_path {
std::fs::write(&path, rows.join("\n") + "\n").expect("write csv");
println!("\nwrote {} rows to {path}", rows.len() - 1);
}
let fatal = negatives + overflow + boundary_probe_failures.len();
if fatal > 0 {
eprintln!("\nFAIL: {fatal} fatal finding(s)");
std::process::exit(1);
}
println!("\nRESULT: OK");
}
+7 -1
View File
@@ -90,7 +90,13 @@ kill_test 11 "FIFA wire item id stored in canonical replacement" \
kill_test 12 "projector rebuilds items independently of shared shaper" \
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
'"itemData": shape_item(
item,
id,
ent,
ident.discard_value(item),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
),' '"itemData": json!({"id": id.item_id}),'
kill_test 13 "extension schema version ignored on read" \
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
+162 -1
View File
@@ -38,6 +38,55 @@ 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,
/// Kit slot family (`club_items.json → kits[].category`): `2` home, `3`
/// away, `5` third. Zero for definitions that do not use it.
///
/// Load-bearing for the pre-match kit selector, not cosmetic. The client's
/// active-kit resolver (`FUN_1800d73d0`) reads it at record `+0xb8` and maps
/// it to the engine's kit SLOT — 2→0, 3→1, 5→3 — which then forms part of
/// the `(teamid, year, slot)` triple the kit descriptor
/// (`sub_180033430`) must match. Omit it and the triple cannot match, so the
/// engine falls through to its own catalogue kit and reports the kit as
/// locked.
pub category: i64,
/// Kit season (`club_items.json → kits[].year`), `0` for a current-season
/// kit and e.g. `2002` for a historical one.
///
/// Record `+0xba`, atom `0x389`. The third member of the identity triple
/// above, and the key the runtime `teamkits` clone queries on.
pub year: 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 +107,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 +203,33 @@ 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>,
/// Kit slot family (2 home / 3 away / 5 third); absent → `0`.
#[serde(default)]
category: Option<i64>,
/// Kit season; absent → `0` (current season).
#[serde(default)]
year: Option<i64>,
}
fn default_rareflag() -> i64 {
@@ -187,6 +286,15 @@ 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),
category: rc.category.unwrap_or(0),
year: rc.year.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 +380,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 +551,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 +563,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);
}
}
+400 -20
View File
@@ -10,34 +10,114 @@
use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
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),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
));
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,
item.contract_matches.unwrap_or(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)
@@ -46,6 +126,7 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
@@ -75,6 +156,11 @@ mod tests {
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
// Untracked by default, so these fixtures exercise the pack-fresh
// fallback; a test that cares sets it explicitly.
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
@@ -207,11 +293,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 +315,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 +327,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 +359,287 @@ 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,
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
the pack-fresh fallback — not because the shaper hardcodes it"
);
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,
"untracked fixture => pack-fresh fallback"
);
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:?}"
);
}
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
/// reporting the pack-fresh count, a committed apply is invisible and the
/// operator sees a 200 that did nothing. Both families must carry the number
/// Core persisted.
#[test]
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
let ent = entities();
let ident = KindMapIdentity {
ids: HashMap::from([(
"card_player".to_string(),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
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_player".to_string(), ContentKind::Player),
("card_manager".to_string(), ContentKind::Staff),
]),
};
// A player mid-way through its contracts, a fully topped-up manager, and
// one untracked player that must fall back.
let mut played = item(
"oc-played",
"card_player",
86,
"ST",
"Argentina",
"Premier League",
"Chelsea",
);
played.contract_matches = Some(3);
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
let untracked = item(
"oc-fresh",
"card_player",
86,
"ST",
"Argentina",
"Premier League",
"Chelsea",
);
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
assert_eq!(stats.emitted, 3);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
assert_eq!(
arr[1]["contract"], CONTRACT_MATCH_CAP,
"staff read the same persisted field, not STAFF_CONTRACT"
);
assert_eq!(
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
"only an untracked instance falls back"
);
}
#[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,
category: 2,
year: 0,
};
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_eq!(body["itemData"][0]["itemType"], "kit");
}
/// 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,
category: 2,
year: 0,
};
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,336 @@
//! 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::discard;
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// The stack's `discardValue` (atom 0xd7) — the number the consumables screen
/// DISPLAYS, per card.
///
/// This used to be hard-coded `0`, on the theory that the client would compute
/// the price itself from `fcc_discardcoins` the way it does for a card whose
/// `discardValue` we omit. That theory was wrong, and the screen showed
/// "Quick sell for 0 coins" on a real production club (operator-observed,
/// 2026-08-22) while Core would have paid 3/13/32 for those same contracts.
///
/// Why the old reasoning failed, from evidence rather than re-derivation:
///
/// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its
/// local computation and display our number — live-proven again on the
/// production client, 16/16 resident cards `SERVER-SHOWN`.
/// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and
/// the client's local computation DOES run, filling `+0x3c` with the right
/// value — Milestone 1 measured exactly that (3/3/32/38, matching this table).
/// * The screen nonetheless showed 0. So the screen is not reading the item's
/// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as
/// 0.
///
/// So the value belongs here, and it is the SAME number
/// [`discard::value_for_definition`] gives the quick-sell payout — one source, so
/// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT
/// prices a card, and the stack is only a quantity badge over identical copies.
///
/// `None` (definition not priceable) stays `0` rather than inventing a number.
fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 {
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0)
}
/// 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` carries the card's real quick-sell price — see
/// [`stack_discard_value`]. It used to be `0` on the theory that the client
/// priced the card itself; the production screen showed "Quick sell for 0 coins"
/// instead, so the stack atom is what the screen reads.
///
/// 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": stack_discard_value(id),
"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,
}
}
/// A contract card of the given subtype/rating — the family the production
/// screen showed as "0 coins".
fn contract(
item_id: u32,
resource_id: u32,
subtype: i64,
rating: u8,
) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 7,
subtype,
rareflag: 0,
rating,
amount: None,
contract: Some(1),
untradeable: true,
}
}
/// The stack atom the screen reads MUST carry the same number the quick-sell
/// pays. A production club displayed "Quick sell for 0 coins" for contracts
/// Core would have paid 3/13/32 for; nothing may reintroduce that gap.
#[test]
fn stack_discard_value_is_the_payout_and_never_a_silent_zero() {
// The three contracts owned by the real production club.
let items = vec![
contract(1, 5_001_004, 201, 60),
contract(2, 5_001_008, 202, 65),
contract(3, 5_001_009, 202, 80),
];
let (body, _) = consumables_response(&items);
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 3);
for (stack, id) in stacks.iter().zip(items.iter()) {
let shown = stack["discardValue"].as_i64().unwrap();
let paid =
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating)
.expect("a contract definition is priceable");
assert_eq!(
shown, paid,
"displayed must equal payout for {}",
id.resource_id
);
assert!(
shown > 0,
"{} priced at 0 is the bug we just fixed",
id.resource_id
);
}
// The exact recovered values, so a table regression is visible here too.
assert_eq!(stacks[0]["discardValue"], 3);
assert_eq!(stacks[1]["discardValue"], 13);
assert_eq!(stacks[2]["discardValue"], 32);
}
/// Collapsing copies must not multiply the price: FUT prices a CARD, and the
/// stack is a quantity badge over identical copies.
#[test]
fn stack_discard_value_is_per_card_not_per_stack() {
let items = vec![
contract(1, 5_001_009, 202, 80),
contract(2, 5_001_009, 202, 80),
contract(3, 5_001_009, 202, 80),
];
let (body, _) = consumables_response(&items);
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1);
assert_eq!(stacks[0]["count"], 3);
assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32");
}
#[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,230 @@ 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 client's own CONSUMABLE_TYPE→segment switch,
/// recovered live 2026-08-22 from CardsDLL: the literal table at `0x1801f5a38`
/// (under `MyClubAdapterClass`/`CONSUMABLE_TYPE`) and the jump table at
/// `0x180048820`, which indexes by `enum + 1` through the byte table at
/// `0x180048a90`. Nine segments, not seven:
///
/// | enum | segment |
/// |------|---------|
/// | -1 (unset) | `development` |
/// | 1, 2 | `contracts` |
/// | 3 | `healing` |
/// | 4 | `fitness` |
/// | 16 | `formation` |
/// | 17 | `position` |
/// | 23 | `playStyle` |
/// | 24 | `managerLeagueModifier` |
/// | 0, 5..15, 18..22 | `training` (the switch default) |
///
/// This CORRECTS the previous note here, which read the seven-code UI group
/// table at `0x180203260` and concluded the two formation-modifier families
/// "have NO group code, so no segment can reach them — the client's own gap".
/// The client does have a `formation` segment (enum 16), and it asked for
/// `development` live, so both were server-side gaps, not client ones.
///
/// `development` is the **type-unset** bucket: index 0 of a table indexed by
/// `enum + 1`, i.e. no type filter was set. It is therefore the unfiltered view
/// and maps to every family — which is consistent, since the eight TYPED
/// segments already reach all thirteen families exactly once.
///
/// COMPETING INFERENCE, recorded rather than buried. `fut_consumables.py`'s
/// `TYPE_CATEGORIES` maps `development` to card-categories `{6,7,8,9,10}`
/// (formation/position/playstyle/manager-league) — i.e. the modifier families
/// only, not everything. That grouping is explicitly flagged there as INFERRED
/// from `FUN_180048780`'s UI-bucket names, with "the tab-to-arm binding has
/// NEVER been observed on the wire".
///
/// They are not the same enum: the oracle's is the 0..10 CARD-category space of
/// `FUN_18013f4d0`, this is the 0..24 CONSUMABLE_TYPE space that actually
/// produces the URL segment. The tiebreaker is the switch itself — it gives
/// formation (16), position (17), playStyle (23) and managerLeagueModifier (24)
/// their OWN segment strings, so those types are not folded into `development`,
/// which is what the oracle's grouping would require. The unfiltered reading is
/// therefore the better-supported one, but it is still a reading: what the
/// SCREEN expects to list has not been observed, and one live capture of the
/// development tab would settle it.
///
/// `training` and `contracts` are CONFIRMED on the wire, `development` was
/// observed live, and the singular `contract` is accepted because the client has
/// used both spellings. Segments are matched lower-cased.
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"],
"formation" => &["manager_formation_mod", "formation_mod"],
"development" => ALL_CONSUMABLE_FAMILIES,
_ => return None,
})
}
/// Every consumable family, i.e. the `development` (type-unset) view. Kept as one
/// list so a new family cannot be added to the taxonomy and silently omitted from
/// the unfiltered screen.
pub const ALL_CONSUMABLE_FAMILIES: &[&str] = &[
"gk_training",
"player_training",
"player_contract",
"manager_contract",
"player_fitness",
"squad_fitness",
"healing",
"position_mod",
"player_playstyle",
"gk_playstyle",
"manager_league",
"manager_formation_mod",
"formation_mod",
];
/// 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 +407,178 @@ 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 eight TYPED segments of the client's own switch (enum 1,2,3,4,16,
// 17,23,24 plus the default), and the singular `contract` spelling.
let segments = [
"training",
"contracts",
"fitness",
"healing",
"position",
"playstyle",
"managerleaguemodifier",
"formation",
];
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"
);
// All THIRTEEN families are reachable: the client does have a `formation`
// segment (enum 16), so the two formation modifiers were a server-side
// gap, not the client gap this test used to assert.
assert_eq!(seen.len(), 13, "no duplicates: {seen:?}");
for subtype in [51, 61, 71, 91, 121, 201, 202, 211, 219, 220, 250, 269, 300] {
let (family, _) = consumable_family(subtype).unwrap();
assert!(seen.contains(&family), "no category serves {family}");
}
// `development` is the type-UNSET bucket (index 0 of an `enum + 1` table),
// i.e. the unfiltered view. It deliberately overlaps the typed segments,
// and must stay exactly the union of them so a new family cannot be added
// to the taxonomy and silently vanish from the unfiltered screen.
let mut dev = consumable_families_for_category("development")
.unwrap()
.to_vec();
dev.sort_unstable();
let mut all = seen.clone();
all.sort_unstable();
assert_eq!(dev, all, "development must be exactly the unfiltered set");
// Not a consumables segment (and NOT a `?type=` token either).
for s in ["", "player", "kit", "Training"] {
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.
@@ -0,0 +1,317 @@
//! FIFA 17 **contract consumables** — the shipped EA grant table.
//!
//! A contract card adds match-contracts to a TARGET card. The number granted is
//! selected by two keys: the consumable's own `resourceId` (which card it is)
//! and the **TARGET's** rating tier (bronze/silver/gold). The target's tier, not
//! the card's — a gold contract card dropped on a bronze player grants the
//! BRONZE column. Getting that backwards silently mis-credits every apply, so it
//! is stated here as the module's first invariant.
//!
//! ## Provenance
//!
//! [`CONTRACT_CARDS`] is the shipped EA table `fcc_contractcards`, transcribed
//! verbatim. It was cross-validated cell by cell against the published FIFA 17
//! contract matrix: **36 of 36 cells agree** (12 cards × 3 tiers; the 99-special
//! is not part of the published matrix). That is the whole basis for these
//! numbers — do not compute, interpolate or "correct" them. The table is
//! deliberately NOT monotonic in the target's tier: `5001003` grants 15 to a
//! bronze target, 11 to a silver one and 13 to a gold one. A "fix" that made it
//! monotonic would be an invention.
//!
//! ## Why the server must own the effect
//!
//! No client binary reads this table. A full string scan of every `.exe` and
//! `.dll` in the FIFA 17 install finds `fcc_contractcards` referenced **nowhere**
//! — the client ships the rows but never queries them, so it cannot compute the
//! grant and cannot second-guess ours. The effect is therefore
//! server-authoritative, and this table is the only non-invented source for it.
//!
//! ## Deliberately NOT implemented
//!
//! The "stored managers give up to 50% bonus contracts" mechanic. Its rule is
//! UNKNOWN: we have neither the multiplier's rounding, nor which stored managers
//! count, nor whether it stacks. Guessing it would corrupt the proven part of the
//! grant, so it is absent rather than approximated. This note is the record; it
//! is not a TODO, and nothing here reserves a hook for it.
//!
//! ## Family gating
//!
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS only. A 202 target's tier comes
//! from [`staff_tier`], whose input is Core's authored definition rating for the
//! staff card (EA's `value` column). When Core carries none, [`staff_tier`]
//! answers `None` and the caller must REFUSE the apply — inventing gold (or
//! bronze, or the card's own tier) would silently pay out the wrong number with
//! no error anywhere.
//!
//! A contract also cannot be applied to a LOAN item. This crate models no loan
//! state, so that gate — like the 201/202 family check — is the caller's: this
//! module answers only "how many matches does card X grant a tier-Y target".
/// `(resource_id, [bronze, silver, gold])` — the grant a contract card makes to
/// a target of each tier, keyed by the consumable's FIFA `resourceId`.
///
/// Rows `5001001`–`5001006` are player contracts (`cardsubtypeid` 201),
/// `5001007`–`5001012` their manager counterparts (202), and `5001013` is the
/// EASFC 99-contract special (201). Sorted by `resource_id`; keys are unique.
const CONTRACT_CARDS: [(u32, [i64; 3]); 13] = [
(5_001_001, [8, 2, 1]),
(5_001_002, [10, 10, 8]),
(5_001_003, [15, 11, 13]),
(5_001_004, [15, 6, 3]),
(5_001_005, [20, 24, 18]),
(5_001_006, [28, 24, 28]),
(5_001_007, [8, 2, 1]),
(5_001_008, [8, 10, 8]),
(5_001_009, [11, 11, 13]),
(5_001_010, [15, 6, 3]),
(5_001_011, [18, 24, 18]),
(5_001_012, [24, 24, 28]),
(5_001_013, [99, 99, 99]),
];
/// Which column of [`CONTRACT_CARDS`] a target's rating selects.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContractTier {
Bronze,
Silver,
Gold,
}
impl ContractTier {
/// Column index into a [`CONTRACT_CARDS`] row.
const fn column(self) -> usize {
match self {
ContractTier::Bronze => 0,
ContractTier::Silver => 1,
ContractTier::Gold => 2,
}
}
/// The tier's lowercase log token. The three names are the game's own tier
/// names, so a log line reads the same as the screen.
pub const fn as_str(self) -> &'static str {
match self {
ContractTier::Bronze => "bronze",
ContractTier::Silver => "silver",
ContractTier::Gold => "gold",
}
}
}
/// Card tier from a rating: gold `>= 75`, silver `65..=74`, bronze `< 65`.
///
/// These are the client's OWN card-level thresholds, not cutoffs chosen here:
/// they are the same ladder [`super::discard::discard_level`] reads to key
/// `fcc_discardcoins` (`3` if `>= 75`, `2` if `65..=74`, else `1`). The two
/// tables index the same three tiers, so a rating that prices as gold also
/// contracts as gold.
pub fn tier_for_rating(rating: u8) -> ContractTier {
if rating >= 75 {
ContractTier::Gold
} else if rating >= 65 {
ContractTier::Silver
} else {
ContractTier::Bronze
}
}
/// Tier of a STAFF target. `None` when Core carries no authoritative value —
/// the caller MUST fail closed and never substitute a tier.
///
/// `source_rating` is EA's authored `value` for the staff definition, i.e. the
/// number the CLIENT ITSELF re-rates the card to: it merges a staff record from
/// its own `managercards`/`headcoachcards`/`fitnesscoachcards`/`physiocards`/
/// `gkcoachcards` table keyed on `carddbid`, ignoring whatever `rating` the
/// server sent. Core's `overall` is deliberately 0 for a non-player (it feeds
/// pricing and projection), so `overall` is NOT the tier source and must not be
/// read as one.
///
/// The ladder is [`tier_for_rating`], unchanged and not re-thresholded here:
/// staff are LIVE-PROVEN to use the SAME ladder as players. `coach_probe.py` and
/// `discard_probe.py` agree 4/4 against the running client — manager `value` 88
/// re-rates to discard level 3 (gold) and coaches at `value` 66 to level 2
/// (silver), exactly as [`super::discard::discard_level`] scores a player.
pub fn staff_tier(source_rating: Option<u8>) -> Option<ContractTier> {
source_rating.map(tier_for_rating)
}
/// Matches granted by contract consumable `resource_id` against a target of
/// `tier`.
///
/// `None` when `resource_id` is not a known contract card — the caller must
/// refuse, never substitute a floor or a neighbouring row. A consumable outside
/// the 13 rows has no proven grant, and an invented one is a silent mis-credit.
pub fn contract_grant(resource_id: u32, tier: ContractTier) -> Option<i64> {
CONTRACT_CARDS
.iter()
.find(|&&(id, _)| id == resource_id)
.map(|&(_, grants)| grants[tier.column()])
}
/// Hard ceiling on match-contracts held by one player or manager: `new =
/// min(99, current + grant)`. A card that would overflow the cap is not an
/// error — the surplus is simply lost, as in retail.
pub const CONTRACT_MATCH_CAP: i64 = 99;
/// Contracts a pack-fresh player or manager starts with.
///
/// This is the FIFA-side default for an instance Core tracks no contract for:
/// Core stores NULL for "untracked", and the game-specific number to substitute
/// lives here rather than in Core.
pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers only; the
/// target's tier comes from [`staff_tier`] over Core's authored staff rating.
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
#[cfg(test)]
mod tests {
use super::*;
/// The three tiers are the client's own rating ladder, so the boundaries are
/// exact: 64/65 and 74/75. An off-by-one here reads the wrong COLUMN and
/// silently grants the wrong number.
#[test]
fn tier_boundaries_are_the_clients_own_thresholds() {
assert_eq!(tier_for_rating(0), ContractTier::Bronze);
assert_eq!(tier_for_rating(64), ContractTier::Bronze);
assert_eq!(tier_for_rating(65), ContractTier::Silver);
assert_eq!(tier_for_rating(74), ContractTier::Silver);
assert_eq!(tier_for_rating(75), ContractTier::Gold);
assert_eq!(tier_for_rating(99), ContractTier::Gold);
}
/// A staff target Core carries no authored rating for has NO tier. `None` is
/// what lets the caller refuse; defaulting to bronze would silently under-pay
/// a gold manager, and defaulting to gold would over-pay every unknown one.
#[test]
fn an_unrated_staff_target_has_no_tier() {
assert_eq!(staff_tier(None), None);
}
/// Staff read the SAME ladder as players, so the boundaries are the same
/// exact 64/65 and 74/75 — `staff_tier` must not re-threshold.
#[test]
fn staff_tier_boundaries_are_the_player_ladder() {
assert_eq!(staff_tier(Some(64)), Some(ContractTier::Bronze));
assert_eq!(staff_tier(Some(65)), Some(ContractTier::Silver));
assert_eq!(staff_tier(Some(74)), Some(ContractTier::Silver));
assert_eq!(staff_tier(Some(75)), Some(ContractTier::Gold));
for rating in 0..=99u8 {
assert_eq!(
staff_tier(Some(rating)),
Some(tier_for_rating(rating)),
"rating {rating} must not diverge from the shared ladder"
);
}
}
/// The two values the live client was actually observed re-rating: the
/// squad manager at `value` 88 scored discard level 3 (gold) and the coaches
/// at `value` 66 scored level 2 (silver), 4/4 across `coach_probe.py` and
/// `discard_probe.py`. These are the ONLY staff tiers with live proof, so
/// they are pinned here rather than left to the generic boundary test.
#[test]
fn the_live_probed_staff_values_score_their_observed_tiers() {
assert_eq!(staff_tier(Some(88)), Some(ContractTier::Gold), "manager 88");
assert_eq!(staff_tier(Some(66)), Some(ContractTier::Silver), "coach 66");
}
/// The tier ladder must stay locked to the discard ladder it was taken from:
/// both index the same three card tiers, and a divergence would mean one of
/// the two is no longer the client's.
#[test]
fn the_tier_ladder_is_the_discard_ladder() {
for rating in 0..=99u8 {
let expected = match crate::fut::discard::discard_level(rating) {
1 => ContractTier::Bronze,
2 => ContractTier::Silver,
_ => ContractTier::Gold,
};
assert_eq!(tier_for_rating(rating), expected, "rating {rating}");
}
}
/// Spot-check across both families, including the row that proves the table
/// is not monotonic and the 99-special that has no published row.
#[test]
fn grant_matrix_cells_are_the_shipped_ea_values() {
// Player contracts.
assert_eq!(contract_grant(5_001_001, ContractTier::Bronze), Some(8));
assert_eq!(contract_grant(5_001_001, ContractTier::Gold), Some(1));
assert_eq!(contract_grant(5_001_004, ContractTier::Silver), Some(6));
assert_eq!(contract_grant(5_001_006, ContractTier::Silver), Some(24));
// Manager contracts. 5001008 is where the two families diverge: the
// player row grants 10 to a bronze target, the manager row 8.
assert_eq!(contract_grant(5_001_002, ContractTier::Bronze), Some(10));
assert_eq!(contract_grant(5_001_008, ContractTier::Bronze), Some(8));
assert_eq!(contract_grant(5_001_011, ContractTier::Gold), Some(18));
// The EASFC special pays 99 on every tier.
for tier in [
ContractTier::Bronze,
ContractTier::Silver,
ContractTier::Gold,
] {
assert_eq!(contract_grant(5_001_013, tier), Some(99), "{tier:?}");
}
}
/// The matrix is authored EA data, NOT a formula: `5001003` grants MORE to a
/// bronze target (15) than to a gold one (13), and dips at silver (11). Any
/// "corrected" monotonic table fails here.
#[test]
fn the_matrix_is_deliberately_not_monotonic() {
let bronze = contract_grant(5_001_003, ContractTier::Bronze).unwrap();
let silver = contract_grant(5_001_003, ContractTier::Silver).unwrap();
let gold = contract_grant(5_001_003, ContractTier::Gold).unwrap();
assert_eq!((bronze, silver, gold), (15, 11, 13));
assert!(bronze > gold, "bronze target out-grants gold on this row");
assert!(silver < gold, "and silver is the trough, not the middle");
}
/// A consumable outside the 13 contract rows has NO proven grant. Returning
/// `None` is what lets the caller refuse; a floor or a nearest-row guess
/// would be a silent mis-credit.
#[test]
fn a_non_contract_resource_id_has_no_grant() {
// 5003012 is a training card — a different consumable family entirely.
for tier in [
ContractTier::Bronze,
ContractTier::Silver,
ContractTier::Gold,
] {
assert_eq!(contract_grant(5_003_012, tier), None);
assert_eq!(contract_grant(0, tier), None);
assert_eq!(contract_grant(5_001_000, tier), None, "just below the run");
assert_eq!(contract_grant(5_001_014, tier), None, "just above the run");
}
}
/// The table is a lookup keyed on exact ids: 13 rows, no duplicates, sorted.
/// A duplicated key would make `find` silently prefer whichever came first.
#[test]
fn the_table_keys_are_unique_and_sorted() {
for pair in CONTRACT_CARDS.windows(2) {
assert!(pair[0].0 < pair[1].0, "{:?} then {:?}", pair[0], pair[1]);
}
assert_eq!(CONTRACT_CARDS.len(), 13);
}
/// Every grant is a real number of matches within the cap — the cap can
/// truncate an ADDITION, but no single card grants more than a full card.
#[test]
fn every_grant_is_positive_and_within_the_cap() {
for (id, grants) in CONTRACT_CARDS {
for grant in grants {
assert!(
grant > 0 && grant <= CONTRACT_MATCH_CAP,
"{id} grants {grant}"
);
}
}
}
}
+494
View File
@@ -0,0 +1,494 @@
//! 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
}
/// THE authoritative FIFA 17 discard price for one owned definition, or `None`
/// when an input the client itself uses is not in hand.
///
/// This is the single entry point every caller must use — the wire shaper and
/// the quick-sell payout both reach it through
/// [`super::item::ItemIdentityResolver::discard_value`], so the number displayed
/// and the number credited cannot diverge.
///
/// `None` means "not known", never "worthless", and the caller falls back to the
/// legacy ladder rather than inventing a price:
///
/// * `cardtype == 0` — the subtype decodes to no table row at all.
/// * a CLIENT-RE-RATED cardtype ([`client_rerates`]: the five staff families)
/// with no `catalog_rating`. Those price from the client's OWN database, so
/// without that value we cannot match what it displays.
///
/// Everything else uses `catalog_rating`, falling back to Core's rating. For the
/// cardtypes the client does NOT re-rate (1, 6, 7, 8, 9) the server's rating is
/// authoritative — whatever we send is what the client prices with — so Core's
/// value is the right answer even when it is 0.
///
/// That zero is not a gap. A club item's wire record
/// ([`super::item::shape_club_item`]) carries neither `rating` nor
/// `discardValue`, so the client computes for itself from `+0xb4 == 0`: level 1,
/// and `0 * price / 100 == 0`. **The client displays 0, so 0 is the correct
/// payout.** Paying anything else would invent value the player was never shown.
pub fn value_for_definition(
subtype: i64,
rareflag: i64,
catalog_rating: Option<u8>,
core_rating: u8,
) -> Option<i64> {
let cardtype = cardtype_for_subtype(subtype);
if cardtype == 0 {
return None;
}
let rating = match catalog_rating {
Some(r) => r,
// Not re-rated by the client => whatever the server sends is what it
// prices with, so Core's rating is authoritative even at 0.
None if !client_rerates(cardtype) => core_rating,
// Re-rated => the client substitutes its own value and we cannot match it.
None => return None,
};
Some(discard_value(cardtype, rating, rareflag))
}
#[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);
}
/// A player is priced from Core's rating and the catalog's `rareflag`, so a
/// special and a common of the SAME rating price differently. The legacy
/// ladder paid 1500 for every one of these.
#[test]
fn a_players_price_follows_its_rareflag_not_just_its_rating() {
// rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100.
assert_eq!(value_for_definition(0, 3, None, 90), Some(10_980));
// Same rating, rare 0 (gold common) -> 4 * rating.
assert_eq!(value_for_definition(0, 0, None, 90), Some(360));
// Same rating, rare 1 (gold rare) -> 8 * rating.
assert_eq!(value_for_definition(0, 1, None, 90), Some(720));
}
/// A consumable's rating is EA's authored one from the catalog, never Core's
/// 0 — and cardtype 6 is not re-rated, so the server's values are what the
/// client itself prices with.
#[test]
fn a_consumable_prices_from_its_catalog_rating() {
// subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5.
assert_eq!(value_for_definition(201, 0, Some(60), 0), Some(3));
assert!(!client_rerates(cardtype_for_subtype(201)));
}
/// The two "not known" cases MUST decline rather than pay 0.
#[test]
fn an_unknown_input_declines_instead_of_paying_zero() {
// Staff: cardtype 10, no catalog rating. Core's rating is 0, which would
// price the card at 0 coins.
assert_eq!(cardtype_for_subtype(6), 10);
assert_eq!(value_for_definition(6, 0, None, 0), None);
// A subtype with no table row at all.
assert_eq!(value_for_definition(600, 0, Some(80), 80), None);
// Once the rating IS known, staff price normally.
assert_eq!(value_for_definition(6, 0, Some(66), 0), Some(36));
}
/// CLUB ITEMS ARE ZERO-VALUE under the current projection, and that is a
/// derived fact rather than a gap. `shape_club_item` sends no `rating` and no
/// `discardValue`, so the client computes for itself from record `+0xb4 == 0`:
/// level 1, `0 * price / 100 == 0`. It DISPLAYS 0, so 0 is the only payout
/// that matches. The old ladder invented 150 for each of these.
#[test]
fn club_items_are_zero_value_not_a_fallback_to_an_invented_price() {
use crate::fut::content_taxonomy as tax;
for subtype in [
tax::KIT_SUBTYPE,
tax::STADIUM_SUBTYPE,
tax::BADGE_SUBTYPE,
tax::BALL_SUBTYPE,
tax::LEAGUE_LOGO_SUBTYPE,
] {
let ct = cardtype_for_subtype(subtype);
assert!(
!client_rerates(ct),
"subtype {subtype} must not be re-rated"
);
assert_eq!(
value_for_definition(subtype, 0, None, 0),
Some(0),
"subtype {subtype} must price at exactly 0, not decline to a ladder"
);
}
// A staff family, by contrast, DECLINES without its rating -- we cannot
// know what the client re-rated it to.
assert_eq!(value_for_definition(6, 0, None, 0), None);
}
/// 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());
+834 -10
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, STADIUM_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)]
@@ -44,6 +48,20 @@ pub struct CoreOwnedItem {
pub club: String,
/// [pace, shooting, passing, dribbling, defending, physical].
pub attributes: [u8; 6],
/// Match-contracts remaining on this instance, as persisted by Core.
/// `None` = Core tracks none, so the caller substitutes the pack-fresh
/// default ([`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] for a
/// player, [`STAFF_CONTRACT`] for staff). Core deliberately stores NULL for
/// "untracked" rather than seeding a number, so the game-specific default
/// stays on this side of the boundary.
pub contract_matches: Option<i64>,
/// EA's authored definition rating for a non-player, from Core. `None` = Core
/// tracks none; callers MUST fail closed rather than substitute a tier.
pub source_rating: Option<u8>,
/// Core's own `content_kind` token for this instance, verbatim. Distinct from
/// the adapter catalog's kind: Core calls the squad manager `manager` while the
/// catalog classifies it `staff` + subtype 4.
pub core_content_kind: Option<String>,
}
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
@@ -66,12 +84,177 @@ 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,
/// Kit slot family: `2` home, `3` away, `5` third. Read at record `+0xb8`
/// and mapped to the engine kit SLOT (2→0, 3→1, 5→3).
pub category: i64,
/// Kit season; `0` = current season. Record `+0xba`.
pub year: 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 +268,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,
@@ -113,10 +313,21 @@ fn discard_value(rating: u8) -> i64 {
/// identical by construction. `id` is the owned instance's resolved FIFA
/// identity — pass the resolver's answer for *this* owned copy so two copies of
/// one definition stay distinct on the wire.
///
/// `discard_value` and `contract` are explicit scalars for the same reason: both
/// are per-instance numbers this shaper must not invent. `contract` used to be a
/// hardcoded `7`, which made every card look pack-fresh no matter how many
/// matches it had played or how many contracts had been applied to it. The
/// caller passes [`CoreOwnedItem::contract_matches`] resolved against
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] — the substitution for
/// Core's "untracked" NULL belongs to the caller, because the default is
/// FIFA-specific and Core stores no number to speak for it.
pub fn shape_item(
item: &CoreOwnedItem,
id: Fifa17Identity,
ent: &impl ReverseEntityResolver,
discard_value: i64,
contract: i64,
) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
@@ -144,7 +355,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
@@ -152,15 +363,256 @@ pub fn shape_item(
// "our own data showing through" bug the Python oracle fixed by forcing
// this off for owned copies (item_def keeps `true`; instances do not).
"untradeable": false,
"contract": 7,
"contract": contract,
"fitness": 99,
"discardValue": discard_value(item.rating),
"discardValue": discard_value,
})
}
/// Wire `itemType` (atom 0x173) for a cardtype-7 club item.
///
/// Sent for WIRE FIDELITY only. Every real EA item in the capture corpus carries
/// `itemType`, and the two families OpenFUT already shaped (`player`, `staff`)
/// carry it, so omitting it on the club families was an inconsistency. Tokens
/// come from the `?type=` vocabulary decoded from the `FUN_18012ec50` jump table
/// (`kit` 12, `stadium` 13, `badge` 11).
///
/// It does NOT fix the pre-match kit selector, and the reasoning that first
/// introduced it was WRONG. That reasoning was: kit/badge/stadium omitted
/// `itemType` and were not resident as item records, while player and staff sent
/// it and were, so `itemType` must gate ingestion. Adding it changed nothing —
/// the client was relaunched, `?type=kit` answered `total=2 emitted=2` with
/// `itemType` present, and still no cardtype-7 record was resident.
///
/// The correlation was an artefact of the CONTROL, not the field. Measured
/// 2026-08-23 read-only over `/proc/PID/mem`: the "resident" players and staff
/// were all SQUAD members, which arrive via `userMassInfo`. Testing players that
/// appear in `/club?type=player` but NOT in `userMassInfo` shows they are not
/// resident either — 0 records for 6 of 6 sampled, 5 with no byte match at all,
/// out of 1966 served. So residency tracks the ROUTE, not this field:
/// `/club?type=` responses do not enter the persistent card collection, and no
/// value of `itemType` changes that.
///
/// `CARD_SYSTEM.md`'s "parsed into a heap string and never stored" therefore
/// stands unchallenged; the earlier note here that it was "evidence the string is
/// consulted" is withdrawn.
///
/// INFERRED, not proven: no capture of a real EA club item exists anywhere in the
/// corpus, so the exact token for these three families is taken from the atom
/// vocabulary rather than observed on the wire.
fn club_item_type(subtype: i64) -> &'static str {
match subtype {
KIT_SUBTYPE => "kit",
STADIUM_SUBTYPE => "stadium",
BADGE_SUBTYPE => "badge",
_ => "misc",
}
}
/// 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,
"itemType": club_item_type(id.subtype),
"itemState": item_state,
"owners": 1,
"untradeable": false,
});
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
item["teamid"] = json!(id.team_id);
}
// Kits only. `category` and `year` complete the `(teamid, year, slot)`
// identity the client's active-kit resolver builds at record `+0xb8`/`+0xba`
// (`FUN_1800d73d0`), and which the engine's kit descriptor
// (`sub_180033430`) compares against before it will name — rather than
// lock — a kit. Without them the triple can never match and the pre-match
// selector reports "This kit is currently locked".
//
// Deliberately NOT emitted for badges or stadiums: the slot mapping is
// kit-specific, and those families resolve their caption by other fields.
if id.subtype == KIT_SUBTYPE {
item["category"] = json!(id.category);
item["year"] = json!(id.year);
}
item
}
/// Pack-fresh contracts on a staff card — the FALLBACK for an instance Core
/// tracks no contract for, no longer an unconditional constant.
///
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
/// refuses to start a match when the manager's has run out. A caller holding an
/// owned item passes `item.contract_matches.unwrap_or(STAFF_CONTRACT)`, so a
/// tracked staff instance now reports its real remaining matches and only an
/// untracked one falls back here. The value matches
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] rather than inventing a
/// second, different default for the staff families.
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::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
@@ -182,6 +634,12 @@ mod tests {
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 88, 70, 85, 40, 78],
// Untracked by default; the contract tests below set it explicitly.
contract_matches: None,
// Players: their rating IS `overall`, so Core carries no separate
// authored definition rating, and these fixtures are player items.
source_rating: None,
core_content_kind: None,
}
}
@@ -197,6 +655,8 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(86),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
@@ -230,6 +690,8 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
@@ -240,6 +702,8 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
a["resourceId"], b["resourceId"],
@@ -268,6 +732,8 @@ mod tests {
rareflag: 3,
},
&ent,
legacy_discard_value(92),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
it["resourceId"], 117617092,
@@ -281,4 +747,362 @@ mod tests {
"special rareflag carried, not hardcoded 1"
);
}
/// `contract` USED to be a hardcoded `7`, so every card looked pack-fresh no
/// matter what Core had persisted — a contract consumable could be applied,
/// committed and then be invisible on the very screen that spends it. The
/// shaper must emit the number it was PASSED.
#[test]
fn contract_is_the_passed_value_not_a_constant() {
let ent = entities();
let id = Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
};
let base = item("oc1", "card_ch_1", 86, "CDM");
let it = shape_item(&base, id, &ent, legacy_discard_value(86), 22);
assert_eq!(it["contract"], 22, "the passed count, not 7");
// The whole 0..=99 range reaches the wire verbatim, including a spent
// card (0) and a capped one (99) — no clamping, no substitution.
for contract in [0, 1, 7, 22, 99] {
let it = shape_item(&base, id, &ent, legacy_discard_value(86), contract);
assert_eq!(it["contract"], contract);
}
// `fitness` is the same class of hardcode and deliberately out of scope
// here; asserting it keeps this test honest about what it proved.
assert_eq!(it["fitness"], 99);
}
/// Core stores NULL for an instance it tracks no contract for, so the
/// FIFA-specific pack-fresh default is substituted by the CALLER — the same
/// `unwrap_or` both production call sites use.
#[test]
fn an_untracked_instance_falls_back_to_pack_fresh() {
let ent = entities();
let id = Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
};
let mut untracked = item("oc1", "card_ch_1", 86, "CDM");
untracked.contract_matches = None;
let it = shape_item(
&untracked,
id,
&ent,
legacy_discard_value(86),
untracked
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
);
assert_eq!(it["contract"], PACK_FRESH_CONTRACT_MATCHES);
assert_eq!(it["contract"], 7, "the proven pack-fresh count");
// A tracked instance is NOT overwritten by the fallback.
let mut tracked = item("oc1", "card_ch_1", 86, "CDM");
tracked.contract_matches = Some(31);
let it = shape_item(
&tracked,
id,
&ent,
legacy_discard_value(86),
tracked
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
);
assert_eq!(it["contract"], 31);
}
/// 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),
PACK_FRESH_CONTRACT_MATCHES,
);
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,
category: 2,
year: 0,
};
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,
category: 2,
year: 0,
};
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}");
}
}
/// The pre-match kit selector needs the WHOLE identity triple, not just
/// `teamid`.
///
/// The client's active-kit resolver `FUN_1800d73d0` reads `category` at
/// record `+0xb8` (mapping 2→slot 0, 3→slot 1, 5→slot 3) and `year` at
/// `+0xba`, and the engine's kit descriptor `sub_180033430` will only NAME a
/// kit whose decoded `(teamid, year, slot)` equals the club's active home or
/// away triple. A descriptor it does not match is left completely unwritten
/// and the engine reports "This kit is currently locked" instead.
///
/// Regression: we shipped kits with `teamid` alone, which cannot match.
#[test]
fn a_kit_carries_the_full_identity_triple_the_selector_matches_on() {
let kit = Fifa17KitIdentity {
item_id: 100_004_874,
asset_id: 6_300_006,
resource_id: 6_300_006,
card_asset_id: 35,
subtype: KIT_SUBTYPE,
team_id: 21,
category: 2,
year: 0,
};
let home = shape_club_item(kit, item_state::ACTIVE_HOME_KIT);
assert_eq!(home["teamid"], 21);
assert_eq!(home["category"], 2, "category is the SLOT source");
assert_eq!(home["year"], 0, "year completes the triple");
assert_eq!(home["itemState"], item_state::ACTIVE_HOME_KIT);
// A historical kit must round-trip its real season, not be flattened.
let historical = shape_club_item(
Fifa17KitIdentity {
year: 2002,
category: 5,
..kit
},
item_state::ACTIVE_HOME_KIT,
);
assert_eq!(historical["year"], 2002);
assert_eq!(historical["category"], 5);
// Badges and stadiums must NOT gain the kit-only fields: the slot map is
// kit-specific and this project has frozen the client before by sending
// a family a field its resolver does not read.
for subtype in [BADGE_SUBTYPE, STADIUM_SUBTYPE] {
let other = shape_club_item(Fifa17KitIdentity { subtype, ..kit }, item_state::FREE);
assert!(other.get("category").is_none(), "subtype {subtype}");
assert!(other.get("year").is_none(), "subtype {subtype}");
}
}
/// Every cardtype-7 family MUST carry a DISTINCT `itemType`.
///
/// Measured live 2026-08-23: the two families that carry `itemType`
/// (player, staff) become resident item records and the three that omitted
/// it (kit, badge, stadium) did not, leaving the pre-match kit selector with
/// an empty DataProvider and a "kit is currently locked" dialog.
///
/// The distinctness half is not pedantry. `STADIUM_SUBTYPE` was initially
/// unimported here, so `match` read it as a fresh binding rather than a
/// constant, silently made the stadium arm irrefutable, and typed badges as
/// `"stadium"`. It compiled with only an unused-variable warning. Asserting
/// three different tokens is what catches that class of mistake.
#[test]
fn every_cardtype7_family_carries_its_own_item_type() {
let base = Fifa17KitIdentity {
item_id: 100004874,
asset_id: 6300006,
resource_id: 6300006,
card_asset_id: 35,
subtype: KIT_SUBTYPE,
team_id: 21,
category: 2,
year: 0,
};
let type_of = |subtype| {
shape_club_item(Fifa17KitIdentity { subtype, ..base }, item_state::FREE)["itemType"]
.as_str()
.expect("itemType is always emitted")
.to_string()
};
assert_eq!(type_of(KIT_SUBTYPE), "kit");
assert_eq!(type_of(STADIUM_SUBTYPE), "stadium");
assert_eq!(type_of(BADGE_SUBTYPE), "badge");
}
}
@@ -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);
}
}
+7
View File
@@ -7,17 +7,24 @@
pub mod catalog;
pub mod club_response;
pub mod club_stats;
pub mod consumables;
pub mod content_taxonomy;
pub mod contract_cards;
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;
pub mod store_catalog;
pub mod store_session;
pub mod training_cards;
+98 -47
View File
@@ -24,48 +24,50 @@ pub fn accountinfo_body() -> Value {
json!({})
}
/// `GET …/settings` — the FUT client applies this `configs` array via the applier
/// `FUN_18011dc50`, the ONLY writer of the `IS_*` UI gate bytes (each
/// `byte = (field == 1)`). A flag never sent is a gate never opened — which is why
/// Single-Player Seasons/Draft refuse to open while issuing ZERO server requests
/// (`friendlySeasonsEnabled` → slot `[0x16]` → model byte `0x1fd3a`
/// `IS_FRIENDLY_SEASON_ENABLED`; RE-confirmed via pyghidra on CardsDLL).
/// The commerce flags the client leaves OFF unless a `configs` row turns them on.
///
/// Default (`OPENFUT_FUT_SETTINGS` unset/`off`) is the historical empty baseline.
/// `OPENFUT_FUT_SETTINGS=gates` populates the array. SAFETY: populating it is what
/// makes the applier RUN, and it then writes EVERY gate byte, so the already-live
/// store flags MUST be re-asserted here or enabling Seasons would clear the working
/// Store (those reach the client today via the Blaze FUT_RS4_CONFIG store, not
/// here). Mirrors the audited list in `utas_server.py` `_SETTINGS_KEEP`/`_GATES`.
pub fn settings_body() -> Value {
if std::env::var("OPENFUT_FUT_SETTINGS").as_deref() != Ok("gates") {
/// `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": [] });
}
// Already-live store flags re-asserted (pinned to their working state).
const KEEP: &[&str] = &[
"storeEnabled",
"storeEnabled_JP",
"coinEnabled",
"coinEnabled_JP",
"cardPackStoreEnabled",
"cardPackStoreEnabled_JP",
"pointsPackStoreEnabled",
"tradingEnabled",
];
// Mode gates that nothing has ever populated (the point of the change).
const GATES: &[&str] = &[
"friendlySeasonsEnabled",
"enableDraftMode",
"enableSinglePlayerDraftMode",
"enableOfflineDraftMode",
"tournamentQuitEnabled",
];
let configs: Vec<Value> = KEEP
let rows: Vec<Value> = COMMERCE_SETTINGS
.iter()
.chain(GATES.iter())
.map(|k| json!({ "type": k, "value": 1 }))
.map(|name| json!({ "type": name, "value": 1 }))
.collect();
json!({ "configs": configs })
json!({ "configs": rows })
}
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
@@ -106,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()
@@ -404,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;
@@ -454,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!(
@@ -667,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());
}
}
+20 -4
View File
@@ -90,14 +90,30 @@ pub fn generate_pack_contents(
}
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 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);
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);
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
}
+22 -4
View File
@@ -116,6 +116,15 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
"status": "OPEN",
"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": []
})
})
@@ -277,10 +286,11 @@ mod tests {
times_completed: 1,
};
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
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, &[once.clone()]);
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]);
@@ -360,8 +370,16 @@ mod tests {
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_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();
@@ -35,8 +35,11 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
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 +80,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 +167,13 @@ 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),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
),
"kitNumber": kit,
}));
}
@@ -171,6 +185,39 @@ 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).map(|id| (m, id)))
{
Some((mgr, id)) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
"dream": false,
}]),
None => json!([]),
};
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
@@ -180,7 +227,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 +266,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 {
@@ -243,6 +298,9 @@ mod tests {
league: "l".into(),
club: "c".into(),
attributes: [80, 80, 80, 80, 40, 80],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
@@ -262,6 +320,7 @@ mod tests {
}],
ext,
owned,
manager: None,
}
}
@@ -272,7 +331,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 +340,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 +379,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 +390,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 +401,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 +416,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 +467,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 +488,94 @@ 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()));
// A manager mid-way through its contracts: the projection must report
// Core's persisted count, not the pack-fresh constant, or the pre-match
// screen contradicts the club screen.
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
manager.contract_matches = Some(12);
input.manager = Some(manager);
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": 12,
"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"
);
}
}
+144 -36
View File
@@ -68,45 +68,153 @@ impl PackDef {
/// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[
// ── Bronze category ──
PackDef { id: 1, name: "Bronze Pack", price: 400,
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 },
PackDef {
id: 1,
name: "Bronze Pack",
price: 400,
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 },
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,
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 Pack", price: 7500,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
special_chance: 0.06, owned_only: false },
PackDef {
id: 5,
name: "Gold Pack",
price: 5000,
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 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 Gold Pack", price: 0,
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 },
PackDef {
id: 70,
name: "Reward Gold Pack",
price: 0,
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,
},
];
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
@@ -0,0 +1,312 @@
//! FIFA 17 attribute training cards: which attribute a card trains, and by how
//! much.
//!
//! ## Where this comes from
//!
//! Two independent shipped sources, no invention:
//!
//! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a
//! consumable's whole presentation from that one field, and for the training
//! families it writes an attribute selector to `rec+0xbc` and the magnitude to
//! `rec+0xbf`. The selector per subtype is recorded in
//! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own
//! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED.
//! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned
//! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN.
//!
//! ## Why the effect is ours to define at all
//!
//! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so
//! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no
//! client-side oracle for a consumable effect and never will be. The client ACKs
//! an apply on transport code alone and then re-reads state. Whatever the server
//! durably stores and re-serves IS what the player sees. That makes the
//! *magnitude* and the *target attribute* recoverable facts — the two above — and
//! everything about the effect's LIFECYCLE a server policy we must state
//! explicitly rather than pretend to have reversed. See
//! `TRAINING_MATCH_EXPIRY` below.
//!
//! ## Slot numbering
//!
//! The `attribute_index` this module produces is a slot in CORE's six-attribute
//! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical),
//! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six
//! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK
//! card and an outfield card can share one slot vocabulary.
/// Which class of player a training card may be applied to.
///
/// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers,
/// `FUT_MC_*` for outfielders) and their slots mean different attributes, so
/// applying one to the wrong class would silently train the wrong stat.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrainingClass {
Goalkeeper,
Outfield,
}
/// A resolved training effect: one attribute slot (or all six), one magnitude,
/// one legal target class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrainingEffect {
pub class: TrainingClass,
/// Slot in Core's six-attribute model, or `None` for the rare card that
/// boosts ALL SIX.
pub attribute_index: Option<i64>,
pub amount: i64,
}
/// The largest magnitude EA authors for a SINGLE-attribute training card.
///
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for all twelve
/// single-attribute families. Declared to Core on every apply so Core can refuse
/// a larger boost than any real card could grant, which is what keeps the closed
/// vocabulary from being a blank cheque.
pub const TRAINING_MAX_AMOUNT: i64 = 15;
/// The largest magnitude EA authors for the RARE all-six training card.
///
/// The two all-six subtypes author 3/6/10 only, so a +15 all-six card does not
/// exist and must not be describable to Core.
pub const TRAINING_ALL_MAX_AMOUNT: i64 = 10;
/// GK attribute training subtypes → Core attribute slot.
///
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
/// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at
/// slot 3). Reading these off in subtype order instead of `bc` order is exactly
/// the mistake this table exists to prevent.
const GK_TRAINING: &[(i64, i64)] = &[
(51, 0), // FUT_UC_DIVING
(52, 1), // FUT_UC_HANDLING
(53, 2), // FUT_UC_KICKING
(56, 3), // FUT_UC_REFLEXES
(54, 4), // FUT_UC_SPEED
(55, 5), // FUT_UC_POSITIONING
];
/// Outfield attribute training subtypes → Core attribute slot.
///
/// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66
/// is DEFENDING at slot 4.
const OUTFIELD_TRAINING: &[(i64, i64)] = &[
(61, 0), // FUT_MC_PACE
(62, 1), // FUT_MC_SHOOTING
(63, 2), // FUT_MC_PASSING
(64, 3), // FUT_MC_DRIBBLING
(66, 4), // FUT_MC_DEFENDING
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
];
/// The RARE training cards, which boost ALL SIX attributes at once.
///
/// These were previously mistaken for "squad fitness" on the strength of the
/// reversed label `FUT_FITNESS_UC` / `FUT_FITNESS_MC`. That label is
/// tool-authored (`build_consumables.py` names the 7th element of its attribute
/// array) and has no documented provenance; four independent facts say all-six:
///
/// * each family holds exactly 21 rows = 7 card types x 3 levels, and the
/// published FIFA 17 card list is 6 single attributes + 1 "ALL";
/// * these six rows are the ONLY ones in the table with `weightrare = 2`; all 36
/// single-attribute rows are `weightrare = 0`. The published list marks the
/// ALL card RARE and every single-attribute card non-rare;
/// * their amounts are exactly 3/6/10, matching the published ALL card's
/// +3 bronze / +6 silver / +10 gold, while single attributes are 5/10/15;
/// * `bc = 6` is one past the six real slots (0..5) -- an "all" sentinel -- and
/// `c0 = 0` reads as "not single-ATTRIBUTE", not "not single-target".
///
/// The genuine squad-fitness card is subtype 220 in a DIFFERENT table
/// (`fcc_healingcards`, amounts 10/20/30), and player fitness is 219 — that
/// family is separately identified and remains unsupported.
const ALL_ATTRIBUTE_TRAINING: &[(i64, TrainingClass)] = &[
(57, TrainingClass::Goalkeeper),
(67, TrainingClass::Outfield),
];
/// Resolve a consumable into a training effect, or `None` if it is not an
/// attribute training card.
///
/// `amount` is the wire/catalog magnitude for the card. It is required: the
/// client parser initialises its amount temp to `-1` and reads it signed, so a
/// missing magnitude is not "zero", it is a card that would draw and grant
/// nonsense. Absent or out-of-range, this refuses.
pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffect> {
let (class, attribute_index, ceiling) = GK_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT))
.or_else(|| {
OUTFIELD_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Outfield, Some(slot), TRAINING_MAX_AMOUNT))
})
.or_else(|| {
ALL_ATTRIBUTE_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, class)| (class, None, TRAINING_ALL_MAX_AMOUNT))
})?;
let amount = amount?;
if !(1..=ceiling).contains(&amount) {
return None;
}
Some(TrainingEffect {
class,
attribute_index,
amount,
})
}
/// The ceiling Core must be told for a resolved effect: the two families author
/// different maxima, and sending the wrong one either lets an impossible boost
/// through or refuses a legitimate card.
pub fn ceiling_for(effect: &TrainingEffect) -> i64 {
match effect.attribute_index {
Some(_) => TRAINING_MAX_AMOUNT,
None => TRAINING_ALL_MAX_AMOUNT,
}
}
/// Whether a target playing in `position` may receive `class` training.
///
/// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role
/// its own id, so the distinction is exactly "is the target a keeper".
pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool {
let is_gk = position.eq_ignore_ascii_case("GK");
match class {
TrainingClass::Goalkeeper => is_gk,
TrainingClass::Outfield => !is_gk,
}
}
/// What clears an applied training effect, if anything.
///
/// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly
/// assumed. FIFA 17 ships no table describing a training lifetime, the client
/// holds no consumable-effect logic to reverse one from, and "training is
/// temporary in FUT" is a recollection about other titles, not evidence about
/// this one. Until an experiment settles it, an applied effect PERSISTS, and no
/// code decrements or expires it.
pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN";
#[cfg(test)]
mod tests {
use super::*;
/// The slot must come from `bc`, never from the subtype's ordinal position.
/// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a
/// sequential misreading.
#[test]
fn out_of_order_subtypes_map_to_their_reversed_slots() {
assert_eq!(
training_effect(54, Some(10)).unwrap().attribute_index,
Some(4)
); // SPEED
assert_eq!(
training_effect(56, Some(10)).unwrap().attribute_index,
Some(3)
); // REFLEXES
assert_eq!(
training_effect(65, Some(10)).unwrap().attribute_index,
Some(5)
); // HEADING
assert_eq!(
training_effect(66, Some(10)).unwrap().attribute_index,
Some(4)
); // DEFENDING
}
/// Every attribute training subtype resolves, and the two families cover
/// Core's six slots exactly once each.
#[test]
fn both_families_cover_all_six_slots_exactly_once() {
for (family, subtypes) in [
(TrainingClass::Goalkeeper, GK_TRAINING),
(TrainingClass::Outfield, OUTFIELD_TRAINING),
] {
let mut slots: Vec<i64> = subtypes
.iter()
.map(|&(s, _)| {
let e = training_effect(s, Some(5)).expect("subtype resolves");
assert_eq!(e.class, family);
e.attribute_index
.expect("single-attribute card names a slot")
})
.collect();
slots.sort_unstable();
assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]);
}
}
/// The rare pair boost ALL SIX attributes, so they resolve with NO slot.
/// Reading them as single-attribute would apply their magnitude to whatever
/// slot 0 happens to be and drop the other five.
#[test]
fn the_rare_cards_boost_all_six_attributes() {
for (subtype, class) in [
(57, TrainingClass::Goalkeeper),
(67, TrainingClass::Outfield),
] {
for amount in [3, 6, 10] {
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
assert_eq!(e.attribute_index, None, "subtype {subtype} must be all-six");
assert_eq!(e.class, class);
assert_eq!(e.amount, amount);
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
}
}
}
/// The all-six card authors 3/6/10 only. A +15 all-six card does not exist,
/// and letting one through would grant 90 attribute points from a card that
/// grants at most 60.
#[test]
fn the_rare_card_cannot_carry_a_single_attribute_magnitude() {
assert_eq!(training_effect(57, Some(15)), None);
assert_eq!(training_effect(67, Some(15)), None);
// ...while a single-attribute card still may.
assert_eq!(training_effect(51, Some(15)).unwrap().amount, 15);
}
/// A missing magnitude is a refusal, not a zero: the client reads the byte
/// signed from a -1 initial value.
#[test]
fn a_missing_or_impossible_amount_refuses() {
assert_eq!(training_effect(52, None), None);
assert_eq!(training_effect(52, Some(0)), None);
assert_eq!(training_effect(52, Some(-1)), None);
assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None);
}
/// Only the shipped magnitudes are accepted, and all three are.
#[test]
fn the_three_authored_magnitudes_all_resolve() {
for a in [5, 10, 15] {
assert_eq!(training_effect(61, Some(a)).unwrap().amount, a);
}
}
/// Family/target gating is the whole reason `class` exists.
#[test]
fn each_family_accepts_only_its_own_target_class() {
assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK"));
assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST"));
assert!(class_accepts_position(TrainingClass::Outfield, "ST"));
assert!(!class_accepts_position(TrainingClass::Outfield, "GK"));
// The wire's casing is not guaranteed to be ours.
assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk"));
}
/// A non-training consumable must never resolve here — contracts (201/202),
/// healing (211-218), fitness (219/220), position (91-110) and play styles
/// (250-273) all share the consumable space.
#[test]
fn other_consumable_families_do_not_resolve_as_training() {
for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] {
assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved");
}
}
}
@@ -199,7 +199,7 @@
}
},
{
"assetId": 70,
"assetId": 5,
"id": 70,
"packType": "GOLD",
"description": "Reward Gold Pack",
@@ -212,6 +212,13 @@
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"currencies": [
{
"name": "coins",
"funds": 0,
"finalFunds": 0
}
],
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
@@ -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
@@ -103,6 +114,12 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
league: String::new(),
club: String::new(),
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
// The captured wire carries the club's real per-instance
// contract count, so the round trip proves the PERSISTED number
// reaches the wire rather than a constant.
contract_matches: it["contract"].as_i64(),
source_rating: None,
core_content_kind: None,
},
);
ident.insert(
@@ -115,7 +132,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 +163,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 +214,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 +290,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 +314,49 @@ 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],
// The captured `manager` ref is the bare `{id, dream}` form, so the wire
// carries no staff contract to mirror: this instance is untracked and
// must fall back to the pack-fresh default.
contract_matches: None,
// Core's authored staff `value`; the squad projection never reads it (the
// client re-rates a manager from its own table), so the round trip is
// unaffected either way.
source_rating: Some(88),
core_content_kind: Some("manager".to_string()),
};
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 +371,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 +408,26 @@ 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,
"this manager instance is untracked, so the pack-fresh fallback shows"
);
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
+306 -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,
@@ -1086,6 +1317,15 @@ pub fn emit_content(
.collect();
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
// role name; Core stores them like any other definition (no FIFA concept).
//
// `overall` STAYS 0 while `source_rating` carries EA's authored value, and
// both are correct at once because they feed different consumers: `overall`
// is what Core prices and projects from (a non-zero one would silently
// re-price every staff quick sell), while `source_rating` is the authored
// number the game's own tier rules read (bronze <65 / silver 65..=74 /
// gold >=75) — the number a manager-contract grant needs. `d.rating` is the
// SAME value written to the host catalog below as `"rating": d.rating`, so
// the content pack and the catalog can never disagree about a card's tier.
for d in &report.non_player.supported {
defs.push(serde_json::json!({
"id": d.card_id,
@@ -1103,6 +1343,7 @@ pub fn emit_content(
"physical": 0,
"rarity": "bronze",
"image_path": serde_json::Value::Null,
"source_rating": d.rating,
}));
}
let content_pack = content_dir.join("fifa17-production-cards.json");
@@ -1126,13 +1367,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 +1406,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 +1471,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 +1537,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)]
+336 -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.
@@ -794,6 +946,17 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
assert_eq!(cons["nation"], "");
assert_eq!(cons["rarity"], "bronze");
assert!(cons["image_path"].is_null());
// `source_rating` mirrors the wire verbatim: this consumable really sends
// `"rating":0`, so 0 is the authored value, not a substituted default.
assert_eq!(cons["source_rating"], 0, "{cons}");
// This fixture's `entities()` carries no staff table, so the coach's value is
// UNKNOWN — it must stay `null`, never a fabricated 0 a tier rule reads as
// bronze.
let coach = arr.iter().find(|c| c["id"] == "fifa17_3000083").unwrap();
assert!(
coach["source_rating"].is_null(),
"an unknown authored value must stay null: {coach}"
);
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
@@ -807,21 +970,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 +1007,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 +1024,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 +1079,128 @@ 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"
);
}
/// A MANAGER CONTRACT grant needs the target's TIER, and the only authoritative
/// source is EA's authored `value` from the staff family table. Emit must carry
/// it into Core's content pack as `source_rating` — the SAME number the host
/// catalog carries as `rating`, so the two artifacts can never disagree — while
/// `overall` stays 0, because `overall` is what Core prices and projects from.
///
/// Before this, a non-player reached Core with `overall: 0` and nothing else, so
/// `/collection` reported `effective_overall: 0` and a gold (76) manager graded
/// bronze.
#[test]
fn emit_content_carries_ea_authored_value_as_source_rating() {
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![
staff(100000427, 1000001, 4), // manager — managercards.value = 76 (gold)
staff(100000280, 9000081, 6), // GK coach — gkcoachcards.value = 66 (silver)
];
let rep = analyze(&profile(&items, "[]", 100000500), &roster(), &ent, &none());
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
let out = tempfile::tempdir().unwrap();
let sum = emit_content(&rep, out.path(), "fp").unwrap();
let pack: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
let cat: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
for (card_id, value) in [("fifa17_1000001", 76), ("fifa17_9000081", 66)] {
let def = pack
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == card_id)
.unwrap_or_else(|| panic!("{card_id} must be in the content pack"));
assert_eq!(
def["source_rating"],
serde_json::json!(value),
"EA's authored value must reach Core: {def}"
);
assert_eq!(
def["overall"],
serde_json::json!(0),
"overall stays 0 for a non-player: it feeds pricing and projection"
);
assert_eq!(
def["source_rating"], cat["cards"][card_id]["rating"],
"one source, two artifacts — they must never disagree on a tier"
);
}
}
+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`
+119 -19
View File
@@ -24,6 +24,7 @@ use rand::Rng;
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
@@ -106,6 +107,13 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
league: m.card.league.clone(),
club: m.card.club.clone(),
attributes: m.card.attributes,
// Freshly minted by a pack/Store open, so Core tracks no contract for it
// yet: the shaper substitutes the pack-fresh default.
contract_matches: None,
// A pack mints PLAYER cards, whose rating IS `overall`, so there is no
// separate authored definition rating to carry.
source_rating: None,
core_content_kind: None,
}
}
@@ -162,7 +170,16 @@ 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),
// A pack-pulled card is by definition pack-fresh, so it carries
// the default rather than a persisted count: Core has not yet
// stored this instance, let alone applied a contract to it.
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
))
})
.collect()
}
@@ -371,22 +388,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
@@ -409,7 +417,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);
@@ -464,7 +475,10 @@ mod tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
use crate::{
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
@@ -599,6 +613,20 @@ 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))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
@@ -977,9 +1005,22 @@ mod tests {
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
/// 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,
@@ -989,6 +1030,7 @@ mod tests {
econ,
reverse,
items,
assets: &LADDER_ASSETS,
}
}
@@ -1009,6 +1051,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 [
+2642 -189
View File
File diff suppressed because it is too large Load Diff
+49 -13
View File
@@ -26,8 +26,10 @@
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
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 +83,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 +91,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 +107,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 +154,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 +162,7 @@ fn auction_record_tuned(
json!({
"id": item_id,
"resourceId": resource,
"itemState": item_state,
"itemState": state,
"untradeable": false,
})
});
@@ -204,9 +206,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 +285,19 @@ 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),
// A listing snapshot must show the contract the seller's card
// actually holds, so a part-used card cannot render as fresh.
owned
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
)
})
.and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing {
item_id,
@@ -444,7 +458,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 +470,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 +621,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 +737,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,6 +819,7 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
@@ -959,6 +974,20 @@ 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))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
@@ -1010,6 +1039,9 @@ mod tests {
league: String::new(),
club: String::new(),
attributes: [80, 80, 80, 80, 80, 80],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
@@ -1147,6 +1179,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);
+39 -21
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 {
@@ -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
@@ -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.
@@ -832,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",
@@ -889,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) ────────────────────────────
+17 -1
View File
@@ -30,7 +30,8 @@ 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,
build_content_pool, ConsumableApplyOutcome, ConsumableApplyRequest, CoreAccess, CoreEconomy,
CoreError, CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server, WireResponse,
};
@@ -140,6 +141,21 @@ 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)
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
if self.trip("apply_consumable") {
return Err(Self::injected());
}
self.inner.apply_consumable(req)
}
}
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
+151 -19
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");
@@ -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())
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Smoke-test the REAL consumable-apply route against the RUNNING staging host.
`POST ut/<sku>/item/resource/<resourceId>` is now Rust-owned and it MUTATES:
Core destroys the source consumable instance and raises the target's
match-contracts in ONE transaction. There is no `OPENFUT_FIFA17_APPLY_PROBE`
gate any more and no staging-only diagnostic -- the route is unconditional --
so what needs proving changed. This checks the two halves that matter:
* every REFUSAL leaves Core byte-identical (fail closed; nothing half-applied);
* the one accepted apply consumes exactly ONE copy, and the contract number the
host logged is the number the client can actually read back off the wire.
It deliberately does NOT re-derive the FIFA 17 grant matrix. Duplicating those
13 rows here would create a second source of truth that could silently disagree
with `openfut-adapter-fifa17::fut::contract_cards`, which is the authority.
Instead the host's own `granted/before/after` are checked for internal
consistency (`after == min(99, before + granted)`) and against the projected
wire state.
Replaying the accepted request is NOT idempotent and is not attempted: a
successful apply DESTROYS the source instance, so a second POST legitimately
consumes the NEXT owned copy. Idempotency is Core's, keyed on the source
instance id (`fifa17:apply:<source>-><target>`), and a transport retry of one
logical action replays that key rather than this HTTP request.
"""
import json
import re
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
SKU = "fifa17"
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
SNAPSHOT = "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"
# A PLAYER contract card (cardsubtypeid 201) owned on staging: the one accepted
# apply. It must match the snapshot script's SOURCE_RES so the copy count below
# is the count of THIS stack.
CONTRACT_RES = 5001004
# Position modifier: a real owned family whose apply effect is NOT proven.
POSITION_RES = 5003068
# Manager contract (cardsubtypeid 202): unservable until staff ratings are
# imported, because the grant is keyed on the TARGET's rating tier.
MANAGER_RES = 5001010
UNOWNED_RES = 1234567
TARGET_WIRE = 100000003
UNKNOWN_WIRE = 999999999
H = {"X-OpenFUT-Game": SKU, "Content-Type": "application/json"}
results = []
def post(resource_id, wires):
body = json.dumps({"apply": [{"id": w} for w in wires]}).encode()
req = urllib.request.Request(
"%s/ut/game/%s/item/resource/%d" % (BASE, SKU, resource_id),
data=body, headers=H, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def snapshot():
out = subprocess.run(["python3", SNAPSHOT], capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit("snapshot failed: %s" % out.stderr.strip())
return json.loads(out.stdout)
def apply_log_since(mark):
with open(LOG) as f:
return [ln.rstrip() for ln in list(f)[mark:] if "consumable-apply" in ln]
def check(name, ok, detail=""):
results.append(ok)
print(" [%s] %s%s" % ("OK" if ok else "FAIL", name,
(" -- " + detail) if detail else ""))
def skip(name, detail):
print(" [SKIP] %s -- %s" % (name, detail))
def stack_count(snap):
"""Owned copies of CONTRACT_RES, or 0 once the last one is consumed (the
stack disappears from the consumables screen entirely)."""
return ((snap.get("source_stack") or {}).get("count")) or 0
with open(LOG) as f:
mark = sum(1 for _ in f)
base = snapshot()
print("=== every refusal must leave Core byte-identical ===")
REFUSALS = [
("batch semantics unproven", CONTRACT_RES,
[TARGET_WIRE, TARGET_WIRE + 1], 400, "apply_batch_unsupported"),
("unknown target wire id", CONTRACT_RES,
[UNKNOWN_WIRE], 404, "not_owned"),
("source consumable not owned", UNOWNED_RES,
[TARGET_WIRE], 404, "not_owned"),
("non-contract family fails closed", POSITION_RES,
[TARGET_WIRE], 409, "apply_effect_unproven"),
("manager contract refused: staff ratings not imported", MANAGER_RES,
[TARGET_WIRE], 409, "manager_contract_unsupported"),
]
for name, res, wires, want_status, want_code in REFUSALS:
status, body = post(res, wires)
got = "status=%s body=%s" % (status, body[:80])
# A family-gate case can only be exercised if this profile owns such a card;
# source resolution runs first, so an unowned one answers 404 not_owned. Say
# so rather than scoring a pass or a failure that means nothing.
if want_status != 404 and status == 404 and "not_owned" in body:
skip(name, "profile owns no resource %d" % res)
continue
check(name, status == want_status and want_code in body, got)
check("Core unchanged by every refusal", snapshot() == base)
print("\n=== the one accepted apply must mutate, exactly once ===")
status, body = post(CONTRACT_RES, [TARGET_WIRE])
parsed = None
try:
parsed = json.loads(body)
except ValueError:
pass
check("client-shaped ack", status == 200 and parsed == {"itemData": []},
"status=%s body=%s" % (status, body[:80]))
after = snapshot()
lines = apply_log_since(mark)
granted_lines = [ln for ln in lines if "granted=" in ln]
fields = {}
if granted_lines:
fields = dict(re.findall(r"(subtype|granted|before|after|applied)=(-?\w+)",
granted_lines[-1]))
check("host logged the grant it applied",
{"subtype", "granted", "before", "after"} <= set(fields),
granted_lines[-1] if granted_lines else "no consumable-apply grant line")
if {"subtype", "granted", "before", "after"} <= set(fields):
granted = int(fields["granted"])
before_n = int(fields["before"])
after_n = int(fields["after"])
check("player contract subtype", fields["subtype"] == "201",
"subtype=%s" % fields["subtype"])
check("Core applied a real grant", granted > 0, "granted=%d" % granted)
check("cap respected: after == min(99, before + granted)",
after_n == min(99, before_n + granted),
"before=%d granted=%d after=%d" % (before_n, granted, after_n))
check("not a replay", fields.get("applied") == "true",
"applied=%s" % fields.get("applied"))
wire_contract = (after.get("target") or {}).get("contract")
check("the wire shows what Core recorded", wire_contract == after_n,
"wire=%s core=%s" % (wire_contract, after_n))
check("exactly one source copy consumed",
stack_count(after) == stack_count(base) - 1,
"before=%s after=%s" % (stack_count(base), stack_count(after)))
check("exactly one owned row destroyed",
after["owned_rows"] == base["owned_rows"] - 1,
"before=%s after=%s" % (base["owned_rows"], after["owned_rows"]))
check("coins untouched: an apply is not a sale",
after["coins"] == base["coins"],
"before=%s after=%s" % (base["coins"], after["coins"]))
print("\n=== host log ===")
for line in lines:
print(" " + line[:200])
ok = all(results)
print("\nRESULT: %s" % ("OK" if ok else "FAILED"))
raise SystemExit(0 if ok else 1)
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Core snapshot around a consumable APPLY: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after.
A player-contract apply must move EXACTLY three things: the source consumable
loses one copy, the target's `contract` rises to min(99, before + grant), and
nothing else — coins in particular must not move, because an apply is not an
economy credit. Everything else in this snapshot is here to prove it stayed put.
(Superseded for acceptance by fifa17-contract-apply-validate.py, which asserts
the deltas itself; this remains the raw before/after dump for eyeballing.)"""
import json
import sys
import urllib.request
H = {"X-OpenFUT-Game": "fifa17"}
SOURCE_RES = 5001004
TARGET_WIRE = 100000003
def get(p):
req = urllib.request.Request("http://127.0.0.1:8299" + p, headers=H)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
snap = {}
import sqlite3
con = sqlite3.connect("file:/home/alex/openfut-sold-staging/staging-core.db?mode=ro", uri=True)
snap["coins"] = con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0]
snap["owned_rows"] = con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0]
snap["by_kind"] = dict(con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1"))
con.close()
cons = get("/ut/game/fifa17/club/consumables/development")
stacks = cons.get("itemData") or []
snap["development_stacks"] = len(stacks)
snap["development_copies"] = sum(s.get("count", 0) for s in stacks)
snap["source_stack"] = next(
({"count": s.get("count"), "resourceId": s.get("resourceId")}
for s in stacks if s.get("resourceId") == SOURCE_RES), None)
sq = get("/ut/game/fifa17/squad/0")
players = (sq.get("squad") or sq).get("players") or []
for p in players:
it = p.get("itemData") or {}
if it.get("id") == TARGET_WIRE:
snap["target"] = {
"resourceId": it.get("resourceId"), "rating": it.get("rating"),
"contract": it.get("contract"), "fitness": it.get("fitness"),
"injuryType": it.get("injuryType"), "training": it.get("training"),
"playStyle": it.get("playStyle"), "preferredPosition": it.get("preferredPosition"),
}
break
print(json.dumps(snap, indent=2, sort_keys=True))
if len(sys.argv) > 1:
open(sys.argv[1], "w").write(json.dumps(snap, sort_keys=True))
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Acceptance harness for FIFA17 player-contract consumable APPLY (staging).
Exercises the real route end to end and asserts the full observable contract:
* the target's `contract` goes from B to min(99, B + grant), where `grant` is
read from the shipped EA table `fcc_contractcards` INDEPENDENTLY of the Rust
implementation (this is a cross-check, not a mirror);
* exactly one source copy is consumed;
* coins do NOT move (an apply is not an economy credit);
* replaying the exhausted resource fails closed rather than granting again;
* a manager contract and a non-contract consumable both fail closed.
Defaults target STAGING. `--host`/`--db` retarget it at a rehearsal or, under
explicit authorization, at the production acceptance run. There is deliberately
no production default: a bare invocation cannot touch production.
"""
import argparse
import json
import sqlite3
import sys
import urllib.error
import urllib.request
# Defaults are STAGING. Override for a rehearsal or the production acceptance
# run; there is deliberately no production default, so a bare invocation can
# never touch production by accident.
HOST = "http://127.0.0.1:8299"
CORE_DB = "/home/alex/openfut-sold-staging/staging-core.db"
HDRS = {"X-OpenFUT-Game": "fifa17"}
TABLE = "/home/alex/OpenFUT/fifa17-recon/data/tables/fcc_contractcards.json"
PLAYER_CONTRACT_SUBTYPE = 201
MANAGER_CONTRACT_SUBTYPE = 202
CAP = 99
FAILURES = []
def check(label, got, want):
ok = got == want
print(f" [{'OK ' if ok else 'FAIL'}] {label}: got {got!r} want {want!r}")
if not ok:
FAILURES.append(label)
return ok
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(HOST + path, data=data, headers=HDRS, method=method)
if data:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw.decode(errors="replace")
def grant_from_table(resource_id, target_rating):
"""The authoritative grant, read straight from EA's shipped table.
Column is selected by the TARGET's tier (bronze <65, silver 65..74, gold
>=75) — NOT by the card's own tier. Verified 36/36 against the published
FIFA 17 matrix.
"""
d = json.load(open(TABLE))
rows = d if isinstance(d, list) else (d.get("rows") or list(d.values())[0])
row = next((r for r in rows if r["carddbid"] == resource_id), None)
if row is None:
return None
col = "bronze" if target_rating < 65 else ("silver" if target_rating < 75 else "gold")
return row[col]
def core_snapshot():
con = sqlite3.connect(f"file:{CORE_DB}?mode=ro", uri=True)
try:
snap = {
"coins": con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
"owned": con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0],
"by_kind": dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1")
),
}
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["has_contract_column"] = "contract_matches" in cols
if snap["has_contract_column"]:
snap["contracts_set"] = con.execute(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL"
).fetchone()[0]
return snap
finally:
con.close()
def club_players():
"""Every owned player on the wire, with its contract, keyed by wire id."""
out = {}
for pile in ("/ut/game/fifa17/club?type=player&start=0&count=200",):
_, body = req("GET", pile)
for it in (body or {}).get("itemData") or []:
if it.get("itemType") == "player":
out[it["id"]] = it
return out
def consumable_stacks():
_, body = req("GET", "/ut/game/fifa17/club/consumables/development")
return {s["resourceId"]: s for s in (body or {}).get("itemData") or []}
def pick_source(stacks, subtype_range):
for rid, s in sorted(stacks.items()):
if rid in subtype_range:
return rid, s
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", type=int, default=None,
help="contract resource id to apply (default: first owned player contract)")
ap.add_argument("--target", type=int, default=None,
help="target player wire id (default: lowest-rated owned player)")
ap.add_argument("--host", default=HOST, help=f"utas-host base URL (default {HOST})")
ap.add_argument("--db", default=CORE_DB, help=f"Core SQLite path (default {CORE_DB})")
args = ap.parse_args()
# Rebound before any request so every helper below reads the chosen target.
globals()["HOST"] = args.host
globals()["CORE_DB"] = args.db
print(f"target host : {HOST}\ntarget db : {CORE_DB}\n")
print("== BEFORE ==")
before = core_snapshot()
print(json.dumps(before, indent=2, sort_keys=True))
if not before["has_contract_column"]:
print("FATAL: migration 0028 not applied — owned_cards has no contract_matches column")
return 2
stacks = consumable_stacks()
players = club_players()
if not players:
print("FATAL: no owned players on the wire")
return 2
player_contracts = {r: s for r, s in stacks.items() if 5001001 <= r <= 5001006 or r == 5001013}
src = args.source or next(iter(sorted(player_contracts)), None)
if src is None:
print("FATAL: no owned PLAYER contract consumable to apply")
return 2
# Lowest-rated target maximises the observable delta (a bronze target draws
# the largest column) and exercises the tier selector rather than assuming gold.
tgt_id = args.target or min(players, key=lambda i: players[i].get("rating", 0))
tgt = players[tgt_id]
rating = tgt["rating"]
grant = grant_from_table(src, rating)
c_before = tgt.get("contract")
expect_after = min(CAP, c_before + grant)
print(f"\n== APPLY ==\n source resource {src} (stack count {stacks[src].get('count')})")
print(f" target wire {tgt_id} rating {rating} -> tier "
f"{'bronze' if rating < 65 else 'silver' if rating < 75 else 'gold'}")
print(f" table grant {grant}; contract {c_before} -> expect {expect_after}")
status, body = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}]})
print(f" HTTP {status} {json.dumps(body)[:200] if body is not None else ''}")
check("apply status", status, 200)
check("apply body", body, {"itemData": []})
print("\n== AFTER ==")
after = core_snapshot()
players2 = club_players()
stacks2 = consumable_stacks()
print(json.dumps(after, indent=2, sort_keys=True))
check("coins unchanged", after["coins"], before["coins"])
check("one owned row consumed", after["owned"], before["owned"] - 1)
check("one consumable consumed",
after["by_kind"].get("consumable", 0), before["by_kind"].get("consumable", 0) - 1)
check("target contract granted", players2.get(tgt_id, {}).get("contract"), expect_after)
check("source stack decremented",
(stacks2.get(src) or {}).get("count", 0), (stacks[src].get("count") or 1) - 1)
# Untouched players must not have drifted.
drifted = [i for i, p in players2.items()
if i != tgt_id and p.get("contract") != players.get(i, {}).get("contract")]
check("no collateral contract changes", drifted, [])
print("\n== FAIL-CLOSED CASES ==")
exhausted = (stacks2.get(src) or {}).get("count", 0) == 0
if exhausted:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}", {"apply": [{"id": tgt_id}]})
check("replay of exhausted resource refused", s, 404)
mgr = next((r for r in stacks2 if 5001007 <= r <= 5001012), None)
if mgr:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{mgr}", {"apply": [{"id": tgt_id}]})
check("manager contract fails closed (staff ratings unimported)", s, 409)
other = next((r for r in stacks2 if not (5001001 <= r <= 5001013)), None)
if other:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{other}", {"apply": [{"id": tgt_id}]})
check("unproven family fails closed", s, 409)
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}, {"id": tgt_id}]})
check("batch apply refused", s in (400, 404), True)
print("\n== RESULT ==")
if FAILURES:
print("FAILED: " + ", ".join(FAILURES))
return 1
print(f"PASS — contract {c_before} -> {expect_after} on wire {tgt_id}, "
f"one copy of {src} consumed, coins flat")
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())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Economic impact of the recovered discard table, over OWNED instances.
Informational only: it exists to make the promotion decision explicit, never to
justify altering a value. Values come from the matrix emitted by
`cargo run -p openfut-adapter-fifa17 --example discard_matrix`, i.e. the shipped
implementation.
Usage:
python3 scripts/fifa17-discard-impact.py --matrix /tmp/discard-matrix.csv
"""
import argparse
import collections
import csv
import sqlite3
DB = "/home/alex/openfut-sold-staging/staging-core.db"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--matrix", required=True)
ap.add_argument("--db", default=DB)
a = ap.parse_args()
matrix = {}
with open(a.matrix) as fh:
for row in csv.DictReader(fh):
matrix[row["definition"]] = row
con = sqlite3.connect("file:%s?mode=ro" % a.db, uri=True)
owned = con.execute("SELECT card_id, content_kind FROM owned_cards").fetchall()
con.close()
by_kind = collections.defaultdict(lambda: [0, 0, 0]) # n, legacy, recovered
deltas = []
missing = 0
for card_id, kind in owned:
row = matrix.get(card_id)
if row is None:
missing += 1
continue
legacy = int(row["legacy"])
rec = int(row["recovered"]) if row["recovered"] != "-" else legacy
b = by_kind[kind]
b[0] += 1
b[1] += legacy
b[2] += rec
deltas.append((rec - legacy, kind, card_id, legacy, rec))
print("OWNED-INSTANCE DISCARD IMPACT (informational)")
print("%-12s %6s %14s %14s %8s" % ("kind", "n", "legacy", "recovered", "ratio"))
tl = tr = tn = 0
for kind in sorted(by_kind):
n, legacy, rec = by_kind[kind]
ratio = (rec / legacy) if legacy else 0
print("%-12s %6d %14s %14s %7.2fx" % (kind, n, f"{legacy:,}", f"{rec:,}", ratio))
tl += legacy
tr += rec
tn += n
print("%-12s %6d %14s %14s %7.2fx"
% ("TOTAL", tn, f"{tl:,}", f"{tr:,}", (tr / tl) if tl else 0))
if missing:
print("\ndefinitions absent from the matrix: %d" % missing)
deltas.sort()
print("\nlargest DECREASES")
for d, kind, cid, legacy, rec in deltas[:5]:
print(" %-10s %-18s %6d -> %-7d (%+d)" % (kind, cid, legacy, rec, d))
print("largest INCREASES")
for d, kind, cid, legacy, rec in deltas[-5:][::-1]:
print(" %-10s %-18s %6d -> %-7d (%+d)" % (kind, cid, legacy, rec, d))
if __name__ == "__main__":
main()
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Validate FIFA17 quick-sell against the recovered discard table, end to end.
STAGING ONLY. Refuses to run against anything but the staging host/DB, and never
touches production.
For a deterministic fixture drawn from every quick-sell-relevant category it:
1. snapshots coins + ownership
2. quick-sells through the real Rust/Core path
3. asserts the payout equals the authoritative table value for that definition
(from `cargo run --example discard_matrix`, i.e. the shipped implementation)
4. asserts the instance is removed exactly once and nothing else moved
5. REPLAYS the same request and asserts no second grant and no resurrection
6. (optionally) restarts and re-checks persistence
7. runs concurrent duplicate sells and asserts exactly one wins
Usage:
python3 scripts/fifa17-discard-validate.py --matrix /tmp/discard-matrix.csv
python3 scripts/fifa17-discard-validate.py --matrix ... --concurrency
"""
import argparse
import collections
import csv
import json
import sqlite3
import sys
import threading
import urllib.error
import urllib.request
HOST = "http://127.0.0.1:8299"
DB = "/home/alex/openfut-sold-staging/staging-core.db"
HDRS = {"X-OpenFUT-Game": "fifa17"}
FORBIDDEN = ("/home/alex/openfut-promotion",)
def guard():
for f in FORBIDDEN:
if DB.startswith(f):
raise SystemExit("refusing: DB path is production")
if "8299" not in HOST:
raise SystemExit("refusing: host is not staging :8299")
def get(path):
req = urllib.request.Request(HOST + path, headers=HDRS)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def delete(path):
req = urllib.request.Request(HOST + path, headers=HDRS, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, None
def db(query, args=()):
con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True)
try:
return con.execute(query, args).fetchall()
finally:
con.close()
def coins():
return db("SELECT coins FROM clubs LIMIT 1")[0][0]
def owned_count():
return db("SELECT count(*) FROM owned_cards")[0][0]
def owned_by_kind():
return dict(db("SELECT content_kind, count(*) FROM owned_cards GROUP BY content_kind"))
def load_matrix(path):
out = {}
with open(path) as fh:
for row in csv.DictReader(fh):
out[row["definition"]] = row
return out
def collect_fixture():
"""One representative owned instance per (kind, rating band, rareflag)."""
picks = []
seen = set()
def add(kind, it, note):
rid = it.get("resourceId")
wire = it.get("id")
if rid is None or wire is None:
return
key = (kind, it.get("rating", 0) // 10, it.get("rareflag", 0))
if key in seen:
return
seen.add(key)
picks.append({
"kind": kind, "wire": wire, "definition": "fifa17_%d" % rid,
"rating": it.get("rating"), "rareflag": it.get("rareflag"),
"subtype": it.get("cardsubtypeid"), "wire_discard": it.get("discardValue"),
"note": note,
})
club = get("/ut/game/fifa17/club?type=player&start=0&count=200")
for it in club.get("itemData") or []:
add("player", it, "club player")
for tok in ("staff", "manager"):
try:
b = get("/ut/game/fifa17/club?type=%s&start=0&count=20" % tok)
except Exception:
continue
for it in b.get("itemData") or []:
add("staff", it, "club %s" % tok)
for seg in ("contracts", "training", "fitness", "healing", "playStyle", "position"):
try:
b = get("/ut/game/fifa17/club/consumables/%s" % seg)
except Exception:
continue
for st in b.get("itemData") or []:
add("consumable", st.get("item", st), "consumable/%s" % seg)
for tok in ("kit", "badge", "stadium", "ball", "misc"):
try:
b = get("/ut/game/fifa17/club?type=%s&start=0&count=10" % tok)
except Exception:
continue
for it in b.get("itemData") or []:
add("clubitem", it, "club %s" % tok)
return picks
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--matrix", required=True)
ap.add_argument("--concurrency", action="store_true")
ap.add_argument("--limit", type=int, default=14)
ap.add_argument("--per-kind", type=int, default=6,
help="cap per content kind so every class is covered")
a = ap.parse_args()
guard()
matrix = load_matrix(a.matrix)
allf = collect_fixture()
per = collections.defaultdict(int)
fixture = []
for f in allf:
if per[f['kind']] >= a.per_kind:
continue
per[f['kind']] += 1
fixture.append(f)
fixture = fixture[: a.limit]
print("fixture: %d instance(s)\n" % len(fixture))
hdr = "%-10s %-16s %-5s %-4s %-5s %8s %8s %8s %s"
print(hdr % ("kind", "definition", "sub", "rat", "rare", "wire", "expect", "paid", "verdict"))
results = []
for f in fixture:
row = matrix.get(f["definition"])
if row is None:
print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"],
f["rareflag"], f["wire_discard"], "?", "-", "NO MATRIX ROW"))
results.append(("NO_MATRIX", f))
continue
expect = int(row["recovered"]) if row["recovered"] != "-" else None
before_c, before_n = coins(), owned_count()
still = db("SELECT count(*) FROM owned_cards")[0][0]
status, _ = delete("/ut/game/fifa17/item/%d" % f["wire"])
after_c, after_n = coins(), owned_count()
paid = after_c - before_c
removed = before_n - after_n
ok = (paid == expect) and removed == 1
# replay: must not grant again, must not resurrect
st2, _ = delete("/ut/game/fifa17/item/%d" % f["wire"])
replay_c, replay_n = coins(), owned_count()
replay_ok = (replay_c == after_c) and (replay_n == after_n)
verdict = "OK" if ok and replay_ok else (
"PAYOUT" if not ok else "REPLAY")
print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"],
f["rareflag"], f["wire_discard"], expect, paid,
"%s%s" % (verdict, "" if replay_ok else " (replay granted!)")))
results.append((verdict, f))
bad = [r for r in results if r[0] != "OK"]
print("\n%d/%d exact; %d problem(s)" % (len(results) - len(bad), len(results), len(bad)))
if a.concurrency:
print("\n=== concurrent duplicate quick-sell ===")
rest = collect_fixture()
target = next((x for x in rest if x["kind"] == "player"), None)
if target:
before_c, before_n = coins(), owned_count()
out = []
def worker():
out.append(delete("/ut/game/fifa17/item/%d" % target["wire"]))
ts = [threading.Thread(target=worker) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()
paid = coins() - before_c
removed = before_n - owned_count()
row = matrix.get(target["definition"])
expect = int(row["recovered"]) if row else None
print(" 4 concurrent DELETEs on wire %d" % target["wire"])
print(" removed=%d (want 1) paid=%d (want %s)" % (removed, paid, expect))
print(" VERDICT: %s" % ("OK" if removed == 1 and paid == expect else "FAIL"))
print("\nownership by kind now: %s" % owned_by_kind())
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Pre/post invariants for the FIFA17 Rust production migration rehearsal.
Covers every domain the authorization names: coins, ownership, squads, managers,
staff, consumables, club items, transfer state, packs/unassigned, SBC state,
match state/history. Read-only; run against a COPY.
"""
import json
import sqlite3
import sys
db = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else None
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
tables = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
def one(sql, default=None):
try:
r = con.execute(sql).fetchone()
return r[0] if r else default
except sqlite3.Error:
return default
def count(t):
return one("SELECT COUNT(*) FROM %s" % t) if t in tables else None
def group(t, col):
if t not in tables:
return None
try:
return dict(con.execute("SELECT %s, COUNT(*) FROM %s GROUP BY 1" % (col, t)))
except sqlite3.Error:
return None
inv = {
"schema_version": one("SELECT MAX(version) FROM _sqlx_migrations"),
"tables": len(tables),
# economy
"coins": one("SELECT coins FROM clubs LIMIT 1"),
"clubs": count("clubs"),
"profiles": count("profiles"),
# ownership
"owned_cards": count("owned_cards"),
"owned_by_kind": group("owned_cards", "content_kind"),
"owned_distinct_cards": one("SELECT COUNT(DISTINCT card_id) FROM owned_cards"),
"owned_loans": one("SELECT COUNT(*) FROM owned_cards WHERE is_loan=1"),
"owned_chem_styles": one(
"SELECT COUNT(*) FROM owned_cards WHERE chemistry_style IS NOT NULL"),
"owned_pos_overrides": one(
"SELECT COUNT(*) FROM owned_cards WHERE position_override IS NOT NULL"),
# squads / managers
"squads": count("squads"),
"squad_managers": count("squad_managers"),
# club items
"club_active_items": count("club_active_items"),
"club_kit_assignments": count("club_kit_assignments"),
# transfer / market / piles
"market_listings": count("market_listings"),
"market_listings_by_status": group("market_listings", "status"),
"market_history": count("market_history"),
"packs": count("packs"),
# FIFA17 opaque squad extension (adapter-owned blob)
"game_entity_ext": count("game_entity_ext"),
"squad_players": count("squad_players"),
"seasons": count("seasons"),
"events": count("events"),
"notifications": count("notifications"),
# sbc
"sbc_submissions": count("sbc_submissions"),
"sbc_challenge_squads": count("sbc_challenge_squads"),
# matches / history
"matches": count("matches"),
"match_completions": count("match_completions"),
"season_history": count("season_history"),
"statistics": count("statistics"),
"consumable_applications": count("consumable_applications"),
}
# integrity
inv["integrity_check"] = one("PRAGMA integrity_check")
inv["foreign_key_violations"] = len(list(con.execute("PRAGMA foreign_key_check")))
con.close()
print(json.dumps(inv, indent=2, sort_keys=True))
if out:
open(out, "w").write(json.dumps(inv, sort_keys=True, indent=2))
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Serve the MIGRATED + RECLASSIFIED rehearsal copy with the candidate Rust
stack and validate the wire surface. Isolated ports, copy-only state, no
production or staging resource touched."""
import json
import os
import signal
import subprocess
import time
import urllib.error
import urllib.request
R = "/home/alex/openfut-migration/rehearsal-20260822"
REPO = "/home/alex/OpenFUT"
CORE_PORT, HOST_PORT = 18099, 18098
H = {"X-OpenFUT-Game": "fifa17"}
procs = []
def start(name, binary, env):
e = dict(os.environ)
e.update(env)
log = open("%s/evidence/%s.log" % (R, name), "w")
p = subprocess.Popen([binary], cwd=REPO, env=e, stdout=log, stderr=log,
start_new_session=False)
procs.append(p)
return p
def get(port, path):
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), headers=H)
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read())
try:
start("rehearsal-core", R + "/artifacts/openfut-core", {
"LISTEN_ADDR": "127.0.0.1:%d" % CORE_PORT,
"DATABASE_URL": "sqlite://%s/work/core.db" % R,
"DATA_DIR": "%s/openfut-core/data" % REPO,
"OPENFUT_CONTENT_PACKS": "%s/emit/content/fifa17-production-cards.json" % R,
"RUST_LOG": "warn",
})
time.sleep(6)
start("rehearsal-host", R + "/artifacts/openfut-utas-host", {
"OPENFUT_UTAS_HOST_ADDR": "127.0.0.1:%d" % HOST_PORT,
"OPENFUT_UTAS_PYTHON_URL": "http://127.0.0.1:19999", # deliberately dead
"OPENFUT_CORE_URL": "http://127.0.0.1:%d" % CORE_PORT,
"OPENFUT_FIFA17_TABLES_DIR": "%s/fifa17-recon/data/tables" % REPO,
"OPENFUT_FIFA17_CATALOG": "%s/emit/content/fifa17-production-catalog.json" % R,
"OPENFUT_IDENTITY_STORE": "%s/work/identity.json" % R,
"OPENFUT_PERSONA_ID": "33068179",
"OPENFUT_MARKET_DB": "%s/work/market.db" % R,
"OPENFUT_PILE_DB": "%s/work/pile.db" % R,
"OPENFUT_FIFA17_DISCARD_TABLE": "1",
"RUST_LOG": "warn",
})
time.sleep(6)
checks = {}
club = get(HOST_PORT, "/ut/game/fifa17/club?type=player&start=0&count=5")
checks["club_total"] = club.get("totalResults")
checks["club_first_discard"] = (club.get("itemData") or [{}])[0].get("discardValue")
checks["club_first_rating"] = (club.get("itemData") or [{}])[0].get("rating")
umi = get(HOST_PORT, "/ut/game/fifa17/userMassInfo")
checks["squad_slots"] = len(((umi.get("userInfo") or {}).get("squad") or
umi.get("squad") or {}).get("players", []) or [])
for cat in ("contracts", "development", "fitness"):
d = get(HOST_PORT, "/ut/game/fifa17/club/consumables/" + cat)
st = d.get("itemData") or []
checks["consumables_" + cat] = (len(st), sum(s.get("count", 0) for s in st))
sq = get(HOST_PORT, "/ut/game/fifa17/squad/0")
checks["squad_players"] = len((sq.get("squad") or sq).get("players") or [])
print(json.dumps(checks, indent=2, sort_keys=True))
# The consumable apply is Rust-owned and MUTATES, so the rehearsal must not
# send one that would succeed: a two-target body is refused (400
# apply_batch_unsupported) before anything is resolved or written. That
# refusal is only reachable if the candidate host CLAIMS the route -- a 502
# means it fell through to the (dead) upstream, i.e. the cutover is missing.
print("\n=== the candidate must OWN the consumable apply ===")
req = urllib.request.Request(
"http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT,
data=b'{"apply":[{"id":100000003},{"id":100000004}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=20) as r:
st, body = r.status, r.read().decode()
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print(" apply route Rust-owned, nothing mutated (must be 400 "
"apply_batch_unsupported): %s"
% (st == 400 and "apply_batch_unsupported" in body))
finally:
for p in procs:
try:
os.kill(p.pid, signal.SIGTERM)
except ProcessLookupError:
pass
time.sleep(2)
for p in procs:
if p.poll() is None:
os.kill(p.pid, signal.SIGKILL)
print("\nrehearsal processes stopped")
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Transactionally-consistent backup of a live OpenFUT Core SQLite DB.
WHY NOT `cp`. Production runs WAL mode with an uncheckpointed WAL that routinely
holds hundreds of KB of committed pages. Copying only `prod-core.db` captures the
main file WITHOUT those pages and silently loses committed transactions; copying
the three files non-atomically can capture a torn set. This uses SQLite's online
backup API, which walks a read transaction and emits ONE standalone, already-
merged database file — no sidecar needed, no writer paused, safe against a live
production process.
RESTORE HAZARD, read this before restoring. The destination of a restore MUST
have its `-wal` and `-shm` removed first. SQLite treats an existing `-wal` as
newer-than-the-database journal content and will replay it over the file you
just put back, resurrecting exactly the state you were trying to abandon. The
emitted `RESTORE.sh` does this in the right order.
Usage:
fifa17-promotion-backup.py <source-db> <backup-dir> [--label NAME]
"""
import argparse
import datetime
import hashlib
import json
import os
import sqlite3
import stat
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def describe(db_path, read_only=True):
uri = f"file:{db_path}?mode=ro" if read_only else db_path
con = sqlite3.connect(uri, uri=True)
one = lambda s: con.execute(s).fetchone()[0]
info = {
"journal_mode": one("PRAGMA journal_mode"),
"page_size": one("PRAGMA page_size"),
"page_count": one("PRAGMA page_count"),
"migration_max": one("SELECT MAX(version) FROM _sqlx_migrations"),
"migration_count": one("SELECT COUNT(*) FROM _sqlx_migrations"),
"integrity_check": one("PRAGMA integrity_check"),
"foreign_key_check": len(con.execute("PRAGMA foreign_key_check").fetchall()),
"owned_cards": one("SELECT COUNT(*) FROM owned_cards"),
"coins": one("SELECT COALESCE(SUM(coins), 0) FROM clubs"),
}
con.close()
return info
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source")
ap.add_argument("backup_dir")
ap.add_argument("--label", default="core")
args = ap.parse_args()
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
os.makedirs(args.backup_dir, exist_ok=True)
dest = os.path.join(args.backup_dir, f"{args.label}-{stamp}.db")
src_info = describe(args.source)
sidecars = {
os.path.basename(args.source) + suf:
(os.path.getsize(args.source + suf) if os.path.exists(args.source + suf) else None)
for suf in ("", "-wal", "-shm")
}
print(f"source : {args.source}")
print(f" journal : {src_info['journal_mode']} migration_max={src_info['migration_max']}")
print(f" sidecars : {sidecars}")
print(f" integrity : {src_info['integrity_check']} fk_violations={src_info['foreign_key_check']}")
# Online backup API. Source opened READ-ONLY: production is never written to,
# and in WAL mode this does not block the live writer.
src = sqlite3.connect(f"file:{args.source}?mode=ro", uri=True)
dst = sqlite3.connect(dest)
with dst:
src.backup(dst)
dst.close()
src.close()
os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP) # read-only: a backup is not scratch space
dst_info = describe(dest)
digest = sha256(dest)
# The backup is only a backup if it independently verifies. A mismatch here
# means DO NOT PROCEED — it does not mean "retry and hope".
checks = {
"integrity_ok": dst_info["integrity_check"] == "ok",
"no_fk_violations": dst_info["foreign_key_check"] == 0,
"migration_matches": dst_info["migration_max"] == src_info["migration_max"],
"owned_matches": dst_info["owned_cards"] == src_info["owned_cards"],
"coins_match": dst_info["coins"] == src_info["coins"],
}
meta = {
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
"source": os.path.abspath(args.source),
"source_sidecar_sizes": sidecars,
"source_info": src_info,
"backup_path": os.path.abspath(dest),
"backup_sha256": digest,
"backup_size": os.path.getsize(dest),
"backup_info": dst_info,
"verification": checks,
"method": "sqlite3 online backup API (Connection.backup), source opened mode=ro",
}
meta_path = dest + ".json"
open(meta_path, "w").write(json.dumps(meta, indent=2, sort_keys=True) + "\n")
restore = os.path.join(args.backup_dir, f"RESTORE-{args.label}-{stamp}.sh")
open(restore, "w").write(f"""#!/bin/sh
# Canonical restore for {os.path.abspath(args.source)}
# Generated {meta['taken_at']} from backup {os.path.basename(dest)}
#
# STOP EVERY WRITER FIRST. Restoring under a live Core corrupts both.
set -eu
TARGET='{os.path.abspath(args.source)}'
BACKUP='{os.path.abspath(dest)}'
test "$(sha256sum "$BACKUP" | cut -d' ' -f1)" = '{digest}' \\
|| {{ echo 'FATAL: backup checksum mismatch, refusing to restore'; exit 1; }}
# The stale -wal/-shm MUST go, or SQLite replays them over the restored file.
rm -f "$TARGET-wal" "$TARGET-shm"
cp "$BACKUP" "$TARGET"
chmod u+w "$TARGET"
sqlite3 "$TARGET" 'PRAGMA integrity_check;' 'PRAGMA foreign_key_check;' \\
'SELECT MAX(version) FROM _sqlx_migrations;'
echo 'restore complete -- now start the PREVIOUS Core and host binaries'
""")
os.chmod(restore, 0o755)
print(f"\nbackup : {dest}")
print(f" sha256 : {digest}")
print(f" size : {meta['backup_size']:,}")
print(f" integrity : {dst_info['integrity_check']} fk_violations={dst_info['foreign_key_check']}")
print(f" migration : {dst_info['migration_max']} owned={dst_info['owned_cards']} coins={dst_info['coins']:,}")
print(f"metadata : {meta_path}")
print(f"restore : {restore}")
print("\nverification:")
for k, v in checks.items():
print(f" [{'OK ' if v else 'FAIL'}] {k}")
ok = all(checks.values())
print("\nRESULT:", "BACKUP VERIFIED" if ok else "BACKUP FAILED VERIFICATION -- DO NOT PROCEED")
raise SystemExit(0 if ok else 1)
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""READ-ONLY state capture of an OpenFUT Core SQLite DB, for promotion gating.
Opened `mode=ro` and never written to, so it is safe against live production.
Emits a deterministic JSON document: run it before a promotion and again after,
then `diff` the two. Every table is counted, so a delta cannot hide in a table
nobody thought to list.
Usage:
fifa17-promotion-snapshot.py <db-path> [out.json]
"""
import hashlib
import json
import sqlite3
import sys
def snapshot(db_path):
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
one = lambda s: con.execute(s).fetchone()[0]
snap = {
"db_path": db_path,
"journal_mode": one("PRAGMA journal_mode"),
"page_size": one("PRAGMA page_size"),
"page_count": one("PRAGMA page_count"),
"sqlite_version": sqlite3.sqlite_version,
}
# Schema version + the full applied-migration ledger. A promotion that
# claims "0028 applied" must show it here, with success=1.
snap["migration_max"] = one("SELECT MAX(version) FROM _sqlx_migrations")
snap["migration_count"] = one("SELECT COUNT(*) FROM _sqlx_migrations")
snap["migrations_failed"] = one(
"SELECT COUNT(*) FROM _sqlx_migrations WHERE success <> 1")
# Every table, counted. Deliberately not a hand-picked list.
tables = [r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
snap["table_counts"] = {t: one(f'SELECT COUNT(*) FROM "{t}"') for t in tables}
# The economically load-bearing figures, called out so a diff is readable.
snap["coins"] = dict(con.execute("SELECT id, coins FROM clubs ORDER BY id").fetchall())
snap["owned_total"] = one("SELECT COUNT(*) FROM owned_cards")
snap["owned_by_kind"] = dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards "
"GROUP BY 1 ORDER BY 1").fetchall())
snap["squad_players"] = one("SELECT COUNT(*) FROM squad_players")
snap["market_listings"] = one("SELECT COUNT(*) FROM market_listings")
snap["market_listings_sold"] = one("SELECT COUNT(*) FROM market_listings WHERE sold = 1")
snap["market_listings_npc"] = one("SELECT COUNT(*) FROM market_listings WHERE is_npc = 1")
snap["game_entity_ext"] = one("SELECT COUNT(*) FROM game_entity_ext")
snap["consumable_applications"] = one("SELECT COUNT(*) FROM consumable_applications")
snap["packs_total"] = one("SELECT COUNT(*) FROM packs")
snap["packs_unopened"] = one("SELECT COUNT(*) FROM packs WHERE opened = 0")
snap["match_completions"] = one("SELECT COUNT(*) FROM match_completions")
# Present only after 0028. Absent => pre-0028 DB, which is itself the signal.
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["owned_cards_columns"] = cols
snap["has_0028"] = "contract_matches" in cols
if snap["has_0028"]:
snap["contract_matches_set"] = one(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL")
snap["contract_matches_sum"] = one(
"SELECT COALESCE(SUM(contract_matches), 0) FROM owned_cards")
# A content fingerprint over ownership: catches a row silently rewritten
# even when every count stays identical.
h = hashlib.sha256()
for r in con.execute(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, "
"chemistry_style, position_override, training_bonus, content_kind, "
"quantity FROM owned_cards ORDER BY id"):
h.update(("|".join("" if v is None else str(v) for v in r)).encode())
snap["owned_cards_fingerprint_pre0028_columns"] = h.hexdigest()
snap["integrity_check"] = one("PRAGMA integrity_check")
snap["foreign_key_check"] = len(con.execute("PRAGMA foreign_key_check").fetchall())
con.close()
return snap
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
raise SystemExit(2)
snap = snapshot(sys.argv[1])
text = json.dumps(snap, indent=2, sort_keys=True)
print(text)
if len(sys.argv) > 2:
open(sys.argv[2], "w").write(text + "\n")
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Restart staging Core + host in place (no re-seed) and verify persistence.
`sold-staging-up.py` re-seeds the club, which would MASK a persistence failure.
So this re-execs the same binaries with the same environment against the same
DB, and compares state across the restart.
STAGING ONLY.
"""
import os
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request
DB = "/home/alex/openfut-sold-staging/staging-core.db"
ROOT = "/home/alex/openfut-sold-staging"
def state():
con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True)
try:
return (con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
con.execute("SELECT count(*) FROM owned_cards").fetchone()[0],
con.execute("SELECT count(*) FROM owned_cards WHERE content_kind='player'").fetchone()[0])
finally:
con.close()
def procinfo(name):
out = subprocess.run(["pgrep", "-af", name], capture_output=True, text=True).stdout
for line in out.splitlines():
pid = int(line.split()[0])
try:
exe = os.path.realpath("/proc/%d/exe" % pid)
except (PermissionError, FileNotFoundError):
# Not ours -- production runs as another user. NEVER touch it.
continue
if ROOT in exe:
env = dict(
kv.split("=", 1)
for kv in open("/proc/%d/environ" % pid).read().split("\0")
if "=" in kv
)
cwd = os.path.realpath("/proc/%d/cwd" % pid)
return pid, exe, env, cwd
return None
def wait_http(url, timeout=40):
end = time.time() + timeout
while time.time() < end:
try:
urllib.request.urlopen(url, timeout=3).read()
return True
except Exception:
time.sleep(0.4)
return False
before = state()
print("before restart : coins=%d owned=%d players=%d" % before)
procs = {}
for name in ("openfut-core", "openfut-utas-host"):
info = procinfo(name)
if not info:
print("FAIL: %s not found under %s" % (name, ROOT))
sys.exit(1)
procs[name] = info
print(" %-18s pid=%d" % (name, info[0]))
for name, (pid, exe, _, _) in procs.items():
assert ROOT in exe, "refusing to signal a process outside staging: %s" % exe
os.kill(pid, signal.SIGTERM)
print("sent SIGTERM to both; waiting for exit")
for _ in range(60):
if all(not os.path.exists("/proc/%d" % p[0]) for p in procs.values()):
break
time.sleep(0.25)
mid = state()
print("after stop : coins=%d owned=%d players=%d" % mid)
for name in ("openfut-core", "openfut-utas-host"):
pid, exe, env, cwd = procs[name]
log = open("%s/logs/%s.restart.log" % (ROOT, name), "ab")
subprocess.Popen([exe], env=env, cwd=cwd, stdout=log, stderr=log,
start_new_session=True)
print(" respawned %s" % name)
ok_core = wait_http("http://127.0.0.1:18081/health") or True # health path may differ
ok_host = wait_http("http://127.0.0.1:8299/ut/game/fifa17/tradePile/counts")
print("host reachable after restart: %s" % ok_host)
after = state()
print("after restart : coins=%d owned=%d players=%d" % after)
print("VERDICT: %s" % ("PERSISTED EXACTLY" if before == after == mid else "MISMATCH"))
sys.exit(0 if before == after else 1)
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Report host-level packet interception affecting OpenFUT endpoints.
WHY THIS EXISTS
---------------
During the 2026-08-22 Rust production cutover, four stale `openfut-switch` nft
rules were still redirecting production-facing traffic to staging:
42127 -> :42227 8081 -> :8281 8094 -> :18094 8099 -> :18106
They matched `ip daddr 10.10.0.120`, so every server-side probe via 127.0.0.1 or
the container IP passed while the CLIENT was refused or silently sent to
staging. That cost an entire false-negative acceptance round: a retail
quick-sell landed on staging while production sat untouched, and the launcher
reported "OpenFUT server is not answering".
The lesson is mechanical, so the check is too: a connectivity gate that only
probes loopback proves nothing about what the client reaches.
READ-ONLY. This tool never deletes a rule. Removing interception is a
deliberate operator act (`openfut-switch.sh off --name <id>`).
Exit status: 0 CLEAN, 1 INTERCEPTION_PRESENT, 2 could not determine.
python3 scripts/openfut-interception-preflight.py
python3 scripts/openfut-interception-preflight.py --advertise 10.10.0.120
"""
from __future__ import annotations
import argparse
import re
import shutil
import socket
import subprocess
import sys
# The endpoints a FIFA 17 client actually dials, plus the staging twins that
# stale rules historically pointed at.
PRODUCTION_PORTS = {
8099: "UTAS (Rust utas-host)",
8081: "roster",
8094: "POW api",
8085: "POW content",
4216: "LSX (client-side)",
42127: "Blaze redirector",
42130: "Blaze main",
42131: "Nucleus",
}
# LSX runs on the GAME machine, not here: the compose file publishes 4216 but
# `OPENFUT_SERVERS` excludes lsx by default, so "published but not served" is
# its normal state and must not be reported as interception. Every other port
# above is expected to be served on this host.
NOT_SERVED_HERE = {4216}
STAGING_PORTS = {8299: "staging UTAS", 42327: "staging redirector",
42330: "staging blaze main", 8281: "staging roster",
18094: "staging POW", 18106: "retired season-shim"}
ALL_PORTS = dict(PRODUCTION_PORTS)
ALL_PORTS.update(STAGING_PORTS)
def _run(cmd: list[str]) -> str:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
return r.stdout
except Exception:
return ""
def switch_status() -> tuple[str, list[str]]:
"""`openfut-switch.sh status`, which owns the redirect lifecycle."""
for path in ("/home/alex/OpenFUT/openfut-blaze-host/openfut-switch.sh",
"openfut-blaze-host/openfut-switch.sh"):
if shutil.which("bash") and subprocess.run(
["test", "-x", path], capture_output=True).returncode == 0:
out = _run([path, "status"])
active = [l.strip() for l in out.splitlines()
if "->" in l and "openfut-switch" in l]
return ("INACTIVE" if "INACTIVE" in out else
("ACTIVE" if active else "UNKNOWN")), active
return "UNKNOWN", []
def nft_redirects(advertise: str) -> tuple[list[str], list[str]]:
"""Split nft rules touching our ports into REDIRECTs (interception) and
Docker's own DNAT (legitimate publishing)."""
out = _run(["sudo", "-n", "nft", "list", "ruleset"])
if not out:
out = _run(["nft", "list", "ruleset"])
redirects, dnats = [], []
port_re = re.compile(r"dport (\d+)")
for line in out.splitlines():
s = line.strip()
m = port_re.search(s)
if not m or int(m.group(1)) not in ALL_PORTS:
continue
if "redirect to" in s:
redirects.append(s)
elif "dnat to" in s:
dnats.append(s)
return redirects, dnats
def reachable(host: str, port: int, timeout: float = 2.0) -> bool:
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--advertise", default="10.10.0.120",
help="the address the CLIENT dials (not loopback)")
args = ap.parse_args()
adv = args.advertise
print("OpenFUT interception preflight — advertise=%s" % adv)
print()
state, active = switch_status()
print("openfut-switch : %s" % state)
for a in active:
print(" %s" % a)
redirects, dnats = nft_redirects(adv)
print("\nnft REDIRECTs on OpenFUT ports : %d%s"
% (len(redirects), " <-- INTERCEPTION" if redirects else ""))
for r in redirects:
print(" %s" % r[:150])
print("nft DNAT (docker publishing) : %d (expected, not interception)"
% len(dnats))
# The point of the whole tool: compare loopback with the address the client
# actually dials. A redirect keyed on the LAN IP is invisible to loopback.
print("\neffective endpoint, loopback vs advertised:")
disagree = []
for port, label in sorted(PRODUCTION_PORTS.items()):
lo = reachable("127.0.0.1", port)
wan = reachable(adv, port)
if lo == wan:
flag = ""
elif port in NOT_SERVED_HERE:
flag = " (not served here -- expected)"
else:
flag = " <-- DISAGREE"
if lo != wan and port not in NOT_SERVED_HERE:
disagree.append((port, label, lo, wan))
print(" :%-6d %-24s loopback=%-5s advertised=%-5s%s"
% (port, label, lo, wan, flag))
intercepted = bool(redirects) or state == "ACTIVE" or bool(disagree)
print()
if intercepted:
print("RESULT: INTERCEPTION_PRESENT")
if disagree:
print(" loopback and the advertised address disagree on: %s"
% ", ".join(":%d" % p for p, _, _, _ in disagree))
print(" Nothing was changed. To clear a switch rule, run explicitly:")
print(" openfut-blaze-host/openfut-switch.sh off --name <id>")
return 1
print("RESULT: CLEAN")
return 0
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
+92
View File
@@ -0,0 +1,92 @@
# OpenFUT systemd units
Supervision for OpenFUT Core and the FIFA17 UTAS host. Replaces the previous
`setsid nohup … nsenter …` launch, which had no restart policy, no boot
persistence and no supervisor-visible logs.
| file | scope | installed? |
|---|---|---|
| `openfut-staging-netns.service` | binds the staging anchor netns | **yes** — proving ground |
| `openfut-staging-core.service` | staging Core, port 18081 | **yes** |
| `openfut-staging-host.service` | staging host, port 8299 | **yes** |
| `openfut-staging-netns-reconcile.{service,timer}` | staging netns lifecycle | **yes** |
| `openfut-netns.service` | binds the production anchor netns to `/run/netns/openfut` | template only |
| `openfut-core.service` | production Core, port 18080 | template only |
| `openfut-host.service` | production host, port 8099 | template only |
| `openfut-netns-reconcile.{service,timer}` | production netns lifecycle | template only |
| `openfut-netns-bind.sh` | resolves the anchor netns by NAME, idempotently, drains stale mounts | helper |
| `openfut-netns-reconcile.sh` | keeps services in the anchor's CURRENT netns | helper |
| `openfut-wait-tcp.sh` | bounded readiness gate | helper |
| `openfut-supervision-install.sh` | install / start / status / uninstall per environment | helper |
Production templates are **not installed**. Deploy only via the plan in
`OpenFUT-Vault/06 Operations/OpenFUT Service Supervision (staging-proven).md`.
## The three decisions worth knowing
**1. `Wants=`, not `Requires=`, from host → Core.** Measured on staging:
`Requires` propagates a Core stop into a host stop, and a later Core start does
*not* bring the host back — a routine Core restart would leave the client with
no server. With `Wants`, the host survives a Core outage, answers
`503 core_unavailable` (never a Python fallback), and resumes the moment Core
returns, with no supervisor intervention.
**2. Readiness is an `ExecStartPre` TCP gate, not ordering.** `After=`/`Wants=`
order units; `Type=exec` only proves the binary exec'd. Neither means Core can
serve. Core binds its listener *after* opening the DB, running migrations and
loading the content pack, so "port open" is a genuine readiness signal. The gate
is bounded and *fails* rather than blocking: a host that waits forever looks
healthy to the supervisor while serving nobody.
**3. The netns is resolved by container NAME at every start.** Production must
run inside `openfut-fut-backend`'s network namespace. The container is
`restart=unless-stopped`, and its netns inode *changes* on restart — observed
`net:[4026539938] → net:[4026540033]`. A hardcoded pid is therefore wrong by
construction, and any process left in the old namespace keeps running with no
interfaces, silently serving nobody. `openfut-netns-bind.sh` re-resolves by name
and refreshes a stale bind mount; `NetworkNamespacePath=` then enters it
declaratively.
**4. Stale namespaces are repaired automatically (this closes the old gap).**
Recreating the anchor strands already-running services in the dead namespace,
and — the dangerous part — *systemd still reports them `active`*. Measured before
the fix: anchor `net:[4026539938] → net:[4026540033]`, Core and host unchanged in
the old one, both units `active`, traffic `ConnectionResetError`.
`openfut-netns-reconcile.sh` on a 10s timer compares the namespace the services
are **actually in** against the anchor's **current** one and, only on a real
difference, performs one stop → rebind → start cycle. It is level-triggered, so
it cannot miss an event and needs no debounce: a burst of three back-to-back
recreations produced exactly **one** rebind.
## Operating
```bash
sudo ./openfut-supervision-install.sh status staging # units, anchor, netns agreement, mounts
journalctl -u openfut-staging-netns-reconcile -f # silent unless it acts
sudo systemctl restart openfut-staging-core # host survives and recovers
sudo ./openfut-supervision-install.sh install staging # copy + enable (does not start)
sudo ./openfut-supervision-install.sh uninstall staging # disable, stop, remove units
```
`status` is the one command worth knowing: it prints each unit's state, the
anchor's current netns, the namespace each service is *actually* in with an
`ok`/`MISMATCH` verdict, and the mount count (`1` healthy, `>1` a leaked stack).
Config lives in `EnvironmentFile`s (`…/systemd/core.env`, `host.env`), generated
from the live process environment so supervision changed *how* the processes
start and nothing about *what* they do. Binaries are immutable copies, so a
later `cargo build` cannot change what is running.
## Interaction with the staging lifecycle script
`scripts/sold-staging-up.py` still starts its own unsupervised processes and
refuses to run while a staging stack is up. Stop the units first:
```bash
sudo systemctl stop openfut-staging-host openfut-staging-core
python3 scripts/sold-staging-up.py --club real --variant highest …
```
Reconciling the two (having the script drive the units) is deliberately out of
scope for the supervision milestone.
@@ -0,0 +1,20 @@
[Unit]
Description=OpenFUT: record unattended proof that supervision recovered after boot
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=openfut-host.service openfut-netns-reconcile.timer docker.service
Wants=openfut-host.service
# Observation only. Deliberately no Requires= and no ordering that anything else
# waits on: this unit must never be able to affect the boot it is measuring.
[Service]
Type=oneshot
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-boot-evidence.sh
TimeoutStartSec=300
# Never fail the boot over evidence collection.
SuccessExitStatus=0 1
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-boot-evidence
[Install]
WantedBy=multi-user.target
+132
View File
@@ -0,0 +1,132 @@
#!/bin/sh
# Capture unattended proof that the OpenFUT supervision chain came back by
# itself after a boot.
#
# This exists because the reboot-survival gate is, by definition, a test no
# operator can stand inside: the machine under test is the machine running the
# session. So the machine records its own recovery.
#
# It observes only — it never starts, stops or repairs anything. If the chain is
# broken, the evidence file says so, which is the point.
#
# usage: openfut-boot-evidence.sh [outdir]
set -u
OUTDIR="${1:-/home/alex/openfut-promotion/boot-evidence}"
ANCHOR=openfut-fut-backend
NSNAME=openfut
DB=/home/alex/openfut-promotion/state/prod-core.db
DEADLINE=180 # seconds to allow for docker + anchor + reconcile to settle
mkdir -p "$OUTDIR"
BOOTID="$(cat /proc/sys/kernel/random/boot_id)"
OUT="$OUTDIR/boot-$(date -u +%Y%m%dT%H%M%SZ)-${BOOTID%%-*}.json"
# Wait until converged, or until the deadline. Converged means both services are
# active and share the anchor's CURRENT namespace. Polling rather than a fixed
# sleep so a boot-time reconcile retry is captured as "settled late", not as a
# failure, and so a healthy boot is recorded promptly.
i=0
while [ "$i" -lt "$DEADLINE" ]; do
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
if [ -n "$cpid" ] && [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
want="$(readlink "/proc/$cpid/ns/net")"
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
if [ -n "$cm" ] && [ "$cm" != "0" ] && [ -e "/proc/$cm/ns/net" ] &&
[ -n "$hm" ] && [ "$hm" != "0" ] && [ -e "/proc/$hm/ns/net" ] &&
[ "$(readlink "/proc/$cm/ns/net")" = "$want" ] &&
[ "$(readlink "/proc/$hm/ns/net")" = "$want" ]; then
break
fi
fi
i=$((i + 2))
sleep 2
done
SETTLED_AFTER="$i"
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
cid="$(docker inspect -f '{{.Id}}' "$ANCHOR" 2>/dev/null || echo none)"
ans="$([ "$cpid" != "0" ] && readlink "/proc/$cpid/ns/net" 2>/dev/null || echo none)"
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
cns="$([ -n "$cm" ] && [ "$cm" != "0" ] && readlink "/proc/$cm/ns/net" 2>/dev/null || echo none)"
hns="$([ -n "$hm" ] && [ "$hm" != "0" ] && readlink "/proc/$hm/ns/net" 2>/dev/null || echo none)"
mounts="$(awk -v t="/run/netns/$NSNAME" '$2==t {n++} END {print n+0}' /proc/mounts)"
# Non-mutating reads, from inside the anchor namespace for Core (it is not
# published to the host namespace) and on the published port for the host.
# One argument only: a second positional would be unbound under `set -u` and
# would abort the subshell, silently yielding an empty probe result.
probe() { nsenter --net="/proc/$cpid/ns/net" python3 - "$1" <<'PY' 2>/dev/null || echo "ERR"
import sys, urllib.request, urllib.error
try:
r = urllib.request.urlopen(urllib.request.Request(sys.argv[1], headers={"X-OpenFUT-Game": "fifa17", "X-UT-SID": "boot-evidence"}), timeout=8)
print(r.status)
except urllib.error.HTTPError as e:
print(e.code)
except Exception:
print("ERR")
PY
}
if [ "$cpid" != "0" ]; then
core_health="$(probe http://127.0.0.1:18080/health)"
core_coll="$(probe 'http://127.0.0.1:18080/collection?limit=1')"
host_acct="$(probe http://127.0.0.1:8099/ut/game/fifa17/user/accountinfo)"
host_club="$(probe 'http://127.0.0.1:8099/ut/game/fifa17/club?count=1&start=0')"
else
core_health=none; core_coll=none; host_acct=none; host_club=none
fi
econ="$(python3 - "$DB" <<'PY' 2>/dev/null || echo '{}'
import sys, sqlite3, json
c = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
q = lambda s: c.execute(s).fetchone()[0]
print(json.dumps({
"schema": q("SELECT MAX(version) FROM _sqlx_migrations"),
"coins": q("SELECT coins FROM clubs"),
"owned": q("SELECT COUNT(*) FROM owned_cards"),
"by_content_kind": dict(c.execute("SELECT COALESCE(content_kind,'(null)'),COUNT(*) FROM owned_cards GROUP BY 1 ORDER BY 1").fetchall()),
"applications": q("SELECT COUNT(*) FROM consumable_applications"),
"contract_sum": q("SELECT COALESCE(SUM(contract_matches),0) FROM owned_cards"),
"squad_players": q("SELECT COUNT(*) FROM squad_players"),
"market_listings": q("SELECT COUNT(*) FROM market_listings"),
"game_entity_ext": q("SELECT COUNT(*) FROM game_entity_ext"),
"integrity": q("PRAGMA integrity_check"),
"fk": len(c.execute("PRAGMA foreign_key_check").fetchall()),
}))
PY
)"
owner_rust="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=RUST')"
owner_py="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=PYTHON')"
cat > "$OUT" <<EOF
{
"boot_id": "$BOOTID",
"captured_utc": "$(date -u +%FT%TZ)",
"settled_after_seconds": $SETTLED_AFTER,
"uptime_at_capture": "$(cut -d' ' -f1 /proc/uptime)",
"anchor": {"container": "${cid%%[!0-9a-f]*}", "pid": "$cpid", "netns": "$ans"},
"core": {"pid": "$cm", "netns": "$cns", "active": "$(systemctl is-active openfut-core.service)", "enabled": "$(systemctl is-enabled openfut-core.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-core.service)"},
"host": {"pid": "$hm", "netns": "$hns", "active": "$(systemctl is-active openfut-host.service)", "enabled": "$(systemctl is-enabled openfut-host.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-host.service)"},
"netns_unit": {"active": "$(systemctl is-active openfut-netns.service)", "enabled": "$(systemctl is-enabled openfut-netns.service)", "result": "$(systemctl show -p Result --value openfut-netns.service)"},
"reconcile": {"timer_active": "$(systemctl is-active openfut-netns-reconcile.timer)", "timer_enabled": "$(systemctl is-enabled openfut-netns-reconcile.timer)", "last_result": "$(systemctl show -p Result --value openfut-netns-reconcile.service)", "acted_this_boot": $(journalctl -u openfut-netns-reconcile -b --no-pager -o cat 2>/dev/null | grep -c 'rebind cycle')},
"netns_agreement": $([ "$ans" = "$cns" ] && [ "$ans" = "$hns" ] && [ "$ans" != "none" ] && echo true || echo false),
"nsfs_mounts": $mounts,
"probes": {"core_health": "$core_health", "core_collection": "$core_coll", "host_accountinfo": "$host_acct", "host_club": "$host_club"},
"authority": {"owner_rust": $owner_rust, "owner_python": $owner_py},
"economy": $econ
}
EOF
chmod 0644 "$OUT"
ln -sfn "$OUT" "$OUTDIR/latest.json"
echo "openfut-boot-evidence: wrote $OUT (settled after ${SETTLED_AFTER}s)"
# Also drop the ordering proof for this boot, so ordering is read from real
# timestamps rather than inferred from unit dependencies.
journalctl -b -u openfut-netns -u openfut-core -u openfut-host -u openfut-netns-reconcile \
-o short-precise --no-pager > "$OUTDIR/latest-journal.txt" 2>/dev/null
exit 0

Some files were not shown because too many files have changed in this diff Show More