82 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
103 changed files with 45556 additions and 279 deletions
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
+54 -7
View File
@@ -518,13 +518,60 @@ CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x
```
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
right, and the stronger statement is now measured: the pre-match kit selector
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
(live: `{1: players, 0: staff}`). Every OTHER input to that gate is already
served. So the empty kit-selection screen is a client dead end, not a missing
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
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`,
+215
View File
@@ -189,3 +189,218 @@ GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category e
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).
+12 -1
View File
@@ -939,7 +939,18 @@ 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 |
+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()
+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())
+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())
+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())
@@ -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 ""))
+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())
+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())
+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
+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())
+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()
+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" \
+86
View File
@@ -41,9 +41,40 @@ pub struct Fifa17CardIdentity {
/// 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`)
@@ -189,6 +220,9 @@ struct RawCard {
/// 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>,
@@ -207,6 +241,12 @@ struct RawCard {
/// 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 {
@@ -264,7 +304,10 @@ impl Fifa17CardCatalog {
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,
@@ -355,6 +398,49 @@ 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.
+104 -5
View File
@@ -10,6 +10,7 @@
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_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
@@ -54,7 +55,13 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent, ident.discard_value(item)));
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,
@@ -83,7 +90,10 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT));
out.push(shape_staff_item(
id,
item.contract_matches.unwrap_or(STAFF_CONTRACT),
));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
@@ -116,6 +126,7 @@ pub fn shape_club_response_with_kits<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;
@@ -145,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,
}
}
@@ -361,7 +377,11 @@ mod tests {
assert_eq!(coach["resourceId"], 3000083);
assert_eq!(coach["cardsubtypeid"], 8);
assert_eq!(coach["itemType"], "staff");
assert_eq!(coach["contract"], STAFF_CONTRACT);
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()
@@ -411,7 +431,10 @@ mod tests {
assert_eq!(mgr["nation"], 45);
assert_eq!(mgr["leagueId"], 53);
assert_eq!(mgr["teamid"], 241);
assert_eq!(mgr["contract"], STAFF_CONTRACT);
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();
@@ -422,6 +445,78 @@ mod tests {
);
}
/// `/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();
@@ -432,6 +527,8 @@ mod tests {
card_asset_id: 35,
subtype: 9,
team_id,
category: 2,
year: 0,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
@@ -466,7 +563,7 @@ mod tests {
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
assert!(body["itemData"][0].get("attributeList").is_none());
assert!(body["itemData"][0].get("itemType").is_none());
assert_eq!(body["itemData"][0]["itemType"], "kit");
}
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
@@ -484,6 +581,8 @@ mod tests {
card_asset_id: art,
subtype,
team_id: 21,
category: 2,
year: 0,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
+111 -5
View File
@@ -33,8 +33,40 @@
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
@@ -43,10 +75,10 @@ use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeSta
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack.
///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
/// live-proven oracle sends on this route. Inventing a price from the player
/// quick-sell table would be a fabricated number the client does not need.
/// `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
@@ -78,7 +110,7 @@ pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, Shape
order.push(id.resource_id);
stacks.push(json!({
"count": 1,
"discardValue": 0,
"discardValue": stack_discard_value(id),
"item": shape_consumable_item(*id),
"resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable),
@@ -109,6 +141,80 @@ mod tests {
}
}
/// 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.
@@ -218,18 +218,55 @@ pub fn consumable_needs(family: &str) -> ConsumableNeeds {
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
/// and this route is the door).
///
/// The segment names are the consumable UI group table at `0x180203260` (seven
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
/// on the wire and the singular `contract` is accepted because the client has
/// used both spellings. Segments are matched lower-cased.
/// The 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:
///
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
/// correspondence is checkable against the panel: training→42, contracts→13,
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
/// NO group code, so no segment can reach them — that is the client's own gap,
/// not an omission here.
/// | 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"],
@@ -239,10 +276,31 @@ pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'sta
"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).
///
@@ -430,7 +488,8 @@ mod tests {
#[test]
fn consumable_route_categories_partition_the_reachable_families() {
// The seven group codes, plus the singular `contract` spelling.
// 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",
@@ -439,6 +498,7 @@ mod tests {
"position",
"playstyle",
"managerleaguemodifier",
"formation",
];
let mut seen: Vec<&str> = Vec::new();
for seg in segments {
@@ -452,22 +512,27 @@ mod tests {
consumable_families_for_category("contracts"),
"both spellings the client has used mean the same set"
);
// Eleven of the thirteen families are reachable; the two formation
// modifiers have no group code in the client's own table.
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
// 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}");
}
for unreachable in [71, 121] {
let (family, _) = consumable_family(unreachable).unwrap();
assert!(
!seen.contains(&family),
"{family} has no group code; claiming it would invent a segment"
);
}
// `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", "development"] {
for s in ["", "player", "kit", "Training"] {
assert!(
consumable_families_for_category(s).is_none(),
"{s:?} is not a consumable category"
@@ -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}"
);
}
}
}
}
+263 -6
View File
@@ -26,7 +26,7 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
MANAGER_SUBTYPE,
MANAGER_SUBTYPE, STADIUM_SUBTYPE,
};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item_state;
@@ -48,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
@@ -80,6 +94,11 @@ pub struct Fifa17KitIdentity {
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
@@ -294,11 +313,21 @@ pub fn legacy_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);
@@ -334,12 +363,52 @@ 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,
})
}
/// 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.
@@ -368,6 +437,7 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
"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,
@@ -375,15 +445,32 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
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
}
/// Contracts remaining on an owned staff card.
/// 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. Core does not model
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
/// for players rather than inventing a second, different default.
/// 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).
@@ -525,6 +612,7 @@ pub const CONSUMABLE_UNTRADEABLE: bool = true;
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;
@@ -546,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,
}
}
@@ -562,6 +656,7 @@ mod tests {
},
&ent,
legacy_discard_value(86),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
@@ -596,6 +691,7 @@ mod tests {
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
@@ -607,6 +703,7 @@ mod tests {
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
a["resourceId"], b["resourceId"],
@@ -636,6 +733,7 @@ mod tests {
},
&ent,
legacy_discard_value(92),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
it["resourceId"], 117617092,
@@ -650,6 +748,74 @@ mod tests {
);
}
/// `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 {
@@ -741,6 +907,7 @@ mod tests {
},
&ent,
legacy_discard_value(86),
PACK_FRESH_CONTRACT_MATCHES,
);
emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item(
@@ -769,6 +936,8 @@ mod tests {
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 2,
year: 0,
};
for state in [
item_state::FREE,
@@ -806,6 +975,8 @@ mod tests {
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);
@@ -848,4 +1019,90 @@ mod tests {
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");
}
}
+2
View File
@@ -9,6 +9,7 @@ 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;
@@ -26,3 +27,4 @@ pub mod squad_ext;
pub mod squad_projection;
pub mod store_catalog;
pub mod store_session;
pub mod training_cards;
+71 -7
View File
@@ -42,6 +42,10 @@ pub const SEASON_ROUNDS: i64 = 10;
/// 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.
@@ -90,9 +94,9 @@ pub struct SeasonUser {
pub data: &'static str,
}
fn round(index: i64) -> SeasonMatch {
fn round(index: i64, opponents: &[i64]) -> SeasonMatch {
SeasonMatch {
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
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,
@@ -104,13 +108,35 @@ fn round(index: i64) -> SeasonMatch {
/// `GET …/season/list` — the offline competitions the club can enter, as wire
/// text (see the module note on key order).
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
///
/// `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(round).collect(),
matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).collect(),
}],
};
serde_json::to_string(&list).expect("season list serialises")
@@ -146,7 +172,7 @@ mod tests {
#[test]
fn list_emits_a_full_round_schedule() {
let body = parsed(&season_list_body(1, 10));
let body = parsed(&season_list_body(1, 10, None));
let season = &body["seasons"][0];
assert_eq!(season["type"], "OFFLINE");
assert_eq!(season["id"], 1);
@@ -161,7 +187,7 @@ mod tests {
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
#[test]
fn matches_are_never_empty_and_every_round_has_a_team() {
let body = parsed(&season_list_body(3, 7));
let 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() {
@@ -181,7 +207,7 @@ mod tests {
/// order them alphabetically and break this.
#[test]
fn type_is_serialised_before_division_id() {
let text = season_list_body(1, 10);
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!(
@@ -190,6 +216,44 @@ mod tests {
);
}
/// 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));
+61
View File
@@ -200,6 +200,67 @@ 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, derive the bench split from the fixed
+1 -1
View File
@@ -61,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)]
@@ -35,10 +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, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
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;
@@ -166,7 +170,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.unwrap_or(0);
players.push(json!({
"index": index,
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
"itemData": shape_item(
item,
id,
ent,
ident.discard_value(item),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
),
"kitNumber": kit,
}));
}
@@ -178,33 +188,53 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
// 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`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
// This is a wire-shape contract, recovered from the client rather than
// guessed, after two earlier shapes both failed:
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// The manager is the same kind of slot in the same object, and the one
// implementation that ever drove a working manager (the Python oracle's
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
// `[{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)) {
Some(id) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, STAFF_CONTRACT),
"dream": false,
}]),
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!({
@@ -224,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
}
@@ -287,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,
}
}
@@ -502,18 +596,22 @@ mod tests {
)]),
);
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
// A manager mid-way through its contracts: the projection must report
// Core's persisted count, not the pack-fresh constant, or the pre-match
// screen contradicts the club screen.
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
manager.contract_matches = Some(12);
input.manager = Some(manager);
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
// 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,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
@@ -521,14 +619,24 @@ mod tests {
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": STAFF_CONTRACT,
"contract": 12,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
"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"
);
}
@@ -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");
}
}
}
+195 -13
View File
@@ -24,16 +24,18 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
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");
@@ -114,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(
@@ -335,6 +343,15 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
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();
@@ -393,10 +410,13 @@ 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"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
// 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"
@@ -405,11 +425,17 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
projected["manager"][0]["dream"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
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);
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"]);
@@ -485,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"]);
@@ -512,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);
}
+10
View File
@@ -1317,6 +1317,15 @@ pub fn emit_content(
.collect();
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
// role name; Core stores them like any other definition (no FIFA concept).
//
// `overall` STAYS 0 while `source_rating` carries EA's authored value, and
// both are correct at once because they feed different consumers: `overall`
// is what Core prices and projects from (a non-zero one would silently
// re-price every staff quick sell), while `source_rating` is the authored
// number the game's own tier rules read (bronze <65 / silver 65..=74 /
// gold >=75) — the number a manager-contract grant needs. `d.rating` is the
// SAME value written to the host catalog below as `"rating": d.rating`, so
// the content pack and the catalog can never disagree about a card's tier.
for d in &report.non_player.supported {
defs.push(serde_json::json!({
"id": d.card_id,
@@ -1334,6 +1343,7 @@ pub fn emit_content(
"physical": 0,
"rarity": "bronze",
"image_path": serde_json::Value::Null,
"source_rating": d.rating,
}));
}
let content_pack = content_dir.join("fifa17-production-cards.json");
+63
View File
@@ -946,6 +946,17 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
assert_eq!(cons["nation"], "");
assert_eq!(cons["rarity"], "bronze");
assert!(cons["image_path"].is_null());
// `source_rating` mirrors the wire verbatim: this consumable really sends
// `"rating":0`, so 0 is the authored value, not a substituted default.
assert_eq!(cons["source_rating"], 0, "{cons}");
// This fixture's `entities()` carries no staff table, so the coach's value is
// UNKNOWN — it must stay `null`, never a fabricated 0 a tier rule reads as
// bronze.
let coach = arr.iter().find(|c| c["id"] == "fifa17_3000083").unwrap();
assert!(
coach["source_rating"].is_null(),
"an unknown authored value must stay null: {coach}"
);
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
@@ -1141,3 +1152,55 @@ fn enrich_staff_fills_staff_and_leaves_everything_else_alone() {
"the wire rating is left untouched"
);
}
/// A MANAGER CONTRACT grant needs the target's TIER, and the only authoritative
/// source is EA's authored `value` from the staff family table. Emit must carry
/// it into Core's content pack as `source_rating` — the SAME number the host
/// catalog carries as `rating`, so the two artifacts can never disagree — while
/// `overall` stays 0, because `overall` is what Core prices and projects from.
///
/// Before this, a non-player reached Core with `overall: 0` and nothing else, so
/// `/collection` reported `effective_overall: 0` and a gold (76) manager graded
/// bronze.
#[test]
fn emit_content_carries_ea_authored_value_as_source_rating() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
let items = vec![
staff(100000427, 1000001, 4), // manager — managercards.value = 76 (gold)
staff(100000280, 9000081, 6), // GK coach — gkcoachcards.value = 66 (silver)
];
let rep = analyze(&profile(&items, "[]", 100000500), &roster(), &ent, &none());
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
let out = tempfile::tempdir().unwrap();
let sum = emit_content(&rep, out.path(), "fp").unwrap();
let pack: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
let cat: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
for (card_id, value) in [("fifa17_1000001", 76), ("fifa17_9000081", 66)] {
let def = pack
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == card_id)
.unwrap_or_else(|| panic!("{card_id} must be in the content pack"));
assert_eq!(
def["source_rating"],
serde_json::json!(value),
"EA's authored value must reach Core: {def}"
);
assert_eq!(
def["overall"],
serde_json::json!(0),
"overall stays 0 for a non-player: it feeds pricing and projection"
);
assert_eq!(
def["source_rating"], cat["cards"][card_id]["rating"],
"one source, two artifacts — they must never disagree on a tier"
);
}
}
+66
View File
@@ -59,6 +59,30 @@ pub struct HostConfig {
/// Disabled by default. Non-off values require three explicit staging guards;
/// see [`parse_sbc_post_commit_fault`].
pub sbc_post_commit_fault: SbcPostCommitFault,
/// Emit the club's active club items in `squad.actives`. **Enabled by
/// default** — this is normal, correct FIFA 17 behaviour, not an experiment.
///
/// `actives` is the carrier that makes a club item resident: the squad
/// parser writes each element straight into a native club-item slot. With it
/// populated the pre-match kit selector works, proven end to end on a retail
/// client — 29 resident nodes with both cardtype-7 kits in club slots 0 and 1
/// (`itemState` 101/102, category 4) alongside 23/23 players and the manager,
/// and the selector rendering the correct distinct home and away kits.
///
/// Scope is deliberately narrow: [`squad_actives`] emits ONLY the home and
/// away kit, both resolved from Core's own active designations and required
/// to be genuinely owned. Badge, ball and stadium are never emitted, so
/// enabling this cannot surface an unproven active family.
///
/// History, so the default is not naively flipped back: an early build was
/// once seen to empty the squad when this array was populated (resident map
/// down to the 2 kits, player vector fully null). That never reproduced, and
/// it can no longer cause durable damage — both squad write-back paths are
/// guarded in Core (`refuse to empty a populated squad`, and an absent
/// manager field no longer meaning "clear").
///
/// Set `OPENFUT_FIFA17_SQUAD_ACTIVES=0` to force it off for diagnostics.
pub squad_actives: bool,
}
#[derive(Debug)]
@@ -91,6 +115,17 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
Ok(val)
}
/// Whether to emit `squad.actives`. Correct FIFA 17 behaviour is ON, so an
/// absent or unrecognised value means ON; only an explicit `0`/`false`/`off`/`no`
/// turns it off, which exists for diagnostics. See
/// [`HostConfig::squad_actives`].
fn parse_squad_actives(raw: Option<&str>) -> bool {
!matches!(
raw.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
}
fn parse_sbc_post_commit_fault(
raw: Option<&str>,
environment: Option<&str>,
@@ -177,6 +212,9 @@ impl HostConfig {
clientdata_path,
account_path,
sbc_post_commit_fault,
squad_actives: parse_squad_actives(
env::var("OPENFUT_FIFA17_SQUAD_ACTIVES").ok().as_deref(),
),
})
}
}
@@ -205,6 +243,34 @@ fn default_account_path(identity_store_path: &str) -> String {
mod tests {
use super::*;
/// `squad.actives` is ON without any environment variable.
///
/// Emitting the club's active home/away kit is normal FIFA 17 behaviour —
/// it is what lets the client make the kits resident and render the
/// pre-match selector — so a correct deployment must not have to opt in.
/// The off switch survives only as a diagnostic.
#[test]
fn squad_actives_is_on_by_default_and_only_explicitly_disabled() {
// The case that matters: nothing configured at all.
assert!(
parse_squad_actives(None),
"a deployment that sets nothing must still emit squad.actives"
);
assert!(parse_squad_actives(Some("")));
assert!(parse_squad_actives(Some(" ")));
// Explicit disable, for diagnostics.
for off in ["0", "false", "off", "no", "OFF", " False "] {
assert!(!parse_squad_actives(Some(off)), "{off} must disable");
}
// Explicit enable stays valid, and anything unrecognised stays ON
// rather than silently disabling the feature.
for on in ["1", "true", "on", "yes", "TRUE", "banana"] {
assert!(parse_squad_actives(Some(on)), "{on} must leave it enabled");
}
}
#[test]
fn sbc_post_commit_faults_require_all_staging_guards() {
assert_eq!(
+24 -2
View File
@@ -24,6 +24,7 @@ use rand::Rng;
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
@@ -106,6 +107,13 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
league: m.card.league.clone(),
club: m.card.club.clone(),
attributes: m.card.attributes,
// Freshly minted by a pack/Store open, so Core tracks no contract for it
// yet: the shaper substitutes the pack-fresh default.
contract_matches: None,
// A pack mints PLAYER cards, whose rating IS `overall`, so there is no
// separate authored definition rating to carry.
source_rating: None,
core_content_kind: None,
}
}
@@ -167,6 +175,10 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
id,
deps.entities,
deps.assets.discard_value(&item),
// A pack-pulled card is by definition pack-fresh, so it carries
// the default rather than a persisted count: Core has not yet
// stored this instance, let alone applied a contract to it.
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
))
})
.collect()
@@ -464,8 +476,8 @@ mod tests {
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
@@ -608,6 +620,13 @@ mod tests {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
@@ -986,6 +1005,9 @@ mod tests {
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -26,6 +26,7 @@
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item_state;
@@ -284,7 +285,19 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
let identity = resolver.resolve(&owned);
let resource_id = identity.map(|id| id.resource_id as i64);
let item_json = identity
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
.map(|id| {
shape_item(
&owned,
id,
ent,
resolver.discard_value(&owned),
// A listing snapshot must show the contract the seller's card
// actually holds, so a part-used card cannot render as fresh.
owned
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
)
})
.and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing {
item_id,
@@ -806,8 +819,8 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -968,6 +981,13 @@ mod tests {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
@@ -1019,6 +1039,9 @@ mod tests {
league: String::new(),
club: String::new(),
attributes: [80, 80, 80, 80, 80, 80],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
+13 -3
View File
@@ -30,9 +30,10 @@ use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
build_content_pool, ConsumableApplyOutcome, ConsumableApplyRequest, CoreAccess, CoreEconomy,
CoreError, CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server, WireResponse,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -146,6 +147,15 @@ impl CoreEconomy for FaultEconomy {
}
self.inner.complete_match(m)
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
if self.trip("apply_consumable") {
return Err(Self::injected());
}
self.inner.apply_consumable(req)
}
}
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
@@ -902,6 +902,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
.to_string_lossy()
.into_owned(),
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
squad_actives: false,
}
}
+476 -14
View File
@@ -16,8 +16,9 @@ use openfut_utas_host::account_store::AccountStore;
use openfut_utas_host::{
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
Server, SquadDeps,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -51,6 +52,10 @@ struct FakeCore {
/// full squad replacement writes it (or clears it with `None`).
manager: Mutex<Option<String>>,
replaced: Mutex<Vec<StoredReplace>>,
/// Recorded role-only patches: (captain_owned_card_id, ext_payload). Kept
/// separate from `replaced` so a test can assert a patch NEVER went through
/// the replacement path.
role_patches: Mutex<Vec<(Option<String>, String)>>,
panic_if_called: bool,
return_err: bool,
}
@@ -95,6 +100,9 @@ impl FakeCore {
fn last(&self) -> Vec<(String, String)> {
self.last_params.lock().clone()
}
fn role_patches(&self) -> Vec<(Option<String>, String)> {
self.role_patches.lock().clone()
}
fn manager(&self) -> Option<String> {
self.manager.lock().clone()
}
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
self.squad.lock().clone().ok_or(CoreError::Status(404))
}
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
assert!(
!self.panic_if_called,
"Core must NOT be called on this path"
);
if self.return_err {
return Err(CoreError::Status(500));
}
// Mirror Core: the captain must already be fielded, otherwise 400.
if let Some(captain) = &req.captain_owned_card_id {
let fielded = self
.squad
.lock()
.as_ref()
.map(|s| s.slots.iter().any(|sl| &sl.owned_card_id == captain))
.unwrap_or(false);
if !fielded {
return Err(CoreError::Status(400));
}
}
self.role_patches
.lock()
.push((req.captain_owned_card_id.clone(), req.ext_payload.clone()));
// Apply to the stored squad WITHOUT touching slots or the manager, so a
// read-after-patch shows exactly what Core would show.
if let Some(sq) = self.squad.lock().as_mut() {
if let Some(captain) = &req.captain_owned_card_id {
for slot in sq.slots.iter_mut() {
slot.is_captain = &slot.owned_card_id == captain;
}
}
sq.ext = CoreExtState::Fresh {
schema_version: req.ext_schema_version,
payload: req.ext_payload.clone(),
};
}
Ok(())
}
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
assert!(
!self.panic_if_called,
@@ -198,11 +245,11 @@ impl CoreAccess for FakeCore {
Ok(self.manager.lock().clone())
}
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
fn set_squad_manager(&self, owned_card_id: &str) -> Result<(), CoreError> {
if self.return_err {
return Err(CoreError::Status(500));
}
*self.manager.lock() = owned_card_id.map(str::to_string);
*self.manager.lock() = Some(owned_card_id.to_string());
Ok(())
}
}
@@ -233,6 +280,9 @@ fn item(
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
@@ -1076,8 +1126,41 @@ fn classify_squad_and_usermassinfo_routes() {
classify("GET", "/ut/game/fifa17/userMassInfo"),
Route::UserMassInfo
);
// GET /squad/active and every numeric GET are the one Core-backed current
// squad, matching the oracle's single-squad response regardless of URL id.
// The retail client sends the lowercase tail too, immediately before the
// canonical one. It must reach the SAME Rust-owned handler: falling through
// to Python is a 502 here and, with Python alive, an authority split.
assert_eq!(
classify("GET", "/ut/game/fifa17/usermassinfo"),
Route::UserMassInfo,
"lowercase usermassinfo is a real retail request, observed twice"
);
assert_eq!(
classify("GET", "/ut/game/fifa17/USERMASSINFO"),
Route::UserMassInfo
);
// Adjacent tails must NOT be swept up by the case-insensitive arm.
assert_eq!(
classify("GET", "/ut/game/fifa17/usermassinfox"),
Route::Passthrough,
"the alias must match the whole tail, not a prefix"
);
assert_eq!(
classify("GET", "/ut/game/fifa17/usermass"),
Route::Passthrough
);
// Method semantics are unchanged: only GET is this route.
assert_eq!(
classify("PUT", "/ut/game/fifa17/usermassinfo"),
Route::Passthrough
);
assert_eq!(
classify("POST", "/ut/game/fifa17/userMassInfo"),
Route::Passthrough
);
// GET /squad/active and every numeric GET resolve to the one Core-backed
// squad. SAFE, not authentic — see is_numeric_squad_tail and the invariant
// test below.
assert_eq!(
classify("GET", "/ut/game/fifa17/squad/active"),
Route::SquadActive
@@ -1147,6 +1230,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1176,11 +1260,16 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
}
/// The squad's manager is an ownership-backed assignment, not an opaque wire
/// echo: a save assigns the owned instance behind the ref, and a later save
/// without a manager CLEARS it (a full replacement replaces the manager too).
/// The REAL captured partial body must succeed and take the PATCH path, not the
/// replacement path.
///
/// Captured 2026-08-25 00:42:42 from the retail client's captain/kick-taker
/// screen (`offline-seasons-squadexp-20260825T0018Z.pcap`). It carries no
/// `players`, so the old code handed Core a replacement with zero slots; the
/// empty-replacement guard refused it (400) and the host reported 502, losing
/// the user's change. The body shape is the operation discriminator.
#[test]
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
fn put_partial_captain_and_kicktakers_patches_roles_without_replacing() {
let items = vec![gk(), st()];
let (resolver, w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
@@ -1189,6 +1278,144 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
// Establish a squad first, so there is something to patch.
let full = put_body(
"f442",
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
Some(w["oc-b"]),
);
let (resp, log) = handle_put_squad(&full, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(core.replaced().len(), 1);
// The captured partial shape: no players, no manager, no formation.
let partial = format!(
r#"{{"id":0,"custom":"[0,8,16,16,8,134220032]","captain":{},"kicktakers":[
{{"index":0,"id":{},"dream":false}},{{"index":1,"id":{},"dream":false}},
{{"index":2,"id":{},"dream":false}},{{"index":3,"id":{},"dream":false}},
{{"index":4,"id":{},"dream":false}}]}}"#,
w["oc-b"], w["oc-a"], w["oc-a"], w["oc-b"], w["oc-b"], w["oc-a"]
);
let (resp, log) = handle_put_squad(partial.as_bytes(), &deps);
assert_eq!(resp.status, 200, "the partial shape must succeed: {log:?}");
assert_eq!(resp.body, br#"{"id":0}"#, "same ack as a full save");
// It went through the PATCH path, never the replacement path.
assert_eq!(
core.replaced().len(),
1,
"a role patch must NOT reach replace_squad — that is what tripped the guard"
);
let patches = core.role_patches();
assert_eq!(patches.len(), 1);
assert_eq!(
patches[0].0.as_deref(),
Some("oc-b"),
"captain resolved to the Core owned id, never the wire id"
);
// Omitted state is UNCHANGED, not cleared.
assert_eq!(
core.manager().as_deref(),
Some("oc-b"),
"a role patch must not touch the manager"
);
// The patch's opaque custom is carried, and kit numbers from the earlier
// full save survive the merge rather than being overwritten away.
assert!(patches[0].1.contains("134220032"), "patch custom carried");
assert!(
patches[0].1.contains("kit_numbers"),
"kit numbers preserved from the stored extension: {}",
patches[0].1
);
}
/// An explicit `"players": []` is still a REPLACEMENT and must still be refused
/// by Core's guard — the patch path must not become a way to smuggle a
/// destructive write past it.
#[test]
fn put_explicit_empty_players_is_still_a_replacement_not_a_patch() {
let items = vec![gk(), st()];
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
let ent = entities();
let deps = SquadDeps {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = br#"{"id":0,"formation":"f442","custom":"[]","players":[],"manager":[]}"#;
let (resp, log) = handle_put_squad(body, &deps);
// Reaches the replacement path (Core decides), and NEVER the patch path.
assert_eq!(
core.role_patches().len(),
0,
"an explicit empty players array must not be treated as a role patch: {log:?}"
);
assert!(
resp.status == 200 || resp.status >= 400,
"handled by the replacement path"
);
assert_eq!(
core.replaced().len(),
1,
"it went to replace_squad, where the empty-replacement guard lives"
);
}
/// A captain the resolver cannot map must refuse the whole patch, not silently
/// apply the kick-takers and report success.
#[test]
fn put_partial_with_unresolvable_captain_refuses_the_whole_patch() {
let items = vec![gk(), st()];
let (resolver, w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
let ent = entities();
let deps = SquadDeps {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let full = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[1]", None);
assert_eq!(handle_put_squad(&full, &deps).0.status, 200);
let partial = br#"{"id":0,"custom":"[9]","captain":999999999,"kicktakers":[]}"#;
let (resp, log) = handle_put_squad(partial, &deps);
assert_eq!(resp.status, 400, "unresolvable captain is a client error");
assert_eq!(log.outcome, "unresolved_captain");
assert_eq!(
core.role_patches().len(),
0,
"nothing may be written when the captain cannot be resolved"
);
}
/// The squad's manager is an ownership-backed assignment, not an opaque wire
/// echo: a save assigns the owned instance behind the ref. A later save that
/// carries NO manager ref does NOT clear it.
///
/// This test previously asserted the opposite ("a full replacement replaces the
/// manager too"). That was the bug: FIFA 17 has no wire shape that removes a
/// manager — the client always sends a ref — so an absent ref means the save
/// says nothing about the manager, not that the user cleared the slot. A client
/// whose squad model had been destroyed sent exactly that shape and deleted a
/// real manager row (WAL commit 468, squad_managers 1 -> 0).
#[test]
fn put_assigns_the_owned_manager_and_a_later_save_without_one_leaves_it() {
let items = vec![gk(), st()];
let (resolver, w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
let ent = entities();
let deps = SquadDeps {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let with_manager = put_body(
@@ -1206,14 +1433,18 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
"manager persisted as the Core owned id, never the wire id"
);
// The exact destructive shape: `"manager": []`.
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
let (resp, log) = handle_put_squad(&without_manager, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(
core.manager(),
None,
"a full replacement without a manager clears the assignment"
core.manager().as_deref(),
Some("oc-b"),
"a save carrying no manager ref must LEAVE the existing assignment alone"
);
// And the squad itself still committed — the manager decision is separate.
assert_eq!(core.replace_calls(), 2, "both saves committed their slots");
}
/// The REAL client always sends a manager ref, and on a real profile it does not
@@ -1231,6 +1462,7 @@ fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
@@ -1276,6 +1508,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1302,6 +1535,7 @@ fn put_rejects_unknown_wire_id() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
// 999_999_999 was never allocated → unresolvable.
let body = put_body(
@@ -1327,6 +1561,7 @@ fn put_rejects_duplicate_owned_item() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1352,6 +1587,7 @@ fn put_core_failure_returns_error_never_python() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
let (resp, log) = handle_put_squad(&body, &deps);
@@ -1369,6 +1605,7 @@ fn repeated_identical_put_is_idempotent() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1405,6 +1642,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1510,6 +1748,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
// Commit a squad so Core has a fresh canonical squad + extension.
let body = put_body(
@@ -1557,6 +1796,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -1611,6 +1851,7 @@ fn stale_extension_is_not_applied_on_reads() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let (resp, log) = handle_squad_list(&deps);
assert_eq!(log.outcome, "stale_integrity");
@@ -1632,6 +1873,7 @@ fn missing_extension_is_explicit_on_reads() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let (resp, log) = handle_squad_list(&deps);
assert_eq!(log.outcome, "missing_integrity");
@@ -1653,6 +1895,7 @@ fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let py_body = json!({
"userInfo": {"personaId": 7},
@@ -1727,6 +1970,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
core: &core,
resolver: &resolver,
entities: &ent,
squad_actives: false,
};
let body = put_body(
"f442",
@@ -2227,7 +2471,6 @@ fn position_tabs_serve_only_their_own_position_group() {
#[test]
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
for (query, reason) in [
("type=equippables", "multi_family_crash_2026_08_05"),
("type=leaguelogos", "subtype_by_elimination_unprobed"),
("type=healing", "served_by_club_consumables_route"),
("type=contract", "served_by_club_consumables_route"),
@@ -2251,6 +2494,35 @@ fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
}
}
/// `?type=equippables` is the family the MY CLUB kit tab actually asks for, and it
/// answers with the KIT family only. Withholding it WAS the "0 kits in my club"
/// symptom — the tab asked, got the withheld empty body, and drew nothing. Serving
/// it is operator-confirmed on the retail client.
///
/// Kits only, deliberately: the 2026-08-05 crash was 30 items across five families
/// at once, and nothing has retested that shape.
///
/// NOT exercised here: `OPENFUT_FIFA17_EQUIPPABLES=0`, which restores the withheld
/// body. It is read through a `OnceLock`, so flipping the process-wide env inside
/// one test would leak into every other test sharing this binary.
#[test]
fn equippables_serves_the_kit_family_because_that_is_the_my_club_kit_tab() {
let (items, _, outcome) = club_query("type=equippables");
assert_eq!(outcome, "ok");
assert_eq!(items.len(), 2, "the fixture club owns exactly two kits");
for it in &items {
assert_eq!(it["itemType"], "kit");
assert_eq!(
it["cardsubtypeid"], 9,
"kits only: mixing families is the shape that crashed the client"
);
}
// The two tabs are the same question, so they must never diverge.
let (kits, _, _) = club_query("type=kit");
assert_eq!(items, kits, "equippables and kit must serve one body");
}
/// The three club-customisation families Core can own are MAPPED (so the arm asks
/// Core for the right rows) but their item record is still withheld, so the answer
/// is an empty list rather than a guessed record — and never another family's.
@@ -2588,3 +2860,193 @@ fn every_ownable_class_projects_on_its_own_arm() {
.all(|i| i["cardsubtypeid"] != 0)
);
}
// ── Training apply: verb authority and the Core wire contract ────────────────
/// METHOD IS PART OF ROUTE AUTHORITY. The same `item/resource/<rid>` path means
/// three different things, and two of them destroy a card. A training apply that
/// slid into the GET arm would read a definition and answer 200 having changed
/// nothing; one that slid into the PUT arm would SELL the card the player asked
/// to spend. Both were live failure modes before the family was read as one unit.
#[test]
fn the_item_resource_family_stays_verb_split_for_training_cards() {
use openfut_utas_host::{classify_economy, EconomyRoute};
// 5003011 is a real GK training card (subtype 54, SPEED +10).
let path = "/ut/game/fifa17/item/resource/5003011";
assert_eq!(
classify_economy("POST", path),
Some(EconomyRoute::ConsumableApply),
"POST must be the apply arm"
);
assert_eq!(
classify_economy("PUT", path),
Some(EconomyRoute::QuickSellResource),
"PUT must remain quick-sell"
);
// GET is NOT an economy route at all: it is the read-only definition lookup,
// so it can never reach the apply transaction.
assert_eq!(
classify_economy("GET", path),
None,
"GET must not be an economy route"
);
assert_eq!(classify("GET", path), Route::Passthrough);
// The bare tail is the definition lookup Rust does claim.
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
Route::ItemDefs
);
}
/// A non-numeric or empty resource id must not be mistaken for an apply — the
/// digit guard is what keeps `item/resource/anything` from reaching Core.
#[test]
fn only_a_numeric_resource_id_can_be_applied() {
use openfut_utas_host::{classify_economy, EconomyRoute};
for tail in ["", "abc", "5003011x", "50030 11"] {
let path = format!("/ut/game/fifa17/item/resource/{tail}");
assert_ne!(
classify_economy("POST", &path),
Some(EconomyRoute::ConsumableApply),
"tail {tail:?} must not classify as an apply"
);
}
}
/// The effect must serialise EXACTLY as Core's closed `InstanceEffect`
/// vocabulary deserialises it. Core dispatches on `kind`, so a drifted token or
/// a renamed field is a 400 at best and a silently skipped mutation at worst.
#[test]
fn the_training_effect_matches_cores_closed_vocabulary() {
use openfut_utas_host::ApplyEffect;
let json = ApplyEffect::ApplyTraining {
attribute_index: Some(4),
amount: 10,
max_amount: 15,
}
.to_json();
assert_eq!(
json,
serde_json::json!({
"kind": "apply_training",
"attribute_index": 4,
"amount": 10,
"max_amount": 15,
})
);
// The rare all-six card MUST serialise a null slot. Sending 0 would train
// pace alone, and omitting the key would leave Core guessing.
let all_six = ApplyEffect::ApplyTraining {
attribute_index: None,
amount: 10,
max_amount: 10,
}
.to_json();
assert_eq!(
all_six,
serde_json::json!({
"kind": "apply_training",
"attribute_index": null,
"amount": 10,
"max_amount": 10,
})
);
// The contract arm must keep its own shape while sharing the enum.
let contract = ApplyEffect::AddContractMatches {
amount: 3,
cap: 99,
default_when_unset: 7,
}
.to_json();
assert_eq!(
contract,
serde_json::json!({
"kind": "add_contract_matches",
"amount": 3,
"cap": 99,
"default_when_unset": 7,
})
);
}
/// Every training subtype the adapter can resolve must declare a magnitude no
/// larger than the ceiling the host sends Core. If these ever disagree, Core
/// refuses a legitimate card — a silent, family-wide outage.
#[test]
fn no_shipped_training_card_exceeds_the_declared_ceiling() {
use openfut_adapter_fifa17::fut::training_cards::{
ceiling_for, training_effect, TRAINING_ALL_MAX_AMOUNT, TRAINING_MAX_AMOUNT,
};
let mut resolved = 0;
for subtype in [51, 52, 53, 54, 55, 56, 61, 62, 63, 64, 65, 66] {
for amount in [5, 10, 15] {
let e = training_effect(subtype, Some(amount)).expect("shipped card resolves");
assert!(
e.amount <= ceiling_for(&e),
"subtype {subtype} amount {amount} exceeds the ceiling sent to Core"
);
assert_eq!(ceiling_for(&e), TRAINING_MAX_AMOUNT);
resolved += 1;
}
}
assert_eq!(resolved, 36, "all 36 single-attribute cards must resolve");
// The six rare all-six cards carry their own, lower ceiling.
let mut rare = 0;
for subtype in [57, 67] {
for amount in [3, 6, 10] {
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
assert_eq!(e.attribute_index, None);
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
rare += 1;
}
}
assert_eq!(rare, 6, "all 6 rare all-six cards must resolve");
}
/// Collapsing every numeric `squad/<id>` onto one squad is safe ONLY while
/// exactly one squad is advertised. This is the tripwire for that assumption.
///
/// FIFA 17 has real multi-squad semantics — the client ships `SelectSquadById`,
/// `RetrieveSquad` (distinct from `LoadActiveSquad`), `CreateSquadWithName`,
/// `RenameSquad`, `DeleteSquad` and indexed `SQUAD_ID-%d` entries. We get away
/// with ignoring the id purely because the client can never learn another one:
/// the wire id is a constant 0 and `/squad/list` advertises a single squad.
///
/// If either fact stops holding, the numeric route needs a real lookup and this
/// test must be the thing that says so.
#[test]
fn numeric_squad_routing_is_safe_only_while_one_squad_is_advertised() {
assert_eq!(
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
0,
"the single advertised squad id is what makes id-collapsing safe"
);
let projected = serde_json::json!({
"id": openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
"squadName": "OpenFUT",
"formation": "f442",
"squadType": "REGULAR_SQUAD",
"rating": 90,
"chemistry": 52,
});
let list = openfut_adapter_fifa17::fut::squad_projection::squad_list(&projected);
let squads = list["squad"].as_array().expect("squad list is an array");
assert_eq!(
squads.len(),
1,
"more than one advertised squad means the client can address a second \
id, and every numeric GET/PUT collapsing onto one squad becomes wrong"
);
assert_eq!(
squads[0]["id"],
openfut_utas_host::ACTIVE_SQUAD_WIRE_ID,
"the advertised id must be the one the client echoes back"
);
}
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Smoke-test the REAL consumable-apply route against the RUNNING staging host.
`POST ut/<sku>/item/resource/<resourceId>` is now Rust-owned and it MUTATES:
Core destroys the source consumable instance and raises the target's
match-contracts in ONE transaction. There is no `OPENFUT_FIFA17_APPLY_PROBE`
gate any more and no staging-only diagnostic -- the route is unconditional --
so what needs proving changed. This checks the two halves that matter:
* every REFUSAL leaves Core byte-identical (fail closed; nothing half-applied);
* the one accepted apply consumes exactly ONE copy, and the contract number the
host logged is the number the client can actually read back off the wire.
It deliberately does NOT re-derive the FIFA 17 grant matrix. Duplicating those
13 rows here would create a second source of truth that could silently disagree
with `openfut-adapter-fifa17::fut::contract_cards`, which is the authority.
Instead the host's own `granted/before/after` are checked for internal
consistency (`after == min(99, before + granted)`) and against the projected
wire state.
Replaying the accepted request is NOT idempotent and is not attempted: a
successful apply DESTROYS the source instance, so a second POST legitimately
consumes the NEXT owned copy. Idempotency is Core's, keyed on the source
instance id (`fifa17:apply:<source>-><target>`), and a transport retry of one
logical action replays that key rather than this HTTP request.
"""
import json
import re
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
SKU = "fifa17"
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
SNAPSHOT = "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"
# A PLAYER contract card (cardsubtypeid 201) owned on staging: the one accepted
# apply. It must match the snapshot script's SOURCE_RES so the copy count below
# is the count of THIS stack.
CONTRACT_RES = 5001004
# Position modifier: a real owned family whose apply effect is NOT proven.
POSITION_RES = 5003068
# Manager contract (cardsubtypeid 202): unservable until staff ratings are
# imported, because the grant is keyed on the TARGET's rating tier.
MANAGER_RES = 5001010
UNOWNED_RES = 1234567
TARGET_WIRE = 100000003
UNKNOWN_WIRE = 999999999
H = {"X-OpenFUT-Game": SKU, "Content-Type": "application/json"}
results = []
def post(resource_id, wires):
body = json.dumps({"apply": [{"id": w} for w in wires]}).encode()
req = urllib.request.Request(
"%s/ut/game/%s/item/resource/%d" % (BASE, SKU, resource_id),
data=body, headers=H, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def snapshot():
out = subprocess.run(["python3", SNAPSHOT], capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit("snapshot failed: %s" % out.stderr.strip())
return json.loads(out.stdout)
def apply_log_since(mark):
with open(LOG) as f:
return [ln.rstrip() for ln in list(f)[mark:] if "consumable-apply" in ln]
def check(name, ok, detail=""):
results.append(ok)
print(" [%s] %s%s" % ("OK" if ok else "FAIL", name,
(" -- " + detail) if detail else ""))
def skip(name, detail):
print(" [SKIP] %s -- %s" % (name, detail))
def stack_count(snap):
"""Owned copies of CONTRACT_RES, or 0 once the last one is consumed (the
stack disappears from the consumables screen entirely)."""
return ((snap.get("source_stack") or {}).get("count")) or 0
with open(LOG) as f:
mark = sum(1 for _ in f)
base = snapshot()
print("=== every refusal must leave Core byte-identical ===")
REFUSALS = [
("batch semantics unproven", CONTRACT_RES,
[TARGET_WIRE, TARGET_WIRE + 1], 400, "apply_batch_unsupported"),
("unknown target wire id", CONTRACT_RES,
[UNKNOWN_WIRE], 404, "not_owned"),
("source consumable not owned", UNOWNED_RES,
[TARGET_WIRE], 404, "not_owned"),
("non-contract family fails closed", POSITION_RES,
[TARGET_WIRE], 409, "apply_effect_unproven"),
("manager contract refused: staff ratings not imported", MANAGER_RES,
[TARGET_WIRE], 409, "manager_contract_unsupported"),
]
for name, res, wires, want_status, want_code in REFUSALS:
status, body = post(res, wires)
got = "status=%s body=%s" % (status, body[:80])
# A family-gate case can only be exercised if this profile owns such a card;
# source resolution runs first, so an unowned one answers 404 not_owned. Say
# so rather than scoring a pass or a failure that means nothing.
if want_status != 404 and status == 404 and "not_owned" in body:
skip(name, "profile owns no resource %d" % res)
continue
check(name, status == want_status and want_code in body, got)
check("Core unchanged by every refusal", snapshot() == base)
print("\n=== the one accepted apply must mutate, exactly once ===")
status, body = post(CONTRACT_RES, [TARGET_WIRE])
parsed = None
try:
parsed = json.loads(body)
except ValueError:
pass
check("client-shaped ack", status == 200 and parsed == {"itemData": []},
"status=%s body=%s" % (status, body[:80]))
after = snapshot()
lines = apply_log_since(mark)
granted_lines = [ln for ln in lines if "granted=" in ln]
fields = {}
if granted_lines:
fields = dict(re.findall(r"(subtype|granted|before|after|applied)=(-?\w+)",
granted_lines[-1]))
check("host logged the grant it applied",
{"subtype", "granted", "before", "after"} <= set(fields),
granted_lines[-1] if granted_lines else "no consumable-apply grant line")
if {"subtype", "granted", "before", "after"} <= set(fields):
granted = int(fields["granted"])
before_n = int(fields["before"])
after_n = int(fields["after"])
check("player contract subtype", fields["subtype"] == "201",
"subtype=%s" % fields["subtype"])
check("Core applied a real grant", granted > 0, "granted=%d" % granted)
check("cap respected: after == min(99, before + granted)",
after_n == min(99, before_n + granted),
"before=%d granted=%d after=%d" % (before_n, granted, after_n))
check("not a replay", fields.get("applied") == "true",
"applied=%s" % fields.get("applied"))
wire_contract = (after.get("target") or {}).get("contract")
check("the wire shows what Core recorded", wire_contract == after_n,
"wire=%s core=%s" % (wire_contract, after_n))
check("exactly one source copy consumed",
stack_count(after) == stack_count(base) - 1,
"before=%s after=%s" % (stack_count(base), stack_count(after)))
check("exactly one owned row destroyed",
after["owned_rows"] == base["owned_rows"] - 1,
"before=%s after=%s" % (base["owned_rows"], after["owned_rows"]))
check("coins untouched: an apply is not a sale",
after["coins"] == base["coins"],
"before=%s after=%s" % (base["coins"], after["coins"]))
print("\n=== host log ===")
for line in lines:
print(" " + line[:200])
ok = all(results)
print("\nRESULT: %s" % ("OK" if ok else "FAILED"))
raise SystemExit(0 if ok else 1)
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Core snapshot around a consumable APPLY: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after.
A player-contract apply must move EXACTLY three things: the source consumable
loses one copy, the target's `contract` rises to min(99, before + grant), and
nothing else — coins in particular must not move, because an apply is not an
economy credit. Everything else in this snapshot is here to prove it stayed put.
(Superseded for acceptance by fifa17-contract-apply-validate.py, which asserts
the deltas itself; this remains the raw before/after dump for eyeballing.)"""
import json
import sys
import urllib.request
H = {"X-OpenFUT-Game": "fifa17"}
SOURCE_RES = 5001004
TARGET_WIRE = 100000003
def get(p):
req = urllib.request.Request("http://127.0.0.1:8299" + p, headers=H)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
snap = {}
import sqlite3
con = sqlite3.connect("file:/home/alex/openfut-sold-staging/staging-core.db?mode=ro", uri=True)
snap["coins"] = con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0]
snap["owned_rows"] = con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0]
snap["by_kind"] = dict(con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1"))
con.close()
cons = get("/ut/game/fifa17/club/consumables/development")
stacks = cons.get("itemData") or []
snap["development_stacks"] = len(stacks)
snap["development_copies"] = sum(s.get("count", 0) for s in stacks)
snap["source_stack"] = next(
({"count": s.get("count"), "resourceId": s.get("resourceId")}
for s in stacks if s.get("resourceId") == SOURCE_RES), None)
sq = get("/ut/game/fifa17/squad/0")
players = (sq.get("squad") or sq).get("players") or []
for p in players:
it = p.get("itemData") or {}
if it.get("id") == TARGET_WIRE:
snap["target"] = {
"resourceId": it.get("resourceId"), "rating": it.get("rating"),
"contract": it.get("contract"), "fitness": it.get("fitness"),
"injuryType": it.get("injuryType"), "training": it.get("training"),
"playStyle": it.get("playStyle"), "preferredPosition": it.get("preferredPosition"),
}
break
print(json.dumps(snap, indent=2, sort_keys=True))
if len(sys.argv) > 1:
open(sys.argv[1], "w").write(json.dumps(snap, sort_keys=True))
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Acceptance harness for FIFA17 player-contract consumable APPLY (staging).
Exercises the real route end to end and asserts the full observable contract:
* the target's `contract` goes from B to min(99, B + grant), where `grant` is
read from the shipped EA table `fcc_contractcards` INDEPENDENTLY of the Rust
implementation (this is a cross-check, not a mirror);
* exactly one source copy is consumed;
* coins do NOT move (an apply is not an economy credit);
* replaying the exhausted resource fails closed rather than granting again;
* a manager contract and a non-contract consumable both fail closed.
Defaults target STAGING. `--host`/`--db` retarget it at a rehearsal or, under
explicit authorization, at the production acceptance run. There is deliberately
no production default: a bare invocation cannot touch production.
"""
import argparse
import json
import sqlite3
import sys
import urllib.error
import urllib.request
# Defaults are STAGING. Override for a rehearsal or the production acceptance
# run; there is deliberately no production default, so a bare invocation can
# never touch production by accident.
HOST = "http://127.0.0.1:8299"
CORE_DB = "/home/alex/openfut-sold-staging/staging-core.db"
HDRS = {"X-OpenFUT-Game": "fifa17"}
TABLE = "/home/alex/OpenFUT/fifa17-recon/data/tables/fcc_contractcards.json"
PLAYER_CONTRACT_SUBTYPE = 201
MANAGER_CONTRACT_SUBTYPE = 202
CAP = 99
FAILURES = []
def check(label, got, want):
ok = got == want
print(f" [{'OK ' if ok else 'FAIL'}] {label}: got {got!r} want {want!r}")
if not ok:
FAILURES.append(label)
return ok
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(HOST + path, data=data, headers=HDRS, method=method)
if data:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw.decode(errors="replace")
def grant_from_table(resource_id, target_rating):
"""The authoritative grant, read straight from EA's shipped table.
Column is selected by the TARGET's tier (bronze <65, silver 65..74, gold
>=75) — NOT by the card's own tier. Verified 36/36 against the published
FIFA 17 matrix.
"""
d = json.load(open(TABLE))
rows = d if isinstance(d, list) else (d.get("rows") or list(d.values())[0])
row = next((r for r in rows if r["carddbid"] == resource_id), None)
if row is None:
return None
col = "bronze" if target_rating < 65 else ("silver" if target_rating < 75 else "gold")
return row[col]
def core_snapshot():
con = sqlite3.connect(f"file:{CORE_DB}?mode=ro", uri=True)
try:
snap = {
"coins": con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
"owned": con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0],
"by_kind": dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1")
),
}
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["has_contract_column"] = "contract_matches" in cols
if snap["has_contract_column"]:
snap["contracts_set"] = con.execute(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL"
).fetchone()[0]
return snap
finally:
con.close()
def club_players():
"""Every owned player on the wire, with its contract, keyed by wire id."""
out = {}
for pile in ("/ut/game/fifa17/club?type=player&start=0&count=200",):
_, body = req("GET", pile)
for it in (body or {}).get("itemData") or []:
if it.get("itemType") == "player":
out[it["id"]] = it
return out
def consumable_stacks():
_, body = req("GET", "/ut/game/fifa17/club/consumables/development")
return {s["resourceId"]: s for s in (body or {}).get("itemData") or []}
def pick_source(stacks, subtype_range):
for rid, s in sorted(stacks.items()):
if rid in subtype_range:
return rid, s
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", type=int, default=None,
help="contract resource id to apply (default: first owned player contract)")
ap.add_argument("--target", type=int, default=None,
help="target player wire id (default: lowest-rated owned player)")
ap.add_argument("--host", default=HOST, help=f"utas-host base URL (default {HOST})")
ap.add_argument("--db", default=CORE_DB, help=f"Core SQLite path (default {CORE_DB})")
args = ap.parse_args()
# Rebound before any request so every helper below reads the chosen target.
globals()["HOST"] = args.host
globals()["CORE_DB"] = args.db
print(f"target host : {HOST}\ntarget db : {CORE_DB}\n")
print("== BEFORE ==")
before = core_snapshot()
print(json.dumps(before, indent=2, sort_keys=True))
if not before["has_contract_column"]:
print("FATAL: migration 0028 not applied — owned_cards has no contract_matches column")
return 2
stacks = consumable_stacks()
players = club_players()
if not players:
print("FATAL: no owned players on the wire")
return 2
player_contracts = {r: s for r, s in stacks.items() if 5001001 <= r <= 5001006 or r == 5001013}
src = args.source or next(iter(sorted(player_contracts)), None)
if src is None:
print("FATAL: no owned PLAYER contract consumable to apply")
return 2
# Lowest-rated target maximises the observable delta (a bronze target draws
# the largest column) and exercises the tier selector rather than assuming gold.
tgt_id = args.target or min(players, key=lambda i: players[i].get("rating", 0))
tgt = players[tgt_id]
rating = tgt["rating"]
grant = grant_from_table(src, rating)
c_before = tgt.get("contract")
expect_after = min(CAP, c_before + grant)
print(f"\n== APPLY ==\n source resource {src} (stack count {stacks[src].get('count')})")
print(f" target wire {tgt_id} rating {rating} -> tier "
f"{'bronze' if rating < 65 else 'silver' if rating < 75 else 'gold'}")
print(f" table grant {grant}; contract {c_before} -> expect {expect_after}")
status, body = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}]})
print(f" HTTP {status} {json.dumps(body)[:200] if body is not None else ''}")
check("apply status", status, 200)
check("apply body", body, {"itemData": []})
print("\n== AFTER ==")
after = core_snapshot()
players2 = club_players()
stacks2 = consumable_stacks()
print(json.dumps(after, indent=2, sort_keys=True))
check("coins unchanged", after["coins"], before["coins"])
check("one owned row consumed", after["owned"], before["owned"] - 1)
check("one consumable consumed",
after["by_kind"].get("consumable", 0), before["by_kind"].get("consumable", 0) - 1)
check("target contract granted", players2.get(tgt_id, {}).get("contract"), expect_after)
check("source stack decremented",
(stacks2.get(src) or {}).get("count", 0), (stacks[src].get("count") or 1) - 1)
# Untouched players must not have drifted.
drifted = [i for i, p in players2.items()
if i != tgt_id and p.get("contract") != players.get(i, {}).get("contract")]
check("no collateral contract changes", drifted, [])
print("\n== FAIL-CLOSED CASES ==")
exhausted = (stacks2.get(src) or {}).get("count", 0) == 0
if exhausted:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}", {"apply": [{"id": tgt_id}]})
check("replay of exhausted resource refused", s, 404)
mgr = next((r for r in stacks2 if 5001007 <= r <= 5001012), None)
if mgr:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{mgr}", {"apply": [{"id": tgt_id}]})
check("manager contract fails closed (staff ratings unimported)", s, 409)
other = next((r for r in stacks2 if not (5001001 <= r <= 5001013)), None)
if other:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{other}", {"apply": [{"id": tgt_id}]})
check("unproven family fails closed", s, 409)
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}, {"id": tgt_id}]})
check("batch apply refused", s in (400, 404), True)
print("\n== RESULT ==")
if FAILURES:
print("FAILED: " + ", ".join(FAILURES))
return 1
print(f"PASS — contract {c_before} -> {expect_after} on wire {tgt_id}, "
f"one copy of {src} consumed, coins flat")
return 0
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Pre/post invariants for the FIFA17 Rust production migration rehearsal.
Covers every domain the authorization names: coins, ownership, squads, managers,
staff, consumables, club items, transfer state, packs/unassigned, SBC state,
match state/history. Read-only; run against a COPY.
"""
import json
import sqlite3
import sys
db = sys.argv[1]
out = sys.argv[2] if len(sys.argv) > 2 else None
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
tables = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
def one(sql, default=None):
try:
r = con.execute(sql).fetchone()
return r[0] if r else default
except sqlite3.Error:
return default
def count(t):
return one("SELECT COUNT(*) FROM %s" % t) if t in tables else None
def group(t, col):
if t not in tables:
return None
try:
return dict(con.execute("SELECT %s, COUNT(*) FROM %s GROUP BY 1" % (col, t)))
except sqlite3.Error:
return None
inv = {
"schema_version": one("SELECT MAX(version) FROM _sqlx_migrations"),
"tables": len(tables),
# economy
"coins": one("SELECT coins FROM clubs LIMIT 1"),
"clubs": count("clubs"),
"profiles": count("profiles"),
# ownership
"owned_cards": count("owned_cards"),
"owned_by_kind": group("owned_cards", "content_kind"),
"owned_distinct_cards": one("SELECT COUNT(DISTINCT card_id) FROM owned_cards"),
"owned_loans": one("SELECT COUNT(*) FROM owned_cards WHERE is_loan=1"),
"owned_chem_styles": one(
"SELECT COUNT(*) FROM owned_cards WHERE chemistry_style IS NOT NULL"),
"owned_pos_overrides": one(
"SELECT COUNT(*) FROM owned_cards WHERE position_override IS NOT NULL"),
# squads / managers
"squads": count("squads"),
"squad_managers": count("squad_managers"),
# club items
"club_active_items": count("club_active_items"),
"club_kit_assignments": count("club_kit_assignments"),
# transfer / market / piles
"market_listings": count("market_listings"),
"market_listings_by_status": group("market_listings", "status"),
"market_history": count("market_history"),
"packs": count("packs"),
# FIFA17 opaque squad extension (adapter-owned blob)
"game_entity_ext": count("game_entity_ext"),
"squad_players": count("squad_players"),
"seasons": count("seasons"),
"events": count("events"),
"notifications": count("notifications"),
# sbc
"sbc_submissions": count("sbc_submissions"),
"sbc_challenge_squads": count("sbc_challenge_squads"),
# matches / history
"matches": count("matches"),
"match_completions": count("match_completions"),
"season_history": count("season_history"),
"statistics": count("statistics"),
"consumable_applications": count("consumable_applications"),
}
# integrity
inv["integrity_check"] = one("PRAGMA integrity_check")
inv["foreign_key_violations"] = len(list(con.execute("PRAGMA foreign_key_check")))
con.close()
print(json.dumps(inv, indent=2, sort_keys=True))
if out:
open(out, "w").write(json.dumps(inv, sort_keys=True, indent=2))
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Serve the MIGRATED + RECLASSIFIED rehearsal copy with the candidate Rust
stack and validate the wire surface. Isolated ports, copy-only state, no
production or staging resource touched."""
import json
import os
import signal
import subprocess
import time
import urllib.error
import urllib.request
R = "/home/alex/openfut-migration/rehearsal-20260822"
REPO = "/home/alex/OpenFUT"
CORE_PORT, HOST_PORT = 18099, 18098
H = {"X-OpenFUT-Game": "fifa17"}
procs = []
def start(name, binary, env):
e = dict(os.environ)
e.update(env)
log = open("%s/evidence/%s.log" % (R, name), "w")
p = subprocess.Popen([binary], cwd=REPO, env=e, stdout=log, stderr=log,
start_new_session=False)
procs.append(p)
return p
def get(port, path):
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), headers=H)
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read())
try:
start("rehearsal-core", R + "/artifacts/openfut-core", {
"LISTEN_ADDR": "127.0.0.1:%d" % CORE_PORT,
"DATABASE_URL": "sqlite://%s/work/core.db" % R,
"DATA_DIR": "%s/openfut-core/data" % REPO,
"OPENFUT_CONTENT_PACKS": "%s/emit/content/fifa17-production-cards.json" % R,
"RUST_LOG": "warn",
})
time.sleep(6)
start("rehearsal-host", R + "/artifacts/openfut-utas-host", {
"OPENFUT_UTAS_HOST_ADDR": "127.0.0.1:%d" % HOST_PORT,
"OPENFUT_UTAS_PYTHON_URL": "http://127.0.0.1:19999", # deliberately dead
"OPENFUT_CORE_URL": "http://127.0.0.1:%d" % CORE_PORT,
"OPENFUT_FIFA17_TABLES_DIR": "%s/fifa17-recon/data/tables" % REPO,
"OPENFUT_FIFA17_CATALOG": "%s/emit/content/fifa17-production-catalog.json" % R,
"OPENFUT_IDENTITY_STORE": "%s/work/identity.json" % R,
"OPENFUT_PERSONA_ID": "33068179",
"OPENFUT_MARKET_DB": "%s/work/market.db" % R,
"OPENFUT_PILE_DB": "%s/work/pile.db" % R,
"OPENFUT_FIFA17_DISCARD_TABLE": "1",
"RUST_LOG": "warn",
})
time.sleep(6)
checks = {}
club = get(HOST_PORT, "/ut/game/fifa17/club?type=player&start=0&count=5")
checks["club_total"] = club.get("totalResults")
checks["club_first_discard"] = (club.get("itemData") or [{}])[0].get("discardValue")
checks["club_first_rating"] = (club.get("itemData") or [{}])[0].get("rating")
umi = get(HOST_PORT, "/ut/game/fifa17/userMassInfo")
checks["squad_slots"] = len(((umi.get("userInfo") or {}).get("squad") or
umi.get("squad") or {}).get("players", []) or [])
for cat in ("contracts", "development", "fitness"):
d = get(HOST_PORT, "/ut/game/fifa17/club/consumables/" + cat)
st = d.get("itemData") or []
checks["consumables_" + cat] = (len(st), sum(s.get("count", 0) for s in st))
sq = get(HOST_PORT, "/ut/game/fifa17/squad/0")
checks["squad_players"] = len((sq.get("squad") or sq).get("players") or [])
print(json.dumps(checks, indent=2, sort_keys=True))
# The consumable apply is Rust-owned and MUTATES, so the rehearsal must not
# send one that would succeed: a two-target body is refused (400
# apply_batch_unsupported) before anything is resolved or written. That
# refusal is only reachable if the candidate host CLAIMS the route -- a 502
# means it fell through to the (dead) upstream, i.e. the cutover is missing.
print("\n=== the candidate must OWN the consumable apply ===")
req = urllib.request.Request(
"http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT,
data=b'{"apply":[{"id":100000003},{"id":100000004}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=20) as r:
st, body = r.status, r.read().decode()
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print(" apply route Rust-owned, nothing mutated (must be 400 "
"apply_batch_unsupported): %s"
% (st == 400 and "apply_batch_unsupported" in body))
finally:
for p in procs:
try:
os.kill(p.pid, signal.SIGTERM)
except ProcessLookupError:
pass
time.sleep(2)
for p in procs:
if p.poll() is None:
os.kill(p.pid, signal.SIGKILL)
print("\nrehearsal processes stopped")
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Transactionally-consistent backup of a live OpenFUT Core SQLite DB.
WHY NOT `cp`. Production runs WAL mode with an uncheckpointed WAL that routinely
holds hundreds of KB of committed pages. Copying only `prod-core.db` captures the
main file WITHOUT those pages and silently loses committed transactions; copying
the three files non-atomically can capture a torn set. This uses SQLite's online
backup API, which walks a read transaction and emits ONE standalone, already-
merged database file — no sidecar needed, no writer paused, safe against a live
production process.
RESTORE HAZARD, read this before restoring. The destination of a restore MUST
have its `-wal` and `-shm` removed first. SQLite treats an existing `-wal` as
newer-than-the-database journal content and will replay it over the file you
just put back, resurrecting exactly the state you were trying to abandon. The
emitted `RESTORE.sh` does this in the right order.
Usage:
fifa17-promotion-backup.py <source-db> <backup-dir> [--label NAME]
"""
import argparse
import datetime
import hashlib
import json
import os
import sqlite3
import stat
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def describe(db_path, read_only=True):
uri = f"file:{db_path}?mode=ro" if read_only else db_path
con = sqlite3.connect(uri, uri=True)
one = lambda s: con.execute(s).fetchone()[0]
info = {
"journal_mode": one("PRAGMA journal_mode"),
"page_size": one("PRAGMA page_size"),
"page_count": one("PRAGMA page_count"),
"migration_max": one("SELECT MAX(version) FROM _sqlx_migrations"),
"migration_count": one("SELECT COUNT(*) FROM _sqlx_migrations"),
"integrity_check": one("PRAGMA integrity_check"),
"foreign_key_check": len(con.execute("PRAGMA foreign_key_check").fetchall()),
"owned_cards": one("SELECT COUNT(*) FROM owned_cards"),
"coins": one("SELECT COALESCE(SUM(coins), 0) FROM clubs"),
}
con.close()
return info
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source")
ap.add_argument("backup_dir")
ap.add_argument("--label", default="core")
args = ap.parse_args()
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
os.makedirs(args.backup_dir, exist_ok=True)
dest = os.path.join(args.backup_dir, f"{args.label}-{stamp}.db")
src_info = describe(args.source)
sidecars = {
os.path.basename(args.source) + suf:
(os.path.getsize(args.source + suf) if os.path.exists(args.source + suf) else None)
for suf in ("", "-wal", "-shm")
}
print(f"source : {args.source}")
print(f" journal : {src_info['journal_mode']} migration_max={src_info['migration_max']}")
print(f" sidecars : {sidecars}")
print(f" integrity : {src_info['integrity_check']} fk_violations={src_info['foreign_key_check']}")
# Online backup API. Source opened READ-ONLY: production is never written to,
# and in WAL mode this does not block the live writer.
src = sqlite3.connect(f"file:{args.source}?mode=ro", uri=True)
dst = sqlite3.connect(dest)
with dst:
src.backup(dst)
dst.close()
src.close()
os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP) # read-only: a backup is not scratch space
dst_info = describe(dest)
digest = sha256(dest)
# The backup is only a backup if it independently verifies. A mismatch here
# means DO NOT PROCEED — it does not mean "retry and hope".
checks = {
"integrity_ok": dst_info["integrity_check"] == "ok",
"no_fk_violations": dst_info["foreign_key_check"] == 0,
"migration_matches": dst_info["migration_max"] == src_info["migration_max"],
"owned_matches": dst_info["owned_cards"] == src_info["owned_cards"],
"coins_match": dst_info["coins"] == src_info["coins"],
}
meta = {
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
"source": os.path.abspath(args.source),
"source_sidecar_sizes": sidecars,
"source_info": src_info,
"backup_path": os.path.abspath(dest),
"backup_sha256": digest,
"backup_size": os.path.getsize(dest),
"backup_info": dst_info,
"verification": checks,
"method": "sqlite3 online backup API (Connection.backup), source opened mode=ro",
}
meta_path = dest + ".json"
open(meta_path, "w").write(json.dumps(meta, indent=2, sort_keys=True) + "\n")
restore = os.path.join(args.backup_dir, f"RESTORE-{args.label}-{stamp}.sh")
open(restore, "w").write(f"""#!/bin/sh
# Canonical restore for {os.path.abspath(args.source)}
# Generated {meta['taken_at']} from backup {os.path.basename(dest)}
#
# STOP EVERY WRITER FIRST. Restoring under a live Core corrupts both.
set -eu
TARGET='{os.path.abspath(args.source)}'
BACKUP='{os.path.abspath(dest)}'
test "$(sha256sum "$BACKUP" | cut -d' ' -f1)" = '{digest}' \\
|| {{ echo 'FATAL: backup checksum mismatch, refusing to restore'; exit 1; }}
# The stale -wal/-shm MUST go, or SQLite replays them over the restored file.
rm -f "$TARGET-wal" "$TARGET-shm"
cp "$BACKUP" "$TARGET"
chmod u+w "$TARGET"
sqlite3 "$TARGET" 'PRAGMA integrity_check;' 'PRAGMA foreign_key_check;' \\
'SELECT MAX(version) FROM _sqlx_migrations;'
echo 'restore complete -- now start the PREVIOUS Core and host binaries'
""")
os.chmod(restore, 0o755)
print(f"\nbackup : {dest}")
print(f" sha256 : {digest}")
print(f" size : {meta['backup_size']:,}")
print(f" integrity : {dst_info['integrity_check']} fk_violations={dst_info['foreign_key_check']}")
print(f" migration : {dst_info['migration_max']} owned={dst_info['owned_cards']} coins={dst_info['coins']:,}")
print(f"metadata : {meta_path}")
print(f"restore : {restore}")
print("\nverification:")
for k, v in checks.items():
print(f" [{'OK ' if v else 'FAIL'}] {k}")
ok = all(checks.values())
print("\nRESULT:", "BACKUP VERIFIED" if ok else "BACKUP FAILED VERIFICATION -- DO NOT PROCEED")
raise SystemExit(0 if ok else 1)
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""READ-ONLY state capture of an OpenFUT Core SQLite DB, for promotion gating.
Opened `mode=ro` and never written to, so it is safe against live production.
Emits a deterministic JSON document: run it before a promotion and again after,
then `diff` the two. Every table is counted, so a delta cannot hide in a table
nobody thought to list.
Usage:
fifa17-promotion-snapshot.py <db-path> [out.json]
"""
import hashlib
import json
import sqlite3
import sys
def snapshot(db_path):
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
one = lambda s: con.execute(s).fetchone()[0]
snap = {
"db_path": db_path,
"journal_mode": one("PRAGMA journal_mode"),
"page_size": one("PRAGMA page_size"),
"page_count": one("PRAGMA page_count"),
"sqlite_version": sqlite3.sqlite_version,
}
# Schema version + the full applied-migration ledger. A promotion that
# claims "0028 applied" must show it here, with success=1.
snap["migration_max"] = one("SELECT MAX(version) FROM _sqlx_migrations")
snap["migration_count"] = one("SELECT COUNT(*) FROM _sqlx_migrations")
snap["migrations_failed"] = one(
"SELECT COUNT(*) FROM _sqlx_migrations WHERE success <> 1")
# Every table, counted. Deliberately not a hand-picked list.
tables = [r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
snap["table_counts"] = {t: one(f'SELECT COUNT(*) FROM "{t}"') for t in tables}
# The economically load-bearing figures, called out so a diff is readable.
snap["coins"] = dict(con.execute("SELECT id, coins FROM clubs ORDER BY id").fetchall())
snap["owned_total"] = one("SELECT COUNT(*) FROM owned_cards")
snap["owned_by_kind"] = dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards "
"GROUP BY 1 ORDER BY 1").fetchall())
snap["squad_players"] = one("SELECT COUNT(*) FROM squad_players")
snap["market_listings"] = one("SELECT COUNT(*) FROM market_listings")
snap["market_listings_sold"] = one("SELECT COUNT(*) FROM market_listings WHERE sold = 1")
snap["market_listings_npc"] = one("SELECT COUNT(*) FROM market_listings WHERE is_npc = 1")
snap["game_entity_ext"] = one("SELECT COUNT(*) FROM game_entity_ext")
snap["consumable_applications"] = one("SELECT COUNT(*) FROM consumable_applications")
snap["packs_total"] = one("SELECT COUNT(*) FROM packs")
snap["packs_unopened"] = one("SELECT COUNT(*) FROM packs WHERE opened = 0")
snap["match_completions"] = one("SELECT COUNT(*) FROM match_completions")
# Present only after 0028. Absent => pre-0028 DB, which is itself the signal.
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["owned_cards_columns"] = cols
snap["has_0028"] = "contract_matches" in cols
if snap["has_0028"]:
snap["contract_matches_set"] = one(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL")
snap["contract_matches_sum"] = one(
"SELECT COALESCE(SUM(contract_matches), 0) FROM owned_cards")
# A content fingerprint over ownership: catches a row silently rewritten
# even when every count stays identical.
h = hashlib.sha256()
for r in con.execute(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, "
"chemistry_style, position_override, training_bonus, content_kind, "
"quantity FROM owned_cards ORDER BY id"):
h.update(("|".join("" if v is None else str(v) for v in r)).encode())
snap["owned_cards_fingerprint_pre0028_columns"] = h.hexdigest()
snap["integrity_check"] = one("PRAGMA integrity_check")
snap["foreign_key_check"] = len(con.execute("PRAGMA foreign_key_check").fetchall())
con.close()
return snap
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
raise SystemExit(2)
snap = snapshot(sys.argv[1])
text = json.dumps(snap, indent=2, sort_keys=True)
print(text)
if len(sys.argv) > 2:
open(sys.argv[2], "w").write(text + "\n")
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Report host-level packet interception affecting OpenFUT endpoints.
WHY THIS EXISTS
---------------
During the 2026-08-22 Rust production cutover, four stale `openfut-switch` nft
rules were still redirecting production-facing traffic to staging:
42127 -> :42227 8081 -> :8281 8094 -> :18094 8099 -> :18106
They matched `ip daddr 10.10.0.120`, so every server-side probe via 127.0.0.1 or
the container IP passed while the CLIENT was refused or silently sent to
staging. That cost an entire false-negative acceptance round: a retail
quick-sell landed on staging while production sat untouched, and the launcher
reported "OpenFUT server is not answering".
The lesson is mechanical, so the check is too: a connectivity gate that only
probes loopback proves nothing about what the client reaches.
READ-ONLY. This tool never deletes a rule. Removing interception is a
deliberate operator act (`openfut-switch.sh off --name <id>`).
Exit status: 0 CLEAN, 1 INTERCEPTION_PRESENT, 2 could not determine.
python3 scripts/openfut-interception-preflight.py
python3 scripts/openfut-interception-preflight.py --advertise 10.10.0.120
"""
from __future__ import annotations
import argparse
import re
import shutil
import socket
import subprocess
import sys
# The endpoints a FIFA 17 client actually dials, plus the staging twins that
# stale rules historically pointed at.
PRODUCTION_PORTS = {
8099: "UTAS (Rust utas-host)",
8081: "roster",
8094: "POW api",
8085: "POW content",
4216: "LSX (client-side)",
42127: "Blaze redirector",
42130: "Blaze main",
42131: "Nucleus",
}
# LSX runs on the GAME machine, not here: the compose file publishes 4216 but
# `OPENFUT_SERVERS` excludes lsx by default, so "published but not served" is
# its normal state and must not be reported as interception. Every other port
# above is expected to be served on this host.
NOT_SERVED_HERE = {4216}
STAGING_PORTS = {8299: "staging UTAS", 42327: "staging redirector",
42330: "staging blaze main", 8281: "staging roster",
18094: "staging POW", 18106: "retired season-shim"}
ALL_PORTS = dict(PRODUCTION_PORTS)
ALL_PORTS.update(STAGING_PORTS)
def _run(cmd: list[str]) -> str:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
return r.stdout
except Exception:
return ""
def switch_status() -> tuple[str, list[str]]:
"""`openfut-switch.sh status`, which owns the redirect lifecycle."""
for path in ("/home/alex/OpenFUT/openfut-blaze-host/openfut-switch.sh",
"openfut-blaze-host/openfut-switch.sh"):
if shutil.which("bash") and subprocess.run(
["test", "-x", path], capture_output=True).returncode == 0:
out = _run([path, "status"])
active = [l.strip() for l in out.splitlines()
if "->" in l and "openfut-switch" in l]
return ("INACTIVE" if "INACTIVE" in out else
("ACTIVE" if active else "UNKNOWN")), active
return "UNKNOWN", []
def nft_redirects(advertise: str) -> tuple[list[str], list[str]]:
"""Split nft rules touching our ports into REDIRECTs (interception) and
Docker's own DNAT (legitimate publishing)."""
out = _run(["sudo", "-n", "nft", "list", "ruleset"])
if not out:
out = _run(["nft", "list", "ruleset"])
redirects, dnats = [], []
port_re = re.compile(r"dport (\d+)")
for line in out.splitlines():
s = line.strip()
m = port_re.search(s)
if not m or int(m.group(1)) not in ALL_PORTS:
continue
if "redirect to" in s:
redirects.append(s)
elif "dnat to" in s:
dnats.append(s)
return redirects, dnats
def reachable(host: str, port: int, timeout: float = 2.0) -> bool:
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--advertise", default="10.10.0.120",
help="the address the CLIENT dials (not loopback)")
args = ap.parse_args()
adv = args.advertise
print("OpenFUT interception preflight — advertise=%s" % adv)
print()
state, active = switch_status()
print("openfut-switch : %s" % state)
for a in active:
print(" %s" % a)
redirects, dnats = nft_redirects(adv)
print("\nnft REDIRECTs on OpenFUT ports : %d%s"
% (len(redirects), " <-- INTERCEPTION" if redirects else ""))
for r in redirects:
print(" %s" % r[:150])
print("nft DNAT (docker publishing) : %d (expected, not interception)"
% len(dnats))
# The point of the whole tool: compare loopback with the address the client
# actually dials. A redirect keyed on the LAN IP is invisible to loopback.
print("\neffective endpoint, loopback vs advertised:")
disagree = []
for port, label in sorted(PRODUCTION_PORTS.items()):
lo = reachable("127.0.0.1", port)
wan = reachable(adv, port)
if lo == wan:
flag = ""
elif port in NOT_SERVED_HERE:
flag = " (not served here -- expected)"
else:
flag = " <-- DISAGREE"
if lo != wan and port not in NOT_SERVED_HERE:
disagree.append((port, label, lo, wan))
print(" :%-6d %-24s loopback=%-5s advertised=%-5s%s"
% (port, label, lo, wan, flag))
intercepted = bool(redirects) or state == "ACTIVE" or bool(disagree)
print()
if intercepted:
print("RESULT: INTERCEPTION_PRESENT")
if disagree:
print(" loopback and the advertised address disagree on: %s"
% ", ".join(":%d" % p for p, _, _, _ in disagree))
print(" Nothing was changed. To clear a switch rule, run explicitly:")
print(" openfut-blaze-host/openfut-switch.sh off --name <id>")
return 1
print("RESULT: CLEAN")
return 0
if __name__ == "__main__":
sys.exit(main())
+35 -16
View File
@@ -182,9 +182,15 @@ DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
# Two authoritative modern kit-card definitions for one real source team. These
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
KIT_TEAM_ID = 21
# The trailing value is the client's own `fcc_kitcards.assetid`, the wire
# `assetId` (record +0x20). It is the ART CLASS, not the carddbid: every
# 63xxxxx (home/third) kit carries 14 and every 64xxxxx (away) kit carries 15,
# per club_items.json and fifa17-kit-map.json's band_x_assetid evidence
# (6300000/14 x828, 6400000/15 x654). Shipping the carddbid here left both
# pre-match kit tiles rendering identically.
STAGING_KITS = [
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006, 14),
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003, 15),
]
# The remaining club-item families, so the rig exercises EVERY ownable class
@@ -198,11 +204,15 @@ STAGING_KITS = [
# badge 39, logo 40), which is what the importer gates on. A league logo has no
# equipped slot, so it is owned as generic `misc` content.
STAGING_CLUB_ITEMS = [
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id)
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21),
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None),
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None),
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None),
# (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id,
# club_asset_id)
# club_asset_id is the wire `assetId` from club_items.json, family specific
# and never the carddbid: badge 6000005 -> its teamid 21, ball 8120194 -> 100,
# stadium 6200000 -> 1. A league logo has no equipped slot and keeps its own.
("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21, 21),
("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None, 100),
("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None, 1),
(None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None, None),
]
# The club manager. FIFA refuses to start a match without one ("your player or
@@ -524,7 +534,7 @@ def materialise(lay: Layout) -> None:
with open(safe_path(lay.catalog)) as fh:
catalog = json.load(fh)
existing = {definition["id"] for definition in definitions}
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
for _slot, _owned_id, card_id, resource_id, club_asset_id in STAGING_KITS:
if card_id not in existing:
definitions.append({
"id": card_id,
@@ -550,10 +560,11 @@ def materialise(lay: Layout) -> None:
"kind": "kit",
"subtype": 9,
"card_asset_id": 35,
"club_asset_id": club_asset_id,
"team_id": KIT_TEAM_ID,
}
for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS:
for _slot, _owned, card_id, resource_id, kind, subtype, art, team, club_asset in STAGING_CLUB_ITEMS:
if card_id not in existing:
definitions.append({
"id": card_id,
@@ -580,6 +591,8 @@ def materialise(lay: Layout) -> None:
"subtype": subtype,
"card_asset_id": art,
}
if club_asset is not None:
entry["club_asset_id"] = club_asset
if team is not None:
entry["team_id"] = team
catalog["cards"][card_id] = entry
@@ -589,7 +602,14 @@ def materialise(lay: Layout) -> None:
definitions.append({
"id": mgr["card_id"],
"name": mgr["label"],
# `overall` STAYS 0. It feeds pricing and squad projection, and a
# staff card is not a player; the authoritative number lives in
# `source_rating` below, which is what the contract-tier rules read.
"overall": 0,
# managercards.value, the same number the catalog carries as
# `rating`. Core owns it so the manager-contract tier is resolved
# from Core-owned state rather than from adapter projection.
"source_rating": mgr["rating"],
"position": "",
"nation": "",
"league": "",
@@ -694,7 +714,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
wanted = set(
[card for _, card in SELLER_SQUAD_CARDS]
+ [DISPOSABLE_CARD]
+ [card for _, _, card, _ in STAGING_KITS]
+ [card for _, _, card, _, _ in STAGING_KITS]
+ [STAGING_MANAGER["card_id"]]
)
what = f"all {len(wanted)} fixture seed card ids"
@@ -705,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
finally:
conn.close()
wanted |= {card for _, _, card, _ in STAGING_KITS}
wanted |= {card for _, _, card, _, _ in STAGING_KITS}
wanted.add(STAGING_MANAGER["card_id"])
what = (f"all {len(wanted)} distinct card ids owned by the real club "
"(plus the kit and manager fixtures)")
@@ -976,7 +996,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
# calling a kit a player, and Core is the ownership authority.
owned = [
(owned_id, seller_club, card_id, "kit", TS)
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
for _slot, owned_id, card_id, _resource_id, _ca in STAGING_KITS
]
owned.append(
(
@@ -989,7 +1009,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
)
owned.extend(
(owned_id, seller_club, card_id, kind, TS)
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team
for _slot, owned_id, card_id, _rid, kind, _st, _art, _team, _ca
in STAGING_CLUB_ITEMS
)
if real_club is None:
@@ -1003,7 +1023,6 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
owned,
)
if real_club is None:
conn.execute(
"INSERT INTO squads (id, club_id, name, formation, created_at, "
@@ -1027,14 +1046,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
"VALUES (?, ?, ?, ?)",
[
(seller_club, f"{slot}_kit", owned_id, TS)
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
for slot, owned_id, _card_id, _resource_id, _ca in STAGING_KITS
]
# badge / ball / stadium are slot-keyed exactly like the kits.
# A league logo has no slot, so it stays owned-but-unequipped —
# which is itself worth exercising.
+ [
(seller_club, slot, owned_id, TS)
for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS
for slot, owned_id, _c, _r, _k, _s, _a, _t, _ca in STAGING_CLUB_ITEMS
if slot is not None
],
)
+92
View File
@@ -0,0 +1,92 @@
# OpenFUT systemd units
Supervision for OpenFUT Core and the FIFA17 UTAS host. Replaces the previous
`setsid nohup … nsenter …` launch, which had no restart policy, no boot
persistence and no supervisor-visible logs.
| file | scope | installed? |
|---|---|---|
| `openfut-staging-netns.service` | binds the staging anchor netns | **yes** — proving ground |
| `openfut-staging-core.service` | staging Core, port 18081 | **yes** |
| `openfut-staging-host.service` | staging host, port 8299 | **yes** |
| `openfut-staging-netns-reconcile.{service,timer}` | staging netns lifecycle | **yes** |
| `openfut-netns.service` | binds the production anchor netns to `/run/netns/openfut` | template only |
| `openfut-core.service` | production Core, port 18080 | template only |
| `openfut-host.service` | production host, port 8099 | template only |
| `openfut-netns-reconcile.{service,timer}` | production netns lifecycle | template only |
| `openfut-netns-bind.sh` | resolves the anchor netns by NAME, idempotently, drains stale mounts | helper |
| `openfut-netns-reconcile.sh` | keeps services in the anchor's CURRENT netns | helper |
| `openfut-wait-tcp.sh` | bounded readiness gate | helper |
| `openfut-supervision-install.sh` | install / start / status / uninstall per environment | helper |
Production templates are **not installed**. Deploy only via the plan in
`OpenFUT-Vault/06 Operations/OpenFUT Service Supervision (staging-proven).md`.
## The three decisions worth knowing
**1. `Wants=`, not `Requires=`, from host → Core.** Measured on staging:
`Requires` propagates a Core stop into a host stop, and a later Core start does
*not* bring the host back — a routine Core restart would leave the client with
no server. With `Wants`, the host survives a Core outage, answers
`503 core_unavailable` (never a Python fallback), and resumes the moment Core
returns, with no supervisor intervention.
**2. Readiness is an `ExecStartPre` TCP gate, not ordering.** `After=`/`Wants=`
order units; `Type=exec` only proves the binary exec'd. Neither means Core can
serve. Core binds its listener *after* opening the DB, running migrations and
loading the content pack, so "port open" is a genuine readiness signal. The gate
is bounded and *fails* rather than blocking: a host that waits forever looks
healthy to the supervisor while serving nobody.
**3. The netns is resolved by container NAME at every start.** Production must
run inside `openfut-fut-backend`'s network namespace. The container is
`restart=unless-stopped`, and its netns inode *changes* on restart — observed
`net:[4026539938] → net:[4026540033]`. A hardcoded pid is therefore wrong by
construction, and any process left in the old namespace keeps running with no
interfaces, silently serving nobody. `openfut-netns-bind.sh` re-resolves by name
and refreshes a stale bind mount; `NetworkNamespacePath=` then enters it
declaratively.
**4. Stale namespaces are repaired automatically (this closes the old gap).**
Recreating the anchor strands already-running services in the dead namespace,
and — the dangerous part — *systemd still reports them `active`*. Measured before
the fix: anchor `net:[4026539938] → net:[4026540033]`, Core and host unchanged in
the old one, both units `active`, traffic `ConnectionResetError`.
`openfut-netns-reconcile.sh` on a 10s timer compares the namespace the services
are **actually in** against the anchor's **current** one and, only on a real
difference, performs one stop → rebind → start cycle. It is level-triggered, so
it cannot miss an event and needs no debounce: a burst of three back-to-back
recreations produced exactly **one** rebind.
## Operating
```bash
sudo ./openfut-supervision-install.sh status staging # units, anchor, netns agreement, mounts
journalctl -u openfut-staging-netns-reconcile -f # silent unless it acts
sudo systemctl restart openfut-staging-core # host survives and recovers
sudo ./openfut-supervision-install.sh install staging # copy + enable (does not start)
sudo ./openfut-supervision-install.sh uninstall staging # disable, stop, remove units
```
`status` is the one command worth knowing: it prints each unit's state, the
anchor's current netns, the namespace each service is *actually* in with an
`ok`/`MISMATCH` verdict, and the mount count (`1` healthy, `>1` a leaked stack).
Config lives in `EnvironmentFile`s (`…/systemd/core.env`, `host.env`), generated
from the live process environment so supervision changed *how* the processes
start and nothing about *what* they do. Binaries are immutable copies, so a
later `cargo build` cannot change what is running.
## Interaction with the staging lifecycle script
`scripts/sold-staging-up.py` still starts its own unsupervised processes and
refuses to run while a staging stack is up. Stop the units first:
```bash
sudo systemctl stop openfut-staging-host openfut-staging-core
python3 scripts/sold-staging-up.py --club real --variant highest …
```
Reconciling the two (having the script drive the units) is deliberately out of
scope for the supervision milestone.
@@ -0,0 +1,20 @@
[Unit]
Description=OpenFUT: record unattended proof that supervision recovered after boot
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=openfut-host.service openfut-netns-reconcile.timer docker.service
Wants=openfut-host.service
# Observation only. Deliberately no Requires= and no ordering that anything else
# waits on: this unit must never be able to affect the boot it is measuring.
[Service]
Type=oneshot
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-boot-evidence.sh
TimeoutStartSec=300
# Never fail the boot over evidence collection.
SuccessExitStatus=0 1
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-boot-evidence
[Install]
WantedBy=multi-user.target
+132
View File
@@ -0,0 +1,132 @@
#!/bin/sh
# Capture unattended proof that the OpenFUT supervision chain came back by
# itself after a boot.
#
# This exists because the reboot-survival gate is, by definition, a test no
# operator can stand inside: the machine under test is the machine running the
# session. So the machine records its own recovery.
#
# It observes only — it never starts, stops or repairs anything. If the chain is
# broken, the evidence file says so, which is the point.
#
# usage: openfut-boot-evidence.sh [outdir]
set -u
OUTDIR="${1:-/home/alex/openfut-promotion/boot-evidence}"
ANCHOR=openfut-fut-backend
NSNAME=openfut
DB=/home/alex/openfut-promotion/state/prod-core.db
DEADLINE=180 # seconds to allow for docker + anchor + reconcile to settle
mkdir -p "$OUTDIR"
BOOTID="$(cat /proc/sys/kernel/random/boot_id)"
OUT="$OUTDIR/boot-$(date -u +%Y%m%dT%H%M%SZ)-${BOOTID%%-*}.json"
# Wait until converged, or until the deadline. Converged means both services are
# active and share the anchor's CURRENT namespace. Polling rather than a fixed
# sleep so a boot-time reconcile retry is captured as "settled late", not as a
# failure, and so a healthy boot is recorded promptly.
i=0
while [ "$i" -lt "$DEADLINE" ]; do
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
if [ -n "$cpid" ] && [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
want="$(readlink "/proc/$cpid/ns/net")"
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
if [ -n "$cm" ] && [ "$cm" != "0" ] && [ -e "/proc/$cm/ns/net" ] &&
[ -n "$hm" ] && [ "$hm" != "0" ] && [ -e "/proc/$hm/ns/net" ] &&
[ "$(readlink "/proc/$cm/ns/net")" = "$want" ] &&
[ "$(readlink "/proc/$hm/ns/net")" = "$want" ]; then
break
fi
fi
i=$((i + 2))
sleep 2
done
SETTLED_AFTER="$i"
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
cid="$(docker inspect -f '{{.Id}}' "$ANCHOR" 2>/dev/null || echo none)"
ans="$([ "$cpid" != "0" ] && readlink "/proc/$cpid/ns/net" 2>/dev/null || echo none)"
cm="$(systemctl show -p MainPID --value openfut-core.service 2>/dev/null)"
hm="$(systemctl show -p MainPID --value openfut-host.service 2>/dev/null)"
cns="$([ -n "$cm" ] && [ "$cm" != "0" ] && readlink "/proc/$cm/ns/net" 2>/dev/null || echo none)"
hns="$([ -n "$hm" ] && [ "$hm" != "0" ] && readlink "/proc/$hm/ns/net" 2>/dev/null || echo none)"
mounts="$(awk -v t="/run/netns/$NSNAME" '$2==t {n++} END {print n+0}' /proc/mounts)"
# Non-mutating reads, from inside the anchor namespace for Core (it is not
# published to the host namespace) and on the published port for the host.
# One argument only: a second positional would be unbound under `set -u` and
# would abort the subshell, silently yielding an empty probe result.
probe() { nsenter --net="/proc/$cpid/ns/net" python3 - "$1" <<'PY' 2>/dev/null || echo "ERR"
import sys, urllib.request, urllib.error
try:
r = urllib.request.urlopen(urllib.request.Request(sys.argv[1], headers={"X-OpenFUT-Game": "fifa17", "X-UT-SID": "boot-evidence"}), timeout=8)
print(r.status)
except urllib.error.HTTPError as e:
print(e.code)
except Exception:
print("ERR")
PY
}
if [ "$cpid" != "0" ]; then
core_health="$(probe http://127.0.0.1:18080/health)"
core_coll="$(probe 'http://127.0.0.1:18080/collection?limit=1')"
host_acct="$(probe http://127.0.0.1:8099/ut/game/fifa17/user/accountinfo)"
host_club="$(probe 'http://127.0.0.1:8099/ut/game/fifa17/club?count=1&start=0')"
else
core_health=none; core_coll=none; host_acct=none; host_club=none
fi
econ="$(python3 - "$DB" <<'PY' 2>/dev/null || echo '{}'
import sys, sqlite3, json
c = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
q = lambda s: c.execute(s).fetchone()[0]
print(json.dumps({
"schema": q("SELECT MAX(version) FROM _sqlx_migrations"),
"coins": q("SELECT coins FROM clubs"),
"owned": q("SELECT COUNT(*) FROM owned_cards"),
"by_content_kind": dict(c.execute("SELECT COALESCE(content_kind,'(null)'),COUNT(*) FROM owned_cards GROUP BY 1 ORDER BY 1").fetchall()),
"applications": q("SELECT COUNT(*) FROM consumable_applications"),
"contract_sum": q("SELECT COALESCE(SUM(contract_matches),0) FROM owned_cards"),
"squad_players": q("SELECT COUNT(*) FROM squad_players"),
"market_listings": q("SELECT COUNT(*) FROM market_listings"),
"game_entity_ext": q("SELECT COUNT(*) FROM game_entity_ext"),
"integrity": q("PRAGMA integrity_check"),
"fk": len(c.execute("PRAGMA foreign_key_check").fetchall()),
}))
PY
)"
owner_rust="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=RUST')"
owner_py="$(journalctl -u openfut-host -b --no-pager -o cat 2>/dev/null | grep -c 'owner=PYTHON')"
cat > "$OUT" <<EOF
{
"boot_id": "$BOOTID",
"captured_utc": "$(date -u +%FT%TZ)",
"settled_after_seconds": $SETTLED_AFTER,
"uptime_at_capture": "$(cut -d' ' -f1 /proc/uptime)",
"anchor": {"container": "${cid%%[!0-9a-f]*}", "pid": "$cpid", "netns": "$ans"},
"core": {"pid": "$cm", "netns": "$cns", "active": "$(systemctl is-active openfut-core.service)", "enabled": "$(systemctl is-enabled openfut-core.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-core.service)"},
"host": {"pid": "$hm", "netns": "$hns", "active": "$(systemctl is-active openfut-host.service)", "enabled": "$(systemctl is-enabled openfut-host.service)", "nrestarts": "$(systemctl show -p NRestarts --value openfut-host.service)"},
"netns_unit": {"active": "$(systemctl is-active openfut-netns.service)", "enabled": "$(systemctl is-enabled openfut-netns.service)", "result": "$(systemctl show -p Result --value openfut-netns.service)"},
"reconcile": {"timer_active": "$(systemctl is-active openfut-netns-reconcile.timer)", "timer_enabled": "$(systemctl is-enabled openfut-netns-reconcile.timer)", "last_result": "$(systemctl show -p Result --value openfut-netns-reconcile.service)", "acted_this_boot": $(journalctl -u openfut-netns-reconcile -b --no-pager -o cat 2>/dev/null | grep -c 'rebind cycle')},
"netns_agreement": $([ "$ans" = "$cns" ] && [ "$ans" = "$hns" ] && [ "$ans" != "none" ] && echo true || echo false),
"nsfs_mounts": $mounts,
"probes": {"core_health": "$core_health", "core_collection": "$core_coll", "host_accountinfo": "$host_acct", "host_club": "$host_club"},
"authority": {"owner_rust": $owner_rust, "owner_python": $owner_py},
"economy": $econ
}
EOF
chmod 0644 "$OUT"
ln -sfn "$OUT" "$OUTDIR/latest.json"
echo "openfut-boot-evidence: wrote $OUT (settled after ${SETTLED_AFTER}s)"
# Also drop the ordering proof for this boot, so ordering is read from real
# timestamps rather than inferred from unit dependencies.
journalctl -b -u openfut-netns -u openfut-core -u openfut-host -u openfut-netns-reconcile \
-o short-precise --no-pager > "$OUTDIR/latest-journal.txt" 2>/dev/null
exit 0
+51
View File
@@ -0,0 +1,51 @@
[Unit]
Description=OpenFUT Core (PRODUCTION) — authoritative economy state
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# PRODUCTION TEMPLATE — NOT INSTALLED. Deploy only via the promotion plan in
# `06 Operations/OpenFUT Service Supervision (staging-proven).md`.
#
# Core is the SINGLE WRITER of prod-core.db (verified by lsof: exactly one
# process holds it open). Nothing here may be templated into a second instance.
After=network-online.target docker.service openfut-netns.service
Wants=network-online.target
Requires=openfut-netns.service
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
# [Service] (`systemd-analyze verify` flags it), which would have left the
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
# root, matching the current production processes exactly. Supervision changes
# HOW the process is started, never what it is or what it can reach.
User=root
# The container's netns, published by openfut-netns.service. This replaces the
# hand-typed `nsenter --net=/proc/<pid>/ns/net` in the runbook: same namespace,
# no hardcoded pid, and re-resolved on every start.
NetworkNamespacePath=/run/netns/openfut
EnvironmentFile=/etc/openfut/core.env
# An IMMUTABLE promotion artifact, not target/release. A later `cargo build`
# must not be able to change what production is running — the same invariant
# the promotion process already relies on.
ExecStart=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts/openfut-core
KillSignal=SIGTERM
KillMode=mixed
# Generous, so a WAL checkpoint is never SIGKILLed mid-write. Observed shutdown
# is sub-second.
TimeoutStopSec=30
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-core
[Install]
WantedBy=multi-user.target
+55
View File
@@ -0,0 +1,55 @@
[Unit]
Description=OpenFUT FIFA17 UTAS host (PRODUCTION) — client-facing, Core-dependent
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# PRODUCTION TEMPLATE — NOT INSTALLED.
Requires=openfut-netns.service
After=openfut-netns.service
Wants=openfut-core.service
After=openfut-core.service
# `Wants` on Core, deliberately NOT `Requires`/`BindsTo`/`PartOf` — measured on
# staging: those propagate a Core stop into a host stop, and a later Core start
# does NOT bring the host back, so a routine Core restart would leave the client
# with no server at all. With `Wants` the host survives a Core outage, answers
# 503 `core_unavailable` (never a Python fallback), and resumes serving the
# moment Core returns with no supervisor intervention. Both halves were proven
# on the staging units.
#
# `Requires` on the netns unit IS correct: without the namespace the host would
# bind the wrong network entirely.
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
# [Service] (`systemd-analyze verify` flags it), which would have left the
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=root
NetworkNamespacePath=/run/netns/openfut
EnvironmentFile=/etc/openfut/host.env
# Admission gate. Ordering alone proves nothing about readiness (Type=exec only
# proves the binary exec'd). Core binds its port only after migrations and the
# content pack have loaded, so "port open" is a real readiness signal here.
# Bounded and FAILING rather than blocking: a host that waits forever looks
# healthy to the supervisor while serving nobody.
ExecStartPre=/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18080 30
ExecStart=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts/openfut-utas-host
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=30
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-host
[Install]
WantedBy=multi-user.target
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# Publish a Docker container's network namespace into /run/netns so systemd
# units can enter it declaratively with NetworkNamespacePath=.
#
# WHY THIS EXISTS. Production Core and host must run inside the
# `openfut-fut-backend` container's netns: that is where the published client
# ports live and where the Python oracle answers on 127.0.0.1:8199. Today they
# get there with `nsenter --net=/proc/<pid>/ns/net`, where <pid> is typed by
# hand into a runbook.
#
# THE HAZARD THIS FIXES, MEASURED NOT ASSUMED. The container runs with
# `restart=unless-stopped`. On a container restart its netns inode CHANGES
# (observed 2026-08-22: net:[4026539938] -> net:[4026540033]). A hardcoded pid
# is then simply wrong, and — worse — any process already inside the old
# namespace keeps running in a namespace with no interfaces, silently serving
# nobody. Resolving by container NAME at every start removes the hardcoded pid;
# the companion watcher unit handles the already-running case by restarting the
# stack when the container restarts.
#
# Idempotent: a stale bind mount is unmounted and re-made, so re-running after a
# container restart is the fix, not a second problem.
#
# usage: openfut-netns-bind.sh <container-name> <netns-name>
set -eu
CONTAINER="${1:?container name}"
NSNAME="${2:?netns name}"
TARGET="/run/netns/${NSNAME}"
CPID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || true)"
if [ -z "$CPID" ] || [ "$CPID" = "0" ]; then
echo "openfut-netns-bind: container '$CONTAINER' is not running (pid='$CPID')" >&2
exit 1
fi
if [ ! -e "/proc/$CPID/ns/net" ]; then
echo "openfut-netns-bind: /proc/$CPID/ns/net does not exist" >&2
exit 1
fi
WANT="$(readlink "/proc/$CPID/ns/net")"
mkdir -p /run/netns
# Already published and already CURRENT? Then do nothing — re-mounting under a
# live service would be gratuitous churn.
if mountpoint -q "$TARGET" 2>/dev/null; then
HAVE="ns:[$(stat -c %i "$TARGET" 2>/dev/null || echo 0)]"
if [ "net:[$(stat -c %i "$TARGET" 2>/dev/null)]" = "$WANT" ]; then
echo "openfut-netns-bind: $TARGET already current ($WANT)"
exit 0
fi
echo "openfut-netns-bind: $TARGET is STALE ($HAVE, want $WANT) — refreshing"
# DRAIN, do not just pop. `mount --bind` STACKS: binding over a busy mount
# silently leaves the old one underneath, and staging grew two nsfs entries
# on the first rebind before this loop existed. Left alone that is one
# leaked mount per container recreation, and the buried namespaces are
# exactly the dead ones we are trying to get rid of.
#
# A umount can legitimately fail while a service still holds the old
# namespace open; the caller's job is to stop dependants FIRST. If it is
# still busy we stack rather than fail — a current top-of-stack mount is
# correct, just untidy — and say so.
while mountpoint -q "$TARGET" 2>/dev/null; do
umount "$TARGET" 2>/dev/null || {
echo "openfut-netns-bind: $TARGET still busy; stacking a current mount over it" >&2
break
}
done
fi
[ -e "$TARGET" ] || touch "$TARGET"
mount --bind "/proc/$CPID/ns/net" "$TARGET"
echo "openfut-netns-bind: $TARGET -> $CONTAINER pid $CPID $WANT"
@@ -0,0 +1,23 @@
[Unit]
Description=OpenFUT: reconcile services with the anchor container network namespace
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Wants=docker.service
# PRODUCTION TEMPLATE — NOT INSTALLED.
#
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
# retry, not a failed unit. The script decides that itself and exits 0.
[Service]
Type=oneshot
# Level-triggered. It compares the namespace Core and the host are ACTUALLY in
# against the anchor's CURRENT one, so a burst of container events collapses
# into at most one rebind per tick and no separate debounce is needed.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
openfut-fut-backend openfut \
openfut-netns.service \
openfut-core.service openfut-host.service
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-netns-reconcile
+147
View File
@@ -0,0 +1,147 @@
#!/bin/sh
# Keep supervised native services inside the CURRENT network namespace of a
# Docker anchor container, and stop them when that anchor is gone.
#
# ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
# OpenFUT's Core and host are native binaries that must run inside the anchor
# container's netns. Recreating (or merely restarting) that container gives it a
# NEW netns; the already-running services stay in the old one. Measured on
# staging 2026-08-22:
#
# anchor 37288a0fe816 net:[4026539938] -> 55541a03b66e net:[4026540033]
# core pid 2081773 net:[4026539938] (unchanged, now orphaned)
# host pid 2081791 net:[4026539938] (unchanged, now orphaned)
# systemd: both "active" traffic: ConnectionResetError
#
# Both units report HEALTHY while serving nobody. That is the whole problem:
# the failure is invisible to the supervisor.
#
# ── WHY A RECONCILE AND NOT AN EVENT WATCHER ───────────────────────────────
# There is no systemd-native edge signal to bind to. Containers do appear as
# `docker-<id>.scope` units (cgroup driver is systemd), but the scope NAME
# embeds the container ID, and the ID changes on recreate — so there is no
# stable unit for BindsTo=. `NetworkNamespacePath` is resolved at unit start
# only, and a .path unit on /run/netns/<name> would watch the very file this
# script maintains (circular).
#
# So the trigger is a timer, and the check is LEVEL-triggered: it compares the
# namespace the services are ACTUALLY in against the anchor's CURRENT one. That
# is strictly more robust than an edge-triggered watcher, which can miss events
# while it is itself restarting or while dockerd is down, and it needs no
# debounce logic — a burst of container events collapses into at most one
# reconcile per tick, because the only question asked is "does the observed
# state differ from the desired state right now?".
#
# The trigger is deliberately separable from the action. If detection latency
# ever matters, a `docker events` unit can invoke THIS SAME script; nothing here
# would change.
#
# ── INVARIANTS ─────────────────────────────────────────────────────────────
# * A container PID is runtime state and is NEVER persisted. It is resolved from
# Docker on every run.
# * Identity is the netns inode of /proc/<current-anchor-pid>/ns/net, never the
# container name (same name != same namespace) and never a cached value.
# * systemd stays the service authority: this script only requests start/stop/
# restart, and unit ordering does the sequencing.
# * Silence when correct. It logs only when it acts or fails, so a 10s timer
# does not fill the journal.
#
# usage: openfut-netns-reconcile.sh <container> <nsname> <netns-unit> <unit>...
set -eu
CONTAINER="${1:?container name}"; shift
NSNAME="${1:?netns name}"; shift
NSUNIT="${1:?netns unit}"; shift
[ "$#" -ge 1 ] || { echo "reconcile: at least one dependent unit required" >&2; exit 2; }
UNITS="$*"
HERE="$(dirname "$0")"
log() { echo "openfut-netns-reconcile: $*"; }
# ---- 1. Is Docker even answering? -----------------------------------------
# A daemon outage must be a clean, quiet failure that the timer retries, never a
# spin and never a destructive action taken on incomplete information.
if ! docker info >/dev/null 2>&1; then
log "docker daemon unavailable — taking NO action, will retry on the next tick"
exit 0
fi
# ---- 2. Resolve the anchor, by name, right now ----------------------------
CPID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || true)"
if [ -z "$CPID" ] || [ "$CPID" = "0" ] || [ ! -e "/proc/$CPID/ns/net" ]; then
# Anchor gone. Services must NOT keep pretending to be healthy inside a
# namespace whose owner has died — and must never fall back to host
# networking. Stop them; a later tick starts them again once the anchor is
# back, which is what makes recovery automatic.
RUNNING=""
for u in $UNITS; do
[ "$(systemctl is-active "$u" 2>/dev/null)" = "active" ] && RUNNING="$RUNNING $u"
done
if [ -n "$RUNNING" ]; then
log "anchor '$CONTAINER' is ABSENT — stopping$RUNNING (no host-network fallback)"
# Reverse order: dependants before the thing they depend on.
# shellcheck disable=SC2086
systemctl stop $RUNNING || true
systemctl stop "$NSUNIT" || true
fi
exit 0
fi
WANT="$(readlink "/proc/$CPID/ns/net")"
# ---- 3. Compare against where the services ACTUALLY are -------------------
# Observed state, not a remembered value: this self-heals no matter how the
# drift happened (container recreate, restart, manual nsenter, anything).
NEED_ACTION=0
REASON=""
for u in $UNITS; do
state="$(systemctl is-active "$u" 2>/dev/null || true)"
if [ "$state" != "active" ]; then
NEED_ACTION=1; REASON="$REASON $u=$state"
continue
fi
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
if [ -z "$mp" ] || [ "$mp" = "0" ] || [ ! -e "/proc/$mp/ns/net" ]; then
NEED_ACTION=1; REASON="$REASON $u=nopid"
continue
fi
have="$(readlink "/proc/$mp/ns/net")"
if [ "$have" != "$WANT" ]; then
NEED_ACTION=1; REASON="$REASON $u=$have"
fi
done
if [ "$NEED_ACTION" = "0" ]; then
exit 0 # correct and silent
fi
# ---- 4. One controlled rebind cycle ---------------------------------------
log "anchor '$CONTAINER' pid=$CPID ns=$WANT; drift:$REASON"
log "namespace changed or services adrift — requesting one rebind cycle"
# STOP FIRST, then rebind, then start — not restart-around-a-rebind. While a
# service is still running it holds the OLD namespace open, the umount fails
# busy, and `mount --bind` silently STACKS a second nsfs entry over it. Measured:
# the first rebind left two mounts on the path. Stopping the dependants releases
# the old namespace so the drain actually succeeds.
#
# Reverse order on the way down (dependants before their dependency), forward on
# the way up — and the way up is systemd's job: `start` honours the units' own
# After=/Requires=, so Core is listening before the host's readiness gate runs.
REV=""
for u in $UNITS; do REV="$u $REV"; done
# shellcheck disable=SC2086
systemctl stop $REV || true
"$HERE/openfut-netns-bind.sh" "$CONTAINER" "$NSNAME"
systemctl restart "$NSUNIT"
# shellcheck disable=SC2086
systemctl start $UNITS
for u in $UNITS; do
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
now="$( [ "$mp" != "0" ] && readlink "/proc/$mp/ns/net" || echo '-')"
log "rebound $u pid=$mp ns=$now"
done
@@ -0,0 +1,14 @@
[Unit]
Description=OpenFUT: periodic anchor-namespace reconcile
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# PRODUCTION TEMPLATE — NOT INSTALLED.
[Timer]
# 10s. Anchor recreation is rare and already an outage; converging inside ~10s
# is ample. Measured cost per tick: one `docker inspect` plus two readlinks.
OnBootSec=15s
OnUnitInactiveSec=10s
AccuracySec=1s
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=OpenFUT: publish the FIFA17 container network namespace to /run/netns
Documentation=file:///home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh
# PRODUCTION TEMPLATE — NOT INSTALLED. Staging runs in the host netns and needs
# none of this; only production enters the `openfut-fut-backend` container's
# namespace.
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Re-resolves the container by NAME on every start, so no pid is ever hardcoded
# and a container restart is repaired by restarting this unit. It exits non-zero
# when the container is not running, which is what makes Core's Requires= on it
# a real admission gate rather than decoration.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh openfut-fut-backend openfut
# Deliberately NO ExecStop unmount. Tearing the bind mount down while Core and
# the host are still inside that namespace would strand them; the namespace is
# owned by the container's lifetime, not by this unit's.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-netns
[Install]
WantedBy=multi-user.target
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Roll the production supervision promotion back to the incumbent detached pair.
#
# This restores the process-management arrangement that ran production before
# the supervision cutover: two nohup'd binaries entered into the anchor
# container's network namespace via nsenter, parented to init.
#
# It performs NO database or schema change, because the promotion it reverses
# performs none either. The economy is never at risk here.
#
# Run with --dry-run first; that prints the exact commands and touches nothing.
#
# usage: openfut-rollback-detached.sh [--dry-run]
set -eu
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
ANCHOR=openfut-fut-backend
ART=/home/alex/openfut-migration/promote-contract-20260822-184409/artifacts
ENVJSON=/home/alex/openfut-migration/promote-contract-20260822-184409/env.json
LOGDIR=/home/alex/openfut-promotion/logs
run() {
if [ "$DRY" -eq 1 ]; then printf ' DRY %s\n' "$*"; else printf ' RUN %s\n' "$*"; sh -c "$*"; fi
}
echo "== 1. stop and remove supervision (units only; binaries, env and DB untouched) =="
run "systemctl disable --now openfut-host.service openfut-core.service openfut-netns.service openfut-netns-reconcile.timer || true"
run "/home/alex/OpenFUT/scripts/systemd/openfut-supervision-install.sh uninstall production"
echo "== 2. resolve the anchor's CURRENT namespace (never a remembered pid) =="
if [ "$DRY" -eq 1 ]; then
APID="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo '<anchor pid>')"
echo " DRY anchor $ANCHOR pid=$APID ns=$(readlink "/proc/$APID/ns/net" 2>/dev/null || echo '<ns>')"
else
APID="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR")"
[ -n "$APID" ] && [ "$APID" != "0" ] || { echo "anchor $ANCHOR is not running — cannot roll back into its namespace" >&2; exit 1; }
echo " anchor $ANCHOR pid=$APID ns=$(readlink "/proc/$APID/ns/net")"
fi
# The environment is replayed from the captured live process environment, never
# retyped. jq renders it as KEY=VALUE pairs for env(1).
CORE_ENV="$(jq -r '.core | to_entries[] | "\(.key)=\(.value)"' "$ENVJSON" | tr '\n' ' ')"
HOST_ENV="$(jq -r '.host | to_entries[] | "\(.key)=\(.value)"' "$ENVJSON" | tr '\n' ' ')"
echo "== 3. relaunch incumbent Core (the writer first) =="
run "mkdir -p $LOGDIR"
run "nsenter --net=/proc/$APID/ns/net env $CORE_ENV nohup $ART/openfut-core >> $LOGDIR/prod-core.log 2>&1 &"
run "/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18080 30"
echo "== 4. relaunch incumbent host =="
run "nsenter --net=/proc/$APID/ns/net env $HOST_ENV nohup $ART/openfut-utas-host >> $LOGDIR/prod-host.log 2>&1 &"
run "/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 8099 30"
echo "== 5. verify =="
run "pgrep -af 'openfut-core|openfut-utas-host'"
echo "Rollback complete. No database or schema change was made."
@@ -0,0 +1,59 @@
[Unit]
Description=OpenFUT Core (STAGING) — authoritative economy state
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# Staging Core is the write authority for the staging SQLite DB. Nothing else
# may hold it open, which is why there is no second instance and no oneshot
# migration unit: Core runs its own migrations at startup, before it binds.
After=network-online.target docker.service openfut-staging-netns.service
Wants=network-online.target
# Requires, not Wants: without the anchor namespace Core would bind the WRONG
# network entirely. Absent anchor must mean "do not start", never "start on
# the host network".
Requires=openfut-staging-netns.service
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
# [Service] (`systemd-analyze verify` flags it), which would have left the
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=alex
Group=alex
WorkingDirectory=/home/alex/openfut-sold-staging
# Enter the anchor container namespace declaratively. Equivalent to the
# `nsenter --net=/proc/<pid>/ns/net` production uses today, but with no pid
# baked in: the path is re-resolved by openfut-staging-netns.service.
NetworkNamespacePath=/run/netns/openfut-staging
# Config is DATA, not baked into the unit, so the same unit file promotes to
# production with a different EnvironmentFile.
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/core.env
# The binary is an IMMUTABLE COPY taken at staging-up time. A later `cargo
# build` cannot silently change what staging is running — the same property the
# promotion artifacts rely on.
ExecStart=/home/alex/openfut-sold-staging/bin/openfut-core
# Graceful stop. Both binaries already exit cleanly on SIGTERM (the
# restart-persistence harness has always stopped them that way), and 20s is far
# more than the observed sub-second shutdown; it exists so a slow WAL
# checkpoint is never SIGKILLed mid-write.
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=20
# Restart WITHOUT a tight loop. `on-failure` deliberately excludes a clean
# operator stop. 5s spacing with a 5-in-60s ceiling means a genuinely broken
# build lands in `failed` where it is visible, instead of thrashing the DB.
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-core
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,61 @@
[Unit]
Description=OpenFUT FIFA17 UTAS host (STAGING) — client-facing, Core-dependent
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# ORDERING AND ADMISSION. `Wants` + `After` order the host after Core and pull
# Core in when the host is started; neither gives READINESS, because Type=exec
# only proves the binary exec'd. The ExecStartPre below is what actually admits
# traffic: the host cannot reach "active" while Core is not listening.
Wants=openfut-staging-core.service
After=openfut-staging-core.service
# Requires on the NAMESPACE (hard) but only Wants on Core (soft) — the host
# must never bind the host network, yet must survive a Core blip.
Requires=openfut-staging-netns.service
After=openfut-staging-netns.service
# `Wants`, deliberately NOT `Requires`/`BindsTo`/`PartOf`. Those propagate a
# Core stop into a host stop, and — measured, not assumed — a later Core start
# does NOT bring the host back, so a routine Core restart would silently leave
# the client with no server at all.
#
# With `Wants` the host survives a Core outage and answers 503
# `core_unavailable`, never falling back to Python. That is the behaviour
# production already exhibited on 2026-08-22 when Core was SIGHUP'd out from
# under a live host. Because the host holds no Core state between requests, it
# resumes serving the moment Core returns, with no supervisor intervention.
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
# [Service] (`systemd-analyze verify` flags it), which would have left the
# crash-loop ceiling at the 10s/5 default instead of the intended 60s window.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=alex
Group=alex
WorkingDirectory=/home/alex/openfut-sold-staging
NetworkNamespacePath=/run/netns/openfut-staging
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/host.env
# Readiness gate. Bounded, and FAILS rather than blocking forever: a host that
# waits indefinitely looks healthy to the supervisor while serving nothing.
ExecStartPre=/home/alex/OpenFUT/scripts/systemd/openfut-wait-tcp.sh 127.0.0.1 18081 30
ExecStart=/home/alex/openfut-sold-staging/bin/openfut-utas-host
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=20
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-host
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,22 @@
[Unit]
Description=OpenFUT STAGING: reconcile services with the anchor container network namespace
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Wants=docker.service
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
# retry, not a failed unit. The script itself decides that and exits 0.
[Service]
Type=oneshot
# Level-triggered: compares the namespace the services are ACTUALLY in against
# the anchor's CURRENT one, so a burst of container events collapses into at
# most one rebind per tick and no separate debounce is needed.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
openfut-staging-anchor openfut-staging \
openfut-staging-netns.service \
openfut-staging-core.service openfut-staging-host.service
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-netns-reconcile
@@ -0,0 +1,16 @@
[Unit]
Description=OpenFUT STAGING: periodic anchor-namespace reconcile
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
[Timer]
# 10s cadence. Container recreation is rare and already an outage; converging
# within ~10s is ample, and a level-triggered check at this rate costs one
# `docker inspect` plus two readlinks.
OnBootSec=15s
OnUnitInactiveSec=10s
AccuracySec=1s
# Not Persistent=: there is nothing to catch up on. The check is stateless and
# the next tick after boot is authoritative.
[Install]
WantedBy=timers.target
@@ -0,0 +1,30 @@
[Unit]
Description=OpenFUT STAGING: publish the anchor container network namespace to /run/netns/openfut-staging
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Requires=docker.service
# Re-resolved on every start, so this unit is also the REPAIR action after the
# anchor container is recreated: `systemctl restart openfut-staging-netns` plus
# a restart of the dependants re-enters the current namespace.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=oneshot
RemainAfterExit=yes
# Resolves the anchor BY NAME, never by a stored pid, and exits non-zero when
# the anchor is absent — which is what makes the dependants' Requires= a real
# admission gate rather than decoration.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh openfut-staging-anchor openfut-staging
# No ExecStop unmount: the namespace belongs to the container's lifetime, and
# tearing the bind mount down under a live Core/host would strand them.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-netns
[Install]
WantedBy=multi-user.target
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
# Install or remove the OpenFUT systemd supervision set for one environment.
#
# Environments are symmetric on purpose: staging and production differ only in
# unit prefix, anchor container and EnvironmentFile location, so what staging
# proved is what production gets.
#
# install copy units, daemon-reload, enable (does NOT start)
# start start in dependency order and report the namespace agreement
# status one-screen health: units, anchor, netns agreement, mounts
# uninstall disable + stop + remove units (leaves binaries, DB and env alone)
#
# The install step deliberately does NOT start anything: on production the
# changeover has to be sequenced against retiring the existing detached
# processes, which is an operator decision, not a script's.
#
# usage: openfut-supervision-install.sh <install|start|status|uninstall> <staging|production>
set -eu
ACTION="${1:?install|start|status|uninstall}"
ENVNAME="${2:?staging|production}"
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$ENVNAME" in
staging)
PREFIX="openfut-staging"
ANCHOR="openfut-staging-anchor"
NSNAME="openfut-staging"
UNITS="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.service openfut-staging-netns-reconcile.timer"
ENABLE="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.timer"
;;
production)
PREFIX="openfut"
ANCHOR="openfut-fut-backend"
NSNAME="openfut"
UNITS="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.service openfut-netns-reconcile.timer"
ENABLE="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.timer"
;;
*) echo "unknown environment '$ENVNAME'" >&2; exit 2 ;;
esac
case "$ACTION" in
install)
for u in $UNITS; do
[ -f "$HERE/$u" ] || { echo "missing unit $HERE/$u" >&2; exit 1; }
install -m 0644 "$HERE/$u" "/etc/systemd/system/$u"
echo "installed /etc/systemd/system/$u"
done
systemctl daemon-reload
for u in $UNITS; do systemd-analyze verify "/etc/systemd/system/$u" || true; done
# shellcheck disable=SC2086
systemctl enable $ENABLE
echo "enabled (NOT started — start explicitly once the old processes are retired)"
;;
start)
systemctl start "${PREFIX}-netns.service"
systemctl start "${PREFIX}-core.service"
systemctl start "${PREFIX}-host.service"
systemctl start "${PREFIX}-netns-reconcile.timer"
sleep 3
"$0" status "$ENVNAME"
;;
status)
printf '%-42s %s\n' "unit" "state"
for u in $UNITS; do printf ' %-40s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null || true)"; done
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
if [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
want="$(readlink "/proc/$cpid/ns/net")"
else
want="(anchor absent)"
fi
echo " anchor $ANCHOR pid=$cpid ns=$want"
for u in "${PREFIX}-core.service" "${PREFIX}-host.service"; do
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
ns="-"; [ "$mp" != "0" ] && [ -e "/proc/$mp/ns/net" ] && ns="$(readlink "/proc/$mp/ns/net")"
match="MISMATCH"; [ "$ns" = "$want" ] && match="ok"
printf ' %-40s pid=%-8s ns=%-18s %s\n' "$u" "$mp" "$ns" "$match"
done
# Exact mount-point match. A substring grep for "openfut" also counts
# "openfut-staging" and reports a phantom leaked stack on production —
# a monitoring lie of exactly the kind this tooling exists to remove.
mounts="$(awk -v t="/run/netns/$NSNAME" '$2==t {n++} END {print n+0}' /proc/mounts)"
echo " netns mounts on /run/netns/$NSNAME: $mounts (1 = healthy, >1 = leaked stack)"
;;
uninstall)
# shellcheck disable=SC2086
systemctl disable --now $ENABLE 2>/dev/null || true
systemctl stop "${PREFIX}-netns-reconcile.service" 2>/dev/null || true
for u in $UNITS; do rm -f "/etc/systemd/system/$u"; echo "removed /etc/systemd/system/$u"; done
systemctl daemon-reload
systemctl reset-failed 2>/dev/null || true
# The bind mount is intentionally left: the namespace belongs to the
# container, and tearing it down is not part of removing supervision.
echo "uninstalled. Binaries, EnvironmentFiles, /run/netns and the database are untouched."
;;
*) echo "unknown action '$ACTION'" >&2; exit 2 ;;
esac

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