170 Commits

Author SHA1 Message Date
funman300 e14d3cd063 Trace FIFA17 command 0x128 lifecycle 2026-08-28 01:12:37 +00:00
funman300 f4fc832ace Trace FIFA17 PMA producer lifecycle 2026-08-27 23:27:52 +00:00
funman300 6aab279244 Wire FIFA17 PMA repair candidate 2026-08-27 22:40:04 +00:00
funman300 d3451be17b Trace FIFA17 PMA completion divergence 2026-08-27 21:43:48 +00:00
funman300 0300af3333 Add FIFA17 Kick Off control trace profile 2026-08-26 17:34:43 +00:00
funman300 2c572e918f tools: trace FIFA17 scenario start source chain 2026-08-26 04:46:42 +00:00
funman300 f608dbc438 Add post-kit gameplay transition tracers 2026-08-26 01:40:41 +00:00
funman300 43c460741b trace FIFA17 provider lifecycle 2026-08-25 23:18:08 +00:00
funman300 5eed124b85 Add FIFA17 match transition tracers 2026-08-25 21:37:59 +00:00
funman300 e3ed8c298e fix(fifa17): advance season team compatibility 2026-08-25 20:14:57 +00:00
funman300 5181e103dc Add FIFA17 game-setup context tracers 2026-08-25 18:29:55 +00:00
funman300 433a9b22dd tool(fifa17-recon): trace Offline Seasons team assignment hardware-only
Add a fail-closed, fresh-process native trace workflow for the Offline Seasons
fixture-to-match-team boundary. The supervisor ignores UMU's short-lived
FIFA17.exe process, requires CardsDLL, verifies TracerPid and hardware arming,
rejects pre-existing records, structurally locates fixtures/final records, and
detaches cleanly after capture.

The four payloads reproduce the measured chain without client writes:

  FUN_1800fc500
    -> actual season vector, fixture index 0 / team 73
    -> temporary [73,130000] pair (not the final record)

  CardsDLL service -> engine 0x147c652ce
    -> live final +0x14 writes at the 0x45c side stride
    -> correct [73,130000], then local overwrite [130000,130000]

  engine wrapper 0x147ce47e0
    <- CardsDLL 0x180031861
    <- CardsGameSetupAdapter local `teams` query result already 130000

All execute breakpoints and watchpoints are hardware-only. /proc/PID/mem is
opened rb. No INT3, write_memory, patch, game input, server behavior, or Rust
code. Locator uses zero-based --fixture-index (the live selector is 0 when
season/user.round is 1) and never filters on transient +0x18 handles.
2026-08-25 17:25:41 +00:00
funman300 0701ac94e1 tool(fifa17-recon): live native-RE toolkit (disasm, xref, immediate-store, vtable)
Read-only probes for resolving FIFA17 code paths against a running client
without Ghidra, per the live-disassembly method (/proc/<pid>/mem + objdump).
All open /proc/<pid>/mem 'rb' only.

  ldis.py           image-VA disassembler/hexdump for CardsDLL and FIFA17.exe;
                    recomputes the module base from the NAMED PE-header mapping
                    every run, because Wine maps PE sections anonymously and the
                    mapping that merely CONTAINS an address is not the module.
  xref.py           references to an image VA: call/jmp rel32, rip-relative lea,
                    and absolute pointer slots. An absolute-only hit means the
                    function is virtual and reachable solely via its vtable.
  immstore.py       immediate stores (C7 /0) of a constant to a struct offset.
                    Only an immediate store can INTRODUCE a constant; a register
                    store merely propagates one. Zero hits is a real result: it
                    proves the constant arrives from a call, not a literal.
  classify_calls.py splits call sites of a constant-returning stub into STORE
                    (can assign) vs compare (predicate). Turned 81 call sites of
                    the 130000 provider into 17 assignments.
  vtab.py           dumps a vtable as image VAs and looks for sibling vtables
                    holding a different function in the same slot, which is how
                    a type/mode dispatch shows up.
  scan_mt.py        match-team records by the invariant header (11,7,0,0,76).
                    Never filters on +0x18: that word is a per-session handle
                    (-1 on 2026-08-24, 0x54001/0x54000 on 2026-08-25) and
                    filtering on it previously produced a false negative.

Workflow note: dump .text once and cache the objdump output, then query the
cached listing; a full CardsDLL .text linear disassembly is ~563k lines and
re-disassembling per question is wasteful.
2026-08-25 04:23:22 +00:00
funman300 025122ec9a tool(fifa17-recon): manager_coldproof.py -- read-only manager registration probe
Promotes the throwaway probe used to close the manager cold-load milestone into
fifa17-recon/tools. Read-only (/proc/<pid>/mem opened 'rb', never 'r+b'), pid
optional and overridable, controls overridable via --control WIRE:RESOURCE.

Fail-closed: absent player positive controls exit 3 (INCONCLUSIVE, squad not
loaded) rather than 0, so 'no manager found' can never be reported from a
session that never loaded a squad. Distinguishes real item records from
incidental integer matches by requiring resourceId 0x20 bytes before the wire
id, the layout the player controls exhibit.

Documents the manager wire control, the resourceId control (the actual
verdict), the player positive controls, and what counts as a resident hit.
2026-08-25 02:58:16 +00:00
funman300 b91e707a7e fix(fifa17): squad.manager elements are bare item objects, not itemData wrappers
An owned manager assigned in Core was present everywhere on the server -- in
/club/manager, in club?type=staff, and in userMassInfo -- but the squad UI
showed no manager after a cold client load.

The squad parser FUN_18013d1f0 reaches the item parser FUN_18013fe00 by two
different routes:

  players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
           378 `kitNumber`; the 363 arm at 0x18013d8d9 calls the item parser
           on the NESTED itemData object.
  manager: atom 424 -> array loop at 0x18013da29 calls that same item parser
           DIRECTLY on the array ELEMENT, into squad+0xC0. No `itemData` step.

So a manager element IS an item. We were nesting the fields one level deeper,
so the parser read only the two keys that happen to be item atoms -- `id` and
`dream` -- and left everything else at its default. Measured on a cold client,
the manager record existed at squad+0xC0 with the correct id and resourceId 0,
while sibling players in the same response carried theirs. resourceId is the
merge key compared RAW against carddbid, so 0 resolves no manager: no name, no
rating, no art, empty slot.

The client's own save corroborates the shape: it PUTs
`"manager":[{"id":...,"dream":false}]` -- flat, and both keys are item atoms.

Flatten the element to the item plus `dream`. Cold-load proven on staging: the
manager record now carries resourceId 1000509 in the same layout as its player
siblings (83906881, 84053575) in the same array region, and the operator
confirms a manager is assigned in the squad management screen.

Two earlier shapes are now both explained and covered by tests: `{id, dream}`
carries no merge key, and `{id, itemData, dream}` hides it from this path.
2026-08-25 02:50:22 +00:00
funman300 c3d0e56f69 chore(core): bump pointer for partial squad role update
Core 20e281e adds `PUT /squad/roles`, the role-only patch the FIFA 17
captain/kick-taker screen needs. The host change (e7893a0) requires it.
2026-08-25 01:52:59 +00:00
funman300 e7893a0162 fix(fifa17): route a partial squad PUT to a role patch, not a replacement
FIFA 17 sends two different operations to `PUT …/squad/<id>` and distinguishes
them only by body shape. Across 73 captured squad PUTs in five captures there
are exactly two:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified on staging with the env var REMOVED from host.env entirely: the host
logs squad_actives=true and serves both kits (assetId 14/15, states 101/102)
with 23/23 players and the manager intact.
2026-08-24 21:38:33 +00:00
funman300 ead0426ea0 tools(re): correct transposed field labels in the kit record diff
club_items.json's _record_map is authoritative: cardassetid is +0x1c and
assetId is +0x20. The probe had them the other way round, which made a correct
assetId 14/15 read out as an identical cardassetid and briefly supported the
wrong conclusion that assetId was not the art selector.
2026-08-24 21:01:29 +00:00
funman300 74768693ec fix(fifa17): send a club item's real wire assetId, not its carddbid
A club item's `assetId` (record +0x20) is family specific and is NOT the
carddbid: per the client's own tables a kit carries the art class from
fcc_kitcards.assetid - 14 for the 63xxxxx home/third band, 15 for the 64xxxxx
away band - a badge carries its team id, and a ball and stadium their own
asset number. The catalog shipped `asset_id`, the carddbid, in that slot.

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

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

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

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

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

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

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

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

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

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

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

Players 23/23, manager 1, captain, active club items, coins, integrity and FK
identical before and after, and again after a Core+host restart.
2026-08-24 19:59:09 +00:00
funman300 09bb2dc306 chore: bump openfut-core - refuse squad replacements that empty a populated squad 2026-08-24 19:27:45 +00:00
funman300 42229e5782 tools(re): report club-item slot indices in the residency probe
The squad parser writes actives element i to club-item slot r15d+i, and r15d
is shared scratch that other atom handlers clobber. Which slots are filled
therefore reveals the index the parse actually started from, which is the
open question behind the regression in vault section 18.

probe_resident_fields.py now prints the slot index of every populated entry
plus the empty ones, and verify_kits.sh runs it next to the map census so one
command reports both residency and whether the squad survived.
2026-08-24 18:58:41 +00:00
funman300 d929efdffe fix(fifa17): disable squad.actives by default after it emptied the squad
Populating `squad.actives` is the proven way to make a club item resident —
the squad parser writes each element straight into a club-item slot, both
kits came back resident with the client writing `category 4` itself, and the
pre-match kit selector worked.

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

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

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

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

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

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

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

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

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

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

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

Verified against the client's own fcc_kitcards table: 6300006 is team
21's home card (category 2) and 6400003 the away card (category 3).
2026-08-24 17:59:11 +00:00
funman300 2fc335c37d tools(re): interpret FIFA17 atom mappers and enumerate the resident item map
Two tools, both gated on positive controls because the previous pass
produced a confidently wrong negative result.

atom_mapper_emu.py interprets the atom -> field-id dispatch functions
instead of pattern-scanning them. Its selftest encodes the two decoder
traps that caused earlier mistakes - ModRM rm=5 with mod!=0 is [rbp+disp]
rather than RIP-relative, and a constant may reach its use through a
register - plus the live-verified controls that the item mapper maps atom
568 'players' to field id 1 and atom 11 'actives' to 0. The mandatory
manager atom 424 control still fails as a coverage limit: only 2 of the 52
resolver callers are pure dispatch chains, so the tool refuses to support
any absence claim about the squad mapper.

The live probes enumerate the resident item map at owner+0x160c8, whose
layout came from the lower_bound at 0x180119640: key = wire instance id at
node+0x20, record at node+0x28, count at owner+0x160e8. probe_map2 reaches
22 nodes against a count field of 22, so the enumeration validates itself,
and probe_hunt searches all writable memory with its own in-run positive
control. Result: all four club staff are resident, both kit ids are absent
everywhere, and a lookup miss returns the static sentinel 0x1802c2a28
whose +0x10 is NULL - which is exactly the KIT_SCAN symptom.
2026-08-24 17:24:57 +00:00
funman300 25b7089c54 tools(re): walk the record pool embedded in the club-model owner
Records are 0x180 bytes starting at owner+0x162d8, below the embedded
manager subobject at owner+0x1f9d8. Walking that pool answers whether the
client ever builds a record for an item it was served, independently of
whether the record is filed into a collection.

Result on the live client with the kit selector open: 21 records exist -
18 squad players with category 1, plus one cardtype 10 and two cardtype 4
staff with category 0 - and no cardtype 7 record anywhere, despite two kit
items having been served three times in the same boot.
2026-08-24 17:00:31 +00:00
funman300 8983998707 tools(re): dump resident record fields to settle the kit category gate
The kit clone driver FUN_1801c3480 gates on record+0x60 == 4, and no
instruction stores that immediate, so the value had to be read off a
genuinely resident record. This dumps cardtype, cardsubtypeid, itemState,
category, teamid and teamkittypetechid for both the player vector and the
club-item vector, resolving the store through the same chain as the census.

Result: all 18 resident players carry category 1 and the five club-item
slots are null, which identifies +0x60 as a per-collection tag rather than
item data.
2026-08-24 16:54:04 +00:00
funman300 96610ccb16 tools(re): remove the port-8081 DNAT that breaks FIFA 17's roster TLS
A client-local nat rule redirecting dport 8081 to the plain-HTTP staging
UTAS host captures the roster/squad-update TLS connection, which is
https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml.
The staging host completes the TCP handshake then closes on the
ClientHello, so the client retries and shows "An error occurred
downloading the FUT squad update."

Proven by capturing on the server while probing from the client: a
connection addressed to :8081 arrives as dport 8299, while an :8443
control in the same capture yields 75 packets and a clean handshake.

The script deletes only nat rules whose destination port is 8299, then
verifies the endpoint presents CN = winter15.gosredirector.ea.com.
2026-08-24 16:36:56 +00:00
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
158 changed files with 58953 additions and 1497 deletions
+25
View File
@@ -0,0 +1,25 @@
# OpenFUT Docker stack configuration. Copy to .env and adjust.
# All values have sensible defaults in docker-compose.yml; override as needed.
# --- Container registry (Gitea) ---
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
# e.g. git.aleshym.co/openfut/openfut-core:latest
REGISTRY=git.aleshym.co
NAMESPACE=openfut
TAG=latest
# --- Networking ---
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
BRIDGE_PUBLISH=0.0.0.0
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
CORE_PUBLISH=127.0.0.1
# --- Behaviour ---
# Bridge returns placeholder JSON + captures unknown routes when true.
PLACEHOLDER_MODE=true
# --- Logging (RUST_LOG filters) ---
CORE_LOG=openfut_core=info,tower_http=info
BRIDGE_LOG=openfut_bridge=info,tower_http=info
+163
View File
@@ -0,0 +1,163 @@
# AGENTS.md — OpenFUT
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
## Project
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
project does not bypass ownership checks — it only re-serves the dead online services locally.
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
Three moving parts, kept strictly separate:
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
## Repository map
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
| Path | What it is | Status |
|---|---|---|
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
a task explicitly targets the FIFA 23 port.
## Architecture (live path)
```
FIFA 17 client (Wine/Proton, base 0x140000000)
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
└─ roster :8081 → roster_server.py (FUT roster-update XML)
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
```
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
## Development commands (verified)
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
(sqlite://openfut.db), `DATA_DIR` (data).
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
## Coding conventions
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
`models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
design:** every service reads "the active profile" as the first DB row — intentional, don't
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
names). When changing a responder, preserve byte-exactness — the client is the oracle.
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
## AI-agent rules
1. Read this file before exploring the repo.
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
vault as canonical.
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
stale, update the vault, then its repo `docs/` mirror.
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
6. Don't refactor or rewrite unrelated working code.
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
confident guesses. The live client is the only oracle for whether a gate is satisfied.
9. Clearly separate discovered behavior from hypotheses; record findings in
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
tier — never silently promote a hypothesis to a fact.
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
prefer vault + repository evidence over them when they conflict.
## AI Session Bootstrap
Future agents should start with:
1. Read `AGENTS.md`.
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
Project State, Roadmap/Current Priorities, or Protocol Findings.
4. Inspect only the relevant source directories.
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
anything about FIFA/EA behavior.
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
7. Implement the smallest coherent change.
8. Run the narrowest relevant tests.
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
inaccurate.
Do not reread the entire repository during every session.
## OpenFUT Knowledge Base
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
vault wins on any disagreement. Consult it before starting substantial work and update it after
durable discoveries.
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
Canonical files:
- Dashboard: `00 Dashboard/OpenFUT.md`
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
- Direction history: `04 Decisions/Direction History.md`
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
- Current Priorities: `06 Agent Memory/Current Priorities.md`
- Known Issues: `06 Agent Memory/Known Issues.md`
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
When editing knowledge that exists in both places, edit the vault first, then update the matching
`docs/` mirror so they stay in sync.
+2
View File
@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
## Repository Layout
This is a monorepo containing three independent Rust crates as git submodules:
+4
View File
@@ -1,5 +1,9 @@
# OpenFUT
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
>
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
**Offline Ultimate Team — like SPT, but for FIFA 23.**
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
+93
View File
@@ -0,0 +1,93 @@
# ============================================================================
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
#
# Canonical server (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
#
# `core` (openfut-core) IS still the shared, game-independent backend and is
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
# is the retired FIFA 23 integration, kept for reference.
# Status source of truth: docs/PROJECT_STATE.md
# ============================================================================
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
#
# Bring up: docker compose up -d
# Tear down: docker compose down (keeps data/captures volumes)
# Wipe state: docker compose down -v (also drops volumes)
# Rebuild: docker compose build (or ./scripts/registry.sh build)
# Logs: docker compose logs -f
#
# Images are pulled from / pushed to the Gitea container registry. Override the
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
name: openfut
services:
core:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
build:
context: ./openfut-core
dockerfile: Dockerfile
restart: unless-stopped
environment:
LISTEN_ADDR: 0.0.0.0:8080
DATABASE_URL: sqlite:///app/db/openfut.db
DATA_DIR: /app/data
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
volumes:
- core-db:/app/db
# Bound to localhost by default — the bridge reaches core over the internal
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
# .env if you want to hit the REST API directly from other hosts.
ports:
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 10s
bridge:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
build:
context: ./openfut-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
core:
condition: service_healthy
environment:
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
CORE_URL: http://core:8080
CAPTURES_DIR: /app/captures
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
TLS_ENABLED: "true"
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
volumes:
- bridge-captures:/app/captures
# The FIFA client connects here — publish on all interfaces by default so
# LAN clients (e.g. 10.10.0.0/24) can reach it.
ports:
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 8s
networks:
openfut:
driver: bridge
volumes:
core-db:
bridge-captures:
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 15 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())
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them.
WHY THIS EXISTS
---------------
CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer
table at .data 0x1802d2760), then a per-response-family mapper converts that index
into an internal field id with a chain of integer compares and jump tables.
A previous attempt to recover each mapper's accepted atoms by scanning for
`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it
reported that no mapper accepts atom 424 (`manager`), while a live client plainly
holds a resident manager record. Pattern scanning cannot see control flow, so it
cannot tell which compares are actually reachable.
This module executes the mappers instead. The modelled subset is exactly what these
functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed
jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`.
Anything outside that subset raises Unsupported, so a wrong field id is never
returned silently.
TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE
---------------------------------------------------
1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5
is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses.
Covered by test_rbp_relative_is_not_rip_relative.
2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so
searching for an immediate-to-memory store misses it. The interpreter tracks
register values, so propagated constants are followed.
Covered by test_constant_propagated_through_register.
Run `--selftest` to execute the positive controls. Negative results from this tool
are only admissible when the selftest passes.
"""
from __future__ import annotations
import argparse
import bisect
import struct
import sys
from pathlib import Path
REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15")
ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors()
ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax
ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1
class Unsupported(Exception):
"""The mapper used an instruction or address outside the modelled subset."""
def s32(v: int) -> int:
v &= 0xFFFFFFFF
return v - 0x100000000 if v & 0x80000000 else v
class Image:
"""A parsed PE, with VA<->file mapping and .pdata function bounds."""
def __init__(self, path: Path):
self.buf = path.read_bytes()
b = self.buf
pe = struct.unpack_from("<I", b, 0x3C)[0]
if b[pe:pe + 4] != b"PE\0\0":
raise ValueError(f"{path} is not a PE image")
nsec = struct.unpack_from("<H", b, pe + 6)[0]
optsz = struct.unpack_from("<H", b, pe + 20)[0]
self.base = struct.unpack_from("<Q", b, pe + 24 + 24)[0]
self.sections = []
for i in range(nsec):
o = pe + 24 + optsz + 40 * i
name = b[o:o + 8].rstrip(b"\0").decode(errors="replace")
vsz, va, rsz, raw = struct.unpack_from("<IIII", b, o + 8)
self.sections.append((name, va, vsz, raw, rsz))
self._funcs = None
def va2off(self, va: int):
rva = va - self.base
for _name, sva, vsz, raw, rsz in self.sections:
if sva <= rva < sva + max(vsz, rsz):
off = raw + (rva - sva)
if off < len(self.buf):
return off
return None
def rd8(self, va: int) -> int:
o = self.va2off(va)
if o is None:
raise Unsupported(f"unmapped byte read 0x{va:x}")
return self.buf[o]
def rd32(self, va: int) -> int:
o = self.va2off(va)
if o is None:
raise Unsupported(f"unmapped dword read 0x{va:x}")
return struct.unpack_from("<I", self.buf, o)[0]
def cstr(self, va: int, maxlen: int = 96):
o = self.va2off(va)
if o is None:
return None
end = self.buf.find(b"\0", o, o + maxlen)
if end < 0:
return None
try:
return self.buf[o:end].decode("ascii")
except UnicodeDecodeError:
return None
# ---- .pdata gives exact function bounds; never guess a prologue ----
def functions(self):
if self._funcs is None:
sec = next(s for s in self.sections if s[0] == ".pdata")
_n, _va, vsz, raw, _rsz = sec
out = []
for i in range(vsz // 12):
beg, end, _unw = struct.unpack_from("<III", self.buf, raw + 12 * i)
if beg or end:
out.append((self.base + beg, self.base + end))
out.sort()
self._funcs = out
return self._funcs
def function_of(self, va: int):
fs = self.functions()
starts = [f[0] for f in fs]
i = bisect.bisect_right(starts, va) - 1
if i >= 0 and fs[i][0] <= va < fs[i][1]:
return fs[i]
return None
def atom(self, index: int):
ptr = struct.unpack_from("<Q", self.buf, self.va2off(ATOM_TABLE_BASE) + 8 * index)[0]
return self.cstr(ptr)
def atom_index(self, name: str):
off = self.va2off(ATOM_TABLE_BASE)
for i in range(4096):
ptr = struct.unpack_from("<Q", self.buf, off + 8 * i)[0]
if self.cstr(ptr) == name:
return i
return None
class Mapper:
"""Executes one dispatch function for a given atom index."""
def __init__(self, image: Image, resolver: int = ATOM_RESOLVER):
self.img = image
self.resolver = resolver
def _ea(self, k: int, rex: int, r: dict):
"""Decode ModRM[+SIB][+disp].
Returns (nbytes, dst_reg, addr, src_reg). addr is an int, or the marker
("rip", disp) which the caller resolves once it knows the instruction
length, or None for a register-form operand.
TRAP 1: rm==5 is RIP-relative ONLY when mod==0. With mod 1 or 2 it is
[rbp+disp] and must be resolved from rbp.
"""
b = self.img.buf
modrm = b[k]
mod, rm = modrm >> 6, modrm & 7
dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15]
n = 1
if mod == 3:
return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15]
base_v = idx_v = disp = 0
if rm == 4:
sib = b[k + 1]
n += 1
scale = 1 << (sib >> 6)
ir = ((sib >> 3) & 7) | ((rex & 2) << 2)
br = (sib & 7) | ((rex & 1) << 3)
if (ir & 15) != 4:
idx_v = r[REGS[ir & 15]] * scale
if (sib & 7) == 5 and mod == 0:
disp = struct.unpack_from("<i", b, k + n)[0]
n += 4
else:
base_v = r[REGS[br & 15]]
elif rm == 5 and mod == 0:
disp = struct.unpack_from("<i", b, k + 1)[0]
return n + 4, dst, ("rip", disp), None
else:
base_v = r[REGS[(rm | ((rex & 1) << 3)) & 15]]
if mod == 1:
disp = struct.unpack_from("<b", b, k + n)[0]
n += 1
elif mod == 2:
disp = struct.unpack_from("<i", b, k + n)[0]
n += 4
return n, dst, (base_v + idx_v + disp) & 0xFFFFFFFFFFFFFFFF, None
@staticmethod
def _cond(cc: int, last) -> bool:
a, b = last
sa, sb = s32(a), s32(b)
ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF
if cc == 0x4: return sa == sb
if cc == 0x5: return sa != sb
if cc == 0xF: return sa > sb
if cc == 0xD: return sa >= sb
if cc == 0xC: return sa < sb
if cc == 0xE: return sa <= sb
if cc == 0x7: return ua > ub
if cc == 0x3: return ua >= ub
if cc == 0x2: return ua < ub
if cc == 0x6: return ua <= ub
if cc == 0x8: return sa < sb
if cc == 0x9: return sa >= sb
raise Unsupported(f"condition code 0x{cc:x}")
def run(self, start: int, atom: int, limit: int = 5000) -> int:
b = self.img.buf
r = {k: 0 for k in REGS}
last = (0, 0)
va = start
for _ in range(limit):
i0 = self.img.va2off(va)
if i0 is None:
raise Unsupported(f"pc unmapped 0x{va:x}")
j = i0
while b[j] in (0x66, 0x67, 0xF2, 0xF3):
j += 1
rex = 0
if 0x40 <= b[j] <= 0x4F:
rex = b[j]
j += 1
op = b[j]
pre = j - i0
if op == 0xC3:
return r["rax"] & 0xFFFFFFFF
if op == 0xCC:
raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function")
if op == 0xE8:
tgt = va + pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
if tgt != self.resolver:
raise Unsupported(f"call to non-resolver 0x{tgt:x} at 0x{va:x}")
r["rax"] = atom & 0xFFFFFFFF # resolver returns the atom index
va += pre + 5
continue
if op == 0xE9:
va += pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
continue
if op == 0xEB:
va += pre + 2 + struct.unpack_from("<b", b, j + 1)[0]
continue
if 0x70 <= op <= 0x7F:
nxt = va + pre + 2
rel = struct.unpack_from("<b", b, j + 1)[0]
va = nxt + rel if self._cond(op & 0xF, last) else nxt
continue
if op == 0x0F and 0x80 <= b[j + 1] <= 0x8F:
nxt = va + pre + 6
rel = struct.unpack_from("<i", b, j + 2)[0]
va = nxt + rel if self._cond(b[j + 1] & 0xF, last) else nxt
continue
if 0xB8 <= op <= 0xBF:
r[REGS[((op - 0xB8) | ((rex & 1) << 3)) & 15]] = struct.unpack_from("<I", b, j + 1)[0]
va += pre + 5
continue
if op in (0x05, 0x2D, 0x3D):
# accumulator short forms: add/sub/cmp eax, imm32
imm = struct.unpack_from("<i", b, j + 1)[0]
cur = r["rax"] & 0xFFFFFFFF
if op == 0x3D:
last = (cur, imm & 0xFFFFFFFF)
elif op == 0x2D:
r["rax"] = (cur - imm) & 0xFFFFFFFF
last = (r["rax"], 0)
else:
r["rax"] = (cur + imm) & 0xFFFFFFFF
last = (r["rax"], 0)
va += pre + 5
continue
if op in (0x81, 0x83):
w = 4 if op == 0x81 else 1
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
imm = struct.unpack_from("<i" if w == 4 else "<b", b, j + 2)[0]
ext = (modrm >> 3) & 7
cur = r[reg] & 0xFFFFFFFF
if ext == 7:
last = (cur, imm & 0xFFFFFFFF)
elif ext == 5:
r[reg] = (cur - imm) & 0xFFFFFFFF
last = (r[reg], 0)
elif ext == 0:
r[reg] = (cur + imm) & 0xFFFFFFFF
last = (r[reg], 0)
else:
raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}")
va += pre + 2 + w
continue
if op == 0xFF and b[j + 1] >> 6 == 3:
ext = (b[j + 1] >> 3) & 7
reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15]
if ext == 1:
r[reg] = (r[reg] - 1) & 0xFFFFFFFF
last = (r[reg], 0)
va += pre + 2
continue
if ext == 4:
va = r[reg]
continue
raise Unsupported(f"ff /{ext} at 0x{va:x}")
if op == 0x0F and b[j + 1] == 0xB6:
n, dst, addr, src = self._ea(j + 2, rex, r)
end = va + pre + 2 + n
if isinstance(addr, tuple):
addr = end + addr[1]
r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF
va = end
continue
if op in (0x8B, 0x8D):
n, dst, addr, src = self._ea(j + 1, rex, r)
end = va + pre + 1 + n
if isinstance(addr, tuple):
addr = end + addr[1]
if op == 0x8D:
if addr is None:
raise Unsupported(f"lea with register operand at 0x{va:x}")
r[dst] = addr
else:
if addr is None:
# register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax)
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
else:
r[dst] = self.img.rd32(addr)
va = end
continue
if op == 0x89:
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"89 memory store at 0x{va:x}")
src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
va += pre + 2
continue
if op == 0x63:
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"63 memory form at 0x{va:x}")
src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF
va += pre + 2
continue
if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B,
0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85):
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF
if op == 0x01:
r[a] = (r[a] + r[c]) & m
elif op == 0x03:
r[c] = (r[c] + r[a]) & m
elif op == 0x29:
r[a] = (r[a] - r[c]) & m
last = (r[a] & 0xFFFFFFFF, 0)
elif op == 0x2B:
r[c] = (r[c] - r[a]) & m
last = (r[c] & 0xFFFFFFFF, 0)
elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33):
fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y,
0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y,
0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op]
if op in (0x09, 0x21, 0x31):
r[a] = fn(r[a], r[c]) & m
last = (r[a] & 0xFFFFFFFF, 0)
else:
r[c] = fn(r[c], r[a]) & m
last = (r[c] & 0xFFFFFFFF, 0)
elif op == 0x85:
last = ((r[a] & r[c]) & 0xFFFFFFFF, 0)
elif op == 0x39:
last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF)
else:
last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF)
va += pre + 2
continue
if op == 0x90:
va += pre + 1
continue
if op == 0x0F and b[j + 1] == 0x1F:
n, _d, _a, _s = self._ea(j + 2, rex, r)
va += pre + 2 + n
continue
raise Unsupported(f"opcode {op:02x} at 0x{va:x}")
raise Unsupported("instruction limit reached")
def find_mappers(img: Image, resolver: int = ATOM_RESOLVER):
"""Every function containing a direct call to the atom resolver."""
sec = next(s for s in img.sections if s[0] == ".text")
_n, tva, _vsz, traw, trsz = sec
out = {}
for i in range(traw, traw + trsz - 5):
if img.buf[i] != 0xE8:
continue
va = img.base + tva + (i - traw)
if va + 5 + struct.unpack_from("<i", img.buf, i + 1)[0] == resolver:
f = img.function_of(va)
if f:
out.setdefault(f[0], []).append(va)
return out
# --------------------------------------------------------------------------
# selftest: the two decoder traps plus the live-verified positive controls
# --------------------------------------------------------------------------
def test_atom_anchors(img: Image) -> list:
"""The atom table base must reproduce known anchors, or every index is wrong."""
anchors = {11: "actives", 363: "itemData", 376: "kicktakers",
424: "manager", 568: "players", 718: "squadActives"}
fails = []
for idx, want in anchors.items():
got = img.atom(idx)
if got != want:
fails.append(f"atom[{idx}] = {got!r}, expected {want!r}")
return fails
def test_rbp_relative_is_not_rip_relative(img: Image) -> list:
"""TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative.
Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101).
A decoder that treats rm==5 as RIP-relative computes a wildly different
address and silently reads the wrong memory.
"""
m = Mapper(img)
r = {k: 0 for k in REGS}
r["rbp"] = 0x140000000
saved = img.buf
try:
img.buf = bytes.fromhex("8b4d20")
n, dst, addr, _src = m._ea(1, 0, r)
finally:
img.buf = saved
fails = []
if isinstance(addr, tuple):
fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]")
elif addr != 0x140000020:
fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020")
if dst != "rcx":
fails.append(f"destination decoded as {dst}, expected rcx")
if n != 2:
fails.append(f"modrm+disp8 consumed {n} bytes, expected 2")
return fails
def test_constant_propagated_through_register(img: Image) -> list:
"""TRAP 2. A constant reaching a use through a register must be followed.
Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is
only observable if register values propagate. Scanning for an immediate
store would see nothing.
"""
m = Mapper(img)
saved = img.buf
prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3")
try:
img.buf = prog
img_va2off = img.va2off
img.va2off = lambda va: va if 0 <= va < len(prog) else None
got = m.run(0, 0)
finally:
img.buf = saved
img.va2off = img_va2off
return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"]
def test_item_mapper_controls(img: Image) -> list:
"""Live/disassembly-verified behaviour of the item mapper."""
m = Mapper(img)
fails = []
got = m.run(ITEM_MAPPER, 568)
if got != 1:
fails.append(f"item mapper atom 568 'players' -> {got}, expected 1")
got = m.run(ITEM_MAPPER, 11)
if got != 0:
fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0")
return fails
def test_manager_424_is_accepted_somewhere(img: Image) -> list:
"""MANDATORY control. A live client holds a resident manager record, so some
mapper must map atom 424 to a non-zero field id. The previous pattern-scan
method failed exactly here, and any replacement must not."""
m = Mapper(img)
accepting = []
for start in find_mappers(img):
try:
if m.run(start, 424):
accepting.append(start)
except Unsupported:
continue
if not accepting:
return ["no mapper maps atom 424 'manager' to a non-zero field id, "
"which contradicts the live resident manager record"]
return []
def selftest(img: Image) -> int:
checks = [
("atom table anchors", test_atom_anchors),
("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative),
("trap 2: constant via register", test_constant_propagated_through_register),
("item mapper positive controls", test_item_mapper_controls),
("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere),
]
bad = 0
for name, fn in checks:
try:
fails = fn(img)
except Exception as exc: # noqa: BLE001 - report, don't mask
fails = [f"raised {type(exc).__name__}: {exc}"]
if fails:
bad += 1
print(f" FAIL {name}")
for f in fails:
print(f" {f}")
else:
print(f" ok {name}")
print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible")
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--atom", type=int, action="append", default=[],
help="atom index to resolve through every mapper")
ap.add_argument("--name", action="append", default=[],
help="atom name to resolve through every mapper")
args = ap.parse_args()
img = Image(args.image)
if args.selftest:
return selftest(img)
atoms = list(args.atom)
for nm in args.name:
idx = img.atom_index(nm)
if idx is None:
print(f" atom {nm!r} not found in the table")
return 2
atoms.append(idx)
if not atoms:
ap.error("give --atom/--name, or --selftest")
m = Mapper(img)
mappers = find_mappers(img)
print(f" {len(mappers)} mapper function(s) found\n")
for a in atoms:
print(f" === atom {a} ({img.atom(a)!r}) ===")
rows, unsup = [], 0
for start in sorted(mappers):
try:
fid = m.run(start, a)
except Unsupported:
unsup += 1
continue
if fid:
rows.append((start, fid))
for start, fid in rows:
print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})")
print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n")
return 0
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())
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Classify call sites of the 130000/130001 provider stubs.
A call whose result is COMPARED implements a predicate ("is this the FUT custom
club?"). Only a call whose result is STORED can assign a team id. This turns an
unreadable 81-site list into the handful that could actually introduce 130000
into a struct.
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
"""
import re
import sys
asm = sys.argv[1]
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
lines = []
for l in open(asm, errors="replace"):
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
if m:
lines.append((int(m.group(1), 16), m.group(3).strip()))
idx = {a: i for i, (a, _t) in enumerate(lines)}
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
for tgt in targets:
print(f"\n ===== callers of 0x{tgt} =====")
stores, cmps, other = [], [], []
for i, (a, txt) in enumerate(lines):
if not txt.startswith("call") or tgt not in txt:
continue
# look at the next few instructions for the fate of eax
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
verdict, detail = "other", window[0] if window else ""
for w in window:
if STORE.match(w):
verdict, detail = "STORE", w
break
if CMP.match(w):
verdict, detail = "compare", w
break
if MOVREG.match(w):
verdict, detail = "movreg", w
break
rec = (a, detail)
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
print(f" STORE (can assign) : {len(stores)}")
for a, d in stores:
print(f" 0x{a:x} {d}")
print(f" compare (predicate) : {len(cmps)}")
print(f" other/moved to reg : {len(other)}")
for a, d in other[:14]:
print(f" 0x{a:x} {d}")
+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()
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
The generated GDB program uses hardware breakpoints, only reads registers and
client memory, logs, and continues. Seven breakpoints are rotated so no more
than four are enabled. It never calls client functions, writes client memory,
emits events, or drives input.
command_128_trace.py [pid] [--output PATH]
command_128_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
SCREEN_VTABLE_RVA = 0x03B3ECC0
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
def addresses(base: int) -> dict[str, int]:
return {
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
"scheduler": base + SCENARIO_SCHEDULER_RVA,
"manager_start": base + SCENARIO_MANAGER_START_RVA,
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
"screen_vtable": base + SCREEN_VTABLE_RVA,
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_VTABLE_RVA,
}
def gdb_prelude(pid: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted off
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
"""
def build_script(pid: int, fifa_base: int, output: str) -> str:
address = addresses(fifa_base)
return (
gdb_prelude(pid, output)
+ f"""define snapshot_gameplay
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
set $snap_listener_manager = 0
set $snap_listener_table = 0
set $snap_listener_index = -1
set $snap_free_roam = 0
set $snap_free_state = -1
set $snap_free_111 = -1
set $snap_free_112 = -1
set $snap_free_124 = -1
set $snap_selected = 0
set $snap_selected_vtable = 0
set $snap_selected_mode = -1
if $snap_gameplay_global != 0
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
end
if $snap_listener_manager != 0
set $snap_listener_table = *(void**)$snap_listener_manager
end
if $snap_listener_table != 0
set $snap_free_roam = *(void**)$snap_listener_table
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
if $snap_listener_index >= 0 && $snap_listener_index < 3
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
end
end
if $snap_free_roam != 0
set $snap_free_state = *(int*)($snap_free_roam+0x30)
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
set $snap_free_124 = *(int*)($snap_free_roam+0x124)
end
if $snap_selected != 0
set $snap_selected_vtable = *(void**)$snap_selected
set $snap_selected_mode = *(int*)($snap_selected+0x18)
end
end
set $action_count = 0
hbreak *0x{address['manager_select_action']:x}
commands
silent
set $action_count = $action_count+1
set $provider = $rbx
snapshot_gameplay
if $action_count <= 128
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
end
if $eax == 0x30
bt 16
end
continue
end
hbreak *0x{address['instructions_screen']:x}
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
commands
silent
set $screen = $rcx
set $screen_owner = *(void**)($screen+0x140)
set $screen_owner_vtable = 0
if $screen_owner != 0
set $screen_owner_vtable = *(void**)$screen_owner
end
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
bt 16
continue
end
hbreak *0x{address['command_dispatch']:x}
condition 3 $edx == 0x128
commands
silent
set $command_dispatcher = $rcx
set $command_table = *(void**)$command_dispatcher
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 1
disable 2
disable 3
enable 5
continue
end
hbreak *0x{address['free_roam_case']:x}
commands
silent
set $owner = $rbx
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
continue
end
hbreak *0x{address['scheduler']:x}
disable 5
commands
silent
set $owner = $rcx
set $manager = *(void**)($owner+0x168)
set $manager_vtable = 0
set $manager_mode = -1
set $child = 0
set $child_vtable = 0
if $manager != 0
set $manager_vtable = *(void**)$manager
set $manager_mode = *(int*)($manager+0x50)
set $child = *(void**)($manager+0x8)
end
if $child != 0
set $child_vtable = *(void**)$child
end
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
disable 4
disable 5
enable 6
continue
end
hbreak *0x{address['manager_start']:x}
disable 6
commands
silent
set $manager = $rcx
set $child = *(void**)($manager+0x8)
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
disable 6
enable 7
continue
end
hbreak *0x{address['scenario_start']:x}
disable 7
commands
silent
set $ctx = $rcx
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 7
continue
end
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']:x}\\n"
continue
"""
)
def effective_environment(pid: int) -> dict[str, str]:
values: dict[str, str] = {}
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
if not item.startswith(b"OPENFUT_FIFA17_"):
continue
key, _, value = item.decode("utf-8", errors="replace").partition("=")
values[key] = value
return values
def selftest() -> None:
address = addresses(0x140000000)
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
assert address["manager_select_source"] == 0x14705A620
assert address["manager_select_action"] == 0x147CDC4A6
assert address["instructions_screen"] == 0x147DCA400
assert address["command_dispatch"] == 0x147A8F6C0
assert address["free_roam_case"] == 0x147A92B0F
assert address["scheduler"] == 0x147AC3A40
assert address["manager_start"] == 0x147B1C2B0
assert address["scenario_start"] == 0x147B1C190
assert script.count("hbreak *") == 7
assert "set $action_count = 0" in script
assert "MANAGER_SELECT_ACTION" in script
assert "condition 2 $edx == 0x30" in script
assert "condition 3 $edx == 0x128" in script
assert "disable 4" in script
assert "disable 5" in script and "enable 5" in script
assert "disable 6" in script and "enable 6" in script
assert "disable 7" in script and "enable 7" in script
assert "set *(" not in script
print("command_128_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
cards_base = 0
cards_path = "<not-loaded>"
try:
cards_base, cards_path = transition.cards_mapping(pid)
except RuntimeError:
pass
else:
transition.validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
script = build_script(pid, fifa_base, output)
environment = effective_environment(pid)
print(
"COMMAND128 PREPARED "
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
f"cards_path={cards_path} "
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-command-128-{pid}.gdb"
Path(script_path).write_text(script, encoding="utf-8")
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(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
@@ -0,0 +1,126 @@
"""Hardware-only trace of the engine-local overwrite wrapper entry.
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
caller return address and R8D is the team ID later written to the final match
record. This closes the one frame Wine PE unwinding could not recover.
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
WRAPPER_VA = 0x147CE47E0
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, path: str):
self.path = path
self.index = 0
def log(self, kind: str, **payload):
self.index += 1
thread = _thread()
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": thread,
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class WrapperBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
super().__init__(
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
stack = _reg("rsp")
caller_return = _u64(stack)
self.state.log(
"engine_overwrite_wrapper_entry",
wrapper_va=WRAPPER_VA,
caller_return_address=caller_return,
source_team_id=_reg("r8") & 0xFFFFFFFF,
side_argument=_reg("rdx") & 0xFFFFFFFF,
registers=_registers(),
caller_disassembly=(
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
if caller_return else None
),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
traceback=traceback.format_exc()
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
breakpoint = WrapperBreakpoint(_STATE)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,226 @@
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
READ-ONLY hardware debug only:
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
At the first team-like source value, derives both fixed-stride record fields
from live RCX and arms 4-byte WRITE watchpoints on:
teamId A = rcx + 0x44
teamId B = rcx + 0x44 + 0x45c
The execute breakpoint records the intended source value before every call. The
watchpoints then capture both the expected write and any later overwrite, even
if the overwrite comes from a different function.
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
WRITER_VA = 0x147C652CE
POST_WRITER_VA = 0x147C652D3
SIDE_STRIDE = 0x45C
TEAM_FIELD_OFF = 0x44
RECORD_FIELD_OFF = 0x14
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, log_path: str):
self.log_path = log_path
self.event_index = 0
self.engine_base = None
self.watch_a = None
self.watch_b = None
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def arm_fields(self, engine_base: int):
if self.engine_base == engine_base and self.watch_a and self.watch_b:
return
for watchpoint in (self.watch_a, self.watch_b):
if watchpoint is not None:
try:
watchpoint.delete()
except gdb.error:
pass
self.engine_base = engine_base
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
self.watch_b = TeamFieldWatchpoint(
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
)
self.log(
"team_field_watchpoints_armed",
engine_base=engine_base,
team_id_a_address=self.watch_a.address,
team_id_b_address=self.watch_b.address,
watchpoint_a=self.watch_a.number,
watchpoint_b=self.watch_b.number,
)
class TeamFieldWatchpoint(gdb.Breakpoint):
def __init__(self, state: State, side: int, address: int):
self.state = state
self.side = side
self.address = address
super().__init__(
f"*(int*)0x{address:x}",
type=gdb.BP_WATCHPOINT,
wp_class=gdb.WP_WRITE,
internal=False,
)
self.silent = True
def stop(self):
try:
pc = _reg("rip")
writer = WRITER_VA if pc == POST_WRITER_VA else None
record_start = self.address - RECORD_FIELD_OFF
record = _read(record_start, 0x7C)
self.state.log(
"final_team_field_write_post",
side=self.side,
watch_address=self.address,
value=_i32(self.address),
stopped_pc=pc,
writer_va=writer,
record_start=record_start,
record_hex=record.hex() if record else None,
registers=_registers(),
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error",
where="team_field_watchpoint",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class FinalWriterBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
super().__init__(
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
engine_base = _reg("rcx")
side_offset = _reg("rdx")
source_value = _reg("r8") & 0xFFFFFFFF
if source_value in TEAM_LIKE:
self.state.arm_fields(engine_base)
destination = engine_base + side_offset + TEAM_FIELD_OFF
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
self.state.log(
"final_writer_pre",
instruction_va=WRITER_VA,
engine_base=engine_base,
side_offset=side_offset,
side=side,
destination=destination,
record_start=destination - RECORD_FIELD_OFF,
source_register="r8d",
source_value=source_value,
prior_value=_i32(destination),
team_id_a_address=engine_base + TEAM_FIELD_OFF,
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
registers=_registers(),
disassembly=gdb.execute("x/6i $pc", to_string=True),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error",
where="final_writer",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
writer = FinalWriterBreakpoint(_STATE)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={
"final_writer": {"number": writer.number, "va": WRITER_VA},
},
side_stride=SIDE_STRIDE,
team_field_offset=TEAM_FIELD_OFF,
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,225 @@
"""Hardware-only origin trace for the exact SetTeam team context.
Matches the typed integer context pointer selected by SetTeam to the constructor
invocation that produced it. No client memory writes.
"""
from __future__ import annotations
from collections import deque
import json
import os
import struct
import time
import traceback
import gdb
CONTEXT_REUSE = 0x1477C17FC
CONTEXT_ALLOCATED = 0x1477C18C1
SET_TEAM_STUB = 0x147060A80
LOCKED_SETTER_RETURN = 0x1477C2415
CONTEXT_STACK_COUNT = 0x144BCEDA0
CONTEXT_STACK_ARRAY = 0x144BCEDA8
INTERESTING = {73, 130000, 130001}
_STATE = None
def _reg(name):
return int(gdb.parse_and_eval(f"${name}"))
def _read(address, size):
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address):
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address):
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread():
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
class State:
def __init__(self, path):
self.path = path
self.index = 0
self.total_constructor_hits = 0
self.interesting_constructor_hits = 0
self.pending_allocations = {}
self.origins = deque(maxlen=4096)
def log(self, kind, **payload):
self.index += 1
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def thread_key(self):
return tuple(_thread().get("ptid", ()))
def remember_origin(self, context, origin):
if context:
self.origins.append({**origin, "context": context})
def find_origin(self, context):
return next((origin for origin in reversed(self.origins)
if origin["context"] == context), None)
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state, address):
self.state = state
self.address = address
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class ContextReuseBreakpoint(HardwareBreakpoint):
def stop(self):
self.state.total_constructor_hits += 1
try:
value = _reg("rcx") & 0xFFFFFFFF
if value not in INTERESTING:
return False
self.state.interesting_constructor_hits += 1
rsp = _reg("rsp")
direct_return = _u64(rsp + 0x28)
origin = {
"value": value,
"direct_return_address": direct_return,
"upstream_return_address": (
_u64(rsp + 0x68)
if direct_return == LOCKED_SETTER_RETURN
else direct_return
),
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
"constructor_hit": self.state.total_constructor_hits,
}
context = _reg("rax")
if context:
self.state.remember_origin(context, origin)
else:
self.state.pending_allocations[self.state.thread_key()] = origin
except Exception as exc:
self.state.log(
"trace_error",
where="context_reuse",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class ContextAllocatedBreakpoint(HardwareBreakpoint):
def stop(self):
try:
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
if origin is not None:
self.state.remember_origin(_reg("rdx"), origin)
except Exception as exc:
self.state.log(
"trace_error",
where="context_allocated",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class SetTeamStubBreakpoint(HardwareBreakpoint):
def stop(self):
try:
count = _i32(CONTEXT_STACK_COUNT)
array = _u64(CONTEXT_STACK_ARRAY)
team_context = (
_u64(array + (count - 2) * 8)
if array and count is not None and count >= 2
else None
)
side_context = (
_u64(array + (count - 1) * 8)
if array and count is not None and count >= 1
else None
)
rsp = _reg("rsp")
self.state.log(
"set_team_stub_entry",
context_stack_count=count,
team_context=team_context,
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
team_value=_i32(team_context + 0x10) if team_context else None,
side_context=side_context,
side_value=_i32(side_context + 0x10) if side_context else None,
matched_origin=self.state.find_origin(team_context),
caller_return_address=_u64(rsp),
entry_registers={
name: _reg(name)
for name in ("rcx", "rdx", "r8", "r9")
},
backtrace=gdb.execute("bt 32", to_string=True),
total_constructor_hits=self.state.total_constructor_hits,
interesting_constructor_hits=self.state.interesting_constructor_hits,
)
except Exception as exc:
self.state.log(
"trace_error",
where="set_team_stub",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log(
"inferior_exited",
detail=str(event),
total_constructor_hits=_STATE.total_constructor_hits,
interesting_constructor_hits=_STATE.interesting_constructor_hits,
)
def start_trace(log_path, _cards_base):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
points = {
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={
name: {"number": point.number, "va": point.address}
for name, point in points.items()
},
hardware_only=True,
client_memory_writes=False,
matching="exact_context_pointer",
)
@@ -0,0 +1,167 @@
"""Hardware-only trace of engine game-setup context selection.
Captures the function that requests team/side, selector indices 1/0, selected
transient context objects, and the typed value getter. No client writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
DISPATCH = 0x147060D00
SELECT_VALUE = 0x147572C50
CONTEXT_SELECTED = 0x1477C845D
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _printable_pointers(address: int, data: bytes) -> dict:
found = {}
for offset in range(0, len(data) - 7, 8):
pointer = struct.unpack_from("<Q", data, offset)[0]
raw = _read(pointer, 128)
if not raw:
continue
value = raw.split(b"\0", 1)[0]
try:
text = value.decode("utf-8")
except UnicodeDecodeError:
continue
if len(text) >= 3 and all(char.isprintable() for char in text):
found[hex(offset)] = {"pointer": pointer, "text": text}
return found
class State:
def __init__(self, path: str):
self.path = path
self.index = 0
self.requested_indices = {}
def log(self, kind: str, **payload):
self.index += 1
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
"thread": _thread(), **payload}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush(); os.fsync(handle.fileno())
def key(self):
return tuple(_thread().get("ptid", ()))
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
self.address = address
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class DispatchBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
caller = _u64(rsp)
self.state.log(
"game_setup_dispatch_entry",
caller_return_address=caller,
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
if caller else None),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
return False
class SelectValueBreakpoint(HardwareBreakpoint):
def stop(self):
try:
index = _reg("rcx") & 0xFFFFFFFF
self.state.requested_indices[self.state.key()] = index
self.state.log("context_value_request", index=index)
except Exception as exc:
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
return False
class ContextSelectedBreakpoint(HardwareBreakpoint):
def stop(self):
try:
index = _reg("rdi") & 0xFFFFFFFF
context = _reg("rbx")
data = _read(context, 0x80) or b""
self.state.log(
"context_selected",
requested_index=self.state.requested_indices.get(self.state.key()),
selector_index=index,
context=context,
type_flags=_i32(context + 8),
value_i32=_i32(context + 0x10),
value_qword=_u64(context + 0x10),
context_hex=data.hex(),
printable_pointers=_printable_pointers(context, data),
)
except Exception as exc:
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
points = {
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,264 @@
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
Breakpoints:
FUN_180031340 entry incoming teamId/side/context
0x18003148f pre-call query id, selector, output/count pointers
0x180031495 post-call complete 48-byte records and count
0x180031861 submit original incoming teamId sent to engine
This proves whether query 13 influences the overwrite. No INT3/software
breakpoints, client writes, or game input.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
ENTRY = 0x180031340
QUERY_PRE = 0x18003148F
QUERY_POST = 0x180031495
SUBMIT = 0x180031861
MAX_RECORDS = 100
RECORD_SIZE = 48
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0 or size < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
def _printable_pointer(pointer: int) -> str | None:
data = _read(pointer, 96)
if not data:
return None
raw = data.split(b"\0", 1)[0]
if len(raw) < 3:
return None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return None
return text if all(char.isprintable() for char in text) else None
def _decode_record(data: bytes, address: int) -> dict:
words = list(struct.unpack("<12i", data))
qwords = list(struct.unpack("<6Q", data))
strings = {}
for index, pointer in enumerate(qwords):
text = _printable_pointer(pointer)
if text:
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
interesting = {
str(value): [index * 4 for index, word in enumerate(words) if word == value]
for value in (73, 240, 241, 243, 130000, 130001)
if value in words
}
return {
"address": address,
"hex": data.hex(),
"i32": words,
"u32": [value & 0xFFFFFFFF for value in words],
"f32": list(struct.unpack("<12f", data)),
"qwords": qwords,
"strings": strings,
"interesting_values": interesting,
}
class State:
def __init__(self, path: str, cards_base: int):
self.path = path
self.cards_base = cards_base
self.index = 0
self.calls = {}
def log(self, kind: str, **payload):
self.index += 1
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def thread_key(self):
return tuple(_thread().get("ptid", ()))
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, image_va: int):
self.state = state
self.image_va = image_va
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class EntryBreakpoint(HardwareBreakpoint):
def stop(self):
try:
self.state.log(
"game_setup_entry",
incoming_context=_reg("rcx"),
incoming_side=_reg("rdx") & 0xFFFFFFFF,
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
incoming_r9=_reg("r9"),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
return False
class QueryPreBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
adapter = _reg("rcx")
vtable = _u64(adapter)
count_pointer = _u64(rsp + 0x20)
state = {
"adapter": adapter,
"adapter_vtable": vtable,
"query_target": _u64(vtable + 0xE0) if vtable else None,
"query_id": _reg("rdx") & 0xFFFFFFFF,
"selector": _reg("r8") & 0xFFFFFFFF,
"output_buffer": _reg("r9"),
"count_pointer": count_pointer,
"sixth_argument": _u64(rsp + 0x28),
"count_before": _i32(count_pointer) if count_pointer else None,
"saved_incoming_team_id": _i32(rsp + 0x34),
"saved_side": _i32(rsp + 0x50),
"saved_engine_context": _u64(rsp + 0x68),
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
}
self.state.calls[self.state.thread_key()] = state
self.state.log(
"query13_pre",
**state,
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
return False
class QueryPostBreakpoint(HardwareBreakpoint):
def stop(self):
try:
state = self.state.calls.get(self.state.thread_key(), {})
count_pointer = state.get("count_pointer")
output = state.get("output_buffer")
count = _i32(count_pointer) if count_pointer else None
safe_count = min(max(count or 0, 0), MAX_RECORDS)
records = []
for index in range(safe_count):
address = output + index * RECORD_SIZE
data = _read(address, RECORD_SIZE)
if data and len(data) == RECORD_SIZE:
records.append(_decode_record(data, address))
self.state.log(
"query13_post",
query_state=state,
count_after=count,
records=records,
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
saved_side_after=_i32(_reg("rsp") + 0x50),
registers=_registers(),
)
except Exception as exc:
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
return False
class SubmitBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
self.state.log(
"game_setup_submit",
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
submitted_side=_reg("rdx") & 0xFFFFFFFF,
engine_context=_reg("rcx"),
saved_incoming_team_id=_i32(rsp + 0x34),
saved_side=_i32(rsp + 0x50),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
points = {
"entry": EntryBreakpoint(_STATE, ENTRY),
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
"submit": SubmitBreakpoint(_STATE, SUBMIT),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
record_size=RECORD_SIZE,
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,388 @@
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
memory.
Breakpoints (CardsDLL image VAs):
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
The dynamic watchpoint catches the exact write establishing pair[1], whether it
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
ENTRY_RVA = 0x0FC500
LOOKUP_TO_TEAM1_RVA = 0x0FC595
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
TEAM1_POST_PC_TO_WRITER = {
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
}
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0 or size < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u8(address: int) -> int | None:
data = _read(address, 1)
return data[0] if data else None
def _u32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<I", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _cstring(address: int, maximum: int = 256) -> str | None:
data = _read(address, maximum)
if not data:
return None
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
def _rtti_name(vtable: int, cards_base: int) -> str | None:
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
PE RVAs in the locator are module-relative. Failure is evidence-free and is
logged as null; no pointer is named from an offset coincidence.
"""
locator = _u64(vtable - 8) if vtable else None
if not locator:
return None
raw = _read(locator, 24)
if not raw:
return None
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
"<IIIiii", raw
)
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
return None
image_base = locator - self_rva
if abs(image_base - cards_base) > 0x100000:
return None
return _cstring(image_base + type_rva + 16)
def _object(address: int, cards_base: int) -> dict:
vtable = _u64(address) if address else None
return {
"address": address,
"vtable": vtable,
"vtable_image_va": (
CARDS_IMAGE_BASE + (vtable - cards_base)
if vtable and cards_base <= vtable < cards_base + 0x400000
else None
),
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
}
def _registers() -> dict:
names = (
"rax",
"rbx",
"rcx",
"rdx",
"rsi",
"rdi",
"rbp",
"rsp",
"r8",
"r9",
"r10",
"r11",
"r12",
"r13",
"r14",
"r15",
"rip",
)
return {name: _reg(name) for name in names}
def _provenance(state, destination: int | None = None) -> dict:
"""Recover the candidate's live input chain without naming the objects."""
regs = _registers()
context = regs["rbx"]
output_pair = regs["r14"]
obj = regs["rbp"]
nested = _u64(obj + 0xB0) if obj else None
field_2e8 = nested + 0x2E8 if nested else None
source_base = _u64(field_2e8) if field_2e8 else None
participant_holder = regs["r12"]
participant = _u64(participant_holder) if participant_holder else None
index_70 = _u8(participant + 0x70) if participant else None
source_address = (
source_base + index_70 * 16
if source_base is not None and index_70 is not None
else None
)
source_bytes = _read(source_address, 16) if source_address else None
decoded = None
if source_bytes and len(source_bytes) == 16:
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
decoded = {
"team_id": team_id,
"byte_4": byte4,
"byte_5": byte5,
"pad_6": pad,
"word_8": word8,
"word_c": wordc,
}
pair_bytes = _read(output_pair, 8) if output_pair else None
return {
"destination": destination,
"context": _object(context, state.cards_base),
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
"output_pair": output_pair,
"entry_output_pair": state.current_entry.get("output_pair"),
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
"output_team_id_0": _i32(output_pair) if output_pair else None,
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
"obj": _object(obj, state.cards_base),
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
"field_plus_2e8_address": field_2e8,
"source_array_base": source_base,
"participant_holder": participant_holder,
"participant": _object(participant or 0, state.cards_base),
"participant_plus_70": index_70,
"source_record_address": source_address,
"source_record_hex": source_bytes.hex() if source_bytes else None,
"source_record": decoded,
"registers": regs,
}
class State:
def __init__(self, log_path: str, cards_base: int):
self.log_path = log_path
self.cards_base = cards_base
self.current_entry: dict = {}
self.watchpoint = None
self.event_index = 0
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class Team1Watchpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
self.address = address
super().__init__(
f"*(int*)0x{address:x}",
type=gdb.BP_WATCHPOINT,
wp_class=gdb.WP_WRITE,
internal=False,
)
self.silent = True
def stop(self):
try:
pc = _reg("rip")
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
source_value = None
if writer == 0x1800FC595:
source_value = _reg("rcx") & 0xFFFFFFFF
elif writer == 0x1800FC5A0:
source_value = _reg("rax") & 0xFFFFFFFF
self.state.log(
"team1_write_post",
watch_address=self.address,
value=_i32(self.address),
stopped_pc=pc,
stopped_image_va=image_pc,
writer_image_va=writer,
source_value=source_value,
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
provenance=_provenance(self.state, self.address),
)
if writer is not None:
# The output pair is a short-lived stack buffer. Leaving the
# watchpoint active after the candidate's exact write produced
# 114k unrelated events when that stack memory was reused.
# The two hardware lookup breakpoints remain armed, so disabling
# only this completed one-shot watch loses no provenance.
self.enabled = False
self.state.log(
"team1_watchpoint_disabled",
watch_address=self.address,
reason="candidate exact write captured",
)
except Exception as exc: # GDB must continue even if evidence rendering fails.
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
traceback=traceback.format_exc())
return False
class EntryBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
super().__init__(
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
context, output_pair = _reg("rcx"), _reg("rdx")
self.state.current_entry = {
"context": context,
"output_pair": output_pair,
"entry_thread": _thread(),
}
if self.state.watchpoint is not None:
try:
self.state.watchpoint.delete()
except gdb.error:
pass
initial = _i32(output_pair + 4)
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
self.state.log(
"candidate_entry",
entry_image_va=0x1800FC500,
context=_object(context, self.state.cards_base),
output_pair=output_pair,
team_id_1_address=output_pair + 4,
team_id_1_initial=initial,
watchpoint_number=self.state.watchpoint.number,
backtrace=gdb.execute("bt 24", to_string=True),
registers=_registers(),
)
self.state.log(
"team1_watchpoint_armed",
watch_address=output_pair + 4,
watchpoint_number=self.state.watchpoint.number,
)
except Exception as exc:
self.state.log("trace_error", where="candidate_entry", error=str(exc),
traceback=traceback.format_exc())
return False
class LookupStoreBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
self.state = state
self.image_va = image_va
self.destination_offset = destination_offset
super().__init__(
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
destination = _reg("r14") + self.destination_offset
self.state.log(
"opponent_lookup_store_pre",
writer_image_va=self.image_va,
destination=destination,
destination_offset=self.destination_offset,
source_register="ecx",
source_value=_reg("rcx") & 0xFFFFFFFF,
disassembly=gdb.execute("x/5i $pc", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
provenance=_provenance(self.state, destination),
)
except Exception as exc:
self.state.log("trace_error", where="lookup_store", error=str(exc),
traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
"""Called from the supervisor's gdb command file after attach."""
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
lookup_team1 = LookupStoreBreakpoint(
_STATE,
cards_base + LOOKUP_TO_TEAM1_RVA,
0x1800FC595,
4,
)
lookup_team0 = LookupStoreBreakpoint(
_STATE,
cards_base + LOOKUP_TO_TEAM0_RVA,
0x1800FC5B8,
0,
)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
cards_base=cards_base,
breakpoints={
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
"lookup_to_team1": {
"number": lookup_team1.number,
"image_va": 0x1800FC595,
},
"lookup_to_team0": {
"number": lookup_team0.number,
"image_va": 0x1800FC5B8,
},
},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,170 @@
"""Hardware-only origin trace for CardsDLL team-pair submissions.
Distinguishes the three callers of the engine team-id service that can submit a
full two-team pair, plus the mode-76 builder that prepares its pair:
0x1800c7583 correct fixture pair control
0x1800c6c23 generic pair submitter
0x1800c8dc1 mode-76 pair submitter
0x1800c8bf0 mode-76 pair builder entry
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
SITES = {
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
}
MODE76_BUILDER = 0x1800C8BF0
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _pair(address: int) -> list[int] | None:
data = _read(address, 8)
return list(struct.unpack("<2i", data)) if data else None
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, log_path: str, cards_base: int):
self.log_path = log_path
self.cards_base = cards_base
self.event_index = 0
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class PairSubmitBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
self.state = state
self.image_va = image_va
self.name = name
self.pointer_reg = pointer_reg
self.index_reg = index_reg
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
def stop(self):
try:
pointer = _reg(self.pointer_reg)
index = _reg(self.index_reg) & 0xFFFFFFFF
pair_base = pointer - index * 4
self.state.log(
self.name,
instruction_image_va=self.image_va,
source_value=_reg("r8") & 0xFFFFFFFF,
side=_reg("rdx") & 0xFF,
engine_base=_reg("rcx"),
pair_pointer=pointer,
pair_index=index,
pair_base=pair_base,
pair=_pair(pair_base),
registers=_registers(),
disassembly=gdb.execute("x/5i $pc", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where=self.name, error=str(exc),
traceback=traceback.format_exc()
)
return False
class Mode76BuilderBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
def stop(self):
try:
self.state.log(
"mode76_builder_entry",
instruction_image_va=MODE76_BUILDER,
object=_reg("rcx"),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where="mode76_builder", error=str(exc),
traceback=traceback.format_exc()
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
breakpoints = {}
for image_va, (name, pointer_reg, index_reg) in SITES.items():
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
breakpoints[name] = {"number": bp.number, "image_va": image_va}
builder = Mode76BuilderBreakpoint(_STATE)
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints=breakpoints,
hardware_only=True,
client_memory_writes=False,
)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
immstore.py <imm_dec> [disp_hex|any] [--exe]
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
ldis.py. Read-only.
"""
import glob
import os
import re
import struct
import sys
CARDS_IMG = 0x180000000
EXE_IMG = 0x140000000
def pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
raise SystemExit("FIFA17.exe not running")
P = pid()
def module_base(n):
for l in open(f"/proc/{P}/maps"):
if n.lower() in l.lower():
return int(l.split("-")[0], 16)
raise SystemExit(f"{n} not mapped")
def text_spans(base):
out = []
started = False
for l in open(f"/proc/{P}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
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 lo == base:
started = True
continue
if started:
if not path.strip() and "x" in perms:
out.append((lo, hi))
elif out:
break
return out
args = [a for a in sys.argv[1:] if a != "--exe"]
exe = "--exe" in sys.argv
imm = int(args[0], 0)
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
img = EXE_IMG if exe else CARDS_IMG
base = module_base("FIFA17.exe" if exe else "CardsDLL")
mem = open(f"/proc/{P}/mem", "rb", 0)
immb = struct.pack("<i", imm)
hits = 0
for lo, hi in text_spans(base):
mem.seek(lo)
buf = mem.read(hi - lo)
img_lo = img + (lo - base)
i = buf.find(b"\xc7", 0)
while i >= 0:
modrm = buf[i + 1] if i + 1 < len(buf) else 0
if (modrm & 0x38) == 0: # /0
mod, rm = modrm >> 6, modrm & 7
if mod == 1 and i + 7 <= len(buf): # disp8
disp, ib = buf[i + 2], i + 3
sz = 7
elif mod == 2 and i + 10 <= len(buf): # disp32
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
sz = 10
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
disp, ib = 0, i + 2
sz = 6
else:
disp = None
if disp is not None and buf[ib:ib + 4] == immb:
if want_disp is None or disp == want_disp:
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
hits += 1
i = buf.find(b"\xc7", i + 1)
print(f" {hits} immediate store(s) of {imm}"
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
+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())
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
ldis.py --bytes <image_va_hex> [nbytes] hexdump
ldis.py --map show module bases
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
but this is recomputed and printed so the offset trap stays visible.
"""
import re
import subprocess
import sys
import tempfile
PID = None
CARDS_IMG = 0x180000000
EXE_IMG = 0x140000000
def pid():
global PID
if PID is None:
import glob, os
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
PID = int(os.path.basename(d))
break
except OSError:
pass
if PID is None:
raise SystemExit("FIFA17.exe not running")
return PID
def module_base(needle):
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
anonymously, so never trust the mapping that merely CONTAINS an address)."""
for l in open(f"/proc/{pid()}/maps"):
if needle.lower() in l.lower():
return int(l.split("-")[0], 16)
raise SystemExit(f"module {needle} not mapped")
def live(va, exe=False):
if exe:
return module_base("FIFA17.exe") + (va - EXE_IMG)
return module_base("CardsDLL") + (va - CARDS_IMG)
def read(va, n, exe=False):
la = live(va, exe)
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
m.seek(la)
return la, m.read(n)
def main():
a = sys.argv[1:]
if not a or a[0] == "--map":
print(f" pid = {pid()}")
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
return
hexdump = a[0] == "--bytes"
if hexdump:
a = a[1:]
exe = "--exe" in a
a = [x for x in a if x != "--exe"]
va = int(a[0], 16)
n = int(a[1]) if len(a) > 1 else 160
la, buf = read(va, n, exe)
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
if hexdump:
for i in range(0, len(buf), 16):
c = buf[i:i + 16]
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
return
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
f.write(buf)
f.flush()
out = subprocess.run(
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
f"--adjust-vma=0x{va:x}", f.name],
capture_output=True, text=True).stdout
for line in out.splitlines():
if re.match(r"\s+[0-9a-f]+:", line):
print(" " + line.strip())
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
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
The pre-match selector draws each kit from a clone query keyed on the record's
own fields, so if both tiles render identically the question is precisely: which
bytes of the home record differ from the away record? This prints every differing
offset with the known field names attached, and dumps the fields the decoded
clone query consumes.
Read-only. Never writes to the process.
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
teamtechid == record+0x94
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
year == record+0xba
"""
import re
import struct
import sys
PID = int(sys.argv[1])
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
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]
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 = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
if base is None:
sys.exit("CardsDLL mapping not found")
def live(static):
return base + (static - 0x180000000)
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED - wrong base")
print(f" CardsDLL base = {base:#x} (sanity ok)")
owner = q(live(0x1802E6398))
sentinel = owner + 0x160C8
root = q(owner + 0x160D8)
# Known record fields, offset -> (name, width)
FIELDS = {
0x08: ("id", 8),
0x18: ("resourceId/definitionId", 4),
# Offsets per club_items.json `_record_map`, which is authoritative:
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
0x1C: ("cardassetid", 4),
0x20: ("assetId", 4),
0x38: ("discardValue", 4),
0x4C: ("cardtype", 4),
0x50: ("cardsubtypeid", 4),
0x5C: ("itemState", 4),
0x60: ("category(club slot)", 4),
0x8C: ("contract", 4),
0x94: ("teamid", 4),
0xB4: ("rating", 4),
0xB8: ("wire category", 1),
0xBA: ("year", 2),
0x148: ("nation", 4),
0x154: ("leagueId", 4),
}
def walk(node, out):
if not node or node == sentinel:
return
walk(q(node + 0x00), out)
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
walk(q(node + 0x08), out)
nodes = []
walk(root, nodes)
recs = {k: v for k, v in nodes}
found = [(w, recs[w]) for w in WANT if w in recs]
if len(found) < 2:
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
a = rd(ptr_a, 0x180)
b = rd(ptr_b, 0x180)
print(f" A = {id_a} @ {ptr_a:#x}")
print(f" B = {id_b} @ {ptr_b:#x}")
print("\n --- fields the clone query consumes ---")
for off in (0x94, 0x5C, 0xBA):
name = FIELDS[off][0]
w = FIELDS[off][1]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
flag = "" if va != vb else " <== IDENTICAL"
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
print("\n --- every differing byte range ---")
diffs = [i for i in range(0x180) if a[i] != b[i]]
runs = []
for i in diffs:
if runs and i == runs[-1][1] + 1:
runs[-1][1] = i
else:
runs.append([i, i])
for s, e in runs:
named_field = next(
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
)
va = int.from_bytes(a[s : e + 1], "little")
vb = int.from_bytes(b[s : e + 1], "little")
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
print("\n --- known fields, side by side ---")
for off in sorted(FIELDS):
name, w = FIELDS[off]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
mark = " DIFFERS" if va != vb else ""
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Remove the port-8081 DNAT rule that hijacks FIFA 17's roster/squad-update TLS.
#
# Why: the FUT squad update is https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml
# (TLS on port 8081). A DNAT rule rewriting dport 8081 -> 8299 sends that TLS
# handshake to the plain-HTTP staging UTAS host, which closes the connection.
# Proven: a probe to 10.10.0.120:8081 from this box arrives at the server as
# dport 8299. Result: "An error occurred downloading the FUT squad update."
#
# The rule also never redirected UTAS, which lives on :8443, not :8081.
#
# Read-only until it deletes; deletes only nat rules whose target port is 8299.
set -u
echo "== nat OUTPUT rules mentioning 8081 or 8299 =="
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
echo
echo "== deleting DNAT rules that redirect to port 8299 =="
removed=0
# Delete by spec, repeatedly, until no matching rule remains.
while :; do
rule=$(iptables -t nat -S OUTPUT 2>/dev/null | grep -m1 -E '\-\-dport 8081 .*8299|to-destination [0-9.]+:8299')
[ -z "$rule" ] && break
spec=$(printf '%s' "$rule" | sed 's/^-A /-D /')
# shellcheck disable=SC2086
if iptables -t nat $spec 2>/dev/null; then
echo " removed: $rule"
removed=$((removed + 1))
else
echo " FAILED to remove: $rule" >&2
break
fi
done
[ "$removed" -eq 0 ] && echo " (no matching rule found)"
echo
echo "== remaining nat OUTPUT rules mentioning 8081 or 8299 =="
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
echo
echo "== verifying the roster endpoint now presents the correct certificate =="
python3 - <<'PY'
import socket, ssl
host, port, sni = "10.10.0.120", 8081, "winter15.gosredirector.ea.com"
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), 8) as s:
with ctx.wrap_socket(s, server_hostname=sni) as t:
der = t.getpeercert(True)
cn = dict(x[0] for x in t.getpeercert().get("subject", ()))
print(f" PASS {host}:{port} sni={sni} {t.version()} der={len(der)}B subject={cn}")
except Exception as e:
print(f" FAIL {host}:{port} sni={sni} -> {type(e).__name__}: {e}")
print(" The roster path is still broken; do not relaunch yet.")
PY
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
Answers whether a served item was materialised into a record at all, versus
materialised but not attached to a collection. A record is recognised by its
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
Read-only. Never writes.
usage: probe_hunt.py PID id [id ...]
"""
import re, struct, sys
PID = int(sys.argv[1])
IDS = [int(a) for a in sys.argv[2:]]
if not IDS:
sys.exit("give at least one wire id")
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
regions = []
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 not m:
continue
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
if "w" not in perms:
continue
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
continue
regions.append((lo, hi, perms, path))
total = sum(hi - lo for lo, hi, _, _ in regions)
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
needles = {struct.pack("<I", i): i for i in IDS}
hits = {i: [] for i in IDS}
CHUNK = 8 << 20
scanned = 0
for lo, hi, perms, path in regions:
a = lo
while a < hi:
n = min(CHUNK, hi - a)
try:
mem.seek(a)
data = mem.read(n)
except OSError:
a += n
continue
if not data:
a += n
continue
scanned += len(data)
for nd, wid in needles.items():
start = 0
while True:
j = data.find(nd, start)
if j < 0:
break
start = j + 1
va = a + j
# a record would place this id at +0x08
rec = va - 0x08
try:
mem.seek(rec)
r = mem.read(0x100)
except OSError:
continue
if len(r) < 0x100:
continue
ct = struct.unpack_from("<i", r, 0x4c)[0]
res = struct.unpack_from("<I", r, 0x18)[0]
sub = struct.unpack_from("<i", r, 0x50)[0]
cat = struct.unpack_from("<i", r, 0x60)[0]
looks = 0 <= ct <= 32 and res > 1000
hits[wid].append((va, rec, ct, sub, cat, res, looks))
a += n
print(f" scanned {scanned/2**20:.0f} MiB\n")
for wid in IDS:
hs = hits[wid]
recs = [h for h in hs if h[6]]
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
for va, rec, ct, sub, cat, res, _ in recs[:6]:
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
if not recs:
print(" NOT MATERIALISED as a record anywhere in writable memory")
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Identify every resident record by its wire instance id.
Record layout established from known wire values:
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
+0x5c itemState +0x60 category +0x94 teamid
+0xb4 rating +0xba teamkittypetechid (u16)
Walks the contiguous 0x180-stride pool around the manager slot record so records
that are resident but not in any collection are still seen. Read-only.
usage: probe_ids.py PID [expected_id ...]
"""
import re, struct, sys
PID = int(sys.argv[1])
WANT = {int(a) for a in sys.argv[2:]}
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]
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 = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
live = lambda s: base + (s - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
RECSZ = 0x180
def dec(rec):
r = rd(rec, 0x180)
g = lambda o: struct.unpack_from("<i", r, o)[0]
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
rating=struct.unpack_from("<I", r, 0xb4)[0],
kt=struct.unpack_from("<H", r, 0xba)[0])
mgr_rec = q(mgr + 0xc0 + 0x10)
print(f" manager-slot record = {mgr_rec:#x}")
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
# walk backwards to the start of the contiguous run, then forwards
lo = anchor
for _ in range(64):
prev = lo - RECSZ
try:
d = dec(prev)
except OSError:
break
if not (0 < d["ct"] < 64) or d["id"] == 0:
break
lo = prev
print(f" pool run starts at {lo:#x}\n")
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
found = {}
k = 0
addr = lo
while k < 48:
try:
d = dec(addr)
except OSError:
break
if d["id"] == 0 and d["ct"] == 0:
break
tag = ""
if d["ct"] == 7:
tag = " <== CARDTYPE 7"
if d["id"] in WANT:
tag += " <== WANTED"
found[d["id"]] = addr
slot = " [manager slot]" if addr == mgr_rec else ""
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
addr += RECSZ
k += 1
if WANT:
print(f"\n wanted ids: {sorted(WANT)}")
for w in sorted(WANT):
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
v2: identifies each record by its wire instance id (large, unique) and only
accepts a field mapping when the value is distinctive (>= 16) and the same
offset holds the right value for EVERY identified record. This avoids the v1
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
Read-only. Never writes.
usage: probe_layout2.py PID squad_active.json
"""
import re, struct, sys, json, collections
PID = int(sys.argv[1])
SQUAD = json.load(open(sys.argv[2]))
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]
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 = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
live = lambda s: base + (s - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
RECSZ = 0x180
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
wire = {}
for p in SQUAD["players"]:
it = p.get("itemData") or {}
if it.get("id"):
wire[it["id"]] = it
# --- identify each record by its wire instance id ---
ident = {}
for rec in recs:
r = rd(rec, RECSZ)
for off in range(0, RECSZ - 4, 4):
v = struct.unpack_from("<I", r, off)[0]
if v in wire:
ident.setdefault(rec, (v, off))
break
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
id_offs = collections.Counter(o for _, o in ident.values())
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
"discardValue", "cardsubtypeid", "owners", "rareflag")
# --- for every offset, does it hold field F for every identified record? ---
consistent = {}
for off in range(0, RECSZ - 4, 4):
for f in FIELDS:
ok = 0; total = 0; distinct = set()
for rec, (wid, _) in ident.items():
it = wire[wid]
v = it.get(f)
if not isinstance(v, int) or v < 16: # require distinctive values
continue
total += 1
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
if got == v:
ok += 1; distinct.add(v)
if total >= 5 and ok == total and len(distinct) >= 2:
consistent.setdefault(off, []).append((f, total, len(distinct)))
print(f"\n === offsets consistently holding a distinctive wire field ===")
for off in sorted(consistent):
for f, total, nd in consistent[off]:
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
# --- dump the manager and the three club staff for comparison ---
print(f"\n === cardtype-2 slot (manager) ===")
h = q(mgr + 0xc0 + 0x10)
if h:
r = rd(h, RECSZ)
for off in sorted(consistent):
f = consistent[off][0][0]
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
("itemState", 0x5c, 4), ("category", 0x60, 4)):
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Enumerate FIFA 17's resident item map authoritatively.
Layout recovered from the lower_bound at 0x180119640:
owner+0x160c8 sentinel / end marker
owner+0x160d8 root
owner+0x160e8 count
node+0x00, node+0x08 children
node+0x20 key = wire instance id (qword)
node+0x28 the item record
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
Read-only. usage: probe_map2.py PID [id ...]
"""
import re, struct, sys, collections
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
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]
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 = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(base + (0x1802e6398 - 0x180000000))
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
nodes, seen, stack = [], set(), [ROOT]
while stack:
n = stack.pop()
if not n or n == SENT or n in seen or len(seen) > 5000:
continue
seen.add(n)
try:
h = rd(n, 0x30)
except OSError:
continue
if len(h) < 0x30:
continue
nodes.append(n)
stack.append(struct.unpack_from("<Q", h, 0)[0])
stack.append(struct.unpack_from("<Q", h, 8)[0])
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
hist = collections.Counter(); found = {}
rows = []
for n in nodes:
key = q(n + 0x20)
rec = n + 0x28
try: r = rd(rec, 0x180)
except OSError: continue
if len(r) < 0x180: continue
g = lambda o: struct.unpack_from("<i", r, o)[0]
rid = struct.unpack_from("<I", r, 0x8)[0]
res = struct.unpack_from("<I", r, 0x18)[0]
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
hist[ct] += 1
if rid in WANT: found[rid] = rec
rows.append((key, rec, rid, res, ct, sub, st, cat))
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
for w in sorted(WANT):
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Read-only scan of the record pool embedded in the club-model owner object.
The 18 resident player records sit at a fixed stride of 0x180 inside the owner
object, below the embedded manager subobject at owner+0x1f9d8. This walks that
pool to see whether storage for the five club items exists and what it holds.
Read-only. Never writes.
"""
import re, struct, sys
PID = int(sys.argv[1])
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]
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 = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
def live(s):
return base + (s - 0x180000000)
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
first = None
for k in range((end - beg) // 24):
r = q(beg + k * 24 + 0x10)
if r:
first = r; break
if first is None:
sys.exit("no populated player record to anchor the pool")
print(f" owner = {owner:#x} mgr = {mgr:#x} first record = {first:#x}")
print(f" record - owner = {first - owner:#x} pool room to mgr = {(mgr - first) // 0x180} slots of 0x180")
print()
hdr = f" {'idx':>3} {'addr':>12} {'ctype':>6} {'subtyp':>6} {'state':>6} {'cat':>4} {'team':>5} {'kittyp':>6} set"
print(hdr)
n = (mgr - first) // 0x180
for k in range(min(n, 40)):
a = first + k * 0x180
try:
r = rd(a, 0xC0)
except OSError:
print(f" {k:>3} {a:#12x} unreadable"); break
if len(r) < 0xC0:
break
ct, sub, st, cat, team = (struct.unpack_from("<i", r, o)[0] for o in (0x4c, 0x50, 0x5c, 0x60, 0x94))
kt = struct.unpack_from("<H", r, 0xba)[0]
nz = sum(1 for b in r if b)
flag = ""
if ct == 7:
flag = " <== CARDTYPE 7"
elif nz == 0:
flag = " (all zero)"
print(f" {k:>3} {a:#12x} {ct:>6} {sub:>6} {st:>6} {cat:>4} {team:>5} {kt:>6} {nz:>3}/192{flag}")
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Read-only dump of RESIDENT record fields, for both the player and club-item vectors.
Purpose: the kit clone driver FUN_1801c3480 gates on record+0x60 (category) == 4.
No instruction in CardsDLL writes immediate 4 there, so this reads what value a
genuinely resident record actually carries. Read-only. Never writes.
mgr+0x0c0 cardtype-2 single slot
mgr+0x0d8..0x0e0 cardtype-1 (player) vector
mgr+0x108..0x110 club-item vector
record+0x4c cardtype +0x50 cardsubtypeid +0x5c itemState
record+0x60 category +0x94 teamid +0xba teamkittypetechid (u16)
"""
import re, struct, sys, collections
PID = int(sys.argv[1])
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]
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 = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
if base is None:
sys.exit("CardsDLL mapping not found")
def live(static):
return base + (static - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED - wrong base")
print(f" CardsDLL base = {base:#x} (sanity ok)")
owner = q(live(0x1802e6398))
b = rd(q(owner) + 0x4e8, 12)
b = rd(struct.unpack("<Q", struct.pack("<Q", q(q(owner) + 0x4e8)))[0], 12)
getter = q(q(owner) + 0x4e8)
gb = rd(getter, 12)
if gb[0:3] == bytes.fromhex("488d81"):
mgr = owner + struct.unpack_from("<I", gb, 3)[0]
elif gb[0:3] == bytes.fromhex("488d41"):
mgr = owner + gb[3]
else:
sys.exit(f"unexpected getter shape {gb.hex(' ')}")
print(f" owner = {owner:#x} mgr = {mgr:#x}")
FIELDS = ("ctype", "subtype", "state", "cat", "team", "kittype")
def decode(rec):
r = rd(rec, 0xC0)
if len(r) < 0xC0:
return None
return (i32(r, 0x4c), i32(r, 0x50), i32(r, 0x5c), i32(r, 0x60),
i32(r, 0x94), struct.unpack_from("<H", r, 0xba)[0])
for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
("club items", mgr + 0x108, mgr + 0x110)):
try:
beg, end = q(vbeg), q(vend)
except OSError:
print(f"\n {label}: vector unreadable")
continue
span = end - beg
print(f"\n === {label}: {beg:#x}..{end:#x} span={span} ===")
if not (0 < beg <= end) or span > 24 * 100000:
print(" implausible vector, skipping")
continue
# resolve stride: the element must contain a plausible heap pointer
for stride, ptr_off in ((24, 0x10), (16, 0x08), (8, 0x00)):
if span % stride:
continue
n = span // stride
recs, nulls = [], []
ok = True
for k in range(n):
try:
rec = q(beg + k * stride + ptr_off)
except OSError:
ok = False; break
if not rec:
nulls.append(k); continue
d = decode(rec)
if d is None:
ok = False; break
recs.append((k, rec, d))
if not ok:
continue
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
if not recs and len(nulls) != n:
continue
hist = collections.Counter(d[0:2] for _, _, d in recs)
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
print(f" (cardtype,subtype)={key} x{c}")
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
# that other atom handlers clobber. Which slots are filled therefore
# reveals the index the parse actually started from.
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
for k, rec, d in recs[:8]:
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
if nulls:
print(f" empty slots: {nulls[:16]}")
cats = collections.Counter(d[3] for _, _, d in recs)
if cats:
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
break
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
# cardtype-7 records, and are the served kit ids among them?
#
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
#
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
# 4:2, 10:1} and both kit ids ABSENT.
set -u
PID=$(for p in /proc/[0-9]*; do
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
done | head -1)
if [ -z "$PID" ]; then
echo " FIFA17.exe is not running - launch the game and enter FUT first"
exit 1
fi
echo " live FIFA17 pid = $PID"
echo
cd "$(dirname "$0")" || exit 1
python3 probe_map2.py "$PID" 100004873 100004874 100004870
echo
echo " ================ squad survival + slot indices ================"
# The kit milestone is only real if the REST of the squad survives with it.
# A populated `squad.actives` was once seen to leave the map holding just the
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
# the player-vector fill below is a PASS/FAIL gate, not decoration.
#
# The club-item slot indices are the other half: the parser writes element i to
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
# somewhere other than slots 0 and 1 means the index did not start at zero.
python3 probe_resident_fields.py "$PID"
+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())
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
no input to the game, and never opens 'r+b'.
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
[--control WIRE:RESOURCE ...]
Defaults describe the staging profile used to close the manager milestone; pass
the flags for any other profile.
WHAT THIS DECIDES
-----------------
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
two different routes:
players : atom 568 -> per-element atoms 355 index / 363 itemData /
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
on the NESTED itemData object.
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
CONTROLS
--------
manager wire id the instance id. Present even when BROKEN, because `id` is
an item atom the parser reads at element level. Its
presence proves the element was parsed and therefore proves
nothing about registration -- do not use it as the verdict.
manager resourceId THE VERDICT. Resident => the merge key survived the load.
player wire id and positive controls. Players demonstrably render, so if their
player resourceId resourceIds are absent the squad simply is not loaded yet
and the run is INCONCLUSIVE, not a failure.
RESIDENT-MANAGER HIT
--------------------
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
a readable private mapping. Corroborate with the record context printed below:
a real item record carries resourceId eight words ahead of its wire id, which
is the layout the player controls exhibit. Hits without that shape are usually
id lists or unrelated integers -- the layout, not the raw count, is the proof.
LAYOUT ASSUMPTION (the only one)
--------------------------------
Item records place resourceId 0x20 bytes before the wire id. Measured, both
sides:
before b91e707 (pid 126936) -- manager parsed, merge key absent
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
after b91e707 (pid 134118) -- same layout, key present
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
Addresses shift every session and are recorded only as provenance; nothing here
depends on them. The tool re-derives everything by scanning.
EXIT CODES (fail-closed)
------------------------
0 PASS manager resourceId resident, controls present
1 FAIL controls present, manager resourceId absent
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
squad screen. Deliberately NOT 0: absent controls mean the
probe proved nothing.
"""
import argparse
import glob
import os
import re
import struct
import sys
# Staging profile defaults (override on the command line).
DEF_MANAGER_WIRE = 100004870
DEF_MANAGER_RESOURCE = 1000509
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
# Item record layout: resourceId sits this far BEFORE the wire id.
RESOURCE_BACK_OFF = 0x20
def find_pid():
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
for d in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(d, "comm")) as fh:
if fh.read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
continue
return None
def regions(pid):
"""Readable private mappings worth scanning.
Skips device/memfd mappings and anything over 512 MiB (the big reserved
ranges are not where parsed records live and dominate the runtime).
"""
out = []
with open(f"/proc/{pid}/maps") as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
if not m:
continue
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
perms, path = m.group(3), m.group(4)
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
continue
if hi - lo > 512 * 1024 * 1024:
continue
out.append((lo, hi))
return out
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
found = {n: [] for n in needles}
pats = {n: struct.pack("<i", n) for n in needles}
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
for lo, hi in regions(pid):
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue # torn-down or unreadable mapping; not a failure
for n, pat in pats.items():
i = buf.find(pat)
while i >= 0:
if i % 4 == 0:
found[n].append(
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
)
i = buf.find(pat, i + 4)
return found
def words(blob, centre, before=8, after=4):
cells = []
for k in range(-before, after):
o = centre + k * 4
if 0 <= o <= len(blob) - 4:
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
return " ".join(cells)
def record_shaped(blob, centre, resource):
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
item-record layout, as opposed to an incidental integer match."""
o = centre - RESOURCE_BACK_OFF
if o < 0 or o > len(blob) - 4:
return False
return struct.unpack_from("<i", blob, o)[0] == resource
def main():
ap = argparse.ArgumentParser(description="read-only manager registration probe")
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
ap.add_argument(
"--control",
action="append",
metavar="WIRE:RESOURCE",
help="player positive control; repeatable (default: the staging pair)",
)
args = ap.parse_args()
controls = DEF_CONTROLS
if args.control:
try:
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
except ValueError:
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
return 2
pid = args.pid or find_pid()
if not pid:
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
return 2
if not os.access(f"/proc/{pid}/mem", os.R_OK):
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
return 2
print(f" pid={pid}")
needles = [args.manager_wire, args.manager_resource]
for w, r in controls:
needles += [w, r]
try:
res = scan(pid, sorted(set(needles)))
except OSError as e:
print(f" cannot read /proc/{pid}/mem: {e}")
return 2
print("\n ===== hit counts =====")
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
f"hits={len(res[args.manager_resource])}")
for w, r in controls:
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
print("\n ===== record context (8 words before the id, then the id) =====")
shaped = {"manager": 0}
for tag, wire, resource in (
[("manager", args.manager_wire, args.manager_resource)]
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
):
marked = 0
for addr, blob, centre in res[wire]:
ok = record_shaped(blob, centre, resource)
if ok:
marked += 1
if marked <= 2 or ok:
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
f"{words(blob, centre)}")
if marked >= 2:
break
shaped[tag] = marked
ctl_keys = sum(len(res[r]) for _w, r in controls)
mgr_keys = len(res[args.manager_resource])
print("\n ===== verdict =====")
if ctl_keys == 0:
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
return 3
if mgr_keys == 0:
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
f"{ctl_keys} player")
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
return 1
print(f" PASS: manager resourceId {args.manager_resource} is resident "
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
print(" The merge key survived the load; the broken projection had 0.")
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
payload; neither register is a screen key.
The generated GDB program uses hardware-assisted execution breakpoints only.
It never writes client memory and never drives game input.
match_advance_trace.py [pid] [--output PATH]
match_advance_trace.py --print-script [pid]
match_advance_trace.py --selftest
"""
from __future__ import annotations
import argparse
import hashlib
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_transition_trace as transition
FIFA_MODULE = "FIFA17.exe"
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
def module_mapping(pid: int, module: str) -> tuple[int, str]:
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
for line in handle:
fields = line.split(maxsplit=5)
path = fields[5].rstrip() if len(fields) == 6 else ""
if not path.endswith(module):
continue
return int(fields[0].split("-", 1)[0], 16), path
raise RuntimeError(f"{module} is not mapped in PID {pid}")
def validate_file(path: str, expected: str, label: str) -> None:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if actual != expected:
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
}
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
hbreak *0x{address['provider']:x}
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['global_dispatch']:x}
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['controller']:x}
condition 3 $edx == 0x7546
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['bridge']:x}
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
continue
end
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"provider": 0x1801A4CD0,
"global_dispatch": 0x1480D1070,
"controller": 0x1800BF950,
"bridge": 0x1801A4D41,
}
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "CREATE_MATCH_CONTROLLER" in script
assert "PROVIDER_BRIDGE" in script
assert "set *(" not in script
print("match_advance_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
The probe correlates:
* the select-team action handler for UIF action IDs 0x7574..0x757b;
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
* the concrete FutCreateMatchServerResponse data-source request method;
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
Static decoding identifies action 0x7577 as the branch that constructs the
create-match request and calls DataManager for source 0x7546. The trace proves
whether that authentic trigger executes in the failing flow. It uses four
hardware-assisted execution breakpoints, never writes client memory, and never
drives game input.
match_create_action_trace.py [pid] [--output PATH]
match_create_action_trace.py --print-script [pid]
match_create_action_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
DATA_MANAGER_REQUEST_RVA = 0x80D2340
DATA_SOURCE_REQUEST_RVA = 0x120270
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
FIRST_SELECT_TEAM_ACTION = 0x7574
LAST_SELECT_TEAM_ACTION = 0x757B
ACTION_CREATE_MATCH = 0x7577
CREATE_DATA_SOURCE = 0x7546
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
}
def build_gdb_script(
pid: int, cards_base: int, fifa_base: int, output: str
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $create_action_seen = 0
set $manager_request_seen = 0
set $data_source_request_seen = 0
hbreak *0x{address['action_handler']:x}
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
commands
silent
if $edx == 0x{ACTION_CREATE_MATCH:x}
set $create_action_seen = 1
end
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
bt 10
continue
end
hbreak *0x{address['manager_request']:x}
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
commands
silent
set $manager_request_seen = 1
set $tree_sentinel = $rcx + 0x10
set $tree_cursor = *(void**)($rcx+0x20)
set $data_node = $tree_sentinel
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
set $data_node = $tree_cursor
set $tree_cursor = *(void**)($tree_cursor+0x08)
else
set $tree_cursor = *(void**)$tree_cursor
end
end
set $data_source = 0
set $request_method = 0
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
set $data_source = *(void**)($data_node+0x28)
if $data_source != 0
set $request_method = *(void**)(*(void**)$data_source+0x18)
end
end
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
bt 10
continue
end
hbreak *0x{address['data_source_request']:x}
commands
silent
set $data_source_request_seen = 1
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
bt 10
continue
end
hbreak *0x{address['ui_dispatch']:x}
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
bt 10
continue
end
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"action_handler": 0x1800BFCC0,
"manager_request": 0x1480D2340,
"data_source_request": 0x180120270,
"ui_dispatch": 0x1480D1070,
}
script = build_gdb_script(
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
)
assert script.count("hbreak *") == 4
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "request_method" in script
assert "set *(" not in script
print("match_create_action_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
vslot +0x38 call -> create gate return -> returned target -> UI bridge
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
vtable, concrete lookup function, event service, readiness-gate implementation,
every register input, returned target, and whether the native-to-UI bridge
executes. No post-event object identity is used.
The generated GDB program uses hardware-assisted execution breakpoints only.
It never writes client memory and never drives game input.
match_delivery_lifecycle_trace.py [pid] [--output PATH]
match_delivery_lifecycle_trace.py --print-script [pid]
match_delivery_lifecycle_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
CREATE_GATE_RETURN_RVA = 0x0BE647
def trace_addresses(cards_base: int) -> dict[str, int]:
return {
"lookup_call": cards_base + LOOKUP_CALL_RVA,
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
"bridge": cards_base + BRIDGE_CALL_RVA,
}
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $current_provider = 0
set $current_payload = 0
set $current_controller = 0
set $current_vtable = 0
set $current_lookup = 0
set $current_service = 0
set $current_service_vtable = 0
set $current_gate = 0
hbreak *0x{address['lookup_call']:x}
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
set $current_provider = $edi
set $current_payload = $rbp
set $current_controller = $rcx
set $current_vtable = *(void**)$rcx
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
set $current_service = *(void**)($rcx+0x18)
set $current_service_vtable = *(void**)$current_service
set $current_gate = *(void**)($current_service_vtable+0x58)
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
continue
end
hbreak *0x{address['gate_return']:x}
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
continue
end
hbreak *0x{address['lookup_return']:x}
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
continue
end
hbreak *0x{address['bridge']:x}
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
continue
end
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000)
assert address == {
"lookup_call": 0x1801A4CFC,
"gate_return": 0x1800BE647,
"lookup_return": 0x1801A4CFF,
"bridge": 0x1801A4D41,
}
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
assert script.count("hbreak *") == 4
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "LOOKUP_CALL" in script
assert "CREATE_GATE_RETURN" in script
assert "LOOKUP_RETURN" in script
assert "gate_fn" in script
assert "BRIDGE" in script
assert "set *(" not in script
print("match_delivery_lifecycle_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
script = build_gdb_script(pid, cards_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
select-team deleting destructor with its real caller, records entry to the
Gameplay::ScenarioModeStart consumer with the state it would advance, and
identifies the first TestingGame update after the boundary.
The generated GDB program uses four hardware-assisted execution breakpoints.
It never writes client memory, calls client functions, drives input, emits
actions, or changes timing deliberately.
match_drill_transition_trace.py [pid] [--output PATH]
match_drill_transition_trace.py --print-script [pid]
match_drill_transition_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SAVE_KIT_ACTION = 0x7576
SAVE_ACTION_RVA = 0x0BFCC0
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
TESTING_GAME_UPDATE_RVA = 0x05A410C8
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
TESTING_GAME_VTABLE_RVA = 0x035C58A8
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
OWNER_STATE_OFFSET = 0x1958
STATE_GAME_DATABASE_OFFSET = 0x17450
STATE_PHASE_OFFSET = 0x27BEC
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"save_action": cards_base + SAVE_ACTION_RVA,
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
}
def build_gdb_script(
pid: int,
cards_base: int,
fifa_base: int,
output: str,
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $save_count = 0
set $current_controller = 0
set $engine_seen = 0
hbreak *0x{address['save_action']:x}
commands
silent
if $edx == 0x{SAVE_KIT_ACTION:x}
set $save_count = $save_count + 1
set $current_controller = $rcx
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
if $save_count == 2
disable 1
end
end
continue
end
hbreak *0x{address['deleting_destructor']:x}
condition 2 $save_count >= 2 && $rcx == $current_controller
commands
silent
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
x/16gx $rsp
bt 12
disable 2
continue
end
hbreak *0x{address['scenario_mode_start_handler']:x}
commands
silent
set $scenario_wrapper = $rcx
set $scenario_state = *(void**)($scenario_wrapper+0x30)
set $scenario_payload = $r9
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $scenario_state != 0
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
if $scenario_database != 0
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
else
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
end
else
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
end
bt 12
disable 3
continue
end
hbreak *0x{address['testing_game_update']:x}
condition 4 $save_count >= 2 && $engine_seen == 0
commands
silent
set $owner = $rsi
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
if $database != 0
set $engine_seen = 1
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
bt 12
disable 4
end
end
continue
end
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"save_action": 0x1800BFCC0,
"deleting_destructor": 0x1800BE020,
"testing_game_update": 0x145A410C8,
"scenario_mode_start_handler": 0x145A58EC0,
"testing_game_vtable": 0x1435C58A8,
"testing_game_state_vtable": 0x1435C2EE0,
}
script = build_gdb_script(
49938,
0x180000000,
0x140000000,
"/tmp/drill-transition.log",
)
assert script.count("hbreak *") == 4
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
assert "SCENARIO_MODE_START" in script
assert "wrapper=%p state=%p payload=%p" in script
assert "alternate_gate=%d" in script
assert "skill_game_start_constructor" not in script
assert "ENGINE_HANDOFF" in script
assert "GameplayGameDatabase.IsSkillGame" not in script
assert "set *(" not in script
print("match_drill_transition_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Trace FIFA17's post-kit boundary without changing client behavior.
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
native save call, the action-handler return, and select-team provider teardown.
The second 0x7576 action is the temporal boundary for later drill/game-loader
instrumentation.
The generated GDB program uses four hardware-assisted execution breakpoints. It
never writes client memory, calls client functions, drives input, emits actions,
or alters timing deliberately.
match_post_kit_trace.py [pid] [--output PATH]
match_post_kit_trace.py --print-script [pid]
match_post_kit_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SAVE_KIT_ACTION = 0x7576
ACTION_HANDLER_RVA = 0x0BFCC0
SAVE_CALL_RVA = 0x0BFF25
ACTION_RETURN_RVA = 0x0C00AE
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
def trace_addresses(cards_base: int) -> dict[str, int]:
return {
"action": cards_base + ACTION_HANDLER_RVA,
"save_call": cards_base + SAVE_CALL_RVA,
"action_return": cards_base + ACTION_RETURN_RVA,
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
}
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $save_count = 0
set $current_action = 0
set $current_controller = 0
set $current_payload = 0
set $second_save_epoch = 0
hbreak *0x{address['action']:x}
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
commands
silent
set $save_count = $save_count + 1
set $current_action = $edx
set $current_controller = $rcx
set $current_payload = $r8
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
if $save_count == 2
set $second_save_epoch = $event_epoch
end
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
bt 10
continue
end
hbreak *0x{address['save_call']:x}
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
commands
silent
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
x/12gx $rdx
bt 10
continue
end
hbreak *0x{address['action_return']:x}
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
commands
silent
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
set $current_action = 0
bt 10
continue
end
hbreak *0x{address['destructor']:x}
condition 4 $save_count >= 2 && $rcx == $current_controller
commands
silent
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
bt 12
continue
end
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000)
assert address == {
"action": 0x1800BFCC0,
"save_call": 0x1800BFF25,
"action_return": 0x1800C00AE,
"destructor": 0x1800BDEC0,
}
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
assert "second_boundary" in script
assert "NATIVE_SAVE_CALL" in script
assert "SELECT_TEAM_DESTRUCTOR" in script
assert "set *(" not in script
print("match_post_kit_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
script = build_gdb_script(pid, cards_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
The probe correlates four concrete lifecycle boundaries:
* FutCreateMatchServerResponse data-source request;
* the POST /match network response callback;
* the response readiness/completion callback;
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
This distinguishes network completion from the separate DataManager readiness
lifecycle without assuming any screen or heap-object identity. The generated GDB
program uses hardware-assisted execution breakpoints only. It never writes
client memory and never drives game input.
match_provider_producer_trace.py [pid] [--output PATH]
match_provider_producer_trace.py --print-script [pid]
match_provider_producer_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
DATA_SOURCE_REQUEST_RVA = 0x120270
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
CREATE_COMPLETE_RVA = 0x120000
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
CREATE_RESPONSE_OFFSET = 0xA0
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
ACTIVE_CALLBACK_OFFSET = 0x47D0
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
"network_response": cards_base + NETWORK_RESPONSE_RVA,
"create_complete": cards_base + CREATE_COMPLETE_RVA,
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
}
def build_gdb_script(
pid: int, cards_base: int, fifa_base: int, output: str
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $last_central = 0
set $last_response = 0
set $last_data_source = 0
set $last_descriptor = 0
hbreak *0x{address['data_source_request']:x}
commands
silent
set $request_data_source = $rcx
set $request_response = $rcx - 0x50
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
bt 10
continue
end
hbreak *0x{address['network_response']:x}
commands
silent
set $last_central = $rcx
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
set $last_descriptor = $rdx
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx == 0
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
else
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
end
bt 10
continue
end
hbreak *0x{address['create_complete']:x}
commands
silent
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx == 0
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
else
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
end
bt 10
continue
end
hbreak *0x{address['ui_dispatch']:x}
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $last_response == 0
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
else
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
end
bt 10
continue
end
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"data_source_request": 0x180120270,
"network_response": 0x180114D90,
"create_complete": 0x180120000,
"ui_dispatch": 0x1480D1070,
}
script = build_gdb_script(
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
)
assert script.count("hbreak *") == 4
assert "DATA_SOURCE_REQUEST" in script
assert "NETWORK_RESPONSE" in script
assert "CREATE_COMPLETE" in script
assert "UI_DISPATCH" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "set *(" not in script
print("match_provider_producer_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
The tracer covers the client-local path after POST /match:
response callback -> deserializer -> controller event 0x7546
-> FUT_CREATE_MATCH_DP 0x7563
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
same native dispatcher. The generated GDB program uses only hardware-assisted
execution breakpoints. It never writes client memory and never drives game
input.
match_transition_trace.py [pid] [--output PATH]
match_transition_trace.py --print-script [pid]
match_transition_trace.py --selftest
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import os
import shutil
import sys
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
RESPONSE_CALLBACK_RVA = 0x114D90
DESERIALIZE_SUCCESS_RVA = 0x118940
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
PROVIDER_DISPATCH_RVA = 0x1A4CD0
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
FUT_CREATE_MATCH_DP = 0x7563
FUT_GET_MATCH_KITS_DP = 0x7565
def find_pid() -> int | None:
found = []
for directory in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
if handle.read().strip() != "FIFA17.exe":
continue
pid = int(os.path.basename(directory))
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
resident_pages = int(handle.read().split()[1])
found.append((resident_pages, pid))
except (OSError, ValueError, IndexError):
continue
return max(found)[1] if found else None
def parse_cards_mapping(lines) -> tuple[int, str]:
for line in lines:
fields = line.split(maxsplit=5)
path = fields[5].rstrip() if len(fields) == 6 else ""
if not path.endswith(CARDS_MODULE):
continue
start = int(fields[0].split("-", 1)[0], 16)
return start, path
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
def cards_mapping(pid: int) -> tuple[int, str]:
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
try:
return parse_cards_mapping(handle)
except RuntimeError as error:
raise RuntimeError(f"{error} in PID {pid}") from error
def sha256_file(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_cards(path: str) -> None:
actual = sha256_file(path)
if actual != PINNED_CARDS_SHA256:
raise RuntimeError(
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
)
def trace_addresses(base: int) -> dict[str, int]:
return {
"response": base + RESPONSE_CALLBACK_RVA,
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
"provider": base + PROVIDER_DISPATCH_RVA,
}
def build_gdb_script(pid: int, base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
hbreak *0x{address['response']:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx != 0
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
else
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
end
continue
end
hbreak *0x{address['deserialize']:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
continue
end
hbreak *0x{address['controller']:x}
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
continue
end
hbreak *0x{address['provider']:x}
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
continue
end
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
continue
"""
def selftest() -> None:
base = 0x180000000
address = trace_addresses(base)
assert address == {
"response": 0x180114D90,
"deserialize": 0x180118940,
"controller": 0x1800BF950,
"provider": 0x1801A4CD0,
}
mapping = parse_cards_mapping(
[
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
]
)
assert mapping == (
0x6FFFFC0F0000,
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
)
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
assert "T3_RESPONSE_CALLBACK" in script
assert "T4_DESERIALIZE_SUCCESS" in script
assert "T5_CREATE_MATCH_CONTROLLER" in script
assert "T6_PROVIDER_DISPATCH" in script
assert "set *(" not in script
print("match_transition_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
base, cards_path = cards_mapping(pid)
validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
script = build_gdb_script(pid, base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
Never relies on heap addresses or allocator handles. It identifies:
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
sequence (teamId/difficulty/roundId/rewardMult/coins),
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
from the transient +0x18 handle,
* the match-config team pair from structural fields around it, not its team ids.
offline_match_locator.py [pid] [--fixture-index 0] [--json]
offline_match_locator.py --selftest
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import struct
import sys
from dataclasses import asdict, dataclass
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
F01 = 0x3DCCCCCD
@dataclass
class Fixture:
address: int
selected_address: int
selected_index: int
selected_team_id: int
records: list[dict[str, int]]
@dataclass
class MatchTeam:
address: int
team_id: int
marker_18: int
marker_1c: int
xi: list[int]
substitutes: list[int]
@dataclass
class MatchConfig:
pair_address: int
team_id_0: int
team_id_1: int
player_count_0: int
player_count_1: int
def find_pids() -> list[int]:
"""All live FIFA17.exe processes, largest resident set first.
The UMU/Proton launch chain briefly creates a small process with the same
comm before the real game. Returning the first /proc glob match attached
the trace supervisor to that short-lived process and missed the match.
"""
found = []
for directory in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(directory, "comm")) as handle:
if handle.read().strip() != "FIFA17.exe":
continue
pid = int(os.path.basename(directory))
with open(os.path.join(directory, "statm")) as handle:
resident_pages = int(handle.read().split()[1])
found.append((resident_pages, pid))
except (OSError, ValueError, IndexError):
continue
return [pid for _resident, pid in sorted(found, reverse=True)]
def find_pid() -> int | None:
pids = find_pids()
return pids[0] if pids else None
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
return b"".join(
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
for round_id, team_id in enumerate(teams)
)
def readable_regions(pid: int, *, writable_anon_only: bool = False):
with open(f"/proc/{pid}/maps") as maps:
for line in maps:
match = re.match(
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
line,
)
if not match:
continue
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
perms, path = match.group(3), match.group(4).strip()
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
continue
if hi - lo > 512 * 1024 * 1024:
continue
if writable_anon_only and (perms[1] != "w" or path):
continue
yield lo, hi, perms, path
def _i32(buf: bytes, offset: int) -> int:
return struct.unpack_from("<i", buf, offset)[0]
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
pattern = fixture_bytes()
found = []
offset = buf.find(pattern)
while offset >= 0:
records = []
for round_id in range(len(DEFAULT_TEAMS)):
at = offset + round_id * 16
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
"<iBBHii", buf, at
)
records.append(
{
"team_id": team_id,
"difficulty": difficulty,
"round_id": parsed_round,
"reward_mult": reward_mult,
"coins": coins,
}
)
found.append(
Fixture(
address=base + offset,
selected_address=base + offset + selected_index * 16,
selected_index=selected_index,
selected_team_id=records[selected_index]["team_id"],
records=records,
)
)
offset = buf.find(pattern, offset + 4)
return found
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
found = []
offset = buf.find(MATCH_HEADER)
while offset >= 0:
if offset + 0x7C <= len(buf):
found.append(
MatchTeam(
address=base + offset,
team_id=_i32(buf, offset + 0x14),
marker_18=_i32(buf, offset + 0x18),
marker_1c=_i32(buf, offset + 0x1C),
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
)
)
offset = buf.find(MATCH_HEADER, offset + 4)
return found
def _valid_config(buf: bytes, pair: int) -> bool:
required = pair + 0x50
if pair < 0 or required > len(buf):
return False
return (
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
and _i32(buf, pair + 0x38) == 11
and _i32(buf, pair + 0x3C) == 11
and _i32(buf, pair + 0x40) == 0
and _i32(buf, pair + 0x44) == 5
)
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
found = []
offset = buf.find(PARTICIPANT_PREFIX)
while offset >= 0:
pair = offset + len(PARTICIPANT_PREFIX)
if _valid_config(buf, pair):
found.append(
MatchConfig(
pair_address=base + pair,
team_id_0=_i32(buf, pair),
team_id_1=_i32(buf, pair + 4),
player_count_0=_i32(buf, pair + 0x38),
player_count_1=_i32(buf, pair + 0x3C),
)
)
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
return found
def scan_process(
pid: int,
selected_index: int,
*,
include_fixture: bool = True,
writable_anon_only: bool = False,
) -> dict[str, list]:
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
for lo, hi, _perms, _path in readable_regions(
pid, writable_anon_only=writable_anon_only
):
try:
memory.seek(lo)
buf = memory.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
if include_fixture:
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
return result
def selftest() -> None:
fixture = fixture_bytes()
team = bytearray(0x7C)
team[:20] = MATCH_HEADER
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
struct.pack_into("<11i", team, 0x20, *range(11))
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
config = bytearray(0x20 + 0x50)
config[:0x20] = PARTICIPANT_PREFIX
pair = 0x20
struct.pack_into("<ii", config, pair, 130000, 130000)
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
teams = scan_match_team_buffer(buf, 0x1000)
configs = scan_match_config_buffer(buf, 0x1000)
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
assert len(teams) == 1 and teams[0].team_id == 130000
assert len(configs) == 1 and configs[0].team_id_1 == 130000
# The transient handle is never part of the anchor.
struct.pack_into("<i", team, 0x18, -1)
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
print("offline_match_locator selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--fixture-index", type=int, default=0)
parser.add_argument("--json", action="store_true")
parser.add_argument("--selftest", action="store_true")
parser.add_argument("--writable-anon-only", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
print("--fixture-index must be 0..9", file=sys.stderr)
return 2
result = scan_process(
pid,
args.fixture_index,
writable_anon_only=args.writable_anon_only,
)
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
serial["pid"] = pid
if args.json:
print(json.dumps(serial, sort_keys=True))
return 0
print(f"pid={pid}")
for fixture in result["fixtures"]:
print(
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
)
for config in result["match_configs"]:
print(
f"match config pair @0x{config.pair_address:x}: "
f"[{config.team_id_0}, {config.team_id_1}]"
)
for team in result["match_teams"]:
print(
f"match team @0x{team.address:x}: teamId={team.team_id} "
f"handles=[{team.marker_18}, {team.marker_1c}]"
)
print(
f"counts: fixtures={len(result['fixtures'])} "
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
The generated GDB program uses hardware breakpoints, only reads registers and
client memory, logs, and continues. Breakpoints are rotated so no more than four
are enabled. It never calls client functions, writes client memory, emits an
event, or drives input.
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
pma_producer_trace.py --selftest
"""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
VARIANTS = {
"mode0": {
"scenario_rva": 0x07B1C190,
"writer_rva": 0x07B1C26B,
"register_rva": 0x07B1C282,
"writer_context": "$rsi",
"writer_async_requested": "1",
"arm_condition": "1",
},
"alternate": {
"scenario_rva": 0x07B1C050,
"writer_rva": 0x07B1C12F,
"register_rva": 0x07B1C146,
"writer_context": "$rbp",
"writer_async_requested": "$sil",
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
},
}
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
ASYNC_COMPLETION_RVA = 0x07B046C0
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
def addresses(base: int, variant: str) -> dict[str, int]:
config = VARIANTS[variant]
return {
"scenario": base + config["scenario_rva"],
"writer": base + config["writer_rva"],
"register": base + config["register_rva"],
"arm": base + PMA_COMPLETION_ARM_RVA,
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
"completion": base + ASYNC_COMPLETION_RVA,
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
}
def gdb_prelude(pid: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted off
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
"""
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
address = addresses(fifa_base, variant)
config = VARIANTS[variant]
return (
gdb_prelude(pid, output)
+ f"""define snapshot_pma_context
set $snap_ctx = $arg0
set $snap_flag40 = -1
set $snap_callback_vtable = 0
set $snap_callback_owner = 0
set $snap_dispatcher = 0
set $snap_dispatcher_vtable = 0
set $snap_pma = 0
set $snap_pma_flag18 = -1
set $snap_pma_parent = 0
set $snap_pma_machine = 0
set $snap_pma_current = 0
if $snap_ctx != 0
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
set $snap_dispatcher = $snap_ctx+0x80
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
set $snap_sentinel = $snap_ctx+0x88
set $snap_node = *(void**)$snap_sentinel
set $snap_scan = 0
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
set $snap_candidate = *(void**)($snap_node+0x10)
if $snap_candidate != 0
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
set $snap_pma = $snap_candidate
end
end
set $snap_node = *(void**)$snap_node
set $snap_scan = $snap_scan+1
end
if $snap_pma != 0
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
set $snap_pma_parent = *(void**)($snap_pma+0x8)
if $snap_pma_parent != 0
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
end
if $snap_pma_machine != 0
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
end
end
end
end
define snapshot_gameplay
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
set $snap_listener_manager = 0
set $snap_listener_table = 0
set $snap_listener_index = -1
set $snap_free_roam = 0
set $snap_free_roam_state = -1
set $snap_free_roam_111 = -1
set $snap_free_roam_112 = -1
set $snap_free_roam_124 = -1
set $snap_selected = 0
set $snap_selected_vtable = 0
set $snap_selected_mode = -1
if $snap_gameplay_global != 0
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
end
if $snap_listener_manager != 0
set $snap_listener_table = *(void**)$snap_listener_manager
end
if $snap_listener_table != 0
set $snap_free_roam = *(void**)$snap_listener_table
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
if $snap_listener_index >= 0 && $snap_listener_index < 3
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
end
end
if $snap_free_roam != 0
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
end
if $snap_selected != 0
set $snap_selected_vtable = *(void**)$snap_selected
set $snap_selected_mode = *(int*)($snap_selected+0x18)
end
end
set $tracked_ctx = 0
hbreak *0x{address['scenario']:x}
commands
silent
set $ctx = $rcx
set $tracked_ctx = $ctx
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 1
enable 2
continue
end
hbreak *0x{address['writer']:x}
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
disable 2
commands
silent
set $ctx = {config['writer_context']}
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 2
enable 3
continue
end
hbreak *0x{address['register']:x}
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
disable 3
commands
silent
set $callback = $rdx
set $ctx = $callback-0x48
snapshot_pma_context $ctx
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
disable 3
continue
end
hbreak *0x{address['arm']:x}
condition 4 {config['arm_condition']}
commands
silent
set $ctx = $rcx
if $tracked_ctx == 0
set $tracked_ctx = $ctx
end
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 4
enable 5
continue
end
hbreak *0x{address['arm_writer']:x}
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
disable 5
commands
silent
set $ctx = $rsi
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 5
continue
end
hbreak *0x{address['completion']:x}
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
commands
silent
set $callback = $rcx
set $ctx = *(void**)($callback+0x30)
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
continue
end
hbreak *0x{address['dispatcher']:x}
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
commands
silent
set $ctx = $rcx-0x80
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 7
enable 8
continue
end
hbreak *0x{address['instructions']:x}
disable 8
commands
silent
set $listener = $rcx
set $parent = *(void**)($listener+0x8)
set $machine = 0
set $current = 0
if $parent != 0
set $machine = *(void**)($parent+0x8)
end
if $machine != 0
set $current = *(void**)($machine+0x10)
end
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 6
continue
end
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
continue
"""
)
def effective_environment(pid: int) -> dict[str, str]:
values: dict[str, str] = {}
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
if not item.startswith(b"OPENFUT_FIFA17_"):
continue
key, _, value = item.decode("utf-8", errors="replace").partition("=")
values[key] = value
return values
def selftest() -> None:
mode0 = addresses(0x140000000, "mode0")
alternate = addresses(0x140000000, "alternate")
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
assert mode0["scenario"] == 0x147B1C190
assert mode0["writer"] == 0x147B1C26B
assert mode0["register"] == 0x147B1C282
assert alternate["scenario"] == 0x147B1C050
assert alternate["writer"] == 0x147B1C12F
assert alternate["register"] == 0x147B1C146
assert mode0["completion"] == 0x147B046C0
assert mode0["dispatcher"] == 0x147AC87B0
assert mode0["instructions"] == 0x147AC91E0
assert mode0["arm"] == 0x147B1AE60
assert mode0["arm_writer"] == 0x147B1AF33
assert script.count("hbreak *") == 8
assert "condition 7 $tracked_ctx != 0" in script
assert "disable 2" in script and "enable 2" in script
assert "disable 3" in script and "enable 3" in script
assert "disable 5" in script and "enable 5" in script
assert "disable 8" in script and "enable 8" in script
assert "set *(" not in script
print("pma_producer_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
script = build_script(pid, fifa_base, output, args.variant)
environment = effective_environment(pid)
print(
"PMAPRODUCER PREPARED "
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
Path(script_path).write_text(script, encoding="utf-8")
import os
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(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())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Scan for FIFA17 match-team records by the invariant header prefix.
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
per-record marker whose value varies between sessions (-1 on 2026-08-24,
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
scan_mt.py [pid]
"""
import glob
import os
import re
import struct
import sys
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
return None
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
if not pid:
print(" no FIFA17.exe")
raise SystemExit(2)
mem = open(f"/proc/{pid}/mem", "rb", 0)
found = []
for line in open(f"/proc/{pid}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
if not m or m.group(3)[0] != "r":
continue
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
continue
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
i = buf.find(PAT)
while i >= 0:
rec = buf[i:i + 0x80]
if len(rec) >= 0x80:
tid = struct.unpack_from("<i", rec, 0x14)[0]
m18 = struct.unpack_from("<i", rec, 0x18)[0]
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
xi = list(struct.unpack_from("<11i", rec, 0x20))
subs = list(struct.unpack_from("<12i", rec, 0x4c))
found.append((lo + i, tid, m18, m1c, xi, subs))
i = buf.find(PAT, i + 4)
print(f" pid={pid} {len(found)} match-team record(s)")
for addr, tid, m18, m1c, xi, subs in found:
print(f"\n @0x{addr:x}")
print(f" +0x14 teamId = {tid}")
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
print(f" XI = {xi}")
print(f" subs = {subs}")
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
File diff suppressed because it is too large Load Diff
+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())
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Supervise one hardware-only FIFA17 match-team writer capture.
This is the robust fresh-client entry point. It waits for the largest-RSS
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
can construct match teams, and loads a hardware-only GDB Python payload.
The concurrent read-only structural locator proves when the fixture and final
match-team records exist. A zero-hit result is trusted only if gdb is still
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
records pre-existed the trace, and two final records then appeared.
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
watchpoint from live RDX. Other payloads trace the final engine writer or its
caller; all expose the same `start_trace(log, cards_base)` entry point.
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
'rb'. The operator alone drives the game.
trace_match_team_writer.py --status /tmp/mt-status.json \
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
from dataclasses import asdict
from pathlib import Path
from offline_match_locator import find_pids, scan_process
CARDS_IMAGE_BASE = 0x180000000
DEFAULT_TIMEOUT = 45 * 60
def cards_base(pid: int) -> int | None:
try:
with open(f"/proc/{pid}/maps") as maps:
for line in maps:
if "CardsDLL_Win64_retail.dll" in line:
return int(line.split("-", 1)[0], 16)
except OSError:
pass
return None
def tracer_pid(pid: int) -> int | None:
try:
with open(f"/proc/{pid}/status") as status:
for line in status:
if line.startswith("TracerPid:"):
return int(line.split()[1])
except OSError:
pass
return None
def target_state(pid: int) -> str | None:
try:
with open(f"/proc/{pid}/status") as status:
for line in status:
if line.startswith("State:"):
return line.split()[1]
except OSError:
pass
return None
def read_events(path: Path) -> list[dict]:
if not path.exists():
return []
events = []
try:
with path.open(encoding="utf-8", errors="replace") as handle:
for line in handle:
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
return []
return events
def event_counts(events: list[dict]) -> dict[str, int]:
counts: dict[str, int] = {}
for event in events:
kind = event.get("event", "unknown")
counts[kind] = counts.get(kind, 0) + 1
return counts
class Status:
def __init__(self, path: Path, monitor_log: Path):
self.path = path
self.monitor_log = monitor_log
self.data: dict = {"started_unix": time.time(), "state": "starting"}
self.write()
def write(self, **updates):
self.data.update(updates)
self.data["updated_unix"] = time.time()
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
os.replace(temporary, self.path)
def log(self, message: str, **payload):
record = {"time_unix": time.time(), "message": message, **payload}
with self.monitor_log.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
print(message, flush=True)
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
# Wine uses these signals for thread suspension/runtime plumbing. They must
# pass through, or batch gdb stops and silently detaches.
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
f"SIG{number}" for number in range(32, 40)
]
lines = [
"set confirm off",
"set pagination off",
"set height 0",
"set width 0",
f"attach {pid}",
]
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
lines.extend(
[
f"source {payload}",
f'python start_trace({json.dumps(str(trace))}, {cards})',
"continue",
]
)
return "\n".join(lines) + "\n"
def serialise_locations(locations: dict) -> dict:
return {key: [asdict(value) for value in values] for key, values in locations.items()}
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=12)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
deadline = time.time() + 8
while time.time() < deadline and tracer_pid(pid):
time.sleep(0.25)
status.log(
"gdb detached",
gdb_returncode=process.returncode,
tracer_pid=tracer_pid(pid),
target_state=target_state(pid),
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--status", type=Path, required=True)
parser.add_argument("--trace", type=Path, required=True)
parser.add_argument("--gdb-log", type=Path, required=True)
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
parser.add_argument("--fixture-index", type=int, default=0)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--post-record-wait", type=int, default=12)
parser.add_argument(
"--arm-check-seconds",
type=int,
default=0,
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
)
parser.add_argument(
"--wait-for-record-clear",
action="store_true",
help="keep tracing through abandon; accept creation only after old records disappear",
)
parser.add_argument(
"--exclude-pid",
action="append",
type=int,
default=[],
help="ignore an existing FIFA process and attach only after process replacement",
)
parser.add_argument(
"--payload",
default="gdb_match_team_writer_trace.py",
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
)
args = parser.parse_args()
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
path.parent.mkdir(parents=True, exist_ok=True)
for path in (args.trace, args.gdb_log, args.monitor_log):
path.unlink(missing_ok=True)
status = Status(args.status, args.monitor_log)
payload = Path(__file__).with_name(args.payload).resolve()
if not payload.exists():
status.write(state="failed", error=f"missing gdb payload: {payload}")
return 2
deadline = time.time() + args.timeout
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
status.log(
"waiting for FIFA17.exe with CardsDLL",
excluded_pids=args.exclude_pid,
)
pid = None
cards = None
while time.time() < deadline:
# UMU/Proton creates a short-lived small FIFA17.exe before the real
# client. Never bind to the first comm match. Require CardsDLL and prefer
# the largest-RSS process (find_pids is ordered that way).
for candidate in find_pids():
if candidate in args.exclude_pid:
continue
candidate_cards = cards_base(candidate)
if candidate_cards:
pid, cards = candidate, candidate_cards
break
if pid:
break
time.sleep(0.25)
if not pid or not cards:
status.write(state="timed_out", phase="ready_process")
return 3
status.write(state="ready_process_found", pid=pid, cards_base=cards)
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
process = subprocess.Popen(
["gdb", "-q", "-nx", "-x", str(command_path)],
stdout=gdb_handle,
stderr=subprocess.STDOUT,
text=True,
)
status.write(
state="attaching",
pid=pid,
cards_base=cards,
cards_image_base=CARDS_IMAGE_BASE,
gdb_pid=process.pid,
gdb_command_file=str(command_path),
payload=args.payload,
hardware_only=True,
client_memory_writes=False,
)
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
armed = False
arm_deadline = min(deadline, time.time() + 60)
while time.time() < arm_deadline:
if process.poll() is not None:
break
events = read_events(args.trace)
if any(event.get("event") == "trace_armed" for event in events):
armed = True
break
time.sleep(0.25)
if not armed:
gdb_handle.close()
status.write(
state="failed",
phase="arm",
gdb_returncode=process.poll(),
tracer_pid=tracer_pid(pid),
trace_events=event_counts(read_events(args.trace)),
)
if process.poll() is None:
terminate_gdb(process, status, pid)
return 4
attached = tracer_pid(pid) == process.pid
status.write(
state="armed",
tracer_pid=tracer_pid(pid),
target_state=target_state(pid),
trace_events=event_counts(read_events(args.trace)),
execution_breakpoints_armed=True,
team1_watchpoint_armed=False,
)
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
if not attached:
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="failed", phase="attach_verification")
return 4
if args.arm_check_seconds > 0:
time.sleep(args.arm_check_seconds)
events = read_events(args.trace)
counts = event_counts(events)
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
terminate_gdb(process, status, pid)
gdb_handle.close()
passed = (
still_attached
and counts.get("trace_armed", 0) == 1
and counts.get("trace_error", 0) == 0
and tracer_pid(pid) == 0
and target_state(pid) != "T"
)
status.write(
state="arm_check_passed" if passed else "arm_check_failed",
trace_events=counts,
attached_before_detach=still_attached,
tracer_pid_after_detach=tracer_pid(pid),
target_state_after_detach=target_state(pid),
)
status.log("arm check complete", passed=passed, trace_events=counts)
return 0 if passed else 5
# A final record that already exists before arming cannot prove execution
# crossed creation under the debugger. Fail closed instead of converting an
# already-built match into a trusted zero-hit result.
initial_heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
records_preexisting = len(initial_heap["match_teams"]) >= 2
records_cleared = not records_preexisting
if records_preexisting and args.wait_for_record_clear:
status.write(
state="waiting_for_record_clear",
locations=serialise_locations(initial_heap),
target_crossed_match_team_creation=False,
)
status.log(
"trace armed; waiting for old match-team records to disappear",
team_ids=[team.team_id for team in initial_heap["match_teams"]],
)
while time.time() < deadline:
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="failed", phase="record_clear")
return 5
heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
if not heap["match_teams"]:
records_cleared = True
status.write(
state="records_cleared",
cleared_unix=time.time(),
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
target_state=target_state(pid),
)
status.log(
"old match-team records disappeared; next records are a fresh creation",
tracer_pid=tracer_pid(pid),
)
break
time.sleep(2)
if not records_cleared:
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="timed_out", phase="record_clear")
return 3
elif records_preexisting:
counts = event_counts(read_events(args.trace))
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(
state="armed_too_late",
phase="preexisting_records",
trace_events=counts,
locations=serialise_locations(initial_heap),
target_crossed_match_team_creation=False,
tracer_pid_after_detach=tracer_pid(pid),
target_state_after_detach=target_state(pid),
)
status.log(
"match-team records pre-existed trace; no writer claim",
team_ids=[team.team_id for team in initial_heap["match_teams"]],
)
return 6
fixture = None
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
last_fixture_scan = 0.0
records_seen_at = None
record_control = None
try:
while time.time() < deadline:
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
status.write(
state="failed",
phase="monitor",
gdb_returncode=process.poll(),
target_exists=Path(f"/proc/{pid}").exists(),
)
return 5
now = time.time()
if fixture is None and now - last_fixture_scan >= 8:
full = scan_process(pid, args.fixture_index, include_fixture=True)
last_fixture_scan = now
if full["fixtures"]:
fixture = full["fixtures"][0]
latest_locations["fixtures"] = full["fixtures"]
status.log(
"fixture located",
address=fixture.address,
selected_address=fixture.selected_address,
selected_index=fixture.selected_index,
selected_team_id=fixture.selected_team_id,
)
heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
latest_locations["match_teams"] = heap["match_teams"]
latest_locations["match_configs"] = heap["match_configs"]
events = read_events(args.trace)
counts = event_counts(events)
is_attached = tracer_pid(pid) == process.pid
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
status.write(
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
target_state=target_state(pid),
trace_events=counts,
team1_watchpoint_armed=watch_armed,
locations=serialise_locations(latest_locations),
)
if len(heap["match_teams"]) >= 2:
if records_seen_at is None:
if not is_attached or process.poll() is not None:
status.write(
state="failed",
phase="record_creation_control",
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
trace_events=counts,
)
return 5
records_seen_at = now
record_control = {
"gdb_alive": process.poll() is None,
"tracer_pid": tracer_pid(pid),
"attached": is_attached,
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
"team1_watchpoint_armed": watch_armed,
}
status.log(
"two match-team records located",
team_ids=[team.team_id for team in heap["match_teams"]],
trace_events=counts,
**record_control,
)
if now - records_seen_at >= args.post_record_wait:
break
time.sleep(3)
finally:
terminate_gdb(process, status, pid)
gdb_handle.close()
events = read_events(args.trace)
counts = event_counts(events)
final = {
"state": "captured",
"pid": pid,
"cards_base": cards,
"fixture": asdict(fixture) if fixture else None,
"locations": serialise_locations(latest_locations),
"trace_events": counts,
"record_creation_control": record_control,
"gdb_alive_at_record_creation": bool(
record_control and record_control["gdb_alive"] and record_control["attached"]
),
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
"tracer_pid_after_detach": tracer_pid(pid),
"target_state_after_detach": target_state(pid),
"records_preexisting": records_preexisting,
"records_cleared_before_capture": records_cleared,
}
status.write(**final)
status.log("capture complete", **final)
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())
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
different function in the same slot (a type/mode dispatch).
vtab.py <slot_image_va_hex> [before] [after]
"""
import glob
import os
import re
import struct
import sys
CARDS_IMG = 0x180000000
def pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
raise SystemExit("no FIFA17.exe")
P = pid()
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
def img2live(va):
return BASE + (va - CARDS_IMG)
def live2img(la):
return CARDS_IMG + (la - BASE)
slot = int(sys.argv[1], 16)
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
mem = open(f"/proc/{P}/mem", "rb", 0)
start = slot - before * 8
mem.seek(img2live(start))
buf = mem.read((before + after) * 8)
print(f" vtable neighbourhood of image 0x{slot:x}")
target = None
for k in range(0, len(buf) - 7, 8):
a = start + k
p = struct.unpack_from("<Q", buf, k)[0]
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
mark = " <== the team-pair assigner" if a == slot else ""
if a == slot:
target = ivа
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
# Find every other .rdata slot pointing at a DIFFERENT function but whose
# neighbours overlap this vtable -> sibling implementations of the same slot.
print("\n === sibling vtables: same neighbour, different slot function ===")
mem.seek(img2live(0x1801e5000))
rdata = mem.read(0x28a000 - 0x1e5000)
# take the two neighbours around the slot as a signature
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
found = 0
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
pat = struct.pack("<Q", sig)
i = rdata.find(pat)
while i >= 0:
if i % 8 == 0:
here = 0x1801e5000 + i
# the slot in THIS vtable at the same relative position
off = i + (8 if name == "preceding" else -8)
if 0 <= off <= len(rdata) - 8:
fn = struct.unpack_from("<Q", rdata, off)[0]
if BASE <= fn < BASE + 0x400000:
fimg = live2img(fn)
if fimg != target:
print(f" vtable @image 0x{here:x} ({name} matches) "
f"slot -> image 0x{fimg:x} DIFFERENT")
found += 1
i = rdata.find(pat, i + 1)
print(f" {found} sibling implementation(s)")
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Find references to an image VA inside a live module's .text/.rdata/.data.
xref.py <target_image_va_hex> [--exe]
Reports:
call rel32 (e8) / jmp rel32 (e9) -- direct callers
lea rip-rel (48 8d 0x) -- address-taken
absolute 8-byte pointer -- vtable / table slot
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
"""
import glob
import os
import re
import struct
import sys
CARDS_IMG = 0x180000000
EXE_IMG = 0x140000000
def pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
raise SystemExit("FIFA17.exe not running")
P = pid()
def module_base(needle):
for l in open(f"/proc/{P}/maps"):
if needle.lower() in l.lower():
return int(l.split("-")[0], 16)
raise SystemExit(f"{needle} not mapped")
def spans(base, limit=0x400000):
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
out = []
for l in open(f"/proc/{P}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
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 lo == base:
out.append((lo, hi, perms))
continue
if out and lo == out[-1][1] and not path.strip():
out.append((lo, hi, perms))
elif out and lo > out[-1][1]:
break
return out
def main():
a = [x for x in sys.argv[1:] if x != "--exe"]
exe = "--exe" in sys.argv
target = int(a[0], 16)
img = EXE_IMG if exe else CARDS_IMG
base = module_base("FIFA17.exe" if exe else "CardsDLL")
tgt_live = base + (target - img)
mem = open(f"/proc/{P}/mem", "rb", 0)
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
hits = 0
for lo, hi, perms in spans(base):
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError):
continue
img_lo = img + (lo - base)
# rel32 call/jmp
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
i = buf.find(bytes([op]))
while i >= 0:
if i + 5 <= len(buf):
rel = struct.unpack_from("<i", buf, i + 1)[0]
if img_lo + i + 5 + rel == target:
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
hits += 1
i = buf.find(bytes([op]), i + 1)
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
i = buf.find(b"\x48\x8d")
while i >= 0:
if i + 7 <= len(buf):
modrm = buf[i + 2]
if (modrm & 0xC7) == 0x05:
rel = struct.unpack_from("<i", buf, i + 3)[0]
if img_lo + i + 7 + rel == target:
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
hits += 1
i = buf.find(b"\x48\x8d", i + 1)
# absolute pointer (live address stored in a table)
pat = struct.pack("<Q", tgt_live)
i = buf.find(pat)
while i >= 0:
if i % 8 == 0:
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
hits += 1
i = buf.find(pat, i + 1)
print(f" {hits} reference(s)")
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" \
+223 -1
View File
@@ -38,6 +38,69 @@ 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,
/// The wire `assetId` for a CLUB item (record `+0x20`), which is family
/// specific and is NOT the carddbid: a kit carries the art class from
/// `fcc_kitcards.assetid` (`14` home/third band, `15` away band), a badge
/// carries its team id, a stadium and a ball their own asset number.
///
/// Distinct from [`Self::asset_id`], which for these definitions is the
/// carddbid and is what `resource_id` is derived from — so the two cannot be
/// the same field. Shipping the carddbid here is what left the client
/// holding `assetId 6300006` at record `+0x20` where its own table says
/// `14`, with both pre-match kit tiles rendering identically.
///
/// Defaults to `asset_id` when a catalog does not specify it, which is the
/// pre-existing behaviour and is correct for every non-club kind.
pub club_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 +121,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 +217,36 @@ 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>,
/// Wire `assetId` for a club item; defaults to `asset_id`. See
/// [`Fifa17CardIdentity::club_asset_id`].
club_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 +303,16 @@ 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),
club_asset_id: rc.club_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 +398,95 @@ mod tests {
assert_eq!(cat.lookup("card_missing"), None);
}
/// A club item's wire `assetId` is family specific and is NOT the carddbid.
///
/// Regression: the catalog shipped `asset_id` (the carddbid) as the wire
/// `assetId`, so the client held `assetId 6300006` at record `+0x20` where
/// its own `fcc_kitcards` says `14`, and both pre-match kit tiles rendered
/// identically. `resource_id` is derived from `asset_id`, and every home kit
/// shares art class 14, so the two genuinely cannot be one field.
#[test]
fn club_items_carry_their_own_wire_asset_id_distinct_from_the_carddbid() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
"card_asset_id":35,"club_asset_id":14,"team_id":21,"category":2,"year":0},
"fifa17_6400003":{"asset_id":6400003,"kind":"kit","subtype":9,
"card_asset_id":35,"club_asset_id":15,"team_id":21,"category":3,"year":0},
"fifa17_20801":{"asset_id":20801}
}}"#,
)
.unwrap();
let home = cat.lookup("fifa17_6300006").unwrap();
let away = cat.lookup("fifa17_6400003").unwrap();
// resourceId stays the carddbid — it is what the staff/kit merge keys on.
assert_eq!(home.resource_id, 6300006);
assert_eq!(away.resource_id, 6400003);
// The card frame art is shared by the whole kit family.
assert_eq!(home.card_asset_id, 35);
assert_eq!(away.card_asset_id, 35);
// The art class is what distinguishes home from away on the wire.
assert_eq!(home.club_asset_id, 14);
assert_eq!(away.club_asset_id, 15);
assert_ne!(
home.club_asset_id, away.club_asset_id,
"home and away must not present the same assetId"
);
// Absent: defaults to asset_id, which is correct for every non-club kind
// and preserves the behaviour of a catalog that predates the field.
let player = cat.lookup("fifa17_20801").unwrap();
assert_eq!(player.club_asset_id, 20801);
}
/// 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 +612,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 +624,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;
+103 -13
View File
@@ -24,9 +24,50 @@ pub fn accountinfo_body() -> Value {
json!({})
}
/// `GET …/settings` — production oracle returns an empty config list.
pub fn settings_body() -> Value {
json!({ "configs": [] })
/// The commerce flags the client leaves OFF unless a `configs` row turns them on.
///
/// `FutGetSettingsServerResponse` (deser `0x18013c6d0`, read end to end) has a
/// single wrapper key `configs` (0xa2) holding an array of
/// `{ type (0x354), value (0x377) }`, and nothing else. `type` is the setting
/// NAME, not an index.
///
/// These matter because the struct's defaults are NOT uniform: several fields
/// default to 1, but `tradingEnabled` defaults to **0**. It is not a flag we have
/// been overwriting — it is a flag nobody has ever sent. It gates the service
/// half of the `TO_TRADE_PILE` predicate (vtable slot `+0x270` =
/// `FUN_18011c670`, reading gate byte `0x1fd2e`, measured 0 in the live client),
/// which is one of the two reasons "Place on Transfer List" is greyed. The other
/// reason — `untradeable` — this crate already handles: [`crate::fut::item`]
/// emits `false` for owned copies.
const COMMERCE_SETTINGS: [&str; 8] = [
"storeEnabled",
"storeEnabled_JP",
"coinEnabled",
"coinEnabled_JP",
"cardPackStoreEnabled",
"cardPackStoreEnabled_JP",
"pointsPackStoreEnabled",
"tradingEnabled",
];
/// `GET …/settings` — an empty config list by default, matching the production
/// oracle and the live-proven behaviour.
///
/// `enable_commerce` opts in to [`COMMERCE_SETTINGS`]. It defaults OFF because
/// the flags are RECOVERED BUT UNTESTED: the schema is high-confidence and this
/// is the exact row shape the oracle would emit, but no launch has yet confirmed
/// what the client does with them. Turning it on is the server half of the
/// transfer-list fix; it explains why the menu entry is greyed and does NOT
/// promise that the transfer market behind it works.
pub fn settings_body(enable_commerce: bool) -> Value {
if !enable_commerce {
return json!({ "configs": [] });
}
let rows: Vec<Value> = COMMERCE_SETTINGS
.iter()
.map(|name| json!({ "type": name, "value": 1 }))
.collect();
json!({ "configs": rows })
}
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
@@ -67,12 +108,19 @@ pub fn feature_off_body() -> Value {
pub fn item_def(resource_id: i64) -> Value {
let asset = resource_id & 0xff_ffff;
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let (name, rating, pos, nation, league, team, attrs): (
&str,
i64,
&str,
i64,
i64,
i64,
[i64; 6],
) = if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let attribute_list: Vec<Value> = attrs
.iter()
.enumerate()
@@ -365,7 +413,11 @@ pub fn user_mass_info_body(
pub fn format_utc_datetime(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_euclid(86_400);
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
let (hour, min, sec) = (
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
);
// civil_from_days: days is a count of days since 1970-01-01.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
@@ -415,12 +467,44 @@ mod tests {
#[test]
fn static_bodies_match_oracle() {
assert_eq!(accountinfo_body(), json!({}));
assert_eq!(settings_body(), json!({ "configs": [] }));
assert_eq!(settings_body(false), json!({ "configs": [] }));
assert_eq!(leaderboard_options_body(), json!({}));
assert_eq!(match_reset_body(), json!({}));
assert_eq!(club_stats_staff_body(), json!({}));
}
/// The default MUST stay the live-proven empty list, and the opt-in body must
/// match the schema exactly: `configs` holding `{type, value}` rows where
/// `type` is the setting NAME. `tradingEnabled` is the one that matters — the
/// client defaults it to 0 and it gates the transfer-list menu entry.
#[test]
fn commerce_settings_are_opt_in_and_shaped_to_the_schema() {
assert_eq!(
settings_body(false),
json!({ "configs": [] }),
"default must remain the live-proven body"
);
let on = settings_body(true);
let rows = on["configs"].as_array().expect("configs is an array");
assert_eq!(rows.len(), COMMERCE_SETTINGS.len());
for row in rows {
let obj = row.as_object().expect("each config row is an OBJECT");
assert_eq!(
obj.keys().collect::<Vec<_>>(),
vec!["type", "value"],
"the deserializer knows exactly two keys; an extra one is skipped \
at best and a type desync at worst"
);
assert!(obj["type"].is_string(), "type is the setting NAME");
assert_eq!(obj["value"], 1);
}
assert!(
rows.iter().any(|r| r["type"] == "tradingEnabled"),
"the whole point of the opt-in"
);
}
#[test]
fn action_parse() {
assert_eq!(
@@ -628,8 +712,14 @@ mod tests {
});
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
// Flat top-level envelope.
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
assert_eq!(
body["pileSizeClientData"]["entries"][0],
json!({"key": 2, "value": 100})
);
assert_eq!(
body["pileSizeClientData"]["entries"][1],
json!({"key": 4, "value": 50})
);
assert_eq!(body["settings"], json!({"configs": []}));
assert_eq!(body["userData"], json!({}));
// userInfo economy + club identity.
@@ -48,6 +48,9 @@ use std::collections::HashMap;
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
/// no FIFA content taxonomy, so the host applies this filter locally.
pub item_type: Option<String>,
/// Quality filter: `any` (default, always present) or `gold`.
pub level: Option<String>,
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
@@ -66,6 +69,18 @@ pub struct Fifa17ClubQuery {
pub start: Option<u32>,
/// Pagination page size.
pub count: Option<u32>,
/// `defId=` — a comma-joined list of DEFINITION ids. The documented club
/// grammar says the client sends EITHER the filter block above OR this list,
/// never both.
///
/// CAPTURED BUT NOT APPLIED, deliberately. The grammar is single-source (one
/// decompile plus one live log line, and that log line carried no `defId`),
/// so the exact semantics — definition id as `resourceId`, presumably — are
/// not confirmed against an observed request. Filtering on a wrong reading
/// would turn "too many items" into "zero items", which is the worse failure.
/// The host logs it instead, so the first real occurrence is visible and the
/// filter can be written against evidence rather than a guess.
pub def_ids: Vec<i64>,
}
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
@@ -115,6 +130,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
None => (pair, String::new()),
};
match k {
"type" => out.item_type = Some(v),
"level" => out.level = Some(v),
"rare" => out.rare = Some(v),
"position" => out.position = Some(v),
@@ -124,6 +140,14 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
"sort" => out.sort = Some(v),
"start" => out.start = v.parse().ok(),
"count" => out.count = v.parse().ok(),
// Comma-joined, digits only — the same reading the item-definition
// routes already use for this parameter.
"defId" => {
out.def_ids = v
.split(',')
.filter_map(|d| d.trim().parse::<i64>().ok())
.collect()
}
_ => {}
}
}
@@ -325,6 +349,7 @@ mod tests {
assert_eq!(
q,
Fifa17ClubQuery {
item_type: Some("player".into()),
level: Some("gold".into()),
rare: None,
position: Some("ST".into()),
@@ -334,6 +359,7 @@ mod tests {
sort: Some("desc".into()),
start: Some(10),
count: Some(11),
def_ids: Vec::new(),
}
);
}
@@ -523,3 +549,25 @@ mod tests {
);
}
}
#[cfg(test)]
mod def_id_tests {
use super::*;
/// `defId=` is CAPTURED so the host can report it, and deliberately does NOT
/// participate in the Core query — narrowing on an unconfirmed reading of the
/// parameter would answer "zero items" where today we answer "too many".
#[test]
fn def_id_list_is_captured_but_never_narrows_the_core_query() {
let q = parse_club_query("?year=2017&type=player&defId=20801,117617092,84044103");
assert_eq!(q.def_ids, vec![20801, 117_617_092, 84_044_103]);
assert_eq!(q.item_type.as_deref(), Some("player"));
// Non-numeric entries are dropped rather than poisoning the list.
let q = parse_club_query("?defId=20801,,notanid,42");
assert_eq!(q.def_ids, vec![20801, 42]);
// Absent means empty, never a phantom filter.
assert!(parse_club_query("?type=player").def_ids.is_empty());
}
}
+87 -49
View File
@@ -7,20 +7,16 @@
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
//! mints the drawn cards into Core, and shapes them onto the wire.
//!
//! ## Parity note — Python `open_pack` / `_pack_body`
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
//! 3. Each drawn player becomes a special with probability `special_chance`
//! (`random.random() < special_chance`).
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
//! ## Policy (real FUT 17 composition, DESIGNED odds)
//!
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
//! is biased toward a special version with probability `special_chance` (a DESIGNED
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
//! the whole pool so a draw is always possible even when the pool lacks that tier.
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
//! candidates are player defs.
use rand::Rng;
@@ -77,50 +73,83 @@ pub struct GeneratedCard {
pub attributes: [u8; 6],
}
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
/// the pool is empty or the pack awards no cards.
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
/// or the pack awards no cards.
///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
/// the pack's tier (`gold`), biasing each draw toward a special card with
/// probability `special_chance`. An empty tier or partition falls back to the
/// next-wider set so a draw is always possible when the pool is non-empty.
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
/// An empty tier falls back to the whole pool (so a draw is always possible).
pub fn generate_pack_contents(
pack: &PackDef,
rng: &mut impl Rng,
pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 {
if pool.is_empty() || pack.count() == 0 {
return Vec::new();
}
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
pool.iter().collect()
} else {
tier
};
// Partition the tier by special so `special_chance` can bias a draw; either
// partition falls back to the whole tier when empty.
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
let chance = pack.special_chance.clamp(0.0, 1.0);
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let silver: Vec<&GeneratedCandidate> = pool
.iter()
.filter(|c| (65..75).contains(&c.rating))
.collect();
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
let mut out = Vec::with_capacity(pack.count as usize);
for _ in 0..pack.count {
let mut out = Vec::with_capacity(pack.count() as usize);
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
draw_tier(
&mut out,
&silver,
&all,
pack.n_silver,
pack.special_chance,
rng,
);
draw_tier(
&mut out,
&bronze,
&all,
pack.n_bronze,
pack.special_chance,
rng,
);
out
}
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
/// biasing each pick toward a special version with probability `chance`. Either the
/// special or normal partition falls back to the tier when empty.
fn draw_tier(
out: &mut Vec<GeneratedCard>,
tier: &[&GeneratedCandidate],
fallback: &[&GeneratedCandidate],
n: u64,
chance: f64,
rng: &mut impl Rng,
) {
if n == 0 {
return;
}
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
if src.is_empty() {
return;
}
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
let chance = chance.clamp(0.0, 1.0);
for _ in 0..n {
let want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special
} else if !want_special && !normal.is_empty() {
&normal
} else {
&tier
src
};
let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card());
}
out
}
#[cfg(test)]
@@ -156,13 +185,22 @@ mod tests {
]
}
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
PackDef {
id,
name: "Test Pack",
price: 1000,
count,
gold,
n_bronze,
n_silver,
n_gold,
rares: 0,
category: if n_gold > 0 {
"gold"
} else if n_silver > 0 {
"silver"
} else {
"bronze"
},
special_chance,
owned_only: false,
}
@@ -171,7 +209,7 @@ mod tests {
#[test]
fn same_seed_same_output() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let mut a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42);
assert_eq!(
@@ -183,7 +221,7 @@ mod tests {
#[test]
fn different_seeds_can_diverge() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
// Not a hard guarantee, but with this pool/count the two seeds differ.
@@ -196,7 +234,7 @@ mod tests {
let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] {
let p = pack(6, n, true, 0.08);
let p = pack(6, 0, 0, n, 0.08);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n);
for c in &cards {
@@ -212,7 +250,7 @@ mod tests {
#[test]
fn gold_pack_draws_only_gold_tier() {
let pool = pool();
let p = pack(5, 20, true, 0.03);
let p = pack(5, 0, 0, 20, 0.03);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating >= 75),
@@ -223,7 +261,7 @@ mod tests {
#[test]
fn bronze_pack_draws_only_bronze_tier() {
let pool = pool();
let p = pack(1, 20, false, 0.005);
let p = pack(1, 20, 0, 0, 0.005);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating < 75),
@@ -239,7 +277,7 @@ mod tests {
.filter(|c| c.special)
.map(|c| c.card_id.as_str())
.collect();
let p = pack(7, 11, true, 1.0);
let p = pack(7, 0, 0, 11, 1.0);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards
.iter()
@@ -248,7 +286,7 @@ mod tests {
#[test]
fn empty_pool_fails_closed() {
let p = pack(5, 7, true, 0.03);
let p = pack(5, 0, 0, 7, 0.03);
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
}
}
+131 -5
View File
@@ -66,7 +66,7 @@ pub fn sets_body(challenges: &[ChallengeView]) -> Value {
"description": challenge.description,
"priority": challenge.identity.priority,
"challengesCount": 1,
"challengesCompletedCount": i64::from(challenge.times_completed > 0),
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
"awards": [],
"hidden": false,
"endTime": 4_102_444_800_i64
@@ -89,6 +89,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
.iter()
.filter(|challenge| challenge.identity.set_id == set_id)
.map(|challenge| {
// A repeatable challenge is always available to enter again. The FIFA 17
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
// repeatable challenge never reports itself as terminally completed here.
// Core keeps the true completion record (economy authority); this is
// presentation only. Live-proven on the retail client 2026-08-18.
let times_completed = if challenge.repeatable {
0
} else {
challenge.times_completed
};
json!({
"challengeId": challenge.identity.challenge_id,
"setId": challenge.identity.set_id,
@@ -103,8 +114,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
"repeatable": challenge.repeatable,
"trophyId": 0,
"status": "OPEN",
"timesCompleted": challenge.times_completed,
"timesCompleted": times_completed,
"awards": [],
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
// text — it does NOT validate on them. The ordinal->string map is not
// recoverable from any asset we have, so any value we emit would show the
// WRONG requirement to the player. Submission is validated server-side by
// Core regardless; leaving this empty is display-only, never a correctness
// gap. Populate ONLY once the locale ordinal map is captured.
"elgReq": []
})
})
@@ -178,9 +198,15 @@ impl std::error::Error for SbcWireError {}
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
///
/// Retail request captures for this body are unavailable. The parser therefore accepts
/// only the two already-proven FIFA 17 squad containers: a normal `players` array or the
/// SBC `squad` array. Within either, only `itemData.id` is interpreted.
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
/// the manager, chemistry, rating, formation, dream, and index fields are
/// presentation/validation hints and are never consumed. The `squad` array
/// container remains accepted for the alternate proven shape. Within either,
/// only non-zero `itemData.id` values are interpreted.
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
let root: Value =
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
@@ -240,6 +266,53 @@ mod tests {
assert!(submit["grantedSetAwards"].is_array());
}
#[test]
fn repeatable_completed_challenge_stays_enterable() {
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
// a repeatable challenge must always project as not-yet-completed while a
// non-repeatable one keeps its true count. Core holds the real record.
let repeatable = ChallengeView {
identity: CHALLENGES[0],
name: "Bronze Upgrade".into(),
description: "Submit players".into(),
repeatable: true,
times_completed: 3,
};
let once = ChallengeView {
identity: CHALLENGES[1],
name: "Hybrid Nations".into(),
description: "Submit a hybrid squad".into(),
repeatable: false,
times_completed: 1,
};
let repeatable_view =
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
let sets = sets_body(&[repeatable, once]);
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
let repeatable_set = sets_arr
.iter()
.find(|set| set["setId"] == CHALLENGES[0].set_id)
.unwrap();
let once_set = sets_arr
.iter()
.find(|set| set["setId"] == CHALLENGES[1].set_id)
.unwrap();
assert_eq!(
repeatable_set["challengesCompletedCount"], 0,
"a repeatable set never reports itself terminally completed"
);
assert_eq!(
once_set["challengesCompletedCount"], 1,
"a one-shot set counts its single completion"
);
}
#[test]
fn parser_accepts_only_known_squad_containers_and_item_ids() {
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
@@ -259,4 +332,57 @@ mod tests {
Err(SbcWireError::Json(_))
));
}
#[test]
fn parser_matches_captured_retail_challenge_squad_body() {
// Verbatim shape from the 2026-08-18 Gate C retail capture of
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
// non-zero player itemData.id values are consumed, in wire order; the
// manager and all presentation fields are ignored.
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
"manager":[{"id":100000427,"dream":false}],
"players":[
{"index":0,"itemData":{"id":100004227,"dream":false}},
{"index":1,"itemData":{"id":100004233,"dream":false}},
{"index":2,"itemData":{"id":100001317,"dream":false}},
{"index":3,"itemData":{"id":100001531,"dream":false}},
{"index":4,"itemData":{"id":100000966,"dream":false}},
{"index":5,"itemData":{"id":100001947,"dream":false}},
{"index":6,"itemData":{"id":100000169,"dream":false}},
{"index":7,"itemData":{"id":100002017,"dream":false}},
{"index":8,"itemData":{"id":100002765,"dream":false}},
{"index":9,"itemData":{"id":100000147,"dream":false}},
{"index":10,"itemData":{"id":100000311,"dream":false}},
{"index":11,"itemData":{"id":0,"dream":false}},
{"index":12,"itemData":{"id":0,"dream":false}},
{"index":13,"itemData":{"id":0,"dream":false}},
{"index":14,"itemData":{"id":0,"dream":false}},
{"index":15,"itemData":{"id":0,"dream":false}},
{"index":16,"itemData":{"id":0,"dream":false}},
{"index":17,"itemData":{"id":0,"dream":false}},
{"index":18,"itemData":{"id":0,"dream":false}},
{"index":19,"itemData":{"id":0,"dream":false}},
{"index":20,"itemData":{"id":0,"dream":false}},
{"index":21,"itemData":{"id":0,"dream":false}},
{"index":22,"itemData":{"id":0,"dream":false}}
]}"#;
assert_eq!(
parse_wire_item_ids(retail).unwrap(),
[
100_004_227,
100_004_233,
100_001_317,
100_001_531,
100_000_966,
100_001_947,
100_000_169,
100_002_017,
100_002_765,
100_000_147,
100_000_311
],
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
);
}
}
@@ -0,0 +1,273 @@
//! 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.
///
/// The club's OWN kit team is filtered out at schedule time — see
/// [`season_list_body`]. Fixing this list to exclude one id would not do, because
/// which team the club wears is ownership state, not a constant.
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, opponents: &[i64]) -> SeasonMatch {
SeasonMatch {
team_id: opponents[(index as usize) % opponents.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).
///
/// `own_kit_team_id` is the team whose kit the club wears, taken from its active
/// kit items. That team is EXCLUDED from the schedule, because the pre-match kit
/// clone resolves both sides out of the same `teamkits` table keyed on
/// `teamtechid`: drawing your own kit team makes the opponent render your kit, so
/// both sides appear in identical strips. It is also simply wrong data — a club
/// would be playing itself.
///
/// Passing `None` (or a team not in the rotation) keeps the full schedule.
pub fn season_list_body(season_id: i64, division_id: i64, own_kit_team_id: Option<i64>) -> String {
let opponents: Vec<i64> = OPPONENT_TEAM_IDS
.iter()
.copied()
.filter(|id| Some(*id) != own_kit_team_id)
.collect();
// Never emit an empty rotation: `matches` must be non-empty or StartSeason
// dereferences NULL (see the module note), so an exclusion that would empty
// the list is ignored rather than allowed to crash the client.
let opponents: &[i64] = if opponents.is_empty() {
OPPONENT_TEAM_IDS
} else {
&opponents
};
let list = SeasonList {
seasons: vec![SeasonElement {
kind: "OFFLINE",
id: season_id,
division_id,
matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).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, None));
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, None));
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, None);
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}"
);
}
/// The pre-match kit clone resolves both sides out of the same `teamkits`
/// table keyed on `teamtechid`, so drawing the club's own kit team puts the
/// opponent in the club's strip. It is also a club playing itself.
#[test]
fn own_kit_team_is_never_scheduled_as_an_opponent() {
let own = OPPONENT_TEAM_IDS[0];
let body = parsed(&season_list_body(1, 10, Some(own)));
let matches = body["seasons"][0]["matches"].as_array().unwrap();
assert_eq!(matches.len(), SEASON_ROUNDS as usize, "still a full ladder");
for m in matches {
assert_ne!(
m["teamId"].as_i64().unwrap(),
own,
"the club's own kit team must not be an opponent: {m}"
);
}
// The remaining teams are still cycled, so the schedule stays stable and
// every round names a renderable team.
for (i, m) in matches.iter().enumerate() {
assert_eq!(m["roundId"], i as i64);
assert!(m["teamId"].as_i64().is_some_and(|t| t > 0));
}
}
/// Excluding a team that would empty the rotation must NOT produce an empty
/// `matches` array, because that crashes StartSeason.
#[test]
fn an_exclusion_that_would_empty_the_rotation_is_ignored() {
// Stand-in for the degenerate case: pretend every id is the own team by
// excluding each in turn and asserting the ladder is always full.
for own in OPPONENT_TEAM_IDS {
let body = parsed(&season_list_body(1, 10, Some(*own)));
let matches = body["seasons"][0]["matches"].as_array().unwrap();
assert_eq!(matches.len(), SEASON_ROUNDS as usize);
assert!(!matches.is_empty(), "matches must never be empty");
}
}
#[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);
}
}
+113 -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>,
}
@@ -178,14 +200,77 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
}
/// A role-only squad update: the client changed the captain and/or the
/// kick-taker assignments without touching the squad itself.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fifa17SquadRolePatch {
#[serde(default)]
pub id: i64,
/// Opaque 33-int array, verbatim. Differs from the replacement's `custom`
/// in the captures (the role screen writes per-slot values into it), so it
/// MUST be carried through rather than preserved from the stored copy.
#[serde(default)]
pub custom: Option<String>,
#[serde(default)]
pub captain: Option<i64>,
#[serde(default)]
pub kicktakers: Vec<SquadKicktaker>,
}
/// Which mutation a `PUT …/squad/<id>` body actually expresses.
///
/// FIFA 17 sends two different operations down one path, so the BODY SHAPE is
/// the operation discriminator. Across 73 captured squad PUTs spanning five
/// captures there are exactly two shapes:
///
/// * 68x with `players` — a full replacement, also carrying `squadName`,
/// `formation`, `squadType`, `manager`, `chemistry`/`rating`/`starRating`,
/// and (redundantly) `captain`/`kicktakers`.
/// * 5x without `players` — `{id, custom, captain, kicktakers}` only, emitted
/// by the captain/kick-taker screen.
///
/// `players` is therefore the discriminator: its PRESENCE means "this body
/// describes the whole squad". Its ABSENCE means the squad was not part of the
/// edit at all and must be left alone — which is NOT the same as an empty
/// `players` array, and that distinction is the whole point. Serde's
/// `#[serde(default)]` collapses both to an empty vec, so key presence is
/// tested on the raw JSON before deserialising.
///
/// An explicit `"players": []` still classifies as a replacement, so the
/// empty-replacement guard in Core keeps seeing it.
#[derive(Debug, Clone)]
pub enum SquadMutation {
/// Full replacement of the squad's slots and metadata.
Replace(Box<Fifa17SquadPut>),
/// Role-only patch: captain and/or kick-takers, nothing else.
PatchRoles(Fifa17SquadRolePatch),
}
/// Classify a squad PUT body. See [`SquadMutation`] for the discriminator and
/// the capture evidence behind it.
pub fn classify_squad_put(body: &[u8]) -> Result<SquadMutation, SquadError> {
let raw: serde_json::Value =
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))?;
let has_players = raw.as_object().is_some_and(|o| o.contains_key("players"));
if has_players {
return parse_squad_put(body).map(|p| SquadMutation::Replace(Box::new(p)));
}
serde_json::from_slice(body)
.map(SquadMutation::PatchRoles)
.map_err(|e| SquadError::Parse(e.to_string()))
}
/// 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 +290,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 +336,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 +384,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]
+37 -25
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
@@ -70,7 +61,7 @@ pub struct KicktakerRef {
}
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fifa17SquadExtensionV1 {
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
#[serde(default)]
@@ -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,14 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::club_response::ActiveKitAssignments;
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_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
STAFF_CONTRACT,
};
use crate::fut::item_state;
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
@@ -77,6 +83,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 +170,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 +188,55 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
// emitted as a BARE ITEM OBJECT with `dream` beside the item's own fields —
// NOT wrapped in `itemData`.
//
// This is a wire-shape contract, recovered from the client rather than
// guessed, after two earlier shapes both failed:
//
// `[{id, dream}]` — no merge key, so nothing resolves.
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
//
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
// is the whole point:
//
// players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
// 378 `kitNumber`; the 363 arm (0x18013d8d9) calls the ITEM
// parser FUN_18013fe00 on the NESTED itemData object.
// manager: atom 424 -> array loop at 0x18013da29 calls that same item
// parser DIRECTLY on the array ELEMENT, into squad+0xC0. There is
// no `itemData` step at all.
//
// So a manager element IS an item. Nesting the fields one level deeper left
// the parser reading only the two keys that happen to be item atoms — `id`
// (0x14c) and `dream` (0xe7) — and leaving `resourceId` at 0. Measured on a
// cold client: the manager record existed at squad+0xC0 with the correct id
// and `resourceId == 0`, while sibling players in the same response carried
// theirs (83906881, 84053575). `resourceId` is the merge key compared RAW
// against `carddbid`, so zero can never hit the managercards table: no name,
// no rating, no art, and an empty manager slot in the UI.
//
// The client's own save corroborates the shape: it PUTs
// `"manager":[{"id":…,"dream":false}]` — flat, and both keys are item atoms.
//
// 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)) => {
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
if let Some(obj) = item.as_object_mut() {
obj.insert("dream".to_string(), json!(false));
}
json!([item])
}
None => json!([]),
};
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
@@ -180,7 +246,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,
@@ -188,15 +254,76 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
Ok(SquadProjection::Projected(squad))
}
/// The active club items for `squad.actives`, in slot order.
///
/// ## Why this exists, and why it is NOT `[]`
///
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
/// i-th element of the client's five-element club-item array and hands it to the
/// item deserializer as the out-handle:
///
/// ```text
/// cmp edi,0x5 ; at most five entries are read
/// jge <skip>
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
/// call 0x18013fe00 ; the item deserializer, writing that slot
/// ```
///
/// That deserializer inserts the record into the client's resident item map
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
/// slot handle to it. So each element must be a FULL item object, exactly like
/// a `squad.manager[]` element — which reaches this same deserializer the same
/// way, called directly on the array element with no `itemData` step. An id
/// reference alone installs nothing, because the installer looks its id up in
/// that same map and does nothing when it misses.
///
/// An empty array makes the client read the array-end token immediately and
/// parse nothing, which leaves all five slots null. Every later consumer then
/// resolves to the client's static not-found sentinel, whose item pointer is
/// NULL — which is exactly why the pre-match kit selector had no kits.
///
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
/// on the wire but not semantic: both client consumers (the activate path and
/// the store lookup) search the five slots by content — itemState, or
/// cardtype/cardsubtypeid — never by index.
///
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
/// omitted rather than emitted with a fabricated id, matching `/club`.
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
owned: &HashMap<String, CoreOwnedItem>,
ident: &I,
active_kits: ActiveKitAssignments<'_>,
) -> Value {
let mut out = Vec::new();
for (owned_card_id, state) in [
(active_kits.home, item_state::ACTIVE_HOME_KIT),
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
] {
let Some(owned_card_id) = owned_card_id else {
continue;
};
let Some(item) = owned.get(owned_card_id) else {
continue;
};
if let Some(id) = ident.resolve_kit(item) {
out.push(shape_club_item(id, state));
}
}
Value::Array(out)
}
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
/// the session-envelope fields the projector does not own (`personaId`, plus the
/// observed constants `changed: 0`, `actives: []`).
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
/// the session-envelope fields the projector does not own: `personaId`, the
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
let mut obj = projected;
if let Value::Object(map) = &mut obj {
map.insert("personaId".into(), json!(persona_id));
map.insert("changed".into(), json!(0));
map.insert("actives".into(), json!([]));
map.insert("actives".into(), actives);
}
obj
}
@@ -219,14 +346,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 +378,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 +400,7 @@ mod tests {
}],
ext,
owned,
manager: None,
}
}
@@ -272,7 +411,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 +420,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 +459,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 +470,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 +481,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 +496,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 +547,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 +568,103 @@ 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 element IS the item: the squad parser's manager branch calls the
// item parser on the array element itself, with no `itemData` step, so
// the fields must be flat. Nesting them left `resourceId` — the merge
// key — at 0 on a cold client and the slot rendered empty.
assert_eq!(
v["manager"],
json!([{
"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 element is a bare item object carrying `dream`"
);
let element = &v["manager"][0];
assert!(
element.get("itemData").is_none(),
"an `itemData` wrapper is never descended into on the manager path, \
so its presence means the merge key is invisible to the client"
);
assert_eq!(
element["resourceId"], 1_000_509,
"resourceId must be readable at element level: it is the merge key \
compared RAW against carddbid, and 0 resolves no manager"
);
}
#[test]
fn absent_manager_projects_empty_array_never_fabricated() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
// one_slot_input leaves manager: None.
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(
v["manager"],
json!([]),
"no manager assignment => empty array, nothing fabricated"
);
}
}
+355 -86
View File
@@ -1,92 +1,217 @@
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
//! owns coins + unopened packs), so there is no Python dependency and no
//! dual-write/split-brain.
//!
//! ## Scope / split-brain safety
//! ## Relationship to the Python oracle
//!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
//! before that economy authority exists would create a dual-write/split-brain. See
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
//! The wire *shape* was RE'd from the client and cross-checked against the Python
//! oracle's `_pack_body`/`store_catalog`
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
//! Python oracle stays the rollback baseline and is never modified; the
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
//!
//! ## Economy-parameter provenance
//!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
//! EA-authentic (the overnight audit established the store economy is invented). The
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
//! Pack prices and tier composition are the real always-available FUT 17
//! regular-store packs (community-documented on fifauteam). Pack ODDS
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
use serde_json::{json, Value};
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
/// per-tier composition are the real always-available FUT 17 regular-store packs,
/// with DESIGNED (not EA-authentic) `special_chance` odds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef {
pub id: u64,
pub name: &'static str,
pub price: u64,
pub count: u64,
pub gold: bool,
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
/// pack's displayed composition matches what its generator draws.
pub n_bronze: u64,
pub n_silver: u64,
pub n_gold: u64,
/// `rareQuantity` shown on the tile (wire display only).
pub rares: u64,
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
pub category: &'static str,
/// Per-draw probability the awarded card is a special version (DESIGNED
/// placeholder; FUT 17 odds are unrecoverable).
pub special_chance: f64,
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
/// rendered only when owned (in `unopenedPackIds`).
pub owned_only: bool,
}
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
/// compatibility shim, never a catalogue pack (never purchasable/openable).
impl PackDef {
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
pub fn count(&self) -> u64 {
self.n_bronze + self.n_silver + self.n_gold
}
}
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
/// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[
// ── Bronze category ──
PackDef {
id: 1,
name: "Bronze Pack",
price: 400,
count: 5,
gold: false,
special_chance: 0.005,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: false,
},
PackDef {
id: 2,
name: "Premium Bronze Pack",
price: 750,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 3,
category: "bronze",
special_chance: 0.02,
owned_only: false,
},
// ── Silver category ──
PackDef {
id: 3,
name: "Silver Pack",
price: 2500,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.015,
owned_only: false,
},
PackDef {
id: 4,
name: "Premium Silver Pack",
price: 3750,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 3,
category: "silver",
special_chance: 0.03,
owned_only: false,
},
// ── Gold category ──
PackDef {
id: 5,
name: "Gold Pack",
price: 5000,
count: 7,
gold: true,
special_chance: 0.03,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.04,
owned_only: false,
},
PackDef {
id: 6,
name: "Premium Gold",
price: 15000,
count: 11,
gold: true,
special_chance: 0.08,
owned_only: false,
},
PackDef {
id: 7,
name: "Special Players Pack",
price: 25000,
count: 11,
gold: true,
special_chance: 1.0,
name: "Premium Gold Pack",
price: 7500,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.06,
owned_only: false,
},
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
PackDef {
id: 70,
name: "Reward Special Players Pack",
name: "Reward Gold Pack",
price: 0,
count: 11,
gold: true,
n_bronze: 0,
n_silver: 0,
n_gold: 11,
rares: 11,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
PackDef {
id: 71,
name: "Bronze Pack",
price: 0,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: true,
},
PackDef {
id: 72,
name: "Silver Pack",
price: 0,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.02,
owned_only: true,
},
PackDef {
id: 73,
name: "Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.05,
owned_only: true,
},
PackDef {
id: 74,
name: "Rare Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.10,
owned_only: true,
},
PackDef {
id: 75,
name: "Icon Pack",
price: 0,
n_bronze: 0,
n_silver: 0,
n_gold: 12,
rares: 12,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
@@ -97,27 +222,88 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
PACK_CATALOG.iter().find(|p| p.id == id)
}
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
/// one of the six hard-coded tokens the client resolves.
fn category(p: &PackDef) -> &'static str {
if p.special_chance >= 1.0 {
"special"
} else if p.gold {
"gold"
} else {
"bronze"
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
/// `None` and is simply not shown as an openable pack rather than faked.
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
if let Ok(numeric) = definition_id.parse::<u64>() {
return pack_by_id(numeric)
.filter(|pack| pack.owned_only)
.map(|pack| pack.id);
}
let id = match definition_id {
"bronze_pack" => 71,
"silver_pack" => 72,
"gold_pack" => 73,
"rare_gold_pack" => 74,
"icon_pack" => 75,
_ => return None,
};
Some(id)
}
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
/// carries its own token; owned/reward packs take `mypacks` instead (see
/// [`pack_body`]).
fn category(p: &PackDef) -> &'static str {
p.category
}
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
let mtx = std::cmp::max(1, p.price / 100);
let pack_type = match p.category {
"gold" => "GOLD",
"silver" => "SILVER",
_ => "BRONZE",
};
// Group background texture: FIFA 17's all-groups landing (shown on first store
// entry) renders one tile per group whose art is the client-bundled
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
// category and that landing shows native art instead of blank shields. The
// persistent tabbed store draws its pack art from the packs themselves and
// does not depend on this.
let group_bg: u64 = if owned {
3
} else {
match p.category {
"gold" => 3,
"silver" => 2,
_ => 1,
}
};
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
// three store tabs and two reward tiles:
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
// catalogue asset (1-6) makes art appear.
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
// unchanged and only rotated the featured player, which proves this.)
// So a reward tile automatically shows the same art as the equivalent
// purchasable pack; we only need a valid asset, and we use the tier's own store
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
let art_asset: u64 = if owned {
match p.category {
"gold" => 5,
"silver" => 3,
_ => 1,
}
} else {
p.id
};
let mut body = json!({
"assetId": p.id,
"assetId": art_asset,
"id": p.id,
"packType": if p.gold { "GOLD" } else { "BRONZE" },
"packType": pack_type,
"description": p.name,
"state": "active",
"saleType": "promo",
@@ -127,26 +313,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
"purchaseCount": 0,
"isPremium": false,
"sortPriority": idx,
"displayGroupAssetId": group_bg,
// The tile renders `finalFunds` as its coin price; the HUD balance reads
// `funds` from the /credits currencies array. We keep the pair equal.
//
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
// a real-money price" and switches on the broken `or %1s` label — the
// Origin/Dime commerce catalogue that would fill it no longer exists
// offline, so every string stays at its constructor default. Omitting the
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
// experiment #4): it strictly reduces executed client code and leaves every
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
"extPrice": {
"finalPrice": { "amount": mtx, "currency": "mtx" },
"originalPrice": { "amount": mtx, "currency": "mtx" },
},
"packContentInfo": {
"bronzeQuantity": if p.gold { 0 } else { p.count },
"silverQuantity": 0,
"goldQuantity": if p.gold { p.count } else { 0 },
"rareQuantity": if p.gold { p.count } else { 0 },
"itemQuantity": p.count,
"bronzeQuantity": p.n_bronze,
"silverQuantity": p.n_silver,
"goldQuantity": p.n_gold,
"rareQuantity": p.rares,
"itemQuantity": p.count(),
},
"unopened": owned,
});
let obj = body.as_object_mut().expect("pack body is a JSON object");
if owned {
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
// objects makes the client render the price label as literal "undefined".
obj.remove("currencies");
obj.remove("extPrice");
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
// reward packs). My Packs opens through the store purchase flow, so a tile
// with no currency row is not actionable — clicking navigates instead of
// opening. With a free coin row the client sends POST /purchased and the
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
// mtx bug), so the free coin row formats as "0", not an unavailable label.
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
obj.insert(
"displayGroup".into(),
json!({ "value": "mypacks", "priority": idx }),
@@ -165,8 +362,11 @@ pub fn sentinel_body(idx: u64) -> Value {
id: SENTINEL_PACK_ID,
name: "",
price: 0,
count: 0,
gold: true,
n_bronze: 0,
n_silver: 0,
n_gold: 0,
rares: 0,
category: "gold",
special_chance: 0.0,
owned_only: true,
};
@@ -176,13 +376,19 @@ pub fn sentinel_body(idx: u64) -> Value {
.expect("sentinel body is a JSON object");
obj.insert("state".into(), json!("active"));
obj.insert("unopened".into(), json!(false));
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
// for an empty My Packs), so it keeps no purchase path.
obj.remove("currencies");
// Keep the sentinel's own id as its asset: it must render as an inert blank
// placeholder, never borrow a real pack's cover.
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
body
}
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 16)
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
let mut packs: Vec<Value> = PACK_CATALOG
.iter()
@@ -203,10 +409,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
#[cfg(test)]
mod tests {
//! Differential parity against the Python oracle. The fixtures under
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
//! module header).
use super::*;
fn parse(s: &str) -> Value {
@@ -214,7 +421,7 @@ mod tests {
}
#[test]
fn purchasegroup_zero_sentinel_matches_oracle() {
fn purchasegroup_zero_sentinel_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::Sentinel);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
@@ -223,7 +430,7 @@ mod tests {
}
#[test]
fn purchasegroup_zero_clean_matches_oracle() {
fn purchasegroup_zero_clean_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_clean.json"
@@ -232,7 +439,7 @@ mod tests {
}
#[test]
fn purchasegroup_pack70_matches_oracle() {
fn purchasegroup_pack70_matches_golden() {
// Owned pack present -> no sentinel regardless of mode.
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
let want = parse(include_str!(
@@ -247,6 +454,68 @@ mod tests {
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
}
#[test]
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
// Core reward services grant symbolic pack names; each must resolve to an
// owned-only catalogue pack so it renders as an openable My Packs tile.
for (def, want) in [
("bronze_pack", 71),
("silver_pack", 72),
("gold_pack", 73),
("rare_gold_pack", 74),
("icon_pack", 75),
] {
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
assert_eq!(id, want);
assert!(
pack_by_id(id).unwrap().owned_only,
"a reward pack must be owned-only"
);
}
// Imported numeric owned-pack ids resolve to themselves.
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
// Purchasable (non-owned) numeric ids and unknown names never resolve.
assert_eq!(owned_pack_id_for_definition("5"), None);
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
}
#[test]
fn reward_tiles_carry_a_renderable_cover_asset() {
// A reward pack's own id is not a client art asset, so its My Packs tile
// renders blank. Each reward tile must therefore carry a valid catalogue
// assetId (its tier's store pack) while `id` stays the open packId.
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
let tile = pack_body(pack, 1, true);
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
assert_eq!(
tile["assetId"], want_asset,
"reward tile borrows its tier's store-pack cover asset"
);
assert!(
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
"the cover asset must be a real purchasable catalogue pack"
);
}
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
}
#[test]
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
// A granted silver reward pack (resolved to id 72) must appear as an owned
// My Packs tile, not the non-openable sentinel shim.
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
let packs = got["purchase"].as_array().unwrap();
let reward = packs
.iter()
.find(|p| p["id"] == 72)
.expect("reward pack tile present");
assert_eq!(reward["displayGroup"]["value"], "mypacks");
assert!(reward["unopened"].as_bool().unwrap());
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
}
#[test]
fn clean_v1_empty_emits_no_mypacks_group() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
@@ -256,13 +525,13 @@ mod tests {
.iter()
.map(|e| e["id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![1, 5, 6, 7]);
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn category_tokens_are_canonical() {
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
assert_eq!(category(pack_by_id(7).unwrap()), "special");
}
}
@@ -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");
}
}
}
+188 -150
View File
@@ -2,197 +2,235 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
}
},
{
"assetId": 7,
"assetId": 5,
"id": 70,
"packType": "GOLD",
"description": "Reward Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
"name": "coins",
"funds": 0,
"finalFunds": 0
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
"itemQuantity": 11
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
},
{
"assetId": 70,
"description": "Reward Special Players Pack",
"unopened": true,
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 70,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": true
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
@@ -2,171 +2,201 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
}
}
],
"timestamp": 1596326400
@@ -2,197 +2,228 @@
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 1,
"currencies": [
{
"finalFunds": 400,
"name": "coins",
"funds": 400,
"name": "coins"
"finalFunds": 400
}
],
"description": "Bronze Pack",
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
}
},
{
"assetId": 2,
"id": 2,
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"description": "Premium Bronze Pack",
"state": "active",
"unopened": false
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"displayGroupAssetId": 1,
"currencies": [
{
"name": "coins",
"funds": 750,
"finalFunds": 750
}
],
"packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 3,
"id": 3,
"packType": "SILVER",
"description": "Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 2500,
"finalFunds": 2500
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 4,
"id": 4,
"packType": "SILVER",
"description": "Premium Silver Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"displayGroupAssetId": 2,
"currencies": [
{
"name": "coins",
"funds": 3750,
"finalFunds": 3750
}
],
"packContentInfo": {
"bronzeQuantity": 1,
"silverQuantity": 11,
"goldQuantity": 0,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "silver"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 5,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 5000,
"name": "coins",
"funds": 5000,
"name": "coins"
"finalFunds": 5000
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 1,
"itemQuantity": 12
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 6,
"displayGroupAssetId": 3,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
"name": "coins",
"funds": 7500,
"finalFunds": 7500
}
],
"description": "Premium Gold",
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
}
},
{
"assetId": 65534,
"description": "",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 65534,
"isPremium": false,
"packType": "GOLD",
"description": "",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0,
"itemQuantity": 0,
"rareQuantity": 0,
"silverQuantity": 0
"itemQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
"unopened": false,
"displayGroup": {
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
+258 -19
View File
@@ -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,14 +24,18 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, 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::{
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
SquadProjection, SquadProjectionInput,
};
use serde_json::Value;
use serde_json::{json, Value};
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
@@ -49,11 +54,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 +116,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 +134,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 +165,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 +216,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 +292,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 +316,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 +373,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 +410,32 @@ 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 AT ELEMENT LEVEL. 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.
// Wrapping the fields in `itemData` did not work either: the squad parser's
// manager branch calls the item parser on the element itself, so a nested
// item is never read and the merge key stays 0.
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];
assert!(
mgr_item.get("itemData").is_none(),
"the manager element IS the item; a wrapper hides the merge key"
);
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"]);
@@ -428,11 +511,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
&ident,
);
// userMassInfo.squad = the projected object + session envelope.
let ummi = user_mass_info_squad(projected.clone(), 33068179);
// userMassInfo.squad = the projected object + session envelope. `actives` is
// supplied by the caller now, so the envelope must carry it through verbatim
// rather than hardcoding an empty array.
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
assert_eq!(ummi["personaId"], 33068179);
assert_eq!(ummi["changed"], 0);
assert!(ummi["actives"].is_array());
assert_eq!(
ummi["actives"], actives,
"the envelope must pass actives through, not replace it"
);
assert_eq!(ummi["players"], projected["players"], "same projected body");
assert_eq!(ummi["formation"], projected["formation"]);
@@ -455,3 +544,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
// The summary carries only those six keys — no divergent squad shape.
assert_eq!(entry.as_object().unwrap().len(), 6);
}
// ---- squad.actives: the only carrier that makes a club item resident --------
/// A resolver that can answer `resolve_kit`, which the default trait method
/// cannot (it returns `None` for player-only resolvers).
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
impl ItemIdentityResolver for KitIdentity {
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
self.0.get(&it.owned_card_id).copied()
}
}
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned_card_id.to_string(),
card_id: format!("def-{owned_card_id}"),
rating: 0,
position: String::new(),
nation: String::new(),
league: String::new(),
club: String::new(),
attributes: [0; 6],
contract_matches: None,
source_rating: None,
core_content_kind: Some("kit".to_string()),
}
}
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
let owned = HashMap::from([
("oc-home".to_string(), kit_owned("oc-home")),
("oc-away".to_string(), kit_owned("oc-away")),
]);
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
// assetid 14) and 6400003 the away card (category 3, assetid 15).
let ident = KitIdentity(HashMap::from([
(
"oc-home".to_string(),
Fifa17KitIdentity {
item_id: 100004874,
asset_id: 14,
resource_id: 6300006,
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 2,
year: 0,
},
),
(
"oc-away".to_string(),
Fifa17KitIdentity {
item_id: 100004873,
asset_id: 15,
resource_id: 6400003,
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 3,
year: 0,
},
),
]));
(owned, ident)
}
/// The client's squad parser reads at most five `actives` entries and parses each
/// one straight into a slot of its five-element club-item array, so each element
/// must be a full item object carrying a non-zero `id` — an id reference alone
/// installs nothing.
#[test]
fn squad_actives_emits_full_items_for_the_designated_kits() {
let (owned, ident) = home_away_fixture();
let actives = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-home"),
away: Some("oc-away"),
},
);
let arr = actives.as_array().expect("actives is an array");
assert_eq!(arr.len(), 2, "one entry per designated kit");
assert_eq!(arr[0]["id"], 100004874);
assert_eq!(arr[0]["itemState"], "activeHomeKit");
assert_eq!(arr[0]["resourceId"], 6300006);
assert_eq!(arr[1]["id"], 100004873);
assert_eq!(arr[1]["itemState"], "activeAwayKit");
assert_eq!(arr[1]["resourceId"], 6400003);
for entry in arr {
assert_eq!(entry["itemType"], "kit");
assert_eq!(
entry["cardsubtypeid"], 9,
"cardsubtypeid 9 derives cardtype 7"
);
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
assert_ne!(entry["id"], 0, "a zero id is never made resident");
}
}
/// Undesignated slots contribute nothing, and an unresolvable designation is
/// omitted rather than emitted with a fabricated id — the same policy `/club`
/// applies when a card has no FIFA identity.
#[test]
fn squad_actives_omits_absent_and_unresolvable_designations() {
let (owned, ident) = home_away_fixture();
let home_only = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-home"),
away: None,
},
);
assert_eq!(home_only.as_array().unwrap().len(), 1);
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
// Designated but not present in the owned collection.
let dangling = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-missing"),
away: None,
},
);
assert_eq!(dangling.as_array().unwrap().len(), 0);
// Present and designated, but with no resolvable FIFA kit identity.
let unresolvable = squad_actives(
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
&ident,
ActiveKitAssignments {
home: Some("oc-x"),
away: None,
},
);
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
// Nothing designated at all is an empty array, which is what left every
// club-item slot null before this projector existed.
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
assert_eq!(none.as_array().unwrap().len(), 0);
}

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