381 Commits

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

The four payloads reproduce the measured chain without client writes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The script deletes only nat rules whose destination port is 8299, then
verifies the endpoint presents CN = winter15.gosredirector.ea.com.
2026-08-24 16:36:56 +00:00
funman300 704482be84 tools(re): watch resident club-item population for route correlation
Read-only watcher that waits for FIFA17.exe, re-resolves the club-item store
each tick (the manager is reallocated per login), and emits one timestamped line
per CHANGE. Pair it with

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  fcc_kitcards 1482 rows, teamkits 2576 rows.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The hook log showed the dial was ALREADY being intercepted:

    connect_hook: call 20.51.153.159:8081   (octets logged reversed)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three decisions, each measured rather than assumed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The failure mode is mechanical, so the check is:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two consequences:

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

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

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

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

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

With it, one operator apply captured the whole thing:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Host 123 lib + 45 host_test, fmt and clippy clean. tournament/user, livemessage
and activeMessage verified 200 on staging (were 502).
2026-08-21 23:28:29 +00:00
funman300 cd5983ecdd fifa17: cardtype 9 is unnameable -- measured, and the gap closes as a negative
Serving owned balls (subtype 30), league logos (31) and fcc_misccards
(231/232/233/236) was the last projection gap. The open guess was that their
caption would come from `localizedName` on the wire, "probably", and they were
withheld out of caution.

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

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

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

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

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

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

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

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

Verified on staging: the manager now quick-sells for exactly 282. With this, every
card the client prices for itself -- manager, GK coach, both fitness coaches --
is paid the number it displays.
2026-08-21 22:55:01 +00:00
funman300 755f237f17 fifa17: carry the staff rating the client re-rates to, verified live
Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the
client overwrites the rating and rare flag we send with values from its own card
database, and a staff wire record carries no rating, no rareflag and no
discardValue at all. The server had no way to know the displayed price from what
it sent, so pricing declined for staff and fell back to the placeholder ladder.

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

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

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

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

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

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

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

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

Importer 41 tests, fmt and clippy clean.
2026-08-21 22:51:16 +00:00
funman300 49b18dd4ac fifa17: price quick-sell from the client's own discard table
Quick-sell paid an invented five-tier rating ladder (its own comment said
"PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a
94-rated TOTW special and a 94-rated gold common both sold for 1500, and every
non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder
existed in three places (adapter wire, host payout, an integration test's private
copy), which is a drift waiting to happen.

Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins
table and its formula, round_half_up(rating * price / 100), keyed
(cardtype, level, rare). All of it is already reversed in
plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live
club items, 22 of 22 exact. DISCARD_COINS is generated from
fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and
asserts row-for-row agreement, so the transcription cannot drift.

Collapse the three ladders into one method. ItemIdentityResolver::discard_value
both stamps the wire discardValue and prices the sale, because a non-zero
discardValue suppresses the client's local computation -- whatever is sent is
what the player is promised. The host's quick_sell_value is deleted and the
integration test's copy now calls the single implementation. A test with a
resolver double returning an impossible price proves the credit follows the wire;
reverting the payout to a ladder fails it.

Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real
1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for
specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an
operator's decision.

Staff decline to the ladder rather than pay 0: the client re-rates cardtypes
2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not
guessed -- see the falsifier in the doc.

Verified on staging with the real club, both modes: flag off 1500 wire / 1500
paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100).
Consumables price from their catalog rating and agree with the client's own
computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy
clean.
2026-08-21 22:40:04 +00:00
funman300 274838cc2e test(host): pin the ?type= vocabulary to the client's own 30 arms
Decoded the vocabulary from the binary rather than trusting a case count:
FUN_18012ec50 is `cmp ecx,0x1d` plus a 30-entry jump table at 0x18012ed9c, each
case `mov ecx,<atom>; jmp <atom->string>`. Resolving those atoms against
fut_atoms.tsv yields the exact token list, and it matches club_type_filter
one-for-one — 30 implemented, none missing, none invented.

That is worth a test rather than a note. A MISSING arm answers a real tab with
unsupported_type and an empty screen; an INVENTED arm is worse, because it is
dead code that looks like coverage. Mutation-checked: renaming the leaguelogos
arm fails the test.

Two facts fall out that were previously guesswork. There is no
`playergoalkeeper` token — the client has only DEF/MID/FWD tabs — so a
goalkeeper appearing under playerdefender is CORRECT and not a filter bug, which
I had flagged as suspicious while sweeping. And `healing`/`contract`/`training`
exist as ?type= arms even though consumables have their own route.

Also completes the last unapplied item of plan section 7: the full vocabulary is
now written into ENDPOINT_MAP.md with how it was derived.
2026-08-21 22:02:53 +00:00
funman300 d76c184cf1 fix(fifa17): make an unhandled club defId= list visible instead of silent
A health sweep of all 51 host routes found no errors, but did find a gap against
the documented club grammar: the client may send a comma-joined `defId=` list
INSTEAD of the filter block, and `parse_club_query` handled nine parameters
without it. Today such a request is answered with the whole filtered club rather
than the requested definitions — silently.

Deliberately NOT implementing the filter. That grammar is single-source (one
decompile plus one live log line, and the log line carried no defId), so the
reading of "definition id" is unconfirmed against any observed request. Narrowing
on a wrong reading would turn "too many items" into "zero items", which is the
worse failure and the harder one to diagnose.

So the parameter is parsed and reported instead: the filter summary gains
`defId=<n>` and the host logs a NOTICE naming the ids and saying plainly that the
response was not narrowed. The first real occurrence is then impossible to miss,
and the filter can be written against a captured request rather than a guess.

Verified live: the notice fires and total stays 1966.
2026-08-21 21:58:29 +00:00
funman300 d74aee33f7 feat(fifa17): opt-in commerce settings, the server half of the transfer-list fix
"Place on Transfer List" is greyed for two reasons. This crate already fixes one
(owned copies emit `untradeable: false`). The other is `tradingEnabled`: the
client's struct defaults it to 0 — it is not a flag we have been overwriting, it
is a flag nobody has ever sent — and it gates the service half of the
TO_TRADE_PILE predicate (vtable slot +0x270, gate byte 0x1fd2e, measured 0 live).
`GET /settings` has always answered `{"configs": []}`.

The schema is high-confidence: FutGetSettingsServerResponse (deser 0x18013c6d0,
read end to end) has a single `configs` key holding `{type, value}` rows, and the
key ladder holds nothing else. `type` is the setting NAME. The row set is ported
from the shape the Python oracle would emit rather than invented.

Default OFF (`OPENFUT_FIFA17_COMMERCE_SETTINGS=1` opts in), because the flags are
RECOVERED BUT UNTESTED and the empty list is the live-proven body — the house
rule is that a flag defaults to the live-proven value. This also moves the
capability out of the oracle we are retiring and into Rust, where it can actually
be reached once Python is gone.

Verified against a real host on both settings: OFF returns {"configs":[]}
byte-identical to today, ON returns the 8-row body with tradingEnabled. It
explains why the menu entry is greyed; it does not promise the market works.
2026-08-21 21:47:13 +00:00
funman300 52df78d24a docs(fifa17): correct ENDPOINT_MAP club routes, and close plan section 7
Rows 12, 13 and 16 carried guessed or placeholder URLs (`ut/%s/item?type=…`,
`ut/%s/…`). The real binding is a table, not an inference: the 125-row action
table at 0x1802caa20 indexes the 48-entry URL-base table at 0x18021df80 through
column 1, and base index 3 = ut/%s/club is carried by exactly four rows. So the
client can emit exactly four families on that base:
ClubSearch, ClubStats, StaffStats, ConsumablesSearch — which also corrects those
three rows to /club?<query>, /club/stats/staff and /club/consumables/<cat>, and
updates their status now that the Rust host serves them.

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

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

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

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

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

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

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

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

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

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

So the gate is not a wire field we failed to send: the value it demands is never
produced by anything. Decoding it fully also shows every OTHER input is already
served — cardtype 7, itemState 101/102, teamid — leaving only the +0xba variant
selector beneath it, which makes a client-side patch the only remaining avenue.
2026-08-21 21:10:35 +00:00
funman300 7f17cfe439 test(host): name the catalog-card fixture instead of a six-tuple
clippy::type_complexity, and the struct reads better at the call site: the
fixture rows now say which field is the subtype and which is the art id.
2026-08-21 21:00:25 +00:00
funman300 622a6ab353 docs(fifa17): the three withheld families are one cardtype-9 name gap
Ball (30), league logo (31) and misc (231/232/233/236) were tracked as three
separate holes. They are one: cardtype 9 has no database name resolver, so the
displayed name can only come from `localizedName` on the wire, and that single
unproven step gates all three. The cardtype-7 families caption themselves from
the client's own tables, which is why kit, badge and stadium now project.

Ownership, content_kind, club/stats counting and restart durability are already
in place for all three, so the outstanding launch probe is the only remaining
work.
2026-08-21 20:59:12 +00:00
funman300 67d896615c test(host): lock the whole ownable taxonomy end to end
A club holding every ownable class, served through the REAL catalog resolver, so
each family travels the production classification path rather than a stub.

Asserts each family reaches its own `?type=` arm, that the staff arm carries the
manager too, and that `teamid` appears only where a caption resolves
TeamName_Abbr15 (badge yes, stadium no).

The cardtype-9 families are asserted WITHHELD with their catalog entries
RESOLVABLE, so an empty ball list is provably a decision about the family and
not an accident of a missing asset id — the two failure modes are otherwise
indistinguishable from the response.

Mutation-checked: reverting `is_cardtype7_club_item` to Kit-only fails this test
on the badge arm, so it guards the behaviour rather than merely describing it.
2026-08-21 20:58:21 +00:00
funman300 beb505b0fa tools(fifa17): resolve the itemState comparator live — it is CASE-SENSITIVE
The plan recorded this as "almost certainly unresolvable statically", because
`FUN_180008190` is only a forwarding stub through a slot the host fills at
runtime: `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`.

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

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

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

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

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

Staging now seeds the remaining club families (badge, ball, stadium, league
logo) from the client's own tables, so the rig exercises EVERY ownable class
instead of only the two the real club happens to hold.
2026-08-21 20:45:45 +00:00
funman300 9823bdac78 feat(fifa17): project badges and stadiums, the other two cardtype-7 club items
Kit, badge and stadium are ONE record with ONE client-side resolver
(`FUN_180119bd0`, dispatched on `item+0x4c == 7`); they differ only in the field
their caption reads. Kits already ship and render, so the record is live-proven
— badges and stadiums were being withheld as if unreversed when the authority
(`plan-2026-08-06-card-subsystem.md`) marks both CONFIRMED, and its own rollout
order is "kits first, then badges, then stadia".

So the shaper generalises to the family, and carries exactly what each caption
resolves: `teamid` for kit and badge (`TeamName_Abbr15_<teamid>`), withheld for
stadium, whose resolver reads `StadiumName_<assetId>` and never looks at teamid.
Sending a field the resolver does not read is how this project earned a client
freeze.

Ball (30) and league logo (31) stay withheld. They are cardtype 9 with NO
database name resolver, so their name can only come from `localizedName`: the
offset is confirmed, but "the parser reads it" is not "sending it is safe".

Verified against the real club on staging: badge 6000005 emits cardsubtypeid 11
/ cardassetid 39 / teamid 21, stadium 6200000 emits cardsubtypeid 10 /
cardassetid 36 and no teamid, ball and logo emit nothing.
2026-08-21 20:45:29 +00:00
funman300 7a4dab04d2 feat(import-fifa17): classify every club family, not just kits
Kits were recognised by the id range 6_300_000..=6_400_654, so stadiums,
badges, balls and league logos all fell through to `Other` and were silently
dropped from the import — a club could own them and Core would never hear.

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

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

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

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

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

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

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

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

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

These are definition-level fields, so every owned copy must agree; a group that
disagrees is deferred rather than resolved by taking the first copy's value.
Measured on the real profile: observed `amount` equals the `fcc_*` table row for
every consumable that carries one (1, 2, 4, 5, 10, 15), and a wire omission
corresponds to a table amount of 0. It is therefore NOT a stack count — two
copies of 5003068 are two instances — so the import states no `quantity` at all.
2026-08-21 19:50:13 +00:00
funman300 802f0f580f feat(host): serve owned non-player content from Core's ownership truth
Follows Core's kit designations becoming generic active-item slots: the host
reads `GET /club/active-items` (five always-present slots) instead of the
removed `/club/kits`.

Adds the consumables route and widens the club families to every content kind,
all resolved from Core ownership + the FIFA catalog. An item the client sees is
now an item Core actually owns.
2026-08-21 19:49:59 +00:00
funman300 6c7d0856b6 feat(fifa17): project every owned content kind, from one recovered vocabulary
Extends the FIFA17 adapter past players so the wire can carry the rest of a
real club's inventory.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This is NOT a diagnosis of the failure the operator just hit — that squad had
already been filled to 23 valid players before the match was created, and the
host log shows the client issued no request at all after `match-create` beyond
account-sync, so that refusal is decided entirely client-side. It removes the
variable: after a clean bring-up the club is now playable without hand-editing.
2026-08-21 18:12:59 +00:00
funman300 33300f2ad1 fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:

  utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
  utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502

Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.

The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.

`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.

MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.

ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.

THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.

One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.

Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.

Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
2026-08-21 18:00:28 +00:00
funman300 12ad04c9d4 fix(fifa17): carry the manager's item in the squad, not just its id
The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.

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

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

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

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

Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
2026-08-21 17:30:14 +00:00
funman300 9c2edc4eee feat(fifa17): serve staff, so the club has a manager and matches can start
FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.

The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.

Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:

* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
  client's merge never writes, so the server is their only source — they are the
  flag, the league badge and both halves of manager chemistry. Coaches get
  neither, because the four coach tables have no nation/league/team column and
  emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
  mask (players are the only masked family), so `version` must stay 0 or the
  lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
  and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
  omitted because the merge overwrites them from the client's own tables. A
  staff card is therefore never routed through `shape_item`.

Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.

`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.

TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:

1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
   assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
   indistinguishable from "no manager". Now reads `id`, and a present-but-
   unreadable manager is an error rather than a silent absence. The projection
   also now warns when an assignment cannot be resolved to an owned instance,
   which is the documented "Core drops an owned card with no CardDefinition from
   /collection without erroring" trap.

2. `Route::WatchList` was produced by NO classifier arm, so its handler was
   unreachable and every `watchList` request fell through to Passthrough — the
   same defect class as `season/list`. Against a stack whose Python upstream is
   deliberately dead this 502'd. This was failing
   `sbc_survives_complete_core_and_host_restart` at HEAD before this change.

The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.

Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
2026-08-21 17:13:44 +00:00
funman300 de747b79e6 feat(staging): let staging serve the operator's real club, not just the fixture
The operator could not field a starting XI because staging has only ever held the
14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The
real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it
sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts
list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays.

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

Two pieces:

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

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

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

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

Verified end to end against the restored club: /club 29,843,976 coins, /collection
1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated
90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from
the snapshot identity store. The fixture path was re-run afterwards and still seeds
exactly 14 items, so the SOLD experiment is unaffected.
2026-08-21 16:35:06 +00:00
funman300 dddcfb917c feat(fifa17): serve offline Seasons instead of an empty body
Single-player Seasons failed with "There was a problem communicating with the
FIFA Ultimate Team Servers". Two independent faults, both fixed:

1. The client never reached a season endpoint at all. It aborts on a
   prerequisite web file, captured live by the deployed trace:
     SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
     SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
   That is the hook's side (launcher 5294f58): the CDN base is empty in the
   emulator, so the url stays relative and never reaches the POW content server
   on 8085 that actually serves it.

2. `season/list` and `season/user` were not served. Only the EXACT tail
   "season" was classified (as FeatureOffEmpty); every sub-path fell through to
   Passthrough — the deliberately-dead Python upstream — so the mode could not
   have worked even once the web file resolved.

Adds `fut::season_wire` with the reversed element schema (parser FUN_180167740,
stride 0x318; matches elements via FUN_180167fb0) and a `Route::Season` owning
`season…` for GET plus the state-storing PUT. Anything else still proxies rather
than being claimed without evidence.

Two things the types encode because getting them wrong is fatal:

* `matches` is NEVER empty. StartSeason (FUN_1800fc500) indexes
  `matches[*(x+0x70)].teamId` off `elem+0x2e8`; an empty vector makes that a
  NULL dereference and the client dies at CardsDLL+0xfc5b5. A full ten-round
  schedule is emitted, with opponents drawn from team ids observed in this
  client's own database.
* `type` MUST serialise before `divisionId`. `serde_json::Value` is a BTreeMap
  here (no preserve_order), so `json!` sorts keys ALPHABETICALLY and emitted
  divisionId first — caught by a test written for exactly this. The wire shapes
  are therefore `#[derive(Serialize)]` structs (declaration order) rendered
  straight to text via a new `json_text_status`, never round-tripped through
  Value.

Verified on staging: season/list returns the ten-round OFFLINE season with
type before divisionId, season/user the round-1 position, history an empty
list, and the bare tail still {}.

Season progress is not yet persisted: the state-storing PUT is acknowledged
with {} (what the retail wire answers) rather than pretending a round advanced.
2026-08-21 16:20:01 +00:00
funman300 ff915a306e chore(submodules): bump Core + Bridge for the closed legacy match path
Core bae0a2b: `POST /matches/result` fails closed (it was a second economy
authority with no transaction and no idempotency key); loan expiry and Core
season progression move into `complete_match`'s transaction behind opt-in flags
that default OFF, so the FIFA 17 retail path is unchanged; notifications emit
post-commit and only when applied; `/auth/reset` now clears `match_completions`,
which previously made any profile that completed a match unresettable.

Bridge 07e83fe: the two EA result routes and the dashboard submit to
`/matches/complete` with a per-submission `match_identity`. Pushed to
`fix/matches-complete-migration` — the bridge pin is deliberately behind its
origin/main, so this does not touch that branch.
2026-08-21 04:48:45 +00:00
funman300 d6aa704b01 fix(fifa17): a dangling manager ref must not refuse the squad save
Every real squad save was failing with 400 unresolved_wire_ids. Reproduced on
staging with the repo's own seeder, which sends the captured retail body:

  route=squad-replace status=400 outcome=unresolved_wire_ids detail=[[100000427]]

FIFA 17 always sends a manager ref, and on a real profile it does not resolve to
an owned instance. scripts/sold-staging-seed-squad.py already recorded why:
production's own squad points at instance 100000427, which is absent from
production's /club/staff (1975 items spanning 100000001..100004826), and the
client accepts that squad back unchanged -- so the client never validates the
manager against the club, and the pre-0023 server accepted it.

Making the manager ownership-backed (d37a9d5 / 25f4ad1) turned that ref into a
hard refusal, which took out the primary FUT write path: no squad save means no
squad, which means the client will not enter the FUT hub at all.

A manager ref is not a squad slot. An unresolvable PLAYER slot must still refuse
the save -- committing it would silently drop an owned card from the club. An
unresolvable MANAGER ref just means there is no ownership-backed manager, which
is exactly the state before migration 0023: the save commits, the assignment is
cleared as a full replacement should, and the id is reported on
ProposedSquad::unresolved_manager_wire_id so the host can log what it could not
map instead of letting it vanish. A ref that DOES resolve is still assigned and
still authorized against the club.

Verified end to end on staging: the seeder now answers {"id": 0} with 11
occupied slots, the host logs
`manager_ref_unresolved=100000427 (saved with no manager assignment)`, and the
projected squad carries `manager: []`.
2026-08-21 04:28:43 +00:00
funman300 054a912357 fix(fifa17): key the kit home/away split on the carddbid, not assetid
Staging served `kits=2 kitsHome=0 kitsAway=0`: the split compared the catalog
`asset_id` against `fcc_kitcards.assetid` (14/15), but a kit's catalog
`asset_id` IS its carddbid (6300006), not that column, so neither family ever
matched.

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

Verified against the staging stack: `kits=2 kitsHome=1 kitsAway=1`, team-21
bucket 2, `?type=kit` still activeHomeKit/activeAwayKit, `?type=player` 12.
2026-08-21 04:13:11 +00:00
funman300 3442eac6f0 fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate
Four defects found by running the suites and the staging lifecycle end to end
after the kit milestone.

1. club-stats kits were half-implemented. The global `kits` counter was real
   but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded
   0, so the same screen reported two owned kits and zero home/away kits.
   `kits` is a total with a family split, exactly like players/playersGold and
   staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home
   family and 15 the away family, verified across all 1482 rows of the kit
   table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15
   exactly the 64xxxxx ones, 654 rows; no exceptions either way).
   ClubStatInput now carries `asset_id`, and a kit buckets onto the team that
   wears it -- including a team the club owns no player from, the normal case
   for a kit won from a pack. The host reads both from the catalog through new
   NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a
   read-only stats query must never do as a side effect.

2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1 /
   d37a9d5); 56bd9dd updated the squad_projection integration test and stopped
   there. `put_body` hardcoded the captured manager ref 100000427 into EVERY
   save, including tests with no manager fixture, so each one was refused with
   `unresolved_wire_ids` -- the tests were reporting a real invariant against a
   fixture that could not satisfy it. The manager is now an explicit
   `Option<i64>` per test, and FakeCore models Core's manager persistence
   instead of inheriting the "not implemented" default that 502'd every save.
   Added the coverage whose absence let this rot: a manager assignment
   round-trips as a Core owned id, a later save without one CLEARS it, and an
   unowned manager ref refuses the whole save with nothing committed.

3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit`
   forwarding to Core, which the kit commit deliberately replaced with
   host-side pagination. It only ever passed because FakeCore ignored the
   window -- against a real Core, `start=10` over a one-item club was always an
   empty page. Retargeted to the real contract (Core gets semantic filters and
   NO window) plus a new test that the window is applied locally after
   filtering, which the old fake made vacuous.

4. The staging lifecycle scripts identified production by hardcoded pids, so a
   correct teardown FATAL'd: production moved into containers and pids
   3631953/3374264 died with a container restart days ago. A pinned pid rots
   into the worst of both worlds -- a kill-refusal gate that no longer names
   any real production process, and a liveness gate that fails a healthy
   teardown. New shared `scripts/openfut_production.py` resolves production
   pids AND published ports from the container runtime at the moment they are
   needed, refuses to signal anything it cannot see, and proves production is
   the same processes serving the same ports before and after. Both lifecycle
   scripts use it, which also closed a real gap: port 8085 is published by
   openfut-fut-backend but was missing from the up script's forbidden list, so
   staging could have bound a production port.

Also fixes the economy differential, red because `complete_match` unlocks
achievements in the same transaction that pays the match reward -- a deliberate
Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted
that progression did not exist; it now asserts the delta is the 400 match reward
plus exactly the achievements the match unlocked, read from Core's own report.
2026-08-21 04:10:34 +00:00
funman300 db743ffd1f feat(fifa17): project owned kits with active home/away designation
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.

adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
  consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
  optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
  (id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
  untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
  the 101/102 integers are the client's post-deserialisation runtime enum
  (item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.

host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
  defaulting to no active kits so a Core without the endpoint degrades
  instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
  empty page and outcome=unsupported_type, and now always fetches
  unpaginated from Core: kind and transfer-pile membership are host-side
  concepts Core cannot express, so filtering and pagination must both
  happen after shaping or pages come back short.

importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
  kit counts/balances, and card_asset_id/team_id carried into the emitted
  catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
  (missing_kit_render_metadata) rather than defaulted; conflicting render
  metadata across instances defers as render_metadata_conflict.

staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
2026-08-21 03:17:57 +00:00
funman300 ab62440dbf Make FIFA17 roster hostname configurable 2026-08-21 02:35:27 +00:00
funman300 92520de6c3 chore: update launcher WinSock fix 2026-08-21 00:17:33 +00:00
funman300 be4c52ae89 chore: bump openfut-launcher (Milestone B: in-process FIFA17 TLS patch)
Points at launcher b098617: FIFA17 ProtoSSL cert gates + empty-My-Packs store
guard patched in-process by version.dll (fail-closed, ASLR-safe), replacing the
external autopatch's cert pass. External autopatch retained for parity/rollback.
2026-08-20 21:15:35 +00:00
funman300 967a808d73 chore: bump openfut-launcher (retire FIFA 23; FIFA17 config-driven redirect)
Points at launcher 00ad631: retire FIFA 23 as a build target/template while
keeping the hook game-generic by per-game feature, plus the earlier config-driven
FIFA17 socket redirect (16f3452). Build invariant --features fifa17 unchanged.
2026-08-20 20:57:05 +00:00
funman300 f60dd4da31 chore(launcher): bump submodule for Windows LSX-local fix 2026-08-20 19:57:17 +00:00
funman300 c59c7d88f7 chore(launcher): bump submodule for continuous-VRR present fix 2026-08-20 19:38:43 +00:00
funman300 b24e96b7e6 chore(launcher): bump submodule for VRR flicker fix 2026-08-20 19:35:18 +00:00
funman300 a8078e1d8e docs(windows): document native OpenFUT Launcher GUI + elevation model 2026-08-20 19:21:25 +00:00
funman300 c6abc435cb chore(launcher): bump submodule to native Windows support (057cf92) 2026-08-20 19:21:06 +00:00
funman300 9f1fc1b47c feat(windows): native client preflight + launch/RE docs
Add read-only preflight verifier and Windows-client documentation for the
reimaged native-Windows FIFA17 client host (10.10.0.105). No launcher script:
the native model is _fifa17.exe run as admin (RUNASADMIN + shortcut). Covers
routing (openfut.cfg -> 10.10.0.120), rollback (version.dll swap), and the
x64dbg RVA<->VA (ASLR) attach workflow (ImageBase 0x180000000 CardsDLL/powdll).
2026-08-20 18:43:19 +00:00
funman300 d8d704d441 chore(submodules): bump openfut-core -> 2fb8352 (match economy + club manager, pushed) 2026-08-20 18:21:27 +00:00
funman300 07d4a92309 fix(clippy): use slice::from_ref instead of clone in sbc challenge test 2026-08-20 18:17:30 +00:00
funman300 afa5f620bd style(fifa17): cargo fmt match wire + host match integration 2026-08-20 17:59:03 +00:00
funman300 56bd9ddc85 test(fifa17): update squad_projection integration test to ownership-backed manager
The manager moved from an opaque extension field to an ownership-backed
canonical assignment: SquadProjectionInput.manager (owned item) replaces
Fifa17SquadExtensionV1.manager.

- project_put stand-in passes manager: None; baseline asserts the manager
  projects empty when none is owned.
- persisted_read registers the manager's owned instance + identity and
  passes it as the assignment, asserting the resolved [{id,dream}] ref
  round-trips to the read oracle's manager.
2026-08-20 17:38:32 +00:00
funman300 9ddd80993c feat(fifa17): route match completion to Core exactly-once economy
Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason
enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end
payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the
reward-response projection (only reversed fields; never bidTokens/
qualifiedChampionEventId). Match logic removed from economy_policy.rs
(kept pack/fee); registered match_wire in fut/mod.rs.

Host: handle_match_end now applies the match to Core's authoritative
complete_match (POST /matches/complete) fail-closed — any Core error is a
503, never a Python fallback — and renders Core's authoritative coins.
Per-match identity from matchReportId or a body fingerprint keys Core's
durable idempotency. New CoreEconomy::complete_match transport +
CoreMatchCompletion/CoreMatchReceipt.

Tests: adapter endReason/parse/projection; host shaping, fail-closed,
malformed, identity dedupe; integration replay + rebased balance chains
(match now also grants XP/level-up/achievement coins).
2026-08-20 17:30:17 +00:00
funman300 25f4ad12bc feat(host): wire ownership-backed squad manager through Core
Thread the manager assignment between the FIFA squad path and Core:
- CoreAccess gains get_squad_manager/set_squad_manager (GET/PUT
  /club/manager); HttpCoreClient implements both.
- project_active_squad fetches the assigned manager owned item and passes
  it to the projector (non-fatal on error/absence).
- handle_put_squad authorizes the resolved manager against the active
  club (like a slot) and persists it via set_squad_manager after the
  atomic squad replace; fails loudly, never silently drops it.
2026-08-20 16:44:40 +00:00
funman300 d37a9d5b5e feat(fifa17): ownership-backed squad manager, not opaque round-trip
Move the squad manager from an opaque, unvalidated wire ref in the squad
extension to a resolved, ownership-backed assignment (Core migration 0023
squad_managers).

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

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

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

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

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

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

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

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

LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards on the
retail client (host log: POST /purchased/items 200 -> GET /purchased/items).
2026-08-19 01:50:51 +00:00
funman300 1d6a6fffcc fifa17 store: make reward packs (SBC/draft/season/...) openable
SBC completion and the other Core reward services grant packs with symbolic
definition_ids ("silver_pack", "gold_pack", ...). The FIFA 17 pack system keys
entirely on numeric catalogue ids, and entitlement_pack_ids / handle_pack_open
resolved definition_ids with definition_id.parse::<u64>(), so every symbolic
reward pack was silently dropped from the openable My Packs list. The unopened
count (recoveredPacks, = entitlement count) still counted them, so the client
showed "you have N packs" but had no tile to open -> "no pack available".

Fix (adapter-layer, Core stays game-neutral): add owned-only reward pack
catalogue entries 71-75 (bronze/silver/gold/rare_gold/icon) and a resolver
owned_pack_id_for_definition() that maps both numeric owned ids and the symbolic
reward names to their numeric owned-only pack. entitlement_pack_ids and the
pack-open entitlement selection now use it, so reward packs render as openable
My Packs tiles and redeem their entitlement for free (consume-once, no debit).

Server-verified on staging: 3 Core reward entitlements now render 3 openable
mypacks tiles matching recoveredPacks=3. Tests: adapter resolver + rendering,
host symbolic-reward open flow; full adapter + host suites green.
2026-08-19 01:27:22 +00:00
funman300 f56aa613da fifa17 SBC: keep repeatable challenges re-enterable after completion
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the
repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry
even when repeatable=true. So a repeatable challenge now always projects
timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0
(sets_body); a non-repeatable challenge keeps its true count and stays locked
once completed. Core keeps the authoritative completion record — economy is
unaffected; this is presentation only.

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

LIVE-CONFIRMED end-to-end: native tabbed store renders the real 6-pack catalogue
with correct prices (Gold 5000 / Premium Gold 7500), counts (12 items, 10 gold,
1/3 rares), pack art, and no "or %1s" line.
2026-08-19 00:02:13 +00:00
funman300 c68c10cf04 fifa17 store: real 6-pack economy + full-DB pool; drop extPrice
- store_catalog: replace the invented catalogue with the real always-available
  FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
  real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
  FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
  composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
  FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
  authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.

Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
2026-08-18 23:26:55 +00:00
funman300 7116046195 Lock FIFA17 SBC challenge-squad parser to captured retail wire body
Retail Gate C captured PUT /sbs/challenge/101/squad: 23-slot players[] of
{index,itemData:{id,dream}} plus manager/chemistry/rating/formation siblings.
parse_wire_item_ids already handles it (players[].itemData.id, non-zero only);
update the stale "captures unavailable" note and add a verbatim regression test.
2026-08-18 21:29:47 +00:00
funman300 dcac2c546b Bump launcher: FIFA17 SBC dispatch notifier lifecycle correction 2026-08-18 20:59:07 +00:00
funman300 5c8e2dc0bd Bump launcher: FIFA17 SBC dispatch response-class live vtable fix 2026-08-18 20:53:05 +00:00
funman300 bd03aec82a Gate FIFA17 SBC dispatch acceptance 2026-08-18 20:20:38 +00:00
funman300 e8d1c1ddac test(fifa17): harden SBC retail acceptance 2026-08-18 19:01:33 +00:00
funman300 f9740f640d feat(fifa17): route SBCs through atomic Rust Core 2026-08-18 18:26:35 +00:00
funman300 bf6db98f0d Migrate club rename and numeric squad reads 2026-08-18 17:29:24 +00:00
funman300 fc55de19fa test(fifa17-tls): reproducible isolated confirmation of the roster-cert fix
Runs the REAL roster_server.py under `sudo unshare -n` (so port 8081 is free and
production is never touched) and validates its certificate BY THE DIALED IP, proving the
before/after the SAN fix (fbc0da2) targets:

  * OLD cert (DNS-only, production's current shape) -> a by-IP-verifying client is
    rejected with "IP address mismatch, certificate is not valid for '127.0.0.1'" — the
    certificate_unknown class the FIFA client hit.
  * NEW cert (fixed generator, DNS + IP SANs) -> verifies through the actual roster
    server and returns the roster XML (200, application/xml).

roster-cert-verify.py is the client probe (trusts the served self-signed cert as CA,
checks it against the dialed IP, then GETs /fifa17/fut/rosterupdate.xml).
roster-cert-iso-test.sh drives the real server with each cert and asserts new=pass,
old=fail. Two harness bugs were found and fixed while writing it (a shared /tmp log the
production run owns, and a subshell pid that left the first server alive so the "old"
probe hit a stale server presenting the new cert — the tell was "self-signed" instead
of "IP mismatch"), so the final before/after is clean.

Complements the in-process check: this exercises the production server code path, not a
hand-rolled server. Live production confirmation still needs the container rebuilt with
OPENFUT_ADVERTISE set (operator-gated).
2026-08-18 16:37:19 +00:00
funman300 6ae3364bd0 docs: remove docs/ — migrated into the OpenFUT-Vault (single source of truth)
The entire docs/ tree (24 top-level notes, 68 evidence captures, 2 plans, research) has
been migrated into ~/OpenFUT-Vault, the curated Obsidian vault, which is now the sole
home for project documentation. Merges preserved all detail (obsolete material kept
under "Superseded" sections); evidence/plans were copied byte-identical; every migrated
note records its Source: docs/<original>.md provenance. Vault commit f55a5ba.

Documentation lives in the vault from here on. Code/script comments that still reference
docs/ paths are stale pointers only (no build dependency); they can be repointed at the
vault opportunistically. References to fifa17-recon/docs/ are a different tree and are
unaffected.
2026-08-18 16:26:44 +00:00
funman300 5c40b4993f docs: mark the FUT Squad Update cert fix applied (fbc0da2)
Updates status from root-caused to fixed, and records that option 1 (IP SAN) was
taken across the three cert generators, with the verification and the operator-gated
production rebuild that remains.
2026-08-18 15:58:18 +00:00
funman300 fbc0da2a1b fix(fifa17-tls): carry the advertised IP in the roster/redirector cert SAN
The FUT hub failed to load with "An error occurred downloading the FUT Squad
Update" because the client dials the roster (https://<advertise>:8081) and the
redirector BY IP, while the served certificate carried DNS SANs only
(winter15.gosredirector.ea.com + wildcards). The client aborts that handshake with
fatal certificate_unknown. Root cause and evidence in
docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md (commit 082246c): a wire capture shows the client
offering TLS1.2 with RSA suites, the server selecting them, then rejecting the cert —
and autopatch demonstrably patched both ProtoSSL gates in that process, so this
validation path is NOT one of the two the client-side patch covers. The SAN is the fix.

Three generators produced the cert and none put the advertised IP in the SAN:

* docker entrypoint.sh — the production path. The advertised IP is a RUNTIME value
  (OPENFUT_ADVERTISE), unknown at image-build time, so the cert is now reconciled at
  startup: reissued with IP:$ADV,IP:127.0.0.1 in the SAN only when the current cert
  lacks it. That makes a restart reuse the same cert (no per-start fingerprint churn,
  which would otherwise recreate the Aug-13 surprise) and self-heal if $ADV changes.
* Dockerfile — installs openssl unconditionally so the entrypoint can reissue at
  runtime (previously it was dropped with the apt lists), and bakes a loopback-IP
  baseline cert so a plain `docker build` still yields a usable image.
* openfut-fut.sh — the local orchestrator. ensure_cert now defaults the SAN IP to this
  host's primary LAN IP (OPENFUT_ADVERTISE overrides) and reissues when the cert lacks
  it, instead of only generating when the file is absent.

Verified without the client, which is the strongest evidence obtainable here: a
verifying TLS client checking the cert BY IP rejects the old DNS-only cert ("IP address
mismatch, certificate is not valid for '10.10.0.120'") and accepts the new
IP-bearing cert; and the entrypoint reconcile is idempotent end to end — an old cert is
reissued to carry IP:$ADV, a simulated restart leaves the fingerprint unchanged, and
the final SAN carries both the advertised and loopback IPs.

Live confirmation needs the production container rebuilt with OPENFUT_ADVERTISE set
(operator-gated); production is otherwise untouched.

entrypoint.sh carries unrelated pre-existing uncommitted work (env-based component
selection) that is not on any branch; only the cert-reconcile block is committed here,
and that work is left intact in the working tree.
2026-08-18 15:57:19 +00:00
funman300 750d6c2e18 feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).

openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.

openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').

The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.

VERIFICATION — the ports are checked against the Python, not against themselves:

* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
  constants, which proves consistency, not parity, and the Python cannot run here
  (pycryptodome absent) with the client host unreachable. So the LCG and key derivation
  were transcribed from the Python and run as plain arithmetic, and every AES value came
  from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
  954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
  key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
  payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
  netns, so the smoke test runs under `unshare -n` — the real binary on the port the
  client actually dials, with no port-override hack and no risk to production. A
  hand-written client read the unprompted <Challenge>, completed the handshake, and
  decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
  derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
  candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
  the exact Python message, dual stdout+logfile output, and a missing value rejected
  with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
  every constant by executing the Python module and drove the binary against a synthetic
  client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
  confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
  own parser fixture, so backend capability registration still works.

Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
2026-08-18 05:31:00 +00:00
funman300 082246c085 docs: root-cause the FUT Squad Update download failure (client rejects the roster cert)
Recovered the client's own dialog text from memory rather than inferring from the
server, which is what finally identified the subsystem: "An error occurred downloading
the FUT Squad Update" is the ROSTER update, not the player's lineup. Four squad-shaped
fixes before that were aimed at the wrong thing.

Wire capture shows the client aborting the handshake itself: it offers TLS1.2 with RSA
suites, the server selects TLS1.2 and sends its certificate, and the client replies
fatal certificate_unknown. So protocol and ciphers are compatible and the certificate
is the problem. That certificate is DNS-SAN-only while the advertised ROSTERUPDATE_URL
is an IP literal, and it was regenerated Aug 13 -- after the Aug 12 session being used
as the known-good control, which therefore says nothing about the current cert.

Notably autopatch DID patch both ProtoSSL gates in the failing process (log line plus
live bytes reading back patched) and the client still rejected, so those gates do not
govern this path -- contradicting roster_server.py's standing comment that they make
self-signed certs acceptable.

Documents what was ruled out with evidence (hub route shapes, squad shape, squad
round-trip, advertised hosts, TLS version, Blaze health), the probing gotcha that a
default modern TLS context misreports this server as broken, the unresolved question of
why production appears unaffected, and three fix options with a recommendation. No fix
applied.
2026-08-18 05:12:00 +00:00
funman300 16771b0b33 test(scripts): recover the client's error text, and diff hub shapes against production
Three diagnostics from chasing a FUT error that four server-side fixes failed to
resolve, kept because the technique generalises.

client-error-string.py recovers FIFA's on-screen message from /proc/<pid>/mem,
read-only, scanning ASCII and UTF-16LE (FIFA UI strings are wide). This ended the
guessing: the dialog reads "An error occurred downloading the FUT Squad Update.
Please try again." -- a CONTENT DOWNLOAD failure, not the player's lineup. Every
squad fix before it was aimed at the wrong subsystem, because "squad update" in FIFA
means the roster update, and the server-side symptom (a squad the client would not
accept) was consistent with both readings. When the server says 200 and the client
says no, the client's own words are the cheapest evidence available and should have
been the FIRST thing recovered, not the fifth.

hub-dump.py + hub-diff-prod-staging.py diff every hub route between production
(known-good, same client accepts it) and staging, comparing key presence and JSON
types rather than values, since values legitimately differ. Result: 0 structural
differences across 14 routes, which retired the whole "a missing field breaks
bootstrap" line of investigation in one run instead of one restart at a time.

Also ruled out with evidence: cert gates ARE patched (autopatch logs
"pid 56298: PATCHED cert gates", and the gate bytes read back as the patched
patterns); the roster server serves the FUT Squad Update fine (TLS1.2
AES256-GCM-SHA384, HTTP/1.0 200, application/xml) once probed with
ALL:@SECLEVEL=0 -- a default modern context gets SSLV3_ALERT_HANDSHAKE_FAILURE and
would have been a false alarm; production and staging Blaze advertise identical
roster/POW hosts; the Blaze session is healthy and answering PINGs; and the squad
round-trips exactly through PUT/GET.
2026-08-18 04:30:08 +00:00
funman300 022634704a fix(scripts): staging squad now matches production's known-good shape exactly
Adds the manager reference, the last remaining difference from the squad the same
client demonstrably accepts. Staging's squad shape is now identical to production's:
zero missing keys, zero type differences, zero empty-vs-populated mismatches.

The manager looked unfixable. Production points at instance 100000427 while the
staging club holds 11 players and zero staff, so there was apparently nothing to
reference, and inventing an id would have pointed at a non-existent item.

Checking production properly dissolved the problem: 100000427 is absent from
production's OWN club listing too. /club/staff returns 1975 items spanning ids
100000001..100004826 and 100000427 is not among them, and the type=staff/type=manager
filters are ignored (200 players either way). Production's manager reference is
dangling and the client accepts that squad anyway, which proves the client does not
validate the manager id against the club -- only a populated array matters.

So the reference is mirrored verbatim, dangling id included. That replicates the
known-good state exactly and is better than pointing the manager slot at a player,
which would have been a guess dressed up as a fix.

Method note: every step here came from diffing against production rather than reading
the client. The host reported squad-active 200 outcome=ok throughout, and 200 with the
right players was never evidence the client accepted the body.
2026-08-18 03:49:39 +00:00
funman300 96ca7c0484 fix(scripts): the seeded squad was structurally valid but the client still refused it
First seed sent only squadName/formation/captain/players. The host logged squad-active
200 outcome=ok and /squad/0 showed 11 occupied slots, yet the client still threw a FUT
squad update error -- a 200 with the right players is not proof the client accepts the
body.

Diffed against production's known-good squad, which the same client accepts, comparing
key presence and JSON TYPES rather than values. Staging returned null for exactly the
five fields the PUT never carried, because the extension stored nothing for them:
squadType (a string enum), chemistry, rating, starRating (ints) and custom (the opaque
33-int tactics array the client definitely parses). kicktakers was empty where
production carries five.

Now sends all of them: squadType REGULAR_SQUAD, chemistry, rating/starRating derived
from the XI's mean rating, production's custom array verbatim (opaque server-side, only
its shape matters), and five kicktakers. Re-diff leaves exactly one difference --
manager, which production points at owned staff instance 100000427 while the staging
club holds 11 players and zero staff. Left empty rather than inventing an id that
references a non-existent item; recorded in the code as the one known remaining gap.

Also fixes a KeyError from the rewrite dropping the players key.
2026-08-18 03:33:42 +00:00
funman300 202366611e test(scripts): front-load the hub preconditions instead of finding them one restart at a time
The sold A/B stalled twice on preconditions no headless check exercised: the staging
identity had no squad (the hub refuses to open, showing a squad update error), and any
route without a Rust owner falls through to a deliberately dead Python upstream and
answers 502. Each cost a full operator cycle.

Sweeps the routes the client is observed to request and separates three failure classes
that need different fixes: 502/PYTHON_FALLBACK (no Rust owner), missing_integrity (200
but the underlying state is absent -- exactly 'no extension stored' before the squad
was seeded), and 200-but-unusable (a squad with zero occupied slots). A 200 is not
proof the client is satisfied, so squad responses are judged on occupied slots.

Also reads the host's own classification for the requests just made, since the host is
the authority on ownership and integrity rather than the response body.

Every path is verified against what the client actually sends. A first pass flagged
five 'fatal' routes that were my own guesses -- /accountinfo (client uses
/user/accountinfo), bare /squad (uses /squad/active), and /watchlist (camelCase
watchList). Crying wolf about the stack is worse than not checking, so the list now
carries only observed paths and that trap is written down in the comment.

Current result: 14 ok, 0 integrity warnings, 0 fatal.
2026-08-18 03:14:29 +00:00
funman300 ea92057e53 test(scripts): seed the staging seller an XI so the FUT hub will open
The A/B identity had owned items but no squad, because the sold-row work only ever
needed the tradePile wire. Every headless check passed -- none of them asks for a
squad -- but the real client refuses to enter the FUT hub with an empty one and shows
a squad update error. A squad is a hub precondition, not a Transfer-List detail.

Seeds via the real PUT /ut/game/fifa17/squad/0, the same request the client sends, so
parse_squad_put/build_squad_write produce exactly what a genuine save would. Writing
Core rows by hand could yield a shape the live path never emits, which is the kind of
divergence that quietly invalidates an experiment.

Picks one owned player per 4-3-3 slot, best rating first, without reusing an instance;
fills the fixed 23-slot array with 0..=10 as the pitch and empty slots as
itemData.id == 0; refuses to write a partial XI and reports any out-of-position
substitution loudly rather than silently reproducing the broken state. Verified
0 -> 11 occupied with no substitutions and a {"id":0} ack.
2026-08-18 03:07:42 +00:00
funman300 c71593b286 test(scripts): enable the hook on the deployed launcher without a rebuild
Bridges openfut-launcher c542415 onto the already-built launcher on .105 by writing
WINEDLLOVERRIDES=version=n,b into game_profile.env, which that build does apply.
Forward-compatible: the fixed launcher defers to a profile that already pins
version=, so this value simply wins.

Records the prior value -- including its absence, as the literal <absent> -- to a
sidecar before mutating, so revert restores the real previous state instead of
assuming the key was missing. Refuses to edit while the launcher runs, since it holds
its config in memory and would write the stale value back.
2026-08-18 02:59:41 +00:00
funman300 413ad901fb launcher: bump gitlink to c542415 (hook WINEDLLOVERRIDES fix)
Without version=n,b the launcher's own launch path never loaded the version.dll hook
proxy, so the Blaze ports it writes to openfut.cfg were ignored and the client
silently reached production via /etc/hosts instead of the configured server.
2026-08-18 02:57:38 +00:00
funman300 e0e46d8a57 fix(scripts): the port switcher was editing a derived file, so the A/B ran on production
openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher
is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json,
fail-closed, immediately before every launch. So `staging` set the ports, verified
them, and the next launch silently reverted them.

Caught only because the capture harness cross-checks instead of trusting the screen.
The operator reported "the Transfers tile does not show Sold" -- which looked like a
clean negative result about the sold counter, and was in fact a reading of their
PRODUCTION club, where sold:0 is correct. Evidence chain:

  * route-log delta contained 5 lines, all of them the harness's own GETs; the
    client issued nothing to staging at all;
  * `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130
    (production Blaze) plus TIME-WAIT to :8099 (production UTAS);
  * the hook logged `blaze_redir=42127 blaze_main=42130`;
  * openfut.cfg mtime was 2s before process start, sha back to the production value.

Had the harness reported the tile at face value, the sold counter recovered from
CardsDLL would now be recorded as refuted by a run that never reached the code.

Fixes: own the launcher config (source) before openfut.cfg (derived), with the same
record-before-mutate sidecar discipline on both; refuse to edit while the launcher is
running, since it holds config in memory and would write the stale values back;
report both files and both guards in `show`. launcher_running() matches the
kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has
bitten this project before.

No production change; staging stack and its variant-A sold row untouched.
2026-08-18 02:40:31 +00:00
funman300 fbe29da05b fix(scripts): sold-client-ports guard self-matched its own shell, wedging it ON
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line
contains the literal pattern -- so fifa_running() always returned True and the port
switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a
FIFA client is running" moments after FIFA had actually exited.

Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and
blocked the A/B entirely.

Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking
shell reads as zsh and cannot self-match, while a genuine FIFA process still does.
Validated both directions with the same loop -- it found pid 36958 while FIFA was up,
and reports gone once it exited. Still fail-closed on read errors.

The lesson generalises: a pattern-matching process guard checked over a transport
that carries the pattern in its own argv is self-satisfying, and a guard that can
only ever say "yes" is not a guard.
2026-08-18 02:33:53 +00:00
funman300 9ffbd651b1 test(market): one-command live capture for the sold A/B, with in-run validation
Turns the operator's job into "navigate, say go" and removes any chance of a
half-recorded variant. One command captures and labels: the staging wire surfaces,
the client's OWN auction record decoded read-only from /proc/<pid>/mem (STATE,
YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE), and the
staging host route-log DELTA since the last capture -- which is how a client-issued
DELETE .../trade/sold gets OBSERVED rather than assumed.

The part that matters is the wire-vs-memory cross-check. It validates the
observation mechanism against a known-positive in the SAME run: if the wire says
bidState "highest" and the client's memory decodes 2(highest), the probe is
demonstrably reading the right struct this time. It also recomputes the native
IS_GLOW/INBOX formulas from the wire and compares them to what the client stored.

Proven honest on first run: with the client attached to PRODUCTION and not on the
Transfer List, it reported the staging sold row on the wire, 0 client records, and
INSTRUMENTATION NOT VALIDATED -- refusing to draw a conclusion from an empty read.
Two earlier sessions were misled by exactly that (a sampler bug printing
"countdown NO", and auction containers read while the screen was unbound), so an
empty container is explicitly not treated as an empty pile.

Probe base-address discovery was separately confirmed against the live client
(pid 36958, FNV control=MATCH, model resolved, containers read cleanly), and
production's wire independently agreed at total=0.

No production change. Client config untouched (still production Blaze ports).
2026-08-18 02:29:17 +00:00
funman300 aa5fb2cc40 test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the
client's reaction is attributable to neither. The env knobs were already orthogonal
(--variant and --coins-processed are independent, cp defaults to 0), but
sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly
the contaminated A/B the brief forbids. Fixed: the primary pair now holds
coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState'].

New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic
sale, then re-reads every seller-facing surface under each variant by restarting only
the host (same Core, same DBs, same sale), and diffs with explicit classification --
MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?".
Two orthogonal pairs:

  PRIMARY     bidState highest vs buyNow, coinsProcessed held at 0
  ORTHOGONAL  coinsProcessed 0 vs 1,      bidState held at highest

Result, 36/36: the ONLY finding on /tradePile is
VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs
in exactly the same one path; counts are byte-identical. The orthogonal pair's only
finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the
capture is reproducible rather than merely consistent.

Counts states the live run has to interpret, measured not guessed:
  S1  0 active + 1 sold -> count 0, selling 0, sold 1
  S2  1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode)
That divergence IS the open question for the client; production is unchanged.

scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is
built so restoration cannot depend on memory: it records the production values to a
sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing
if it is absent. It rewrites only known keys (a missing key is an error, never a
silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA
client is running because the hook reads the file at connect time.

Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface,
one file per variant so A can never overwrite B.

Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105
(pid 32188), and live-session mutual exclusion applies. The client config was NOT
touched -- the switcher's guard refused, as designed.

Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0,
counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.
2026-08-18 02:22:12 +00:00
funman300 468bc0fba9 feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.

PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.

  OPENFUT_FIFA17_SOLD_EXPERIMENT      = highest | buyNow   (else OFF)
  OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1                  (else 0)
  OPENFUT_FIFA17_SOLD_COUNT_MODE      = active_plus_sold    (else active)

WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.

CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.

FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.

STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
  * scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
    utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
    own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
    responder and asserts every substitution applied, so a silent no-op cannot leave
    it pointing at production. Kills only recorded pids whose cmdline contains the
    staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
  * docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
  * src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
    (CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
    leaves the listing live with nothing moved. Refuses any path containing
    openfut-promotion or the production ports.
  * scripts/sold-wire-check.py -- proves the whole flow headless before any operator
    time is spent.

WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.

Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.

Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.

The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
2026-08-18 02:14:18 +00:00
funman300 571c5f9261 docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.

RECOVERED FROM THE BINARY

1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
   rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
   (invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
   activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
   MUST therefore be a combination of existing atoms.

2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
     IS_GLOW = (tradeState==closed) ? bidState != none
                                    : bidState in {outbid, buyNow}
     INBOX   = bidState in {highest, buyNow}

3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
   partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
   coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
   its consumer was never found; it is now traced. DURATION also renders the
   localised FUT_AUCTION_EXPIRED when expires underflows.

4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
   yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
   as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
   values - the discrimination exists and lives entirely in unread ActionScript.
   This retires the question as a static target, and it contradicts the
   third-party lore that a seller's sold row is closed+buyNow (the corpus's own
   lifecycle table says closed+highest and assigns buyNow to the buyer).

5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
   field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
   class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
   request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
   makes sense if sold rows PERSIST in the seller's pile until cleared, which is
   incompatible with our Fix A invariant - so the sold path will require revisiting
   it under live validation.

6. The seller's SOLD counter is real, proven end to end with no inference: the hub
   tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
   tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
   caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
   count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
   Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
   bucket can never fill.

7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
   table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
   and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
   resolve a name by the pointer slot INSIDE the table, never by the first matching
   string in the binary, or you get confident nonsense.

8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
   0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
   candidate function was checked for the value-SKIP/atom-loop signature and none
   qualifies. Server-side or telemetry only; it does not carry sold state here.

TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.

Settlement NOT promoted. No production process, port or database was touched.
2026-08-18 01:32:02 +00:00
funman300 cb32fe9b84 docs(market): close Gap 2 and freeze the transfer-list lifecycle as known-good
Return to Club is durable across a full FUT exit/re-entry — the last claim the
forSale promotion could only make server-side.

Same disposable card as Gap 1 (75 ST, res 212188, wire 100000178, tradeId
1000000178), after the 1h auction expired NATURALLY. No timestamp was mutated in
either gap; a read-only sampler watched the whole hour (55 samples), because
`expires` is derived from created_at + duration and watching is the only honest way
to see expiry:

  active   expires 3175 -> counting down -> expired expires 0, itemState forSale
  (absent) total 0                                  <- Return to Club

itemState stayed forSale across active -> expired, the one state change Fix B had
never been watched through live.

The host log then shows the coupled transition and TWO session boundaries:

  route=move-items wire=100000178 pile=club auction_cancelled=1
  route=auth-delete
  route=auth ... sid_opened=true      (fresh session, x2)
  route=hub clubPlayers=1966 auctionCount=0
  route=club total=1986 emitted=1966

auction_cancelled=1 is cancel_active_for_core_item firing, so pile membership and
auction lifecycle cannot disagree. Two independent fresh sessions each rebuilt the
state from durable storage and the operator confirmed the card was still in My Club;
one boundary was the requirement.

18/18 server checks pass IDENTICALLY before and after re-entry: /tradePile total 0,
counts all zero, tradeId -> closed with expires 0 (still resolves, correctly
terminal), /club 1966 with itemState free, 0 duplicate ids, market store cancelled
with 0 active and 0 reserved, coins 29,843,976 unchanged throughout.

No code change was required for EITHER gap. Both tests existed to find out whether
the promoted implementation was already correct on paths it had not been exercised
on, and it was.

Also freezes the full CLUB -> list -> active -> expired -> Return to Club -> CLUB
lifecycle as the reference baseline, with the eight invariants it pins, so a future
change that alters any line is a regression until proven otherwise. Explicitly NOT
established: the SOLD path, /tradePile/counts semantics, the AVM1 gate.
2026-08-18 01:12:46 +00:00
funman300 fbe9804d3e core: quick-sell FK fix (gitlink 637a21e)
Quick-selling a card that was in any squad failed with SQLite 787 FOREIGN KEY
constraint failed, on the live FIFA 17 path (economy_store -> econ.sell_item ->
POST /economy/sell-item). Reproduced, then fixed by evicting the item from every
lineup inside sell_item's existing transaction. routes/cards.rs::delete_owned_card
folded into the same authority, which also gives it the transaction it never had.

Not deployed.
2026-08-18 01:01:20 +00:00
funman300 f6606accb3 feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live
here.

FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and
match_reward_total because 5% is a game policy constant and Core must stay
game-neutral — Core only validates 0 <= fee <= gross and never computes a rate:

  TRANSFER_MARKET_FEE_PERCENT = 5
  transfer_market_fee(gross)  = floor(gross * 5 / 100), i128 intermediate
  seller_proceeds(gross)      = gross - fee

Integer only. Floating point is never used for coin settlement: 0.05 is not
representable in binary and a f64 round trip can create or destroy a coin at large
prices. Widening to i128 makes overflow unreachable for any i64 price, so no price
ceiling has to be assumed.

ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller
keeps the fractional coin, chosen because it makes fee + proceeds == gross hold
exactly at every input — the property the accounting invariant rests on. The
discriminating case against flooring the seller's 95% instead is a gross of 150:
this rule pays 143, the alternative 142. Nothing in the corpus or the client binary
settles which the real server did (the client is only ever told the gross; no
tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/
150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep.

HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on
HttpCoreClient as POST /economy/settle-sale. Request field names were checked
against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club
ids are OMITTED from the body (not null), which is what Core's Outside/active-club
defaults depend on, so a unit test pins that body shape. handle_market_buy is
deliberately untouched: the synthetic buy path has no counterparty, so minting there
is correct.

HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real
HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a
hard deny-list checked in three places), seeds the canonical two-party fixture,
prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31
pass. It found the rejection-precedence bug fixed in Core, and that Core's content
preflight aborts startup on an owned card whose CardDefinitionId no pack defines.

Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code
fmt-clean. Nothing deployed; no production process, port or database was touched.
2026-08-18 00:51:37 +00:00
funman300 0a007f4941 docs(market): close Gap 1 — active seller row under forSale is live-confirmed
The one claim the Fix B promotion left open: no active listing existed during
that session, so only the expired path had been exercised.

Closed with a disposable card (75 ST, res 212188, wire id 100000178 — one of
three identical copies, not in the squad) listed through the real FIFA 17 client
at 150/200 for 1h, so expiry arrives naturally. No timestamp touched.

Wire: itemState=forSale, tradeState=active, 12 atoms, prices intact, expires
3562 -> 3556 over a 6s sample (live clock), /trade/status coherent, counts
{count:1, selling:1}, coins unchanged, zero inactive rows (Fix A intact).

The decisive evidence is client-side, not ours: a read-only /proc/<pid>/mem
decode of the live trade-pile auction record returned
  itemState=5(forSale)
on the very field that read -1(<unrecognised>) under listFS. Direct A/B on the
only changed field, taken from the client's own memory.

Operator confirmed the row renders under LISTED ITEMS with correct prices, a
counting-down timer, normal art, and correctly non-actionable while active.

No code change required — the promoted implementation was already correct on the
active path. Claim boundary unchanged: this proves the client DECODES the token
and says nothing about the Flash action-gate term.
2026-08-18 00:15:51 +00:00
funman300 0c4aee6164 market: promote forSale — live-confirmed on the expired path
Operator drove the expired row 1000000155 (res 158023, 93 RW) in FIFA 17 with the
candidate deployed: the row was still actionable, Return to Club was offered, and
it worked — "the card is back in my club".

Server-verified durable afterwards, which is what a fresh session reconstructs:
/tradePile total 0 with zero rows, /tradePile/counts all zero, the card present in
/club (1965 -> 1966 items, itemState "free"), zero duplicate ids, no stale active
listing anywhere in the store (both rows cancelled), and the ended auction
projecting as `closed` (4) on /trade/status. Fix A intact: zero `inactive` rows.

So `listFS` -> `forSale` is protocol-correct AND behaviour-preserving on the path
that matters, and `listFS` is gone from production serialization.

Also worth recording what the result rules out: CARD_OFFERSTATE is NOT a gate term
that requires -1. Every actionable row we had ever seen carried itemState -1, which
looked like a possible client rule; it was a coincidence of our own invalid token.
An expired row decoding CARD_OFFERSTATE = 5 stayed actionable.

Deliberately NOT claimed: anything about the Flash action gate itself. STATE and
the RESERVEDPRICE/MAX_CREDITS pair are untouched and still confounded, so the gate
remains Category C / STRONGLY SUPPORTED / not proven, and AVM1 disassembly of
tradepile.isInActiveAuction is still the separate next investigation.

One gap left open honestly: no ACTIVE seller row existed during the session, so
active-row rendering under `forSale` is unverified. /transfermarket has always
emitted `forSale` on active rows, so it is expected-safe, but it has not been seen.
2026-08-17 23:58:29 +00:00
funman300 a57f4930f0 market: emit FIFA 17's own forSale itemState, not the oracle's listFS
Single-field protocol-correctness fix, deployed as a candidate for a live A/B.

`itemData.itemState: "listFS"` on the seller's own auction rows is not a FIFA 17
token at all: zero occurrences in `CardsDLL_Win64_retail.dll` (md5
4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory, and it
decodes to -1 through `FUN_180166660` — so the client was handed an unrecognised
`CARD_OFFERSTATE`. FIFA 17's value for an item offered for sale is `forSale` (5),
from the 12-row table at 0x180229cc0.

Changed only where the invalid token was emitted: `handle_market_query`
(GET …/tradePile) and `handle_market_status` (GET …/trade/status). The market
search path already emitted `forSale` and is untouched — which is also why the
risk here was lower than it looked: the client has been decoding `forSale` on a
live route all along, and only the seller's own pile carried the bad value.

Wire A/B on the same expired row: EXACTLY one field differs. tradeId, tradeState,
expires, startingBid, buyNowPrice, currentBid, bidState, sellerName,
sellerEstablished, watched, coinsProcessed, the twelve-atom count and the whole
itemData card are byte-identical; coins unchanged at 29,843,976; Fix A's zero
`inactive` rows intact.

The differential asserted PARITY on this field and therefore passed while BOTH
sides were wrong — the exact mechanism by which the defect survived every run.
`market query tradePile` is now DIFFERENT-BY-DESIGN, pinning oracle == "listFS"
and rust == "forSale" so the divergence cannot silently close again. Where the
FIFA 17 binary contradicts the Python oracle, the binary wins.

Gates: 126 host tests, 214 adapter tests, fmt clean, clippy clean.

NOT claimed: that this preserves the list -> expire -> Return-to-Club lifecycle.
That needs an operator FIFA 17 session and has NOT been observed yet. Also not
claimed: anything about the Flash action gate — `CARD_OFFERSTATE` is one of three
still-confounded candidates and this change does not test it. Revert is one line
if the live test fails.
2026-08-17 23:26:06 +00:00
funman300 f9ca901a50 market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left
open, and the answer retracts Q2 rather than completing it.

`tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at
`0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same
edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of
`[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0
inbox=0). The value is then handed to the movie verbatim as the Flash property
`STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the
trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction`
(traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows
reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders
and can never be acted on, which is exactly what the operator saw.

So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER,
which is not the same claim, and I treated it as if it were.

The corpus said this before any of it was built —
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client
path treats it specially; do not emit it." The earlier note explaining that the
warning "was written about the PRESENTATION function" was motivated reasoning.
This also fires the corpus's own pre-registered falsifier E3 (:368-373).

Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`,
`UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`,
`Server::resolve_trade_pile`, and the two helpers that existed only to feed them
(`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`).
Unlisted trade-pile membership is now internal state with no wire expression.

Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an
unlisted pile member stays visible in the club, which is where the client can act
on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive`
rows, `/trade/status` resolving only the real auction, coins unchanged at
29,843,976, and all six former rows present in `/club` (1965 items).

Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that
pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and
`trade_status_answers_only_about_real_auctions`.

NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17
token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in
4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and
the Python oracle emits `listFS` too — which is why the differential never caught
it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and
every actionable row observed carried -1, so that change ships alone with its own
live A/B.
2026-08-17 23:14:35 +00:00
funman300 3ce69f8951 launcher: one-button launch flow with an explicit state machine (gitlink 3174fe4)
Normal users press Launch FIFA 17; LSX, autopatch, client preparation and the
pre-launch checks are orchestrated automatically, reusing whatever is already
healthy, and every manual control moves under Advanced / Diagnostics. Service
ownership is tracked so a service the launcher did not start is never killed.
2026-08-17 22:44:30 +00:00
funman300 acd1def00d launcher: machine-independent preflight tests (gitlink 504ceee)
Bumps openfut-launcher past two test-hygiene fixes found by running the suite on
the game machine (.105) instead of only on the server host: the new hook-config
check added a second warning on any box with a hook deployed, and the
shadowed-hostname test was asserting, via backend_reachable's live sockets, that
the local machine has the OpenFUT ports open. Suite now passes on both hosts.
2026-08-17 22:06:58 +00:00
funman300 5ec9c7f8bf launcher: guided first-run flow + hook-config reconcile (gitlink 357501f)
Bumps openfut-launcher to 357501f: Welcome/"Get started" onboarding, Settings as
the single owner of the server address, server-authoritative account claiming,
and `openfut.cfg` reconciled before every launch so a settings change can no
longer leave FIFA pointed at the previous server. Cargo.lock picks up
parking_lot for the launcher (already used by openfut-utas-host and
openfut-identity).
2026-08-17 21:47:05 +00:00
funman300 11c028e6eb fix(market): /trade/status must resolve the unlisted ids /tradePile advertises
Explains and fixes the Phase C partial failure WITHOUT changing a single wire field.

The operator saw a difference between the one-item probe (Time Remaining "-") and the
generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding.
/tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status)
resolved ids from the market store only -- and an unlisted pile member has no listing
row, so the poll returned an empty auctionInfo. Observed live as
`route=market-status requested=1 returned=0` repeating for the row the operator had
selected, while that same id was present in /tradePile. The client polls status for
the row it displays and degrades it when the answer is empty, which is also why no
actions were offered. The probe showed "-" only because the client had not yet polled
that id (logs of the time show only tradeIds=1000000097).

So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was
guessed and no field changed: both routes now share one pile enumeration
(Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on
/trade/status. The corpus predicted exactly this -- tradeId must resolve across
/transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not
coverage. Same defect class as the original empty-trade/status bug.

Status still answers only the ids actually asked about, and a real auction always wins
over an inactive row for the same tradeId. Regression test covers all four cases.

Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle
discriminator; whether the full actionable contract is now complete is the operator's
next test. itemState/pile recovery was queued on the assumption the encoding was
incomplete -- neither was touched, and both remain the next candidates if actions are
still absent.

342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from
returned=0 to returned=6.
2026-08-17 21:01:07 +00:00
funman300 afadb13de4 feat(market): PHASE C — expose every unlisted trade-pile item as tradeState "inactive"
Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start
Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED
ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded
one-item probe to the real behaviour: the env gate is gone and /tradePile now
enumerates the whole trade pile.

Mechanism: read the pile (async), resolve each member to a shaped card (sync, because
the identity/Core resolvers are not `Send`), then build the response (async). The
core->wire lookup is `wire_for_owned_id`, which uses the identity store's
NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a
wire id for an item the client has not seen. Items with no mapping, no Core record or
no resolvable FIFA identity are skipped, never faked.

Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its
auction, so after a sale the seller's `trade` row is stale, and filtering only on
ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now
by listing state via `blocking_core_items()` -- active (real auction shown instead),
reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT
suppressed, because a cancelled listing means the card came back to the pile. New test
covers all three plus the store-level rule.

counts semantics deliberately unchanged: `count`/`selling` still track auctions only.

341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items
now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of
the pile. Body preserved as phase-c-full-pile-exposed.json.
2026-08-17 20:50:51 +00:00
funman300 b6398c44e6 docs: record the LIVE-CONFIRMED inactive UI contract and the expired->Club transition
Operator confirmed in the real client that a tradeState "inactive" row lands under the
right-hand TRANSFER LIST section and renders Start Price 0 with Buy Now, Current Bid
and Time Remaining all absent, while an active row in the same body continued to
render separately under LISTED ITEMS. That is the Q2 representation confirmed live,
with the token itself dumped from the client's own string table rather than guessed.

Records the observed wire->UI contract as a table plus a fixture
(inactive-row-live-confirmed.json), and names the regression tests that pin it,
including the two guards that the row is never emitted for an item outside the trade
pile and never duplicates a real auction.

Also records the expired->Club coupled transition verified server-side: the listing
went to `cancelled`, the pile went to `club`, /tradePile dropped the item, /club
regained it, and clubPlayers went 1964 -> 1965. That is durable store state rather
than a client-local view, so it survives a session boundary by construction; tagged
pending the operator's final exit/re-enter confirmation.

Adds the counts observation table. `count` currently tracks AUCTION entries and not
total Transfer List membership; semantics deliberately left unchanged until the full
state set has been observed.

No behaviour change in this commit.
2026-08-17 20:31:20 +00:00
funman300 a2bd048ace feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:

    'active' = 1   'inactive' = 2   'expired' = 3   'closed' = 4

The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.

PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.

Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.

Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.

340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
2026-08-17 20:25:46 +00:00
funman300 2e97ff1461 docs+tools: dump the CardsDLL route table; narrow Q2 to one candidate by elimination
Re-entry discriminator came back a CONFIRMED BUG: an unlisted transfer-list item does
not survive a fresh FUT session, so our representation cannot reconstruct trade-pile
membership. Evidence acquisition per instruction, corpus and PE first, no guessing.

Adds route_table_dump.py: static read-only dump of CardsDLL's route table from the
on-disk PE, resolving VA->file offset through the real section table instead of
assuming a single .text mapping. Output preserved as evidence. It settles "is
/tradePile the only relevant route?" -- the table holds 45 routes plus 3 empty admin
slots, and row 30 `ut/%s/tradePile` is the ONLY trade-pile route. There is no
trade-pile items route.

That plus three existing PE facts narrows the representation to exactly one candidate
by ELIMINATION rather than choice: the route carries only twelve-atom auction records;
`pile` (0x226) has no arm in the item deserializer so membership is conferred by the
owning list and cannot be added as a field; of the twelve atoms only tradeState
expresses lifecycle; and tradeState's closed vocabulary (active=1 inactive=2
expired=3 closed=4) has exactly one value not already spoken for.

So an unlisted item can only be an auctionInfo record with tradeState "inactive".
Tagged INFERRED-BY-ELIMINATION, not CONFIRMED: the remaining unknown is whether the
Flash Transfer List RENDERS such a record in the unlisted section. Records the
acceptance test (survive a full FUT reload) and the revised invariant that a
transition is complete only when a fresh session reconstructs the same visible state.

No behaviour change in this commit.
2026-08-17 20:10:26 +00:00
funman300 7f37b37be3 docs: capture the unlisted transfer-list state and model the pile/auction boundary
Q2 measured, not guessed. The operator moved a card Club -> Transfer List without
listing it (PUT /item, no POST /auctionhouse) and the state was captured read-only.

Finding: we do not represent the unlisted state on the wire AT ALL. Such an item is
byte-identical to a club item -- itemState `free`, no `pile` field emitted, still
returned in /club and counted in clubPlayers -- while an actively-listed item is
correctly excluded from /club and present in tradePile. Only the host's own pile
store knows the difference. Trade pile held 6 items: 1 listed, 5 unlisted.

The FIFA 17 ENCODING of that state stays UNKNOWN on purpose: returned itemData.pile
is numeric with an unrecovered mapping, and tradeState is a closed table walk where
an unrecognised bidState is silently swallowed as `none`, so a wrong enum produces a
plausible-looking but wrong UI. The corpus warns `inactive` decodes but no client
path treats it specially. One client-only discriminator is recorded instead.

Also models the domain boundary both limbo bugs came from: pile membership and
auction lifecycle are separate facts requiring coordinated transitions. States the
testable invariant -- an item must never be simultaneously excluded from /club and
absent from /tradePile -- with the two ways it was reachable and the commits that
closed each.
2026-08-17 20:01:51 +00:00
funman300 4e31fb98a2 fix(market): returning an item to the club ends its auction; close the panel probe
CLOSES the active-own-auction Actions-panel investigation. Live client plus the RE
corpus plus historical FUT behaviour all agree: an active auction is COMMITTED until
sale or expiry and is not seller-actionable, while an expired unsold item becomes
actionable (relist / return to club). Every observation fits that lifecycle --
active+frozen expires was non-selectable, expired was selectable and relisted fine,
relisting made it active and non-selectable again, and the client never emits a
cancel. Documented with confidence tags, and the dead ends are named so they are not
retried: MAY_BE_REMOVED is a constant 1, and the eight-flag array is the CLUB-CARD
menu with no auction-cancellation flag in it.

Implements the return-to-club transition that closure exposes. A pile move to `club`
now cancels any ACTIVE listing on that item, because the auction that put the card
in the pile has to end with it. Otherwise the pile reads `club` while the row stays
`active`, so the card is filtered out of /club (exclusion keys on active listings)
AND still rendered in the Transfer List: the move appears to do nothing. This is the
same limbo class as the earlier pile-vs-listing bug, found by reasoning about the
transition rather than by another live failure.

Scoped to `active` only: a `reserved` row is mid-sale and a `sold` row is already
gone, so cancelling either would let one card be both sold and returned. Two tests
cover exactly that boundary.

338 tests pass, 0 failed, clippy clean.
2026-08-17 19:56:06 +00:00
funman300 6cc22e5cc5 docs: freeze the known-good auction state and mark tradeOwner DISPROVEN
Records the live-client resolution so the market shape is now a fixture rather than
folklore, and so a future agent cannot burn deployments on tradeOwner again.

Confidence notes updated: tradeOwner remains FIFA17-HISTORICAL (it does exist in
the FIFA 17-era API) but "required by FIFA17.exe Transfer List Actions" is now
DISPROVEN for this client path -- it is not among the twelve atoms the client's
auctionInfo deserializer reads, and it was implemented, deployed, observed inert
and removed.

The real blockers are recorded as CONFIRMED live-client findings: trade/status
polling is load-bearing, `expires` must EVOLVE with wall-clock time (a frozen
value is structurally valid and behaviourally broken), and relist must persist
through the PK conflict that FIFA's re-sent ISStart necessarily causes.

Freezes the known-good bodies under docs/evidence/market-lifecycle-2026-08-17/
with a machine-checkable countdown proof (_index.json._countdown_proof records
expires decrementing, frozen:false) rather than asserting the clock in prose.

States the general rule this cost us: a response can pass differential parity and
render perfectly while still being wrong, because FIFA expects an evolving
server-side state machine, not a static object that resembles one.
2026-08-17 19:42:15 +00:00
funman300 b1d7ed2570 fix(market): relisting an expired auction actually relists it
The client's relist arrives as a fresh ISStart (`POST /auctionhouse`) for an item
that ALREADY has a listing row, so `create_listing` hit a primary-key conflict. The
handler treated `Err(Conflict)` as success: it logged `listed=true`, handed the
client its trade id, and persisted nothing. The stale row kept its old `created_at`,
so the card stayed expired and the relist appeared to do nothing -- observed live,
with the client's price-limits fetch and the ISStart POST both in the log.

The PK conflict IS the relist path. `relist_listing` now resets `created_at` to now
and takes the new prices and duration, so the auction actually returns to the market
with a fresh countdown.

Refuses to revive a `sold` or `reserved` row: re-opening a sold auction would sell
the same card twice. `cancelled` rows ARE relistable (the card is back in the pile).
Missing rows report NotFound rather than silently succeeding. The failure paths still
ack so the screen cannot wedge, but they now say `relisted=false reason=...` in the
log instead of claiming success.

Three store tests: the clock/price reset, the sold+reserved revival guard (plus the
cancelled-is-relistable case), and NotFound.

336 tests pass, 0 failed, clippy clean.
2026-08-17 19:17:35 +00:00
funman300 dcbef721f2 docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.

Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.

This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
2026-08-17 19:09:03 +00:00
funman300 772f8a615a fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock
Corrects the record against the CLIENT BINARY rather than library hearsay, using
the project's own reverse-engineering record
(fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE).

REVERTED (refuted): `tradeOwner`, `sellerId`, `offers`. FIFA 17's auctionInfo
deserializer (0x18013e410) reads exactly TWELVE atoms -- bidState, buyNowPrice,
currentBid, expires, itemData, sellerEstablished, sellerName, startingBid,
coinsProcessed, tradeId, tradeState, watched -- and value-SKIPs everything else at
0x180135ff0. Those three fields were added last commit on the strength of
contemporaneous FIFA 17 libraries; the PE says the client never reads them, so they
were inert and could not have been the Actions-panel gate. A preservation emulator
must not emit fields the client does not consume. New test pins the exact set.

ADDED: the auction clock. `expires` is SECONDS REMAINING (never an epoch) and the
client renders a LIVE COUNTDOWN it expects to reach 0. We hardcoded 3600, so no
auction ever aged or ran out. Now `duration` is taken from the ISStart body
(additive `duration_secs` column, defaulting to 3600) and `expires` is derived from
created_at + duration - now, clamped at 0. An active listing whose clock has run
out projects as `expired`/`none`/`expires: 0` -- FIFA 17's relistable state, per the
lifecycle table (active=1 inactive=2 expired=3 closed=4; none=0 outbid=1 highest=2
buyNow=3, both closed vocabularies). Pure projection: no row is mutated, so no
sweeper and no race with the economy.

ADDED: `duplicateItemIdList: []` on GetTradePile, which shares one deserializer
(0x18013e7f0) with ISSearch/ISWatchList over four members and we were omitting one.

CONFIRMED by the same source, so kept: `GET ut/{ns}/trade/status?tradeIds=a,b,c` is
real (ISVIEWTRADE) and my handler matches it exactly, including the comma list.
`ISREMOVETRADE` is `DELETE ut/delete/{ns}/trade/{tradeId}` -- our ORIGINAL spelling
was right. The plain-DELETE arm stays because the same source advises dispatching
on path and being method-agnostic (HTTP verbs are not statically recoverable).

Differential returns to strict key-set parity, with a comment recording WHY parity
is not sufficient: a field absent from both sides is invisible to it.

333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the
four-member envelope, and the listing correctly reading expires=0 / expired after
aging past its hour.
2026-08-17 19:06:33 +00:00
funman300 bf9ae20367 docs: FIFA 17 transfer-market wire findings with confidence tags
Records the auction-record field set, route spellings, pile encoding and the four
open UNKNOWNs so future agents neither reopen settled questions nor re-guess enum
values. Each claim tagged CONFIRMED / FIFA17-HISTORICAL / INFERRED / UNKNOWN.

Captures the key methodological lesson: oracle parity is necessary but NOT
sufficient for a flow the oracle itself never served -- our auction record matched
the oracle key-for-key while both omitted the FIFA 17 ownership fields.
2026-08-17 18:51:13 +00:00
funman300 58d1f9426f fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.

1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
   (bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
   purpose-built "this auction is mine" flag, and without it the Transfer List has
   nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
   configured persona so it agrees with `tradeOwner` and `sellerName` instead of
   telling three different stories. Persona is threaded from config, never baked in.

2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
   Transfer List polls this continuously to refresh live auction state. The tail has
   no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
   where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
   client asked for the state of its own listings and was repeatedly told there was
   none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
   unknown ids are absent rather than an error, so a poll never fails closed.

3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
   cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
   `/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
   the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
   map to MarketCancel. Kept the oracle spelling: the differential exercises it.

Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.

Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.

332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
2026-08-17 18:50:19 +00:00
funman300 3cd31c4322 fix(market): stamp the player's persona as sellerName, not EA's house name
A card listed on the Transfer Market rendered correctly in the Transfer List but
pressing it opened NO Actions panel, so Remove / Re-list were unreachable. The one
field where our auction record diverged from the oracle was the seller: we stamped
"EASFC" while the oracle stamps the account's persona name. `fut_account.py`
annotates that very property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona /
UTAS sellerName", so EA's house name on the player's OWN listing is simply wrong,
whether or not it proves to be the gate on the Actions panel.

Introduces `non_economy::PERSONA_DISPLAY_NAME` as the single source of truth and
uses it both for the `account/sync` default (previously a bare "CAGE" literal) and
as the market seller. Every listing in this store is the player's own -- there is no
NPC seller in a single-account emulator -- so the fallback is the player.

Also strengthens the differential: it compared only auctionInfo LENGTH and
tradeState, so it was structurally blind to this. It now compares the record key
set and each shared field against the live Python oracle, asserts the seller is the
persona rather than EA, and asserts itemData is the full card rather than a stub.

That strengthened comparison passes against the real oracle subprocess, which
establishes two things: our record's key set is IDENTICAL to the oracle's (we are
missing no field relative to it), and sellerName was the only divergence.

NOTE the limit of that evidence: the oracle's own Transfer List remove flow has
never been confirmed against a real client either (the only live datapoint is a
counts-tile bug), so parity is necessary but may not be sufficient. If the client
still offers no dialog, the missing field is missing on BOTH sides and must come
from client instrumentation, not from the oracle.

14 targets green, clippy clean. Deployed and verified live: sellerName='CAGE',
listing intact, coins unchanged.
2026-08-17 18:29:19 +00:00
funman300 ae5feb05b7 docs: record the external FIFA 17 FUT hub behavioural spec + cross-check
Operator-supplied research document (authored outside this repo) describing the
player-visible FUT hub state machine. Stored verbatim so it cannot drift, with a
provenance header pinning its standing: it is a BEHAVIOUR target, never a protocol
reference. Its own §43 already forbids inventing route/field/sentinel/empty-state
details from it, which matches project policy (guessing wire spellings is the
documented client-freeze class).

Appended a repo-grounded cross-check that tags each relevant claim CONFIRMED /
CONFLICT / GAP / UNVERIFIED, so a future agent cannot mistake the aspirational
parts for observed behaviour. Notably it CONFLICTS with the recovered client
tables twice (Manager League is deliberately excluded from the consumable
overlay; there is no apply-consumable endpoint upstream at all), and it usefully
confirms that "sent to the Transfer List but not currently listed" is a real FUT
state -- which is exactly the limbo f2c4927 worked around.
2026-08-17 18:25:23 +00:00
funman300 f2c4927ea6 fix(club): hide only ACTIVELY-LISTED cards, not the whole trade pile
Keying the club exclusion on the `trade` pile put cards in limbo: the pile can
hold cards with no active listing (a bare "Place on Transfer Market" move, or a
listing later cancelled/sold), and `/tradePile` renders ONLY active listings — so
those cards were invisible in BOTH views. Live prod had 5 trade-pile rows but 1
active listing, so 4 owned cards had no reachable screen (clubPlayers 1966->1961).

Key on the ACTIVE LISTING instead (market store `core_item_id` of `state=active`).
This is self-healing: the moment a listing stops being active the card is back in
the club, with no extra transition to maintain and no need to invent an
"unlisted transfer-list" wire shape (`tradeState` has no verified spelling for
that state, and guessing enum spellings is the documented client-freeze class).

A bare pile move therefore no longer hides a card. That is deliberate: our
`/tradePile` shows only active listings, so hiding on the move alone would
reintroduce the limbo it is meant to prevent.

Verified live: clubPlayers 1961 -> 1965 (exactly the one listed card hidden, the
4 stranded cards recovered); listed wire still absent from /club; counts and
tradePile unchanged. 14 targets green + clippy clean.
2026-08-17 18:10:32 +00:00
funman300 aa2abc2772 fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:

1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
   client greyed out "Place/List on Transfer Market" for the whole club. Owned
   and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
   for owned copies too (item_def keeps `true`; instances do not).

2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
   FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
   the handler fail-closed and returned 200 while persisting NOTHING. It now
   resolves server-side: wire id -> Core owned instance -> its card_id (minted on
   a synthetic buy) + FIFA resourceId (the auction record). This also enforces
   that a listing can only name a card the club actually owns.

3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
   row the client could not draw -> "1 item listed" but no visible sale. A
   listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
   additive migration) built by the same `shape_item` shaper `/club` and the
   squad projection use, so the auction card renders identically to the club
   card. The seller's own pile stamps `itemState: listFS`; market search keeps
   `forSale` (the oracle distinguishes these).

4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
   deserializers: `/counts` is FutGetAuctionCount, five scalar ints
   (count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
   everything else. Served the `auctionInfo` body it left every count at 0, so
   the Transfer List screen showed no active sale while the hub tile showed one.
   New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
   also accepts the /counts path).

Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.

Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.

Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.

Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
2026-08-17 18:03:06 +00:00
funman300 1aa84afa9a feat(host): migrate item-defs + marketdata UTAS reads to Rust
Two more real client-hit reads move off the Python proxy:

- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
  for every >=3-digit id in the query, replicating the oracle's item_def
  (assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
  "Player" 75 CM placeholder). The client renders the real card from its local
  DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
  pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
  {defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
  OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
  array froze a live client at the listing screen, so the handler picks it from
  the path.

Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
2026-08-17 16:21:17 +00:00
funman300 33e9118329 feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust
FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.

- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
  fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
  Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated.
2026-08-17 16:10:53 +00:00
funman300 e06fd57211 feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
  persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
  (nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.

Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
2026-08-17 16:04:20 +00:00
funman300 42fd3c7e90 core: deploy correctness fixes (SBC exploit + economy TOCTOU) + docs
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card
exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions
club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19).

Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and
docs/OVERNIGHT_HANDOFF_2026-08-17.md.
2026-08-17 16:01:27 +00:00
funman300 0bc71dbd74 docs(evidence): capture full-length UTAS responses + userMassInfo envelope
Fix extractor truncation (bound each HTTP message by Content-Length): userMassInfo
(8 KB) and purchasegroup responses are now complete in the committed corpus, not
cut at 4 KB. Document the userMassInfo envelope contract (target shape for a future
full-Rust migration; needs the clubAbbr/established account triad Core lacks).
2026-08-17 04:13:55 +00:00
funman300 2ecd830d75 docs(evidence): commit fresh sanitised UTAS wire captures (2026-08-15 live A/B)
Rebuilds the primary-capture corpus lost to .gitignore (Known Issues #200): 10
real-client requests + 12 responses captured during the post-P1 staging A/B on a
real FIFA 17 client, sanitised (SID/authCode/deviceId/MAC/tokens redacted; raw pcap
withheld). Documents the account/sync, empty-My-Packs 65534 sentinel, and userMassInfo
contracts, incl. the finding that account/sync is coupled to Python active-profile
selection (so it can't migrate standalone from the userMassInfo hybrid).
2026-08-17 04:09:56 +00:00
funman300 3a51b0ebd4 docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader
trap called out in Known Issues:
- heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The
  round-trip only validates the payload length + TDF body; decode->encode with the
  same mislabelled header trivially reproduces the capture, so it never tested the
  [10:16] field boundaries. Marked superseded; cite the proven layout; warn at
  build_fire2_frame. Code unchanged (dead tooling).
- fifa-blaze frame.rs: see submodule commit f4f3396.

Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod
container; no prod impact).
2026-08-16 21:29:52 +00:00
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes
them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the
certificate mismatch that cost three live gates. The redirector classified the
opening before the acceptor to keep a benign probe from forging a TLS fault, but
the roster host (the second FIFA-facing TLS host) did not, so the documented
hazard 'remains in any other TLS host that has not adopted it' was live there.

Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream)
peek helper into the shared openfut-tls crate (game-independent; +unit tests).
The redirector now re-exports them (public API + its probe_classification test
unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a
probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of
failing the handshake. New roster probe_classification integration test (3 cases:
bare probe classified, real client after a probe still served 200, speaks-then-
fails still reported as a fault). Full workspace tests green; clippy -D clean.
2026-08-16 20:30:50 +00:00
funman300 12fb9fc38b chore: update workspace Cargo.lock after excluding openfut-hook
openfut-hook (now its own workspace root) and its windows-sys deps are no longer
part of this workspace's lockfile.
2026-08-15 19:32:18 +00:00
funman300 7b580a0070 chore: bump openfut-hook clippy -D warnings cleanup (0d3f33c -> d1a71bd) 2026-08-15 19:31:25 +00:00
funman300 22443a3810 build(hook): exclude openfut-hook from workspace so its release profile applies
openfut-hook (Windows version.dll injected into the FIFA client) declared a
[profile.release] with panic=abort/strip/opt-level=s that Cargo silently ignored
because it was a non-root workspace member (per-package `panic` overrides are
forbidden). Move it out of `members` into `exclude`; the submodule now carries a
matching empty [workspace] table so it builds as its own root. Fixes cross-FFI
panic-unwind UB in the injected DLL, shrinks it 1200126 -> 861696 B, and lands the
artifact in openfut-hook/target/ (matching launcher config.rs hook_dll_path).

Bumps openfut-launcher submodule ca7ce26 -> 0d3f33c.
2026-08-15 19:25:19 +00:00
funman300 0fce1e521c docs: mark club/stats/{year,consumables} Rust-owned in authority matrix
Global MY-CLUB stat set now Rust (staging-verified RUST route=club-stats
owned=1982 players=1962); only club/stats/{country,league,team} nation-bucket
context sub-screens remain proxied (low value, inert atoms).
2026-08-15 18:04:23 +00:00
funman300 71fcf5e251 feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)
Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing
Core-accurate counts: player tiers + rare from the collection, staff/consumable
families from catalog kind+subtype, per-nation buckets via the reverse entity
resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts +
context_rows). Unlike the oracle (stale profile + synthetic consumable shelf),
this reflects the real imported content (incl. the content-gap consumables/staff).
Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain
Python (documented). Adds adapter club_stats module (5 tests), host handler +
classify arm + resolver subtype_of/rareflag_of, ownership + integration tests;
reachability tool splits club/stats global(migrated) vs context(residual).
2026-08-15 17:56:37 +00:00
funman300 979e71fbea docs: record precise blockers for remaining Python non-economy routes 2026-08-15 17:31:15 +00:00
funman300 45e0b0bd95 docs: mark hub + club/stats/staff migrated in authority matrix 2026-08-15 17:18:55 +00:00
funman300 70eb3fc13f feat(fifa17): own FUT hub tile counts in Rust
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from
authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff
excluded via catalog kind; may be lower than Python's profile count by the
deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from
the durable market store via the async bridge. Fail-closed 503 on Core error;
market read failure degrades cosmetic counts to 0. Adds classify arm, handler,
ownership + integration tests; reachability tool marks hub migrated.
2026-08-15 17:18:06 +00:00
funman300 b30aa352f6 docs: record post-P1 candidate migration status + clientdata Core blocker 2026-08-15 17:13:47 +00:00
funman300 67cc33cfee feat(fifa17): own club/stats/staff (empty stat set) in Rust
Migrate GET club/stats/staff from the Python proxy to a Rust static handler.
The production oracle returns {} for the staff-bonus stat set (deliberately
empty); the Rust host now owns it (owner=RUST route=club-stats-staff). Updates
classify(), the pre-existing near-miss test (now club/stats/year), the ownership
matrix test, and the no-fallback integration test. club/stats/{year,consumables}
remain Python (aggregation) pending the Core-derived club-stats migration.
2026-08-15 17:13:16 +00:00
funman300 6eec3b9ec7 test(fifa17): add UTAS route-reachability reporter (Python-hit gate)
Parses the host owner= dispatch log into per-owner + per-domain counts and gates
on the post-P1 invariants: economy Python hits == 0 (P1 regression), migrated
non-economy routes (accountinfo/settings/leaderboards/match-reset/phishing) ==
0, and residual Python domains == documented set. Read-only; staging-preflight
and Phase 40 live-ownership use.
2026-08-15 17:01:11 +00:00
funman300 57773b98ec docs: Python retirement readiness classification (post-P1) 2026-08-14 05:36:32 +00:00
funman300 fe9b899a0e docs(fifa17): record .105 Legends unrecoverable verdict (dcplayernames empty) 2026-08-14 05:26:02 +00:00
funman300 abe9e663c1 feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
2026-08-14 05:26:02 +00:00
funman300 f5a33eb58c test(fifa17): prove non-economy routes are Rust-owned with no Python fallback 2026-08-14 05:08:37 +00:00
funman300 a85090c3c6 feat(fifa17): own non-economy static + security-question routes in Rust
Migrate 5 non-economy UTAS route families from the Python oracle proxy to
Rust host ownership: user/accountinfo, settings, leaderboards/options,
match/reset, and phishing/{trusteddevice,question,validate}.

- adapter fut::non_economy: pure IO-free shapers matching the observed prod
  oracle bodies + a verbatim port of security_question_route (stateless ack;
  answer never stored/compared; trusted-device is an invariant constant).
- host: Route variants + classify() arms + owner=RUST dispatch; the
  security-question X-UT-SID gate reuses SessionStore::session_known.
- tests: 9 adapter unit tests (contract) + host non_economy_route_ownership
  (classify + classify_economy negatives).
2026-08-14 04:57:28 +00:00
funman300 97d48d8371 docs: record post-P1 production authority matrix + FIFA17 content completeness 2026-08-14 04:57:28 +00:00
OpenFUT Agent 5020137050 docs(fifa17): record retail economy route grammar (purchased/items, tradePile) 2026-08-14 00:50:03 +00:00
OpenFUT Agent cf05ab2a9e test(fifa17): replay retail purchased/items BUY + route matrix via dispatch
- pure_economy_routes (NEVER-BOTH + no-fallback) gains POST/GET purchased/items,
  lowercase tradepile, tradePile/counts -> all asserted Rust-owned, proxy 0.
- retail_purchased_items_buy_debits_core_through_dispatch: the exact round-2
  live failure -> POST /purchased/items now debits Core, reveal shows minted
  items, repeat reveal idempotent, Python proxy count 0.
2026-08-14 00:50:03 +00:00
OpenFUT Agent a6416a3f1d fix(fifa17): classify full retail economy route shapes
Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
  GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
  (hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
2026-08-14 00:50:03 +00:00
OpenFUT Agent 47ced228de docs(route-authority): record v1/v2 economy URL prefix contract
Accepted prefixes /ut/game/<sku>/ and /ut/v2/game/<sku>/ for every economy
route; StoreBuy accepts store/transaction and store/transaction/<txn-id>.
2026-08-13 23:53:44 +00:00
OpenFUT Agent b8beeba98d test(fifa17): cover retail v2 Store route shapes
Through real handle_with_ip dispatch against live Core:
- pure_economy_routes gains the retail v2 Store family (transaction/0,
  purchasegroup, purchased GET/POST) so NEVER-BOTH + no-fallback assert them
  Rust-owned (Python proxy count 0) and fail-closed 503 on dead Core.
- Part-7 repro: PUT /ut/v2/game/fifa17/store/transaction/0 returns a Rust
  createPackResponse + debits Core (NOT the Python TRANSACTIONCANCEL no-op).
- retail_v2_store_flow_matches_v1_through_dispatch: v1 and v2 BUY of the same
  pack debit + mint identically; v2 purchasegroup + reveal Rust-owned.
2026-08-13 23:53:44 +00:00
OpenFUT Agent df6994c957 fix(fifa17): classify retail v2 economy routes
Live staging (S2) showed the retail FIFA17 client issues the Store family
under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0),
which escaped Rust economy authority to Python. Fix classify_economy:
- ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the
  same tail (generic sku, never hard-coded fifa17); delete family likewise
  accepts /ut/v2/delete/game/.
- StoreBuy matches store/transaction and store/transaction/<digits> via a
  bounded is_store_transaction_tail (never store/transactions, ...foo, or
  .../<id>/extra), mirroring the Python bare /store/transaction regex.
Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2
unit tests (lib 74).
2026-08-13 23:53:44 +00:00
OpenFUT Agent d74e86c065 docs(fifa17): record Rust economy authority proof (E1 cutover ready)
Final per-route authority table: every economy route owner=Rust, Python
proxy=NO (userMassInfo the one hybrid: Python envelope + Rust economy/squad
overlay). Barrier 93a46d4; from_config 43917a0. Proofs: differential 15 PARITY
+ 1 DIFFERENT-BY-DESIGN, concurrency 8x50, failure 10 (complete-sale SAFE, no
E3), importer 5-step, from_config E2E, NEVER-BOTH / no-fallback / stale-reader.
Python source byte-unchanged; oracle suite 32/32.
2026-08-13 22:44:12 +00:00
OpenFUT Agent 76512f6048 test(fifa17): prove post-barrier economy authority (never-both / no-fallback / stale-reader)
barrier_never_both_no_fallback_and_stale_reader drives the REAL post-barrier
handle_with_ip against a live in-process Core + a mock Python upstream that
counts every request and answers with a coins=111 marker:
- STALE READER: credits + userMassInfo show the Core balance, never 111
  (userMassInfo proxies the Python envelope but the Rust economy overlay wins).
- NEVER BOTH (Core up): every pure economy route returns a Rust body (no
  __python__ marker) and the Python proxy call-count stays 0.
- NO FALLBACK: a server pointed at a dead Core port (built without probing Core:
  empty catalog + empty pool) fails closed (credits/match -> 503) and STILL never
  proxies to Python (call-count unchanged).

Also parametrizes build_econ_server's pass URL so the mock upstream can be
injected. host 71 lib + 4 integration + differential + concurrency + failure +
24 host_test all green; clippy -D warnings + fmt clean.
2026-08-13 22:39:45 +00:00
OpenFUT Agent 93a46d4de7 feat(fifa17): cut over FUT economy authority to Rust
The economy authority barrier. `handle_with_ip` now dispatches every
economy-touching route to Rust/Core via `try_handle_economy` BEFORE consulting
`classify()`, so a migrated route can never also reach the Python passthrough
(NEVER BOTH). With economy services wired (production `from_config`) an economy
route ALWAYS returns Some — fail-closed 503 on any Core error — so there is no
Python economy fallback. `userMassInfo` stays a hybrid by design: Python
supplies the non-economy envelope; Rust overlays BOTH the squad and the economy
fields (coins + unopened-pack count from Core), so no stale Python economy value
is visible.

Routing only — no handler/test changes buried here. Routes now Rust-owned:
/user/credits, /store/purchasegroup, /store/transaction, POST+GET /purchased,
PUT /item, item DELETE forms, /match (ut/delete), /auctionhouse, /tradePile,
/trade, ut/delete trade; plus the userMassInfo economy overlay.
2026-08-13 22:39:36 +00:00
OpenFUT Agent 3ba24a0faf test(import): verify durable FIFA17 economy migration
openfut-import-fifa17/tests/durable_import.rs drives the REAL import pipeline
(analyze -> Report dry-run; emit_content; plan_apply -> GenericImportRequest +
deterministic identity mappings + watermark; the staging/preflight/seed/
post-validate gates over a real JsonIdentityStore; openfut_core::services::
import::apply_profile_import in one Core SQLite tx) against a disposable
temp-file Core DB, from a small sanitized in-test fixture (750000 coins, 3
resolvable base players, unopenedPackIds [70,70,101], one squad).

Five ordered steps on one durable target, all green:
  A dry-run: report exposes persona/coins/inventory/unopened/fingerprint; ZERO
    DB mutation (all Core tables COUNT=0, identity store empty).
  B apply: coins=750000 exact, owned=3, packs=3 (opened=0), squad_players=2,
    deterministic owned ids, import_fingerprint recorded, identities reverse-
    resolve both ways, watermark=100000600.
  C restart: close+reopen the SAME sqlite file -> identical state.
  D re-apply same source -> AlreadyImported (fingerprint), no doubling.
  E conflict (coins 750000->750001 flips the fingerprint for the same game)
    -> apply fails closed ('different source'); DB unchanged.

Fingerprint = FNV-1a-64 hex of the source snapshot, carried into
ProfileImportRequest.source_fingerprint = Core profiles.import_fingerprint, the
per-game rerun-identity key. dev-deps added to openfut-import-fifa17
(openfut-core path, tokio, sqlx). No production/live data.
2026-08-13 22:31:01 +00:00
OpenFUT Agent 6926bb9528 test(fifa17): add Python-oracle economy differential coverage
economy_differential.rs boots the REAL Python oracle (fifa17-recon/tools/
utas_server.py) as an isolated subprocess (env FUT_PROFILE/FUT_ACCOUNT_PATH/
FUT_PORT into a temp dir + loopback port; no production container/port/save;
killed on Drop) AND the real Rust stack (seeded in-process Core + a real Server
with EconomyServices), seeds a semantically-aligned fixture on both, and drives
16 ops through the REAL surfaces (oracle over HTTP; Rust via
Server::try_handle_economy on the off-runtime thread).

Result: 15 PARITY, 1 DIFFERENT-BY-DESIGN.
- PARITY: credits, userMassInfo economy, purchasegroup (pack70/sentinel/clean-v1
  incl. the real SessionStore capability handshake), Store BUY, POST /purchased
  open, GET /purchased reveal (VERIFIED: durable single-profile purchased pile
  on BOTH — the hypothesised per-SID cache does NOT exist, so PARITY not
  DIFFERENT-BY-DESIGN), quick-sell (both forms), move, match WIN (+400 byte
  shape), market list/query/cancel.
- DIFFERENT-BY-DESIGN: market second-buy. First buy debits buyNowPrice + closes
  on both. Rust's MarketStore is a crash-consistent single-debit ledger (second
  buy of a sold listing = no-op, pinned by assertion); the oracle's buyable
  market is a stateless PACK_POOL sample that re-debits on repeat. Compat impact
  NONE (buy-now is one-shot); Rust is a strict correctness improvement.

No Python source changes; no classifier changes. Deterministic (3 runs).
2026-08-13 22:30:47 +00:00
OpenFUT Agent b1643309f6 test(fifa17): prove host economy concurrency and failure rollback
Two real host-dispatch test files (no fakes) driving Server::try_handle_economy
against a live in-process Core over the real blocking client + durable
MarketStore/PileStore + JsonIdentityStore, each racer its own OS thread
(off-runtime pattern).

economy_concurrency.rs — 8 races x 50 iterations:
  A two BUYs (coins for one) -> exactly one 200 + one 461, final 0, one debit.
  B duplicate owned-pack open -> one redemption, +11 once, entitlement once.
  C duplicate quick-sell -> one sell + one credit + one removal.
  D two market buyers -> one win, one debit, one mint, sold once.
  E reward+BUY -> no lost update (Core relative UPDATE under BEGIN IMMEDIATE).
  F move+quick-sell / G list+quick-sell -> one coherent transition.
  H 1000 concurrent mints -> unique + reversible wire ids, monotonic watermark.

economy_failure.rs — 10 fault-injection sub-cases, all fail-closed:
  BUY/open-redeem/generator/pile/identity, quick-sell, move, market
  reserve/purchase/complete. CRITICAL complete-sale-after-commit = SAFE: the
  listing is left `reserved` (not active), so the active->reserved reserve CAS
  can never win again -> not buyable, exactly one debit + one mint. No E3.

Fault injection uses test-file CoreEconomy/ExternalIdentityStore doubles plus a
NARROW, inert-by-default `StoreFault` seam in market_store.rs + pile_store.rs
(the concrete stores have no trait boundary; 3 `tripped()` checks + a field,
zero behaviour unless a test arms it). `parking_lot` promoted to a normal dep
(the seam's Mutex is used at lib scope). Classifier/ROUTE_AUTHORITY/Python
untouched. host lib 71/71; both new tests pass.
2026-08-13 22:30:35 +00:00
OpenFUT Agent 43917a0051 feat(fifa17): attach economy services in Server::from_config
Wire the PRODUCTION constructor so the economy authority is not test-only.
Server::from_config now builds one process-lifetime AsyncBridge, opens the
durable MarketStore + PileStore (paths from config), shares one HttpCoreClient
as both CoreAccess and CoreEconomy, builds the content pool from Core, and
attaches EconomyServices via with_economy. Stores/bridge are host-lifetime, never
per request.

- config.rs: required OPENFUT_MARKET_DB / OPENFUT_PILE_DB (durable file paths;
  must survive host restart — no temp defaults).
- Fail-closed startup: a bridge/store that cannot initialize returns Err from
  from_config (host refuses to start) — NEVER a silent omission or a Python
  economy fallback.

Test: from_config_constructs_and_serves_economy — builds the Server via the REAL
from_config (disposable config: temp market/pile/identity + a catalog file
derived from seeded content + the real tables dir) against a live Core, drives
credits / purchasegroup / Store BUY / market list-query-buy through it, then
rebuilds from the SAME config after a Core restart and asserts the balance
persisted. host 71 lib + 3 integration + 24 host_test green; clippy/fmt clean.
2026-08-13 21:59:29 +00:00
OpenFUT Agent 1fac71e3ef docs(route-authority): market resourceId mapping + reveal contract landed
Records fe72f0d (resourceId->Core card_id reverse mapping; listings carry both
identities; full Core+store restart E2E) and 747cc23 (GET /purchased reveal =
durable purchased pile, idempotent). Both pre-barrier correctness gaps closed.
Remaining: Python differential, host concurrency matrix, failure injection,
importer, from_config attachment, then the classifier barrier + reachability.
2026-08-13 21:51:52 +00:00
OpenFUT Agent 747cc234c1 fix(fifa17): preserve owned-pack reveal state for GET /purchased
Closes the reveal contract gap: POST /purchased opens a pack and returns
metadata; the client then polls GET /purchased for the opened items. Store BUY
returns items inline, but owned reward-pack (e.g. pack 70) opens had no reveal
read path, so a real FIFA session would show nothing after opening.

Faithful to the Python oracle (fut_store.last_pack / purchased pile): the reveal
is the set of owned items currently in the FIFA "purchased" pile — durable,
idempotent on repeat GET, cleared per-item when a card is moved to the club, and
appended-to by each open. Not a replay cache; presentation state derived from
the durable pile store + Core inventory.

- pile_store.rs: `list_by_pile(pile) -> Vec<core_item_id>` (reveal membership).
- economy_store.rs: `PurchasedPileSink` trait + optional `StoreDeps.purchased`;
  handle_store_buy/handle_pack_open record each minted item into the "purchased"
  pile. `shape_purchased_reveal` (pure): filter Core inventory to the purchased
  pile, shape with the SAME `shape_club_response` /club uses. Grants nothing,
  consumes no entitlement, allocates no id, moves no coins.
- lib.rs: EconomyRoute::PackReveal + classify_economy (GET purchased);
  BridgedPurchasedSink (records via the runtime bridge from the sync dispatch
  thread); dispatch reads the pile async + Core inventory sync + pure-shapes.

Scoping: single fifa17 profile/club (like the Python oracle), so all sessions
share one purchased pile — DIFFERENT-BY-DESIGN vs a per-SID cache, matching the
oracle's single-profile model.

Tests: pile_store::list_by_pile_filters_and_reflects_moves; and the dispatch E2E
now opens pack 70 (entitlement seeded via the Core economy API) and asserts GET
/purchased reveals the opened items and is idempotent on repeat. host 71 lib +
2 integration + 24 host_test green; clippy -D warnings + fmt clean.
2026-08-13 21:51:12 +00:00
OpenFUT Agent fe72f0def2 fix(fifa17): map market resource ids to authoritative Core card ids
Closes the market correctness gap: handle_market_list recorded listing.card_id
from the raw FIFA wire resourceId, so a synthetic buy minted a card_id Core
could not resolve — it survived the immediate response but Core's content
preflight rejected it on reboot.

- catalog.rs: keep the by_resource reverse index (was built then discarded) and
  expose `card_id_for_resource(resource_id) -> Option<&str>` — exact reverse of
  the card_id->asset catalog, no heuristics, unknown => None.
- lib.rs: `impl MarketCardResolver for Fifa17IdentityResolver` delegates to the
  same catalog /club shaping uses; Core never sees a FIFA resource id.
- market_store.rs: listings now carry BOTH `card_id` (authoritative Core content,
  what a buy MINTS) and `wire_resource_id` (the FIFA wire id, echoed in the
  auction record). New column; create_listing takes both; row/Listing updated.
- market.rs: `MarketCardResolver` trait; handle_market_list resolves resourceId
  -> Core card_id and fails closed (persists nothing) on an unmappable resource;
  auction_record emits `resourceId` from wire_resource_id. Dispatch passes the
  resolver.

Tests: list_unknown_resource_fails_closed_no_listing (B),
list_persists_core_card_and_wire_resource_across_reopen (C), catalog reverse
lookup; and the dispatch E2E now RESTORES the full Core+store restart
(economy_full_sequence_through_dispatch_and_restart) — the synthetic buy mints a
real reverse-mapped card_id, so Core's content preflight passes on reboot (A+D).
market 23 lib + catalog 15 + 2 integration green; clippy -D warnings + fmt clean.
2026-08-13 21:43:48 +00:00
OpenFUT Agent 884ecbba64 docs(route-authority): async bridge + dispatch wiring + real E2E landed (580d80a)
Records the AsyncBridge + classify_economy + try_handle_economy dispatch
(unrouted) and the economy_full_sequence_through_dispatch E2E, the
reqwest-blocking-in-async fix (off_runtime), and narrows Remaining to: Python
differential, host concurrency matrix, failure injection, importer, then the
from_config attachment + classifier barrier + reachability proofs. Flags the
two market-handler gaps (resourceId->card_id mapping; GET /purchased reveal
cache).
2026-08-13 21:30:56 +00:00
OpenFUT Agent 580d80a86e feat(fifa17): wire async economy handlers into the host via a runtime bridge (unrouted)
Bridges the synchronous thread-per-connection host to the async
transfer-market/pile handlers WITHOUT flipping the classifier. classify()
is untouched; production still proxies every economy route to Python. The
new dispatch is exercised only by the integration harness via
Server::try_handle_economy — handler wiring, not authority cutover.

async_bridge.rs: AsyncBridge owns ONE process-lifetime multi-threaded Tokio
runtime, shared by every connection via Arc. block_on() runs a future from
the sync dispatch thread; if invoked from within an ambient runtime it
offloads onto its own runtime + a std channel instead of panicking
("cannot start a runtime from within a runtime"). 4 unit tests incl. the
nested-runtime-safety case and concurrent multi-thread drivers.

lib.rs: EconomyRoute + classify_economy (mirrors the Python route table:
credits, purchasegroup, store/transaction, purchased, item DELETE/PUT,
ut/delete match/item/trade, auctionhouse/transfermarket, tradePile, trade).
EconomyServices (Core econ transport + durable MarketStore/PileStore + the
bridge + the pack-content pool), attached via Server::with_economy (kept out
of `new`/`from_config` so existing tests build a DB-less Server; production
from_config attachment is the barrier step). Server::try_handle_economy
dispatches: sync handlers (credits/purchasegroup/store-buy/pack-open/
quick-sell/match) inline; async handlers (market list/query/buy/cancel,
move) on the bridge via owned `async move` blocks. build_content_pool
derives the resolvable FIFA∩Core candidate pool from Core content.

market.rs: FIX the load-bearing hazard the FakeEconomy tests missed — the
async market handlers call the BLOCKING reqwest Core client, which panics
(reqwest::blocking::wait::enter) when run while a Tokio runtime is entered.
off_runtime() hops each Core call to a fresh OS thread with no runtime
entered, so blocking is legal. handle_move_items resolver gains `+ Sync`
(future must be Send for the bridge).

tests/economy_integration.rs: economy_full_sequence_through_dispatch drives
the WHOLE cluster through the REAL Server dispatch + bridge against a live
in-process Core (seeded with fifa17 dev content: 100k coins + owned cards),
on a plain OS thread (direct bridge path), over the real blocking
HttpCoreClient — no fakes: Store BUY (pool draw + shape + debit 400 + mint 5),
credits, quick-sell (reverse-resolve + credit), match WIN (+400), market
list->query->buy->query(sold)->second-buy-fails(no double debit), cancel
(cancelled not buyable), move-items, then reopen the durable market/pile
stores from disk (sold + pile persist). Deterministic. start_core_seeded
loads fifa17 dev content so /collection renders real definitions.

Tests: host 68 lib (+4 bridge) + 2 economy_integration + 24 host_test, all
green; adapter unchanged-green; clippy -D warnings + fmt clean.
2026-08-13 21:30:10 +00:00
OpenFUT Agent 0e2ca5a7c3 docs(route-authority): record landed Store/Market/pack handlers (unrouted)
Update cutover progress: Store BUY/pack-open/quick-sell, market
list/query/cancel/buy + move-items, durable listing/pile stores, and the
pack-content generator are implemented + tested (4d2b8b9) but NOT routed.
Remaining before the single classifier barrier: sync<->async Server wiring +
classify() routes, host<->Core writer E2E, Python differential, host
concurrency matrix, importer restart/idempotency, then the barrier + no-fallback
proofs.
2026-08-13 20:48:39 +00:00
OpenFUT Agent 4d2b8b9be3 economy(fifa17): land Store + Market writer handlers + pack generator (unrouted)
Implements the FIFA17 economy WRITER cluster on top of the landed Core
economy authority + host CoreEconomy client + identity/item-shaper infra.
Handlers are pub, unit-tested, and NOT yet routed: classify() and
ROUTE_AUTHORITY are untouched — the classifier barrier is a later single
coherent flip. No stubs; real Core-backed behavior; fail-closed on CoreError.

Pack generator (adapter fut/pack_content.rs):
  generate_pack_contents(&PackDef, &mut impl Rng, &[GeneratedCandidate])
  -> Vec<GeneratedCard>. Pure, seeded (deterministic), gold-tier split +
  special_chance gate as documented OPENFUT PLACEHOLDER policy (Python
  open_pack/_pack_body parity note inline). Fail-closed empty on empty pool.

Store/item writers (host economy_store.rs), matching oracle wire shapes:
  - handle_store_buy   PUT /store/transaction -> purchase_items (debit+mint N)
    -> createPackResponse; cancel/unknown/owned_only -> 200 {}; insufficient
    -> 461 {reason,credits}; CoreError -> 503.
  - handle_pack_open   POST /purchased -> owned_only consumes the unopened
    entitlement (redeem_entitlement, consume-once); normal packs debit+mint.
  - handle_quick_sell{_path,_body}  DELETE .../item/<id> + POST /ut/delete/.../item
    -> reverse-resolve wire->Core id (SquadWireResolver) -> sell_item ->
    {items:[{id}],totalCredits}; not-owned skipped.
  Production OwnedItemLookup = CoreItemLookup over CoreAccess.

Market (host market_store.rs / pile_store.rs / market.rs), synthetic-seller:
  - MarketStore over sqlx SQLite (WAL-once + busy_timeout=5s + BEGIN IMMEDIATE
    for writes, mirroring openfut-core::db). listings(active/reserved/sold/
    cancelled), owner-checked cancel, CAS reserve/complete_sale/rollback.
    Typed errors NotFound/Sold/Cancelled/WrongOwner/Conflict.
  - PileStore: durable pile/location metadata keyed by Core item id.
  - handle_market_{list,query,cancel,buy} + handle_move_items. Buy-now =
    reserve (CAS) -> balance precheck (461) -> Core purchase_item (mint+debit)
    -> complete_sale; any Core failure rolls the reservation back active.
    Two concurrent buyers -> exactly one sale + one debit.

Deps (additive): rand 0.8 (adapter+host), sqlx 0.7 sqlite/runtime-tokio (host).
Tests: adapter +7 (pack_content), host +43 (economy_store 20, market/store 23
incl two_reservers_exactly_one_wins, two_buyers_exactly_one_sale_one_debit,
state_survives_reopen, move_persists_across_reopen). All green; clippy
-D warnings clean; rustfmt clean.
2026-08-13 20:47:57 +00:00
OpenFUT Agent 0b31abe1d1 test(fifa17): run economy harness on a real multi-connection Core pool
The fresh-DB write-lock race is fixed (core fbb54ea: BEGIN IMMEDIATE writes),
so the E2E+restart harness now uses max_connections=5; 10/10 deterministic.
2026-08-13 20:20:58 +00:00
OpenFUT Agent 6b652cb0a2 chore(core): BEGIN IMMEDIATE write transactions (75b1830 -> fbb54ea) 2026-08-13 20:20:21 +00:00
OpenFUT Agent 3507c5714d feat(fifa17): add purchase_items to host CoreEconomy client
Complete the transport contract: purchase_items (atomic debit + mint N) on the
CoreEconomy trait + HttpCoreClient (POST /economy/purchase-items) + FakeEconomy.
This is the open-on-buy primitive the Store BUY handler will use (createPackResponse
returns the minted itemList). Fail-closed like the rest of the client.
2026-08-13 20:06:54 +00:00
OpenFUT Agent 4f78b9a875 test(fifa17): keep economy harness serialized; document multi-conn blocker
WAL-establish-once + busy_timeout (core 75b1830) reduced but did not eliminate a
brand-new-DB multi-connection warm-up 'database error'; the E2E+restart harness
stays on a single serialized connection for determinism, and the residual
multi-connection concurrency issue is documented as the remaining Part-T blocker.
2026-08-13 20:05:34 +00:00
OpenFUT Agent f3aebafcc7 chore(core): serialize WAL establishment (75b1830) 2026-08-13 20:03:40 +00:00
OpenFUT Agent 1df0bc4f00 test(fifa17): make economy integration harness deterministic
Serialize Core access with a single pooled connection (the harness drives Core
sequentially via the blocking client) to avoid a WAL-mode-establishment race
across connections warming up on a brand-new DB file, and raise the readiness
ceiling for heavy parallel test-binary load. 10/10 deterministic. (Fixed
alongside a real Core robustness fix: per-connection pragmas + busy_timeout,
core 0360135.)
2026-08-13 19:57:53 +00:00
OpenFUT Agent 7d5d0cff06 chore(core): sqlite busy_timeout + per-connection pragmas (bcc4f51 -> 0360135) 2026-08-13 19:55:13 +00:00
OpenFUT Agent 8d752cb0e4 test(fifa17): real host<->Core economy integration harness
Spawn Core (axum) on an ephemeral loopback port backed by a disposable temp-file
SQLite, seed a fifa17 profile via the real Core HTTP API, then drive the HOST's
REAL transport (HttpCoreClient: CoreEconomy) + handlers against it — no fakes:
credits reads Core balance; match-reward writer credits via Core grant_reward;
purchasegroup full-gen renders the owned pack from a Core entitlement (no
sentinel); userMassInfo overlay derives coins from the same Core state
(credits==massinfo==Core invariant). Restart phase reboots Core from the same
on-disk DB and proves coins + entitlements persist. Temp dir + 127.0.0.1:0 only;
no prod DB/ports/containers/.105. dev-deps: openfut-core, tokio, axum.
2026-08-13 19:50:42 +00:00
OpenFUT Agent 96e80ab293 feat(import-fifa17): seed unopenedPackIds as Core entitlements
Carry unopenedPackIds through the importer: Profile model -> Report ->
ApplyPlan. GenericImportRequest now emits entitlements[] (one definition_id
per unopened pack instance, order+duplicates preserved), which Core's import
seeds as unconsumed packs rows in the same transaction. Closes the economy
import gap so a migrated profile's unopened packs become Core entitlements
(feeding purchasegroup/credits/userMassInfo). Idempotency unchanged (import
fingerprint). +1 test; 26 pass, clippy -D warnings clean.
2026-08-13 19:38:04 +00:00
OpenFUT Agent 49c5185ae3 docs(utas-host): update economy cutover progress (readers + match writer landed)
Core API + purchase_items + entitlement import + host reader handlers
(credits/purchasegroup/userMassInfo) + match reward writer landed and tested;
classifier still unflipped (barrier pending BUY/pack-open/quick-sell item shaping,
market listing state, differential/concurrency/restart).
2026-08-13 19:32:45 +00:00
OpenFUT Agent 181bd94341 feat(fifa17): Rust purchasegroup, userMassInfo economy, match reward handlers
All Core-backed, fail-closed (503, never Python), NOT yet classifier-routed
(coherent barrier pending full cluster + Core seed):
- handle_purchasegroup: full Rust body from Core entitlements + StoreMode via
  the oracle-fixture-tested build_purchasegroup (no Python body dependency).
- overlay_massinfo_economy: set userInfo.currencies coins + unopenedPacks
  recoveredPacks from Core, preserving all other fields.
- handle_match_end + build_match_reward_body: derive outcome from endReason,
  credit via Core grant_reward, oracle-shaped destroy_match_body.
Invariant test: credits == userMassInfo == purchasegroup all read one Core state.
7 new host tests (+ FakeEconomy write methods honor fail flag).
2026-08-13 19:31:23 +00:00
OpenFUT Agent 09675a9f7f feat(fifa17): economy policy mappers (match reward, pack price)
Pure FIFA17 policy: match_result_coins/match_reward_total (oracle MATCH_COINS
won 400/draw 200/loss 100 + participation 0), result_from_end_reason (endReason
enum -> outcome, draw default), pack_price (catalogue buy-now, None for
unknown/owned-only). 3 unit tests.
2026-08-13 19:31:23 +00:00
OpenFUT Agent 56c364e4c9 chore(core): purchase_items + entitlement import (d32dc6e -> bcc4f51) 2026-08-13 19:27:06 +00:00
OpenFUT Agent 3021a4e761 docs(utas-host): record economy cutover progress in route-authority gate
Core economy HTTP API + host CoreEconomy client (fail-closed) + credits reader
landed; classifier not yet flipped (coherent barrier pending full cluster + Core
seed).
2026-08-13 19:14:39 +00:00
OpenFUT Agent d240a61157 feat(fifa17): host Core economy client + credits reader vertical
Add CoreEconomy transport (trait + HttpCoreClient impl over Core /economy/*):
balance, entitlements, purchase_entitlement, redeem_entitlement, sell_item,
grant_reward, purchase_item. Fail-closed by contract: any transport/status/parse
error surfaces a controlled error and NEVER falls back to Python (a fallback
would be a second writer).

Add the credits reader vertical: build_credits_body (byte-shape-identical to the
Python oracle: credits + currencies[].funds/finalFunds + optional
unopenedPacks.recoveredPacks) and handle_credits (coins = Core balance,
recoveredPacks = Core entitlement count; 503 fail-closed on Core error). Not yet
classifier-routed: the coins cluster flips as one coherent barrier once every
writer+reader moves together and Core is seeded. FakeEconomy double + 3 tests
(oracle shape, Core-backed read, fail-closed).
2026-08-13 19:14:13 +00:00
OpenFUT Agent d7c5307045 chore(core): expose economy HTTP API (c8269d0 -> d32dc6e)
Advance openfut-core gitlink to d32dc6e: generic /economy/* HTTP routes over
services::economy, server-side club resolution (game-scoped active profile, no
client-supplied club id), + list_unopened_entitlements reader. This is the
transport the FIFA17 economy cutover binds to. Core matrix 43 lib + 115
integration green; clippy clean; boundary audit clean.
2026-08-13 19:11:04 +00:00
OpenFUT Agent 838d76f95e docs(utas-host): add economy route-authority cutover gate
Machine-auditable ownership table for every FIFA17 UTAS route touching the
economy cluster (coins/inventory/entitlements), plus the writer->Core-primitive
map and the single-writer rule. Grounded in the Python economy writer audit:
4 coin-mutation routes (match reward, pack BUY, quick-sell, market buy-now),
synthetic-seller market (no sale-credit/expiry/fee), dead grant_coins/
grant_unopened_pack, no points writer. This is the deployment gate: no proxied
Python route may touch Core-owned state before R1.
2026-08-13 18:59:46 +00:00
OpenFUT Agent 9791eee67b chore(core): add generic purchase_item economy primitive (ee2caa0 -> c8269d0)
Advance openfut-core gitlink to c8269d0, which adds services::economy::purchase_item
(atomic debit + mint) — the generic Core primitive the FIFA17 synthetic-seller
transfer market needs. Evidence: the Python economy audit proved market buy-now
mints a new item with no real counterparty, so debit+mint (not two-party transfer)
is the correct generic model. Core matrix + clippy green.
2026-08-13 18:58:49 +00:00
OpenFUT Agent 5d119f5555 chore(core): reconcile Core to validated trunk + generic economy
Advance the openfut-core gitlink 3084a46 -> ee2caa0. This does two things:

1. Reconciliation: moves the canonical Core lineage onto the committed,
   validated migration trunk (66c88fb: game-scoped opaque extension,
   inventory service, squad-ext routes, content-pack loader, generic
   transactional profile-import service). The divergent local Core refactor
   that was dirtying the eab522a checkout is preserved verbatim on branch
   wip/core-local-development (f70cf44) for separate reconciliation; nothing
   is lost.

2. Economy foundation: ee2caa0 adds services::economy, a generic atomic
   profile-economy authority (currency/inventory/entitlements over the
   existing durable tables, single-transaction compound ops, fail-closed).
   The FIFA17 adapter economy engine sits on top of these primitives.

Core builds green; full matrix 41 lib + 111 integration + 16 = all pass;
clippy clean.
2026-08-13 18:45:21 +00:00
funman300 46e5f612c8 feat(fifa17): add authoritative economy engine + fut_profile importer
Adds openfut-adapter-fifa17 fut::economy — the single-writer FIFA 17 economy engine
the eventual cluster cutover needs: coins + unopened-pack entitlements + owned
inventory + stable item ids, with all-or-nothing transactional mutations faithfully
ported from the Python oracle's fut_store.Store primitives.

Atomic ops: debit (fail-closed), credit, grant_pack/consume_pack (consume-once),
allocate_item_id (unique/monotonic), add_item, and composed transactions buy_pack,
open_pack, quick_sell, market_buy_now, grant_reward. Fail-closed everywhere; the
65534 sentinel can never be bought/granted/opened (defense at the grant primitive,
mirroring grant_unopened_pack rejecting non-catalogue ids). from_fut_profile importer
round-trips coins/unopenedPackIds/items/nextItemId and floors nextItemId past the
highest existing id so re-import cannot mint a duplicate. 10 unit tests (atomicity,
sentinel safety, consume-once, quick-sell, item-id uniqueness, import round-trip).

NOT wired (R3): the live coin balance is one indivisible writer set spanning Store
BUY, pack-open, quick-sell, match rewards AND the transfer market, all in
fut_profile.json; and the generic home (OpenFUT Core) is a preserved-dirty/frozen
submodule. So a safe single-writer cutover cannot be wired yet — this engine +
importer is the coherent prerequisite. No dual-write introduced. No deployment.
2026-08-13 18:26:09 +00:00
funman300 41494bd18f feat(fifa17): port Store pack catalog + purchasegroup wire model (oracle parity)
Adds openfut-adapter-fifa17 fut::store_catalog — a pure, faithful Rust port of the
Python oracle's PACK_CATALOG + _pack_body + store_catalog assembly at production
flag defaults (FUT_STORE_DISPLAYGROUP=1, GROUPID=0, PRICE_PROBE=0):

- PackDef + PACK_CATALOG (ids 1/5/6/7/70; economy numbers are OpenFUT PLACEHOLDER,
  wire shape is oracle-verified; 65534 deliberately absent).
- pack_body() (_pack_body port), sentinel_body() (id-65534 compatibility shim),
  build_purchasegroup(unopened_ids, StoreMode) mirroring store_catalog(3627).
- Differential parity: fixtures generated from the Python oracle
  (tests/fixtures/purchasegroup_{zero_sentinel,zero_clean,pack70}.json); Rust output
  matches semantically (6 tests). Adapter 140 tests, host 24, fmt/clippy clean,
  Python A-R oracle green.

PURE wire shaping — NOT wired into the live host. Serving purchasegroup from Rust
requires an authoritative Rust owner of unopenedPackIds, which is blocked on the
economy-authority prerequisite (R3): coins are one shared balance written by many
Python-oracle routes (BUY spend, quick-sell credit, SBC/match/objective rewards)
persisted to fut_profile.json, so no single coin-touching route can move without a
whole-cluster migration. No dual-write introduced; no production deployment.
2026-08-13 18:16:47 +00:00
funman300 40ebf7c1e7 feat(fifa17): own UTAS auth/capability/purchasegroup session vertical in Rust
openfut-utas-host now classifies and owns three routes, wiring the landed
adapter store_session state machine while keeping the Store economy Python's:

- POST /ut/auth: proxy to Python (which mints X-UT-SID, adopts persona, refreshes
  save), OBSERVE the returned sid, and open a Rust session bound to peer IP +
  configured persona. Account/economy authority stays Python.
- POST /openfut/fifa17/capability: Rust-owned, no proxy — validate + register into
  SessionStore (bound/pending/ignored-late); fail-closed 400 on unsupported.
- GET .../store/purchasegroup: proxy to Python for the authoritative economy body,
  then overlay ONLY the empty-My-Packs topology from the frozen session mode —
  strip the 65534 sentinel for a verified clean-v1 SID, keep it otherwise. Rust
  never writes economy state.

Session state (Arc<Mutex<SessionStore>> + monotonic clock) lives on Server; new()
and from_config() initialise it (signatures unchanged). handle() gains a peer-IP
variant (handle_with_ip) threaded from handle_conn. Strict never-both routing is
preserved. Pure helpers (observe_sid, parse_capability_request, overlay_empty_mypacks)
+ classifier are unit-tested; adapter+host tests + Python A-R oracle all pass.

STOP-GATE: Store BUY / coins / unopenedPackIds NOT migrated — Rust has no
authoritative FIFA17 economy-mutation path (Python fut_profile.json is the source;
Core's economy is separate/unwired), so moving BUY would split store authority.
That cluster migration is the remaining R2 gap. No production deployment.
2026-08-13 17:38:59 +00:00
funman300 c7609252d2 feat(fifa17): add Rust FUT session/capability state machine (empty My Packs)
Ports the novel per-session empty-My-Packs capability negotiation — proven live
on staging and currently Python-only (fifa17-recon/tools/utas_server.py) — into
the production Rust FIFA17 adapter as a pure, dependency-free state machine
(openfut-adapter-fifa17 fut::store_session).

It owns: per-login X-UT-SID session table, single-use (ip,persona) launcher
capability hand-off (pending), capability binding (bound/pending/ignored-late),
the once-per-session clean-v1 vs sentinel freeze, TTL reaping, and fail-closed
rules (unknown/expired/ambiguous/late/cross-session/sid-ip-mismatch -> sentinel).
The clock and SID entropy are injected so it is fully unit-testable.

The full Python capability-negotiation matrix A-R is ported as Rust unit tests
(21 pass). Python remains the behavioural oracle. The delicate _pack_body UTAS
wire shaping, /ut/auth persona-adoption, /store/purchasegroup catalogue assembly
and the store BUY path are deliberately NOT ported here (documented gap); wiring
the three routes into openfut-utas-host without splitting store authority is the
remaining bounded slice toward full Rust authority. No production deployment.
2026-08-13 16:52:13 +00:00
funman300 4cf388dd3b chore: reconcile openfut-launcher submodule
Point the launcher gitlink at the reconciled merge ca7ce26
(integration/fifa17-launcher-capability-sbc), which retains BOTH launcher lineages:
  - 13339c1  FIFA 17 verified patched-client capability reporting
  - 958ff245 openfut-hook SBC request tracing / RE instrumentation

The previously-uncommitted openfut-hook WIP that blocked this move is preserved on the
submodule branch wip/openfut-hook-local (commit 4e44a37) + /tmp/openfut-hook-wip-preserved.patch
(sha256 8e65de2c…) + the untracked server.rs copy. Gitlink-only change; no other superproject
dirt staged. Backend/production unchanged.
2026-08-13 15:11:18 +00:00
funman300 00d85aa6e4 docs(fifa17): finalize capability deployment candidate
Record the overnight launcher-lineage reconciliation (merge ca7ce26 retaining both
feat/launcher-arming 13339c1 and feat/sbc-hook-tracing 958ff24; only src/process.rs
conflict, resolved keep-deleted), the deferred superproject gitlink bump (blocked by
uncommitted openfut-hook WIP overlapping the merged hook content), the validated
deployment-candidate commit tuple + local build artifacts, and the controlled A/B/C
deployment sequence. Production stays P2 active-sentinel until the A/B passes.
2026-08-13 05:18:14 +00:00
funman300 d9e80a774a test(fifa17): add explicit per-SID topology-freeze regression
Case R makes the F3 session-topology invariant explicit alongside the A-Q matrix:
for a single X-UT-SID the frozen empty-My-Packs mode never flips in either
direction (Sentinel stays Sentinel even if a capability later appears; Clean stays
Clean even if the capability is wiped), while a fresh SID from the same IP decides
independently. Complements F/G/K.
2026-08-13 05:18:13 +00:00
funman300 a82407c686 docs(fifa17): harden patched-client session binding
Record the per-IP -> per-session correction: why source-IP-only was unsafe (two
FIFA processes share an IP), the authoritative per-login X-UT-SID key with IP and
persona as auxiliary, the Capability/StoreMode state machine, the single-use
short-TTL launcher->session pending hand-off, activity-based session cleanup, and
the documented fail-closed residual for genuinely simultaneous same-(ip,persona)
logins. Design history is retained; the per-IP prototype is marked superseded.
2026-08-13 04:39:53 +00:00
funman300 805d754dc8 fix(fifa17): isolate patched-client capability per session
Harden the empty-My-Packs capability binding so a verified FIFA process can never
enable clean/no-sentinel Store topology for another unverified process that merely
shares its source IP. The prototype keyed the decision by source IP alone; two FIFA
processes (concurrent, or a relaunch) share an IP, so an unpatched process could
inherit a patched one's clean-v1 mode and crash. Source IP is now auxiliary only.

- Authoritative key = the per-login UTAS session id (X-UT-SID). /ut/auth now mints
  a fresh unique SID per login (was a shared constant) and opens a session record
  keyed by that SID; the client echoes it on every later call incl.
  /store/purchasegroup (live-confirmed). The legacy constant is still accepted by
  the retired security-question gate only, never to grant clean-v1.
- Session state: _FIFA17_SESSIONS[sid] = {ip, persona, resolver, mode, created,
  last_seen}. Store mode freezes at the first /store/purchasegroup of the session
  and is immutable thereafter. Fail-closed: unknown SID, or a SID presented from a
  different source IP than it was opened on, resolves to the sentinel.
- Launcher capability (out-of-band; cannot know the SID) is matched by (ip, persona)
  as a SINGLE-USE, short-TTL pending, bound to exactly one session at whichever comes
  first: its login (pending predates auth), the registration (session already live),
  or its first store request. Ambiguous same-(ip,persona) concurrent registration is
  ignored-late -> both sentinel (never a wrong clean).
- Session cleanup: activity-based TTL sweep (sessions 3600s idle, pendings 120s);
  reaping only removes expired entries and never affects another live session.
- account_sync now clears only stale pending for the machine (pre-launch hygiene);
  it no longer resets a per-IP mode (there is no per-IP mode any more).

Backend-only: the launcher registration payload (already carries personaId) is
unchanged. Additive; P2 sentinel remains the else-branch and the default.

Tests: matrix A-Q incl. same-IP concurrent (K), same-IP+persona relaunch (L),
same-IP failed-patch (M), late-registration-vs-frozen-sessions (N), TTL expiry (O),
duplicate/idempotent registration (P), and register-before-login pending (Q).
2026-08-13 04:39:53 +00:00
funman300 d4c3811665 docs(fifa17): document patched-client store negotiation
Design + cross-component contract for verified patched-client capability
negotiation: architecture inventory, transport choice (autopatch stdout ->
launcher, sibling /openfut/fifa17/capability endpoint), the versioned capability
and its VERIFIED semantics, autopatch verification states, launcher per-process
state, source-IP binding, the session-stable freeze point, the additive store
switch, the trust model (local preservation, not attestation), the fail-closed
matrix, and P2 retention.
2026-08-13 04:03:37 +00:00
funman300 b25761ea31 feat(fifa17): negotiate clean empty My Packs mode
Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel
ONLY for a session whose client has registered a verified resolver-guard
capability. Additive; the P2 active-sentinel path is retained as the else-branch
and the universal default. Fail-closed everywhere.

- Per-client state keyed by source IP (client_address[0]; the only per-connection
  discriminator in this single-account, stateless backend): _FIFA17_STORE[ip] =
  {resolver, mode}; mode in {None, "sentinel", "clean-v1"}, guarded by a lock.
- New POST /openfut/fifa17/capability endpoint: accepts only
  {"capability":"empty_mypacks_resolver","version":1,...}; unknown capability or
  version => 400 and records nothing (=> sentinel).
- account_sync (the launcher's required per-launch call) resets the per-ip record
  => a new FIFA process starts unfrozen with no inherited capability.
- Store topology is frozen at the FIRST /store/purchasegroup per session:
  clean-v1 iff a v1 capability is registered, else sentinel; immutable thereafter
  (late capability logged + ignored this session; a disappeared capability does
  not un-freeze a clean session). This enforces the SESSION-STABLE invariant.
- store_catalog zero-owned-packs branch: clean-v1 emits NO mypacks group (the
  client guard routes category -1 to Browse); every other case emits the existing
  active 65534 sentinel verbatim. PACK_CATALOG / pack 70 / normal packs / profile
  untouched. FIFA-17 only; not lifted into game-independent Core.
- Tests: full matrix A-J incl. concurrency isolation (two IPs, no global leak) and
  no cross-process capability leak.

Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
2026-08-13 04:03:37 +00:00
funman300 1c396dd562 feat(fifa17): report verified client patch capability
autopatch side of the verified patched-client capability handshake: prove, at
runtime, that the empty-My-Packs resolver guard is active for a specific FIFA
process, and advertise it once on stdout for the launcher to relay.

- Add a per-pid guard verification state derived by a pure, testable
  guard_state_after(cur_before, orig, patch, wrote_ok, cur_after) returning one
  of VERIFIED / UNSUPPORTED_BUILD / WRITE_FAILED / VERIFY_FAILED (NOT_ATTEMPTED
  is the pre-evaluation constant). VERIFIED means the live bytes at RVA 0x14858
  are 7f 0f (JG) after enforcement (from an applied 75 0f->7f 0f, or already
  patched). The existing fail-closed byte guard (guarded_action / STORE_PATCHES_
  GUARDED) is unchanged — this only observes the outcome.
- Emit exactly once per FIFA pid: on VERIFIED,
    [store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
  otherwise a non-advertising
    [store-guard] guard status=<STATE> fifa_pid=<pid> (no capability advertised)
- Capability constants: EMPTY_MYPACKS_RESOLVER_VERSION=1, fully-qualified name
  "fifa17.empty_mypacks_resolver".
- Tests: 5 guard-state cases + capability-constant assertions (standalone-runnable).

The capability = "the guard was verified in THIS FIFA process", never merely
"the code is present". Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
2026-08-13 04:03:37 +00:00
funman300 fc29c2eb9b docs(fifa17): record no-sentinel client resolver proof
Land the client-side empty-My-Packs resolver evidence and the session-stability
invariant established by the F3/R1 experiments.

- PART III (F3, CONFOUNDED CRASH): a mid-process sentinel -> no-sentinel flip
  left a stale POSITIVE My-Packs ordinal that still took the resolve branch and
  crashed at 0x180014882. Preserved verbatim (not a guard failure).
- PART IV (R1, SUCCESS): backend set no-sentinel first, then a FRESH FIFA
  process; genuine purchasegroup response ids [1,5,6,7] is byte-identical to the
  F3 capture, so client process lifetime is the only changed variable. Store
  opens on Browse Packs, no crash, no dialog. Guard PROVEN on the tested build.
- New INVARIANT: empty-My-Packs capability MUST be session-stable -- the server
  must not switch a running client between sentinel-present and sentinel-absent
  for the My Packs group within one FIFA process, because the client caches the
  group ordinal and a stale positive ordinal still crashes the resolver.
- Both no-sentinel captures kept: client_guard (F3) and freshretest (R1).

Backend P2 active-sentinel (65534) remains production default; no capability
handshake is implemented yet.
2026-08-13 03:13:40 +00:00
funman300 b0d5e04bb9 fix(fifa17): guard missing store category resolution
Port the PROVEN empty-"My Packs" resolver crash-guard into the canonical
autopatch.py /proc-mem patcher. When no `mypacks` purchase group exists, a
fresh FIFA 17 client resolves category id -1; CardsDLL FUN_1800147f0 at RVA
0x14858 (`JNZ 0x14869`, bytes 75 0f) treats every non-zero category as
resolvable, calls FUN_180014420, gets NULL, and dereferences [NULL+0x48] at
0x180014882 (0xC0000005). Rewriting JNZ->JG (7f 0f) preserves positive-category
resolution (EDI>0) while routing zero/negative categories to the existing
Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.

- STORE_PATCHES_GUARDED table pins RVA 0x180014858 orig 75 0f -> patch 7f 0f.
- Applied every tick, fail-closed via guarded_action(): apply only when the
  live bytes are the known original; no-op when already patched; SKIP+log an
  unrecognised CardsDLL build (never blindly overwritten).
- Runtime watch loop moved under `if __name__ == "__main__"` so the module
  imports cleanly for unit testing; script behavior is unchanged. Existing
  ProtoSSL cert-gate and STORE_PATCHES enforcement are byte-identical (indent
  only).
- test_autopatch_guard.py: pure test covering PATCH/NOOP/SKIP and pinning the
  exact guarded RVA/bytes.

Proven on the tested build (CardsDLL 4706a881...) by a clean fresh-process
no-sentinel A/B (R1). Dormant while the backend active-sentinel is present.
See docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV.
2026-08-13 03:13:39 +00:00
funman300 6746c75302 docs(fifa17): RE-backed native client-fix design for empty My Packs
Investigation + design only (no client/backend/binary changes, no live
Store experiment). Reconfirmed CardsDLL_Win64_retail.dll (4706a881..,
unpacked) against a freshly rebuilt Ghidra project on .105; FIFA17.exe
(29c31cef..) is Denuvo-packed so the Scaleform decision is unreadable.

PART II added to docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md:
- Native category path traced: FUN_18007dab0 (store render, RVA 0x7dab0)
  reads screen+0x290; My Packs funnels through FUN_1800147f0 (0x147f0) ->
  FUN_180014420 (0x14420, NULL on ordinal miss) -> crash MOV [RDX+0x8] at
  0x14882 ([NULL+0x48]), matching the Exp-B minidump. Tab->ordinal map
  FUN_180014580 (0=mypacks..5=special); category 0 = list-all (Browse).
- Unopened-pack count is a data-manager singleton (vtbl[0x4d8] get /
  [0x4e0] set), reachable from the store resolver.
- Vehicle: existing openfut-hook -> version.dll proxy (already deployed);
  reuse ssl_patch signature-scan + connect_hook inline detour. No new loader.
- Preferred strategy A: entry-hook FUN_18007dab0; when the requested
  category is My Packs and unopened count==0, force screen+0x290=0 (Browse).
  Removes crash + fake 65534 tile + dialog + nav gate; count>0 untouched.
- Ranked B (resolver NULL fallback, higher risk) and C (null-guard, crash-only).
- Build guard: module gate + SHA/PE + signature scan; unknown build -> no
  patch, backend sentinel remains fallback.
- First experiment design (needs a later, separately-authorized backend
  empty-no-sentinel test mode) + client rollback (config flag / dll swap).
- Keep backend 65534 sentinel deployed until strategy A is verified.

Describes the FIFA 17 client/data model only; not OpenFUT Core assumptions.
2026-08-13 01:48:58 +00:00
funman300 b2697b13dc docs(fifa17): establish verified card taxonomy
Single source of truth for FIFA 17 FUT card families, reconciled against
the authoritative shipped fcc_*.json + staff tables (verified byte-identical
between .105 and this repo, 36/36 sha256).

- docs/CARD_TAXONOMY.md: family -> table/rowcount/subtype/carddbid/cardassetid,
  with OBSERVED/INFERRED/HYPOTHESIS/UNKNOWN labels. Corrects four superseded
  claims (chem styles are 250-273 not 91-136; 6300/6400xxx are kits not badges;
  5004xxx misc and 8010xxx league logos exist). Manager-league precision kept
  distinct: shipped table 300-340 (41 rows) vs client enum 300-341 (341 defined,
  unshipped). Club-item wire subtype->family mapping preserved as UNKNOWN.
- docs/evidence/fifa17-recon/table-hashes.sha256: 36-file provenance manifest
  (31 fcc_*.json + 5 staff tables), combined hash 10f239ad...

Describes the FIFA 17 data/client model only; not OpenFUT Core assumptions.
2026-08-13 01:31:22 +00:00
funman300 e8ee6c34e7 docs(fifa17): record empty My Packs client contract
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up.

Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
2026-08-13 01:08:47 +00:00
funman300 f42279f869 fix(fifa17): keep empty My Packs group client-safe
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".

Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via the movie's CATEGORY_ID -> screen+0x290; no server field gates it). CardsDLL FUN_1800147f0 then dereferences the resolved group with no null guard, so an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.

65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).

Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
2026-08-13 01:08:37 +00:00
funman300 54ad9e8f79 docs(state): Slice 8 — real-data staged retail A/B PASS
Records the operator-assisted live A/B on the REAL imported club (1949/1962, 13
Legends deferred): real club render, clean pagination, Special filter (1665, 0
base leaked), squad edit persistence + cold relaunch, and rollback->Python->Rust
re-enable. Documents the three real-data fidelity fixes (versioned resourceId
e187cd4, rareflag 626c972, rare=SP filter 6f16a23) and the nation/league/team
model correction (44fcf24). Marks PRODUCTION NOT READY pending .105 Legends name
data for the 13 deferred assets. Aggregate counts only; no private identity.
2026-08-12 21:37:42 +00:00
funman300 6f16a231fc fix(fifa17): implement rare=SP 'Special' club filter via rareflag
The club search 'Quality = Special' sends rare=SP, which was a deliberate no-op
('semantics UNKNOWN'), so it returned every card — base golds included. The
rareflag work now grounds it: a special is rareflag > 1 (base rare = 1),
evidence-backed by the FIFA17 taxonomy + the observed profile (base Ronaldo/Messi
rareflag 1; their informs 11/24).

rareflag lives in the FIFA catalog, not Core, so Core cannot filter it:
- map_to_core: rare=SP now sets CoreOwnedQuery.special (host-applied), not
  'unsupported'; any OTHER rare value stays unsupported. special is NEVER a Core
  /collection param. is_special_rareflag(rf)=rf>1 lives in the adapter.
- handle_club special path: fetch all items matching the OTHER filters (offset/
  limit stripped), shape (resolves rareflag), then special_filter_page() keeps
  rareflag>1 and paginates the FILTERED set locally (start/count over specials,
  not Core's unfiltered page) — no base leakage, no post-pagination drops.

Verified live on the real staged club: rare=SP -> 1665 items (=1949-284 base),
rareflag distribution all >1, zero base leaked; pagination page0==full[0:50],
page1==full[50:100], no overlap. Tests: adapter map rare=SP->special, unknown
rare stays unsupported, is_special_rareflag predicate; host special_filter_page
filter+paginate. adapter 113 + host 25 + importer 25 green; clippy -D clean.
2026-08-12 21:25:18 +00:00
funman300 626c972232 fix(fifa17): carry observed rareflag so special cards render as specials
shape_item hardcoded rareflag=1, so all 1949 cards shaped as basic rare gold
regardless of type; informs/specials lost their card art. The dev fixture is
base-only, so this was invisible until the real profile (10 distinct rareflag
values) exposed it on .105.

rareflag is definition-level FIFA identity metadata OBSERVED from the profile
(the raw wire integer, never a guessed marketing label), so it lives in the
FIFA catalog like asset_id/version, not in generic Core:
- ObservedDefinition.rareflag + emitted into the production catalog entry.
- Fifa17CardCatalog RawCard/Fifa17CardIdentity gain rareflag (default 1 when a
  base-only catalog omits it, preserving prior wire behaviour).
- Fifa17Identity.rareflag; host resolver populates it from the catalog.
- shape_item emits id.rareflag instead of a hardcoded 1.

Verified on the real staged /club: wire rareflag distribution == source exactly
(0 per-item mismatches across 1949; e.g. rareflag 3 x591, 24 x302, 21 x256).
adapter 111 + host 24 + importer 25 tests green; clippy -D clean. rareflag lives
in the host catalog, not Core, so no re-import was needed.
2026-08-12 21:13:51 +00:00
funman300 44fcf24d92 fix(import-fifa17): nation/league/team are instance metadata, not definition identity
Evidence (resourceId 169193): its 4 owned copies are IDENTICAL in asset/rating/
position/all attributes and differ ONLY in nation/team/league (and those resolve
inconsistently, e.g. team 240 'Atletico Madrid' under league 16 'Ligue 1'). A
player's club affiliation is an instance-time snapshot, not part of the card
DEFINITION identity.

Correct the model (not a special-case): the definition-consistency gate now
compares a DefIdentity projection (asset_id/version/rating/position/attrs/
rareflag) and EXCLUDES nation/league/team. A club-only difference between copies
of one resourceId is no longer a conflict; a real identity disagreement
(rating/position/attrs/asset) still trips it. The definition's display
nation/league/club use the first-observed copy (deterministic; display-only,
never identity). No --defer-conflict allowlist entry is needed for 169193 now.

On the real profile: conflicts 1->0, 169193 reclassified conflict->NoName
(still deferred, unnameable), supported still 1681, deferred instances still 13,
BLOCKERS none without any --defer-conflict flag. 2 new tests (club-only diff is
not a conflict; rating diff still is). crate suite 25 green; clippy -D clean.
2026-08-12 20:58:33 +00:00
funman300 e187cd49a2 fix(fifa17): preserve versioned resourceId on the wire (no special->base collapse)
shape_item emitted resourceId/definitionId = asset_id (base), collapsing every
versioned (special) card onto its base definition on the /club and squad wire.
The dev 32-card fixture is base-only (version 0), so Slice 7 never exposed it;
the real profile (1531 versioned cards) did.

Fifa17Identity now carries resource_id (= (version<<24)|asset_id, == asset_id
for a base card). shape_item emits resourceId/definitionId from resource_id and
assetId/cardassetid from asset_id — versioned and base stay distinct. The host
resolver populates resource_id from the catalog's reconstructed resource_id
(the catalog already parsed version; it was dropped before shaping).

Regression test: versioned 117617092 (v7 of asset 176580) shapes resourceId/
definitionId=117617092, assetId/cardassetid=176580.

Verified on the real staged /club: 1949 items, wire-id set exact, 0
wire->resourceId mismatches, 0 duplicate-multiplicity mismatches vs the source
manifest. adapter 111 + host 24 tests green; clippy -D warnings clean.
2026-08-12 20:46:48 +00:00
funman300 1631d3b1a2 feat(import-fifa17): --apply — recoverable two-store real-profile import
FIFA17-specific orchestration that installs the real profile across BOTH durable
stores (openfut-identity + Core SQLite) recoverably and idempotently, handing
Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer).

apply module:
- owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace;
  identical in BOTH stores (identity core_id AND Core owned_cards.id), so the
  running host's wire->owned reverse lookup resolves exactly what Core stored.
- plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest
  (card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire)
  mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the
  SAME adapter code (parse_squad_put + build_squad_write) the retail-validated
  live squad path uses. Refuses if the report has blockers.
- Two-store protocol (apply): staging gate -> local Core-preflight mirror ->
  identity dry-preflight -> idempotent seed (insert_existing_mapping per instance
  + set_watermark) -> ONE generic Core import transaction (spawned binary) ->
  cross-store post-validation -> completion record. A crash after identity
  seeding re-converges on re-run (idempotent mappings + Core already_imported):
  no cleanup, no reminting.
- gate_staging: deferred players are ABSENT from an import; allowed only for a
  staged run behind --allow-deferred-players-for-staging (never a silent default;
  prints an INCOMPLETE banner). Production requires zero deferred instances.

CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data,
--identity-store, --allow-deferred-players-for-staging). Depends on
openfut-adapter-fifa17 + openfut-identity + uuid(v5).

Proven end-to-end on the real 33068179/CAGE profile (staged): first apply
imports 1949 supported instances (293 base + versioned), 11/11 f433 squad +
opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an
idempotent no-op (already_imported, DB unchanged); staging-not-default refuses
before any write; every OwnedItemId is an opaque UUID; identity wire-id set ==
source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0.

10 new apply tests (determinism, request/mapping/squad translation, blocker
refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/
idempotency, conflict detection, graceful spawn failure). clippy -D warnings
clean; crate suite 23 tests green.
2026-08-12 20:36:34 +00:00
funman300 c71c2a8d33 feat(import): --emit-content (production pack + host catalog + private manifest)
Fold entity resolution (nation/league/club id->name via committed tables,
mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so
the supported set is honest about unresolved entities too. Add an explicit
--defer-conflict <rid> allowlist: a reviewed conflict (169193) defers, any NEW
conflict still hard-fails (defer never becomes a silent conflict suppressor).

--emit-content writes three files, PUBLIC content separated from PRIVATE account
state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_<resourceId>,
base+versioned, tier rarity, profile-derived, no promo labels), a versioned host
identity catalog {card_id:{asset_id,version}}, and a private import manifest
(supported instances' wire ids + deferred set with reasons + preserved watermark
+ target profile + snapshot fingerprint). Emit refuses while blockers exist.

Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned),
9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949
importable instances, watermark 100004617 -> next 100004617, active squad f433
11/11 supported. fmt + clippy -D warnings clean; 13 tests.
2026-08-12 19:41:17 +00:00
funman300 a51947562c feat(import): identity import API + FIFA17 real-profile dry-run importer
openfut-identity:
- insert_existing_mapping(game,kind,core_id,external_id): preserve an existing
  external wire id instead of minting; idempotent for an identical mapping,
  rejects conflicting forward/reverse with IdError::Conflict, persists atomically.
- persisted per-scope allocator watermark (set_watermark/watermark_for) so a
  future mint continues past the source high-water even across burned-id gaps;
  next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk
  format (legacy bare [Row] still loads). +4 tests (10 total).

openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python
profile for a faithful Core import. Enforces disjoint item-class balance;
proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs
versioned never collapse) with a resourceId-group consistency gate (hard-fail on
disagreement, never pick a winner) and honest buildability (roster name +
version formula + metadata, never fabricated); plans owned-instance identity
(preserve Python wire ids, preserve nextItemId watermark); checks active-squad
coverage. --apply/--emit-content refuse to write in this phase. 11 tests.

Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17
consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9
NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a
divergent nation/team/league); 1949 importable player instances, watermark
100004617 -> first new alloc 100004617; active squad f433 fully supported.
fmt + clippy -D warnings clean.
2026-08-12 19:23:19 +00:00
funman300 63f02c4fb1 docs(state): Slice 7 — FUT squad read+write retail-validated on FIFA 17
Record the staged retail A/B: FIFA consumed the Rust squad path end-to-end
(userMassInfo overlay, in-game swap -> squad-replace {"id":0}, formation
f442->f433 persisted to Core, cold relaunch returned the persisted squad),
with Python rollback / Rust re-enable proven by host-log presence. Note the
OPENFUT_DEV_CONTENT_GAMES=fifa17 startup requirement (silent empty /collection
if omitted) as a needed deployment/preflight assertion.
2026-08-12 18:41:24 +00:00
funman300 4fd5ee2608 redirector: a port probe must not forge the signature of a TLS fault
"TLS HANDSHAKE FAILED: ... unexpected EOF" is exactly how the certificate
mismatch presented -- the defect that cost three live gate attempts and was
invisible everywhere else. It is the one line this project has learned to
treat as serious.

A reachability probe forges it for free: TcpStream::connect followed by a
drop opens the connection and closes without sending a byte, and the
acceptor reports that as "unexpected EOF". The launcher's preflight makes
two such probes per run. On 2026-08-12 they produced ten of these lines and
sent a whole session diagnosing a client-side fault that did not exist --
autopatch, ptrace_scope and client_arm.sh were all investigated before the
pairing of the timestamps gave it away.

Classify before the acceptor sees the connection: peek one byte, and treat
EOF-before-any-byte as a probe with its own quiet line. A timeout is
deliberately NOT a probe -- a slow or broken client must still reach the
acceptor and produce a real diagnostic, since misclassifying a fault as
benign would defeat the point.

The counter exists because the test needs it. A probe produces no response
either way, so a test written against client-visible behaviour passes with
the classification deleted; asserting on a count is what makes the mutation
detectable. Verified: all three mutations (drop the classification, treat
undetermined as a probe, treat a speaking client as a probe) are killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:25:21 +00:00
funman300 c7d4b9f753 style(adapter): rustfmt catalog test assertions
Trailing rustfmt reflow of two catalog unit-test assertions (no logic change); clears the adapter dirty state so verify-build-identity.sh passes for the UTAS A/B.
2026-08-12 17:15:14 +00:00
funman300 37c2e5d7ee feat(utas-host): serve GET /squad/active from Core
Migrate the active-squad READ off the Python oracle to the existing Core-backed projector, completing the squad authority (read + write + /squad/list + userMassInfo overlay) on one projector.

- classify: GET /ut/game/<t>/squad/active -> Route::SquadActive. Numeric GET /squad/<n> stays on Python (no Core multi-squad model yet).
- handle_squad_active returns the projector object via user_mass_info_squad(v, persona) — byte-identical to userMassInfo.squad; degrades to an empty overlay on stale/missing/Core-error, never falls back to Python.
- persona: new REQUIRED OPENFUT_PERSONA_ID (non-zero) on HostConfig, injected not baked, must match LSX/Blaze/POW/UTAS identity.
- tests: squad_active parity test; classify updated; README config table + A/B command.

fmt + clippy -D warnings + tests (24 host + adapter) green.
2026-08-12 17:10:04 +00:00
funman300 7dbd878398 test: update mutation-battery anchors after rustfmt reflow
Formatting reflowed two mutation target lines onto multiple lines; update
the battery anchors (adapter #14 kit lookup, host #19 Content-Length push)
to the new unique substrings. Both batteries kill all mutants again
(adapter 14/14, host 20/20).
2026-08-12 04:01:37 +00:00
funman300 c2e2e0d8f2 style: rustfmt squad host + adapter files
Formatting-only. Runs the project formatter over the files authored/edited
this session (host lib+tests, adapter item/squad_ext/squad_projection/mod +
projection test). The intentionally-preserved dirty catalog.rs and
pre-existing /club-era host drift beyond these files are out of scope.
2026-08-12 03:59:15 +00:00
funman300 afc909fd3b fix(fixtures): sanitize lab subnet from committed UTAS captures
The capture sanitizer redacted tokens/device ids but left the lab Host
address (10.10.0.x) in three committed UTAS fixtures, tripping
scripts/check-no-lab-addresses.sh. Replace with an RFC 5737 TEST-NET
address (192.0.2.120) per the guard's own doctrine. Host header is
plaintext metadata only (tests decode body_b64), so no test is affected.
Pre-existing leak (fixtures committed at 0b66662/eb8311a); no lab address
was introduced this session.
2026-08-12 03:59:15 +00:00
funman300 b607ff28cb chore: bump openfut-core gitlink to rustfmt'd squad-ext routes (3084a46) 2026-08-12 03:58:51 +00:00
funman300 cf86d4e425 test(utas-host): squad host mutation battery (20 mutants, all killed)
mutation-battery.sh injects each of the 20 required wrong behaviours into
the committed host (or the adapter it composes) source, runs the one host
test that must catch it, and requires a non-zero exit (killed), reverting
via git after each. Adds a duplicate-definition host round-trip test.

Kills: authz-skipped, authz-loop-empty, failure-masked-as-success,
PUT-as-partial-diff, extension-dropped, stale-accepted, missing-fabricated,
overlay-clobbers-{userInfo,settings,pile}, list-separate-shaping,
captain-as-resourceId, kit-by-slot, client-eval-dropped, host-trusts-fp,
per-slot-N+1, squad/active-to-Rust, duplicate-collapse, stale-content-length,
python-squad-after-failure. 20/20 killed.
2026-08-12 03:11:25 +00:00
funman300 85761390a8 feat(utas-host): FIFA17 squad authority — PUT, /squad/list, userMassInfo overlay
Extend the UTAS migration host to own the squad slice, reusing the exact
production identity path (Fifa17CardCatalog + ExternalIdentityStore) that
/club uses, so /club, /squad/list, userMassInfo and PUT all agree on
wire<->owned identity.

Routing (classified ONCE, no try-Rust-then-Python):
  PUT  …/squad/<n>   -> Rust (numeric id; …/squad/active stays Python)
  GET  …/squad/list  -> Rust
  GET  …/userMassInfo-> Python proxy, ONLY .squad overlaid
  everything else    -> Python verbatim

CoreAccess gains read_squad_ext / replace_squad / all_owned (HTTP to the new
Core /squad/ext + /squad/replace routes).

PUT pipeline: parse -> build_squad_write (reverse-resolve every wire id;
refuse unresolved/duplicate) -> AUTHORIZE every resolved owned item against
the active club (identity resolution is NOT authorization; a valid wire id
owned by another profile is rejected before any mutation) -> Core atomic
replace+extension -> exactly {"id":0}. No Python fallback on failure; no
host-side second extension store; no fingerprint recomputation.

Read path assembles ONE projection input (one read_squad_ext + one batch
all_owned; no per-slot lookup) and runs the single adapter projector. Both
/squad/list and userMassInfo.squad derive from it. Fresh projects; Stale is
never applied; Missing is never fabricated — both are prominent integrity
failures, never served from Python (no split authority). The overlay
replaces only .squad and preserves userInfo/settings/userData/
pileSizeClientData, fixing Content-Length.

Tests: routing, full-replacement + exact ack, unknown/foreign/duplicate item
rejection with Core unchanged, idempotent repeat PUT, coupled read-after-
write (list + userMassInfo agree, real resourceIds + stable wire ids),
overlay field preservation, bounded no-N+1 reads, Stale/Missing integrity.
2026-08-12 03:04:24 +00:00
funman300 4d30d8b3e8 chore: bump openfut-core gitlink to squad-ext HTTP routes (9b2c6b8)
Exposes GET /squad/ext and PUT /squad/replace so the UTAS host can read
and atomically persist the FIFA17 squad canonical+extension state over
HTTP. Core commit sits atop the frozen contract 615c5fd (branch
rust-migration/squad-ext-routes); no domain change. The preserved dirty
openfut-core worktree (eab522a) is intentionally left untouched, so root
status still shows 'M openfut-core' as before.
2026-08-12 02:47:36 +00:00
funman300 0e30980632 style(adapter): elide redundant lifetime in squad projection test helper 2026-08-12 02:31:13 +00:00
funman300 46a81e7a07 test(adapter): squad mutation battery (14 mutants, all killed)
mutation-battery.sh injects each of the 14 required wrong behaviours into
the committed source, runs the one invariant test that must catch it, and
requires a non-zero exit (mutant killed), reverting via git after each.

Kills: kit-by-slot, captain-as-resourceId, chemistry-reconciled,
custom-regenerated, index-derived, stale-accepted, missing-fabricated,
faked-asset-id, duplicate-instance-collapse, PUT-as-slot-diff,
wire-id-in-canonical, projector-bypasses-shared-shaper, schema-version-
ignored, player-state-keyed-by-definition. 14/14 killed.
2026-08-12 02:30:38 +00:00
funman300 e09344490f feat(adapter): single FIFA17 squad projector + fixture round-trip tests
fut::squad_projection is the ONE projector for every squad read shape.
project_squad(canonical squad + Fresh extension + owned items) -> the FIFA
17 squad wire object; user_mass_info_squad and squad_list are envelope-only
wrappers over the same output (no per-endpoint domain model).

Design guarantees exercised by tests:
  - purity / no N+1: consumes a host-assembled input (read_squad_with_ext +
    one batch owned-cards fetch + in-memory card defs); no per-slot lookup
  - shared shaper: every occupied slot is shaped by fut::item::shape_item,
    so squad items and /club items cannot drift
  - Fresh -> full projection; Stale -> never applied (verdict surfaced);
    Missing -> explicit, never fabricated
  - captain projects as the resolved WIRE id (never resourceId); index and
    formation round-trip verbatim; kit follows the player; two owned copies
    of one definition stay distinct

Adds committed sanitized fixtures decoded from the squad session capture
(swap, f433, persisted userMassInfo.squad read, squad/list) and
tests/squad_projection.rs: baseline / swap / formation-change / persisted
read-after-write round-trips asserted by ownership class (canonical,
extension, shadow, derived identity), plus one-projector no-divergence.
2026-08-12 02:28:05 +00:00
funman300 80a8bc4520 feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder
Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned
payload Core stores opaquely alongside the canonical squad. Carries the
FIFA-only wire state that is not Core-canonical:
  - custom[]        opaque 33-int string, round-tripped verbatim
  - squad_type      observed FIFA token
  - kit_numbers     keyed by owned_card_id (kit follows the PLAYER, proven
                    by the swap/formation captures), never by slot/definition
  - manager         opaque item ref (not a squad player; not shaped)
  - kicktakers      opaque role refs; relationship to captain UNKNOWN, so
                    preserved verbatim and never normalized to the captain
  - client_reported chemistry/rating/starRating shadow, never authoritative
from_payload enforces the payload schema version first (distinct from Core's
DB schema); an unknown version is rejected, never coerced.

build_squad_write turns a parsed PUT + host wire->owned resolver into a
canonical ProposedSquad + extension, refusing on unresolved ids or a
duplicate owned item. Identity resolution is explicitly NOT authorization.

Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical
(FIFA-only + shadow fields moved to the extension); the canonical formation
is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that
could not even represent f433) so formation and index round-trip exactly
with no derivation. Bench split is the fixed 23-slot array convention.
2026-08-12 02:19:22 +00:00
funman300 b50e0359f7 feat(adapter): extract shared FIFA17 FUT item-shaping primitive
Move the per-item card shaper (CoreOwnedItem, Fifa17Identity,
ItemIdentityResolver, ShapeStats, shape_item) out of club_response into
fut::item so /club and the upcoming squad projection emit byte-identical
items from one source of truth. club_response keeps only the /club
{itemData:[...]} envelope and re-exports the moved types for API
stability. shape_item is now pub; no behavior change (all /club and
oracle-parity tests unchanged and green).

Adds item-shaper tests: full-field identity mapping and the duplicate
owned-copy invariant (two instances of one definition keep distinct wire
ids, share one asset id).
2026-08-12 02:14:41 +00:00
funman300 58a300c7f4 core: game-scoped opaque squad extension + atomic fingerprint write (submodule 615c5fd) 2026-08-12 01:40:13 +00:00
funman300 eb8311a5ee evidence: FIFA17 squad controlled retail capture (swap/formation/relaunch) sanitized fixtures 2026-08-12 01:16:46 +00:00
funman300 550a59d12c feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted) 2026-08-12 00:53:46 +00:00
funman300 8c1d1ed958 docs(mirror): /club RUNTIME VALIDATED on retail FIFA 17 2026-08-12 00:36:26 +00:00
funman300 fc00b0c6f9 docs(mirror): /club composition proven live (slice 5) 2026-08-11 23:05:29 +00:00
funman300 5276dd2066 feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test 2026-08-11 23:00:08 +00:00
funman300 88da16a11e feat(fifa17): curated dev content pack generator + Core dev seed (submodule 36abd4b) 2026-08-11 22:57:02 +00:00
funman300 3ef3bc32ec feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders 2026-08-11 22:33:40 +00:00
funman300 36fe1caa3f feat(fifa17): deterministic base-card seed generator + full identity catalog
scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data
(pool.json + roster.json + leagues/nations/teams tables) -> the card-definition
identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>),
version 0 (base cards only; resource_id == asset_id). --check mode diffs against
committed output (drift-detection mutation-proven). Provenance embedded.

Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563
base assets. Semantic definition coverage (to /tmp, not committed here): 17,547
resolvable; 16 skipped for missing roster name (reported, never fabricated).

Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801
-> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards
exist', not 'which the user owns'. Core content seeding + dev-owned set next.
2026-08-11 22:19:57 +00:00
funman300 f55c401b6c feat(fifa17): card-definition identity catalog + owned-item wire-id policy
fut::catalog — Fifa17CardCatalog maps a semantic CardDefinitionId to a FIFA 17
render identity (resource_id = (version<<24)|asset_id; version 0 => resource==
asset). Versioned JSON (schema_version=1, game=fifa17); validates schema/game,
rejects asset_id > 24 bits, and rejects two card ids claiming one resource_id.
Unknown definitions resolve to None (callers drop, never fabricate).
Fifa17WireItemIdPolicy carries the owned-item namespace (base 100_000_000,
first id 100_000_001, per the oracle) supplied to the generic store.

Adds serde derive to the adapter. 8 catalog tests; 3/3 mutations killed
(resourceId-drops-version, conflict-detection-off, asset-range-off).
Phase commit 2/5. No card->asset DATA shipped: the synthetic Core catalogue is
unmappable (see seed plan); the loader + format land now, population later.
2026-08-11 21:59:50 +00:00
funman300 b8037b9b22 feat(identity): generic game-scoped external-identity store
openfut-identity: durable, reversible (game_id, entity_kind, core_id) <->
external wire id mapping. Game-independent infrastructure (adapters supply the
numeric policy via base_floor; the store guarantees stable/unique/reversible/
game-scoped/persistent/atomic/explicit). JSON-file backed behind an
ExternalIdentityStore trait (SQLite can drop in later); parking_lot-guarded,
atomic temp+rename persist, rejects a torn reverse-duplicate on open.

Core never learns FIFA integers; only the host/adapter that owns a game
boundary uses this. 6 tests, 4/4 mutations killed (same-id-for-two-items,
lost-on-restart, broken-reverse, dropped-game-scope). Phase commit 1/5.
2026-08-11 21:57:15 +00:00
funman300 c0a3f68ded feat(utas): FIFA17 UTAS migration host + /club adapter mappings
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).

openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).

openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
2026-08-11 21:40:15 +00:00
funman300 04c5043aba utas: My Squad filter corpus — every filter identified, root cause measured
Controlled retail capture, one criterion at a time, cleared between each.
47 transactions. Every filter the My Squad picker sends is now known from
the wire rather than guessed.

Route: GET /ut/game/fifa17/club -- the picker hits UTAS and reuses the
general club-inventory route.

  level=any|gold        quality      lowercase, ALWAYS present
  rare=SP               "Special"    uppercase, OMITTED when off
  position=ST           position     uppercase, omitted when off
  nation=52             entity id    numeric
  league=13             entity id    numeric
  team=5                entity id    numeric, NESTED under league
  sort=desc                          client constant; the UI has no sort control
  start=/count=11       pagination

Two encoding families: short string enums, and numeric FIFA ids. The ids
must never reach Core. Filters compose as plain ANDs in one query --
string and id filters alike -- so each maps independently.

THE ROOT CAUSE IS SELF-AMPLIFYING.

club_route honours type, team and league; it never reads start, count,
level, sort or year. Because start is ignored, every page returns the
same full set, so the client concludes the page was full and asks for the
next one. One scroll produced 22 requests and 6.2 MB, stopping at
start=200 only because the client gave up -- against a filtered set of 32
items that should have been three pages.

That also explains why the bug reads as erratic rather than broken:
league=13&position=ST returns every Premier League player instead of
Premier League strikers. Plausible, wrongly sized, hard to notice.

Measured filtered sets, from the real cluttered club -- these are the
acceptance test for the fix:

  unfiltered            1962
  league=13              350
  league=13&team=5        32

Two client behaviours worth carrying forward: the picker fires a query
per highlighted entry, not per selection (two requests for one club
pick), and parameter ORDER is not stable, so parsing must be key-value.

FIXTURE SIZE: bodies over 4 KB are truncated in the committed fixture,
with body_full_len and body_full_sha256 retained, because the same 1.1 MB
club response repeats ~25 times and its hash already proves identity.
13.3 MB -> 247 KB. The raw .ofcap keeps every byte, privately and
gitignored. Truncation is recorded per transaction so a trimmed fixture
is never mistaken for a whole response.

Audited across all three identifier surfaces -- headers, JSON bodies,
query strings -- before and after the size change: no leaks. 6/6
sanitiser mutations still killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:32:57 +00:00
funman300 0b66662525 utas: first real corpus, and two sanitiser gaps the audit caught
24 transactions across 11 connections from a retail session: login,
hub, one pack open, two squad saves, a quick-sell, with before/after
state manifests. Raw .ofcap stays gitignored at 0600; the sanitized
corpus is committed as adapter fixtures.

TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER.

1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens
   were being redacted correctly and these were not. A committed fixture
   is a published fixture.

2. Then, with those fixed, the audit fired AGAIN on the file about to be
   committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in
   the QUERY STRING. Three input surfaces carry identifiers -- headers,
   JSON bodies, and query strings -- and the sanitiser knew about two.

Both fixed in the tool rather than by editing the file, with a
regression test and a mutation for the query path.

AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported
the query-redaction mutation as SURVIVED while a hand-run of the same
mutation killed it. Cause: the harness pointed at a stale scratchpad copy
of the test that pre-dated the query assertion, so it was faithfully
testing the mutated tool against a test that could not detect the
mutation. That is the same class as the build guard checking the wrong
binary and cargo reusing a binary compiled from mutated source -- the
third instance today of measuring the wrong artifact. The harness now
resolves ROOT from its own location and runs the COMMITTED test; the
stale copy is deleted.

Harness committed as scripts/mutate-utas-observe.py so this is repeatable
rather than a thing that happened once in a scratch directory. 6/6 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:59 +00:00
funman300 cdea85e214 utas: standalone recording proxy that tees rather than rebuilds
UTAS needs a real request/response corpus before any Rust is written: it
is where protocol shape and FUT state start being coupled, so guessing is
worse here than it was for Blaze. The oracle truncates logged bodies at
~200 chars, and raising that cap would mean editing the behavioural
specification to make it easier to copy -- backwards. A proxy gets the
same evidence and leaves the oracle untouched.

THE DESIGN RULE: TEE, DO NOT REBUILD.

UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive,
pipelining and chunked transfer are all live. A proxy that parses a
request and re-emits it can corrupt the traffic it exists to observe --
and that corruption would present as a UTAS bug, pointing the
investigation in exactly the wrong direction. So bytes are copied
verbatim in both directions and a second copy goes to disk; transactions
are reconstructed later, offline, from that copy. A parser bug therefore
spoils the record and never the session.

Standalone, NOT in the container, so the same tool can later sit in front
of a Rust UTAS host and replay an identical captured request against both.

Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode
0600 and gitignored; sanitized/transactions.jsonl is the committed
artefact. Bodies are preserved EXACTLY and sanitised second -- only
known-secret headers and JSON keys are replaced, structure is never
reshaped, and every redaction is recorded in the transaction so a reader
knows what was touched.

Captured per transaction: connection id, sequence, relative and wall
time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED
ORDER as pairs (a dict would drop duplicates and ordering), raw body and
length for both directions, status, and observed keep-alive.

Verified as two independent properties, because they fail differently:
transparency (bytes through the proxy identical to bytes direct, Date
masked, with the mask asserted to have fired) and fidelity (parsed
transactions match what was sent, including a dechunked response and a
300-byte POST body). 5/5 mutations killed, including "record but do not
forward", "drop the last byte of every chunk" and "stop redacting".

scripts/test-utas-observe.py is committed alongside it: a capture tool
nobody can re-verify is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:13:06 +00:00
funman300 05f6147433 http: extract the shared body drain; fix a flaky test race it exposed
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.

The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
answering while the client is still sending leaves unread data in the
receive queue and Linux turns the close into an RST rather than a FIN --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.

SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:

              redirector   roster
  head cap    65536        16384
  read chunk  4096         1024
  on error    abort        proceed if any bytes arrived

Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.

Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.

Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.

Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.

Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:04:08 +00:00
funman300 8f3b659c33 lifecycle: one host-lifecycle helper; roster.sh; ban pkill -f
redirector.sh and the coming roster.sh needed the same five rules, each
of which cost something to learn:

  * resolve /proc/PID/exe; never match a command line. `pkill -f` /
    `pgrep -f` match any shell whose ARGUMENTS mention the name, including
    the shell running the command. That has killed this session's own
    shell twice, and is now banned in migration tooling -- the helper
    contains no `-f` matching and the header says why.
  * `readlink`, not `readlink -f`. After a rebuild the link reads
    "<path> (deleted)" and -f resolves it to nothing, so the orphan check
    goes blind to exactly the long-lived processes it exists to find. Two
    orphans hid there, one serving the wrong certificate.
  * stop PROVES the process is gone and the port free.
  * an ambiguous binary is an error for start/verify but NOT for
    stop/status: rollback must never be blocked by a question about the
    build tree.
  * verify the RUNNING process's commit, not the artifact on disk, which
    a rebuild can silently advance past.

Copying those into a second script would have been the same mistake as
copying the TLS setup. Instead scripts/host-lifecycle.sh owns them and a
service supplies four facts: name, crate, executable, port variable.
redirector.sh goes from 178 lines to 26 and roster.sh is 24, with no
behaviour change -- the refactored redirector.sh still sees the live
armed process (pid 830736, port 42227) and still refuses correctly
because HEAD has moved past it.

Paths are unchanged (rundir, pidfile, portfile, commit stamp, log), so
the currently running redirector stays manageable across this refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:15 +00:00
funman300 c9ae914910 roster-host: transport host for the FUT roster update, lifecycle-matched
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.

Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:

    dwell after responding   0 ms      (redirector: 300 ms)
    request body             drained   POST answered only once it arrives
    close                    clean FIN, never RST
    keep-alive               none      one request per connection

The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.

Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:

    GET  230B   HEAD 163B   POST 163B
    drained=True  reset=False  answered_before_body=False
    keepalive: second request accepted by the socket, never answered

Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.

9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".

drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.

Not deployed and not switched: Python still serves :8081.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:41:11 +00:00
funman300 84e81f2037 tls: extract a shared listener; move FIFA 17's profile into its adapter
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.

Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.

Split along the line the architecture already draws:

  openfut-tls               how to build an acceptor. Game-independent.
                            Knows nothing about which suites any client
                            offers.
  adapter-fifa17::tls       what FIFA 17 was OBSERVED to offer: the six
                            enabled suites, the two refused, the TLS 1.2
                            window, the EA SNI. Plain strings, so the
                            adapter keeps its lean dependencies -- reading
                            a card table should not build OpenSSL.
  redirector-host           joins the two. Chooses no cipher of its own.

Behaviour is unchanged, and shown to be:

* tests/fifa17_tls_profile.rs carries over every case from the deleted
  module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
  suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
  a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
  when gates 1-14 passed. A "pure refactor" that cannot fail is not a
  claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
  the retail client negotiated at 17:09 today.

Two improvements fall out of having one place to look:

* the startup banner now prints cert_sha256. The mismatch above raised no
  error at startup and broke the client much later with nothing logged;
  it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
  is gate evidence and gets read by people.

Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:30:01 +00:00
funman300 696386a9c1 client_arm: verify the hosts entry by resolution, not by presence
The old check was `grep easw /etc/hosts && echo ok`. It passed on ANY
matching line -- including a line that shadows ours. glibc returns the
first match, and the sed above only deletes lines this script wrote
(`# openfut`), so a foreign entry earlier in the file wins forever and
re-running the script never helps.

Observed today: a leftover `127.0.0.1 easw.easports.com` from the
single-machine era, before the backend moved to its own host. Every arm
reported "/etc/hosts ok" while the name resolved to loopback.

Now it resolves the name -- the same call the game makes -- and compares
address to address, so a server given as a hostname is handled too. On a
mismatch it prints the offending lines with line numbers and says how to
fix them.

It does NOT delete them. This script writes one tagged line and owns only
that line; silently removing entries a user put there by hand is a bigger
hazard than the shadowing it would cure.

Reported as a warning, not an error, because it is survivable: the
responders advertise the server address, so the game stops using this
hostname after the first redirected contact. FIFA reached the FUT hub
today with this exact misconfiguration in place. Claiming it is fatal
would be wrong, and a check that overstates its findings gets ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:19:52 +00:00
funman300 aa2679162d redirector.sh: never leave it ambiguous which binary is under test
Two defects, both found by the guards misfiring rather than by reading:

1. BIN preferred target/debug and fell back to release only when debug was
   absent. `cargo build --release` therefore produced a correct binary while
   the script kept inspecting a stale debug one, and the build guard refused
   with a message naming a commit nobody was trying to run. The guard was
   right that something was stale — it just pointed at the wrong artifact.
   Disagreement between the two is now an explicit refusal naming both, with
   OPENFUT_REDIRECTOR_BIN as the deliberate override.

   The refusal is recorded at load and raised only by `verify` and `start`.
   `stop` and `status` must work in any build-tree state: rollback can never
   be blocked by a question about which artifact would have been started.

2. `verify-running` read the stamp file without checking the process still
   existed. The stamp outlives the process, so after a stop it reported on a
   corpse — either "identity OK" or a REFUSAL naming a commit, both implying
   something was running when nothing was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:20:16 +00:00
funman300 096d1c882f switch: fix the unquoted python string that broke status with no --name
`cmd_status` has two paths. The `--name` path filters on an exact tag and
works. The no-name path — the "show me every switch on this box" survey,
which is how an orphan switch under a different name would be found —
built its python with shell quote-juggling and never closed the string
literal, so it died with a SyntaxError every time.

It failed loudly (rc=1, a traceback) rather than reporting "no rules", so
it never lied about the state. But it also meant the survey path had
never once run, which is the more useful lesson: every branch of a safety
tool needs exercising, not just the branch the happy path takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:16:10 +00:00
funman300 ca63095786 lifecycle: stop the orphan check going blind when the binary is rebuilt
`list_procs` matched on `readlink -f /proc/PID/exe`. Once the binary is
rebuilt -- which happens constantly here, `cargo test` alone is enough -- the
link reads "<path> (deleted)" and -f resolves it to something that matches
nothing. The scan then finds zero processes, so `start`'s orphan check passes
and a second instance can be launched alongside a stray.

Not theoretical. Two orphans were running undetected tonight:

  pid 592731  :42327  a stale-cert redirector left from testing check-tls-parity,
                      still serving F9:16:1A -- the exact certificate whose
                      mismatch cost three live gates
  pid 542693  :42230  a Blaze sidecar debug build from 03:12

Neither was in a client path, so neither was doing harm, but a stray listener
serving the known-bad certificate is precisely what should never sit around
unnoticed.

Fixed by using plain readlink and stripping the " (deleted)" suffix. Shown both
ways: with the bug `status` reports no processes at all for a live pid; with the
fix it reports 604454. The pidfile path was unaffected, which is why `stop` kept
working and hid this.
2026-08-11 05:25:33 +00:00
funman300 c65e9c54ce observe: correct a stale comment calling the catch-all 'other'
It is a TOTAL -- every packet reaching the chain counts there, including ones
already counted by a named-port rule. The old wording invited reading the number
as a remainder, which is how 15 unexplained attempts got misread earlier.
2026-08-11 05:20:24 +00:00
funman300 b1bc7a764e adapter: port the FUT roster-update response, held to the live oracle's bytes
Next component in the migration order (Roster -> LSX -> UTAS). Adapter layer
only: no host, no runtime replacement, nothing armed.

The response is shaped as much by http.server.BaseHTTPRequestHandler as by the
oracle's handler code, so it is captured over the wire rather than reasoned
about:

  * HTTP/1.0 status line -- protocol_version is left at its default, so the
    reply is 1.0 even though the client asks for 1.1
  * send_response injects Server: and Date: BEFORE the handler's own headers
  * POST answers with headers only: the handler writes the body `if method ==
    "GET"`, so a POST advertises Content-Length: 67 and then sends nothing

That last one is preserved, not corrected. It looks like a bug, but "obviously a
bug" has been the wrong call before in this port, and a test now asserts it so a
future cleanup has to argue with something.

Date and Server are volatile and are MASKED in the fixture rather than dropped,
so their presence and position are still asserted. Server is additionally
recorded verbatim: it carries the container's Python version, so a drift away
from roster::ORACLE_SERVER fails a test instead of silently changing every byte
we emit.

generate_roster.py --check FAILS when it cannot reach the oracle rather than
passing, and mutation-testing the mutation harness itself caught two "surviving"
mutations that were really sed no-ops. With application verified, all four
mutations (header order, Content-Length, XML body, Connection) are killed.
2026-08-11 05:19:29 +00:00
funman300 d7c0a5521d switch: refuse to arm at a dead target; watchdog: use a pidfile
Three times now the same sequence has broken the client path: a build guard
correctly refuses to start the Rust replacement, and the `switch on` that
follows in the same script arms anyway, because it never checked whether
anything was listening. The redirect then lands on a closed socket and the
working Python service is bypassed for no benefit.

`on` now refuses unless the target port is listening. ALLOW_DEAD_TARGET=1
overrides it for arming ahead of a service that is about to start, but that has
to be deliberate. Verified both ways: rc=2 and nothing installed against a dead
port, rc=0 and two rules with the override.

The watchdog now writes a pidfile. Stopping it by command-line match is unsafe
-- any shell whose arguments merely mention the script name matches too, which
has now killed the wrong process twice here (once via `pkill -f`, once via a
/proc/*/cmdline substring loop).
2026-08-11 05:13:40 +00:00
funman300 2ae90b1ea9 tooling: watchdog that rolls an armed switch back when the service stops answering
`openfut-switch.sh on` prints "the service MUST stay up" -- true, and useless
when nobody is at the terminal. An armed switch pointing at a dead port means
the client hits a closed socket with no fallback.

This turns the documented rollback into an automatic one, failing toward the
Python oracle. The worst case of a spurious trip is a gate needing re-arming;
it can never leave the client broken.

It only ever REMOVES a switch. It does not install one, restart the Rust
service, or touch Python, and it does not re-arm after tripping -- an
unexplained rollback should be a finding to read, not something hidden by
flapping the switch back on.

The probe goes through the switch and speaks TLS, because a bare TCP connect
would succeed against a process wedged mid-handshake.

Tested both directions, not just the happy path: quiet for 45s against a
healthy service, and against a stopped one it failed 3/3 in 9s, rolled back,
and left Python serving -- verified by re-reading all four tables and by which
implementation's log grew.
2026-08-11 05:11:41 +00:00
funman300 cfb0435d96 redirector.sh: verify the RUNNING process's commit, not just the binary on disk
`verify` inspects `$BIN --identity`, which is the file on disk. That is not
necessarily what is serving. Caught during gate 14 setup: the live process had
been started from 5bc39e9, then `cargo test` re-ran build.rs (the branch ref
moved when an unrelated script was committed) and restamped the on-disk binary
to fc411bb. `verify` then reported "build identity OK" about an artifact that
was not the running service.

`start` now records the stamped commit to $RUNDIR/redirector.commit, and
`verify-running` compares THAT against HEAD, refusing when they differ. The
existing on-disk check stays -- it is the right gate for "may I start this" --
but only the recorded stamp answers "is the thing currently serving the thing I
think it is", which is the question a live gate's evidence depends on.
2026-08-11 05:03:47 +00:00
funman300 fc411bb6f1 scripts: require one certificate across the whole FIFA-facing TLS stack
Two live gates were lost to a second variable I had been asked to eliminate.
The Rust redirector was pointed at the repo's fifa17-recon/tools/redir_cert.pem
(fingerprint F9:16:1A...), while the running container serves a different cert
baked into its image (E7:F9:46...) which the Python redirector, roster and the
rest of the stack all share. So the A/B compared TLS implementation AND
certificate identity at once.

FIFA 17's ProtoSSL caches the server certificate for a backend. The redirector
is the first TLS connection of a session, so its cert becomes the one the client
expects; the next service presenting a different cert fails its handshake. That
is why the redirect itself always succeeded and the failure surfaced later, on
the roster fetch -- "An error occurred downloading the FUT Squad Update".

It stayed invisible because Python's socketserver swallows it: a handshake
failure at accept() raises ssl.SSLError, which subclasses OSError and is
discarded by _handle_request_noblock. No request log, no stderr. Every server
looked healthy while the client could not talk to any of them.

Confirmed on the wire: tls-observe in front of the roster server captured four
ClientHellos from the client, correct SNI and the same 8 static-RSA suites it
offers the redirector, none of which produced a request.

The check is mutation-tested against the real bug: with a redirector started on
the stale repo cert it exits 1 and names the mismatch.
2026-08-11 04:38:23 +00:00
funman300 5bc39e902d tooling: observe client connection ATTEMPTS; make the build guard reject bad args
openfut-observe.sh answers the one question no server log can: when a gate
fails and a service logged nothing, did the client try and fail, or never try?
Both look like silence. Two redirector gates were lost to that ambiguity --
"roster server logged nothing" was equally consistent with a broken roster
service, a wrong roster URL, and a client that never asked.

Built on iptables packet counters because this box has no tcpdump, no
conntrack, and no readable kernel log. That last one is verified rather than
assumed: an initial LOG-based version installed correctly and its rules matched
(counters proved it), but the output went nowhere -- journalctl -k has no
entries and dmesg is empty. Counters are also lower volume and record only SYNs,
so no payload can be captured even in principle.

Validated against the live client, not a loopback stand-in: an initial
self-test using this host's own address counted almost nothing, because
locally-generated packets never traverse PREROUTING. Against the real remote
client it counts 8081 at ~4/min, matching the roster server's own log.

Known gap, recorded rather than hidden: the catch-all TOTAL runs well above the
sum of the named ports, so the client makes steady background attempts to ports
not tracked here. It is present during a working session, so it is not the
failure signature, and it is not chased further here.

verify-build-identity.sh now rejects an argument that is not a commit hash.
Passing the binary path instead of its stamp previously produced a plausible
"REFUSING: binary was built from ./target/release/... but HEAD is <sha>", which
reads as a real stale-build finding rather than a caller mistake -- and a
safeguard that cries wolf is one people learn to route around. Usage error is
now exit 2, distinct from a genuine stale build (1) and success (0).
2026-08-11 04:22:40 +00:00
funman300 e2c4ca6d56 redirector-host: reproduce the oracle's connection lifecycle, not just its bytes
Two live gate attempts failed with "An error occurred downloading the FUT
Squad Update" while the redirect response was verified byte-identical to the
Python oracle. Rolling back to the Python redirector fixed it, so the response
bytes were never the whole contract.

Log archaeology found the discriminator: the client polls
/fifa17/fut/rosterupdate.xml ~4x/min in every successful FUT session, and the
only gap in 300 recorded fetches is 03:47-03:58 -- exactly the two
Rust-redirector sessions. The Blaze RPC sequence over those sessions is
identical (msgNum 0-53), so the divergence is entirely outside Blaze.

A differential lifecycle probe against both redirectors found the two
behaviours this host never reproduced:

  * the oracle drains the request body per Content-Length; this host stopped
    at the header terminator, leaving unread data in the receive queue, which
    makes Linux close with RST rather than FIN
  * the oracle holds the connection open ~300ms before closing
    (time.sleep(0.3)); this host closed at 0ms

Both are now reproduced. The dwell is a named constant, ORACLE_CLOSE_DWELL,
overridable only so the causal experiment -- set it to 0, confirm the failure
returns -- can be run without a rebuild.

The suite could not have caught either: it sent Content-Length: 0, so there
was never a body to drain. It now POSTs a body, and asserts a split-write body
is fully consumed.

Testing the drain via client-visible symptoms does NOT work -- verified by
mutation: with the drain removed the client still reads the buffered response
and sees close_notify before any reset. So the host records a per-connection
ConnOutcome and the test asserts on that. Both mutations (no-dwell, no-drain)
are now each caught by exactly one test.

This does not yet prove causation for the FUT Squad Update failure; it removes
the only two measured divergences. Gate 6 is the test.
2026-08-11 04:12:32 +00:00
funman300 288d990821 empty commit to advance HEAD for the stale-binary mutation test 2026-08-11 03:46:08 +00:00
funman300 c03702707b redirector: commit stamp + shared build-identity verifier that REFUSES
The binary records only the commit it was built from -- no dirty-tree flag.
Cargo will not re-run a build script because another crate's source changed, so
a compiled-in 'clean' claim can be stale and is not a safeguard; that was
verified on the Blaze host.

scripts/verify-build-identity.sh establishes both facts at LAUNCH, where they
cannot go stale: the stamped commit equals HEAD, and the migration crates are
clean. It REFUSES rather than warns, because for a migration gate a warning on
stderr is something to scroll past.

--identity prints the stamp without valid configuration. The launcher must be
able to establish which commit a binary came from BEFORE deciding whether to
run it; requiring a correct environment first would invert the check.

redirector.sh mirrors sidecar.sh: refuses to start with an orphan present or
the port busy, matches the resolved executable rather than the command line
(pgrep -f matches any shell mentioning the name), and stop PROVES the process
is gone and the port free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:46:07 +00:00
funman300 89f77470f3 redirector: Rust host on vendored OpenSSL; shared typed config extracted
TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored`
feature. NOT native-tls. native-tls abstracts over whatever the platform
provides; here the requirement is the opposite -- precise, evidenced behaviour
for one legacy client -- which needs explicit control of the cipher list,
protocol floor/ceiling and security level. Vendored so a distro libssl update
cannot silently change whether FIFA 17 can connect.

Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol
crates gain an OpenSSL dependency.

CIPHERS driven by the captured retail ClientHello, not by generic legacy
assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are
deliberately NOT, even though the client offers them -- it already negotiates
AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the
service for nothing. TLS 1.2 floor and ceiling, matching the observed client;
the floor is not dropped to 1.0 pre-emptively because "the oracle permits it"
is not "the client requires it".

SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed,
and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change
was needed and none is applied; it remains overridable per-listener with
evidence.

CERTIFICATE: the proven Python redirector's material is reused, so the TLS
implementation stays the only variable in an A/B. Verified RSA-2048, CN
winter15.gosredirector.ea.com, cert/key modulus match; the key stays
gitignored.

SHARED CONFIG. New openfut-host-config is now the only crate that reads the
environment, and both hosts resolve endpoints through it. Two hosts each
parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing
endpoints from different sources of truth" the address audit forbids.

VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no
accessor for a context's configured suites at this version, which turned out
better: the host now rehearses the retail handshake at startup with a client
restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so
a cipher/version misconfiguration surfaces at boot rather than as an
unexplained failure during a live gate.

Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates
TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an
RC4-only client is refused; an ECDHE-only client is refused (proving no modern
policy was silently inherited); a full HTTPS round-trip returns bytes
IDENTICAL to the Python oracle's recorded response.

Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117,
openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a
routine bump -- it requires re-running the FIFA compatibility gates.

Gates 6-14 need the retail client and are next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:28:04 +00:00
funman300 0d576a14b7 switch: one generic NAT implementation; blaze-switch becomes a wrapper
The Blaze switch was hardwired to 42130 and could not intercept the redirector.
Rather than clone it, the iptables logic now lives in one place:

  openfut-switch.sh   generic: --server-ip --intercept-port --target-port
                      --name [--client-ip] [--legacy-tag]
  blaze-switch.sh     thin wrapper, CLI and output UNCHANGED so the validated
                      gate runbook and sidecar.sh's cross-check keep working

No deployment IP or port literal in the generic tool; 42130 is supplied by the
wrapper, 42127 by the redirector experiment.

VERIFICATION IS INDEPENDENT OF REMOVAL. Rules are created and deleted by their
comment tag; they are verified by parsing the kernel's own FIELDS (chain,
destination, dport, to-ports) with no reference to the comment. Status detects
duplicates, incomplete pairs, conflicting targets under one name, and foreign
redirects on the same port -- which it reports but never deletes. `off` removes
only rules bearing this switch's exact tag, then re-reads the table to confirm.

THREE BUGS FOUND WHILE BUILDING IT, all in the same family as the original
lying rollback:

1. Renaming the tag ORPHANED live rules. Gate 10 deliberately ended with the
   switch on, so rules carrying the old tag were still installed and the
   renamed tool could not see them -- `off` would have reported success while
   traffic stayed redirected. Hence --legacy-tag: a rename must not strand
   rules it owns.
2. Deleting by re-feeding the raw `iptables-save` line through the shell fails
   on this iptables, which prints `--comment "tag"` WITH quotes; word-splitting
   leaves the quotes inside the value so nothing matches. Bare-comment rules
   deleted fine, which is exactly what made it look like it worked. Deletes are
   now rebuilt from parsed fields and passed as argv elements.
3. `IFS=$'\t' read` collapsed consecutive tabs because tab is IFS *whitespace*,
   so an absent `-s` shifted every later field left and produced
   `-s <dport> --dport <to_ports> --to-ports ''`. Harmless here, but a shifted
   spec that matched a real rule would delete the wrong one. Now uses \x1f.

Mutation-tested against all seven required cases: wrong intercept port, wrong
target port, missing rule, duplicate rule, changed comment representation
(bare vs quoted), and a rollback that leaves a foreign redirect installed --
which exits non-zero rather than claiming success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:12:09 +00:00
funman300 c5807c07a9 blaze-host: passive ClientHello observer for the redirector TLS decision
The redirector TLS question cannot be answered from the cipher OpenSSL
selected: its server follows client preference by default, so FIFA preferring
static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on that
inference would be a guess. This reads the actual ClientHello.

PASSIVE BY CONSTRUCTION. Bytes relay verbatim both ways, nothing is injected
or rewritten, and the handshake is still terminated by the untouched Python
redirector. A parse failure logs and relays anyway -- observation must never be
able to break the path it observes.

Reports record/client version, supported_versions, SNI, every offered suite by
name, extensions, and a verdict on whether ANY forward-secret suite is offered,
which is exactly the rustls question. Unknown suites print as hex rather than
being dropped.

Verified end to end against the live Python redirector with openssl s_client:
31 offered suites parsed, 18 classified forward-secret, and Python logged the
relayed request and served its 406B serverinstanceinfo -- proving observation
AND pass-through in one run.

Unit-tested on truncated and non-TLS input; the verdict is asserted in both
directions so a static-RSA-only hello reports RULED OUT rather than defaulting
to the permissive answer.

NOTE: that 18-suite result is from openssl s_client, NOT from FIFA. It proves
the instrument works. The actual question is still open until a retail FIFA
ClientHello is captured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:04:13 +00:00
funman300 8f5f54833f ci: tripwire against lab addresses creeping back into tracked source
Cheap insurance, explicitly not the real check -- the semantic tests in
deployment_config.rs are what prove propagation, using two TEST-NET addresses
and bind != advertise. This grep only stops the lab subnet reappearing months
from now when the reasoning has been forgotten.

Deployment config legitimately contains real addresses and lives in gitignored
files, so it is never scanned. The frozen baseline doc is allowlisted BY PATH:
it records what a past deployment actually was, and rewriting it would falsify
the record.

Also swapped the lab IP for a TEST-NET placeholder in the usage examples and
error messages of compose/entrypoint/client_arm. Those were already correct
architecture -- every one requires the address via ${VAR:?} -- but using the
real lab IP as the example is the same 'happens to match our lab' smell, and
placeholders keep the tripwire allowlist near-empty.

Mutation-tested: adding a lab address to a source file makes it exit 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:59:41 +00:00
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.

DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.

DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.

CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.

TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.

SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.

MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.

Wire behaviour unchanged: oracle fixtures still current, 153 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:55:50 +00:00
funman300 8aab2c0d41 adapter: FIFA 17 redirector response; Nucleus deliberately not ported
REDIRECTOR. The first hop's <serverinstanceinfo> XML, byte-for-byte against
the oracle across three advertised addresses. Owns the response only; TLS and
HTTP transport belong to a host, exactly as the Blaze adapter owns dispatch
while the sidecar owns the socket.

The <secure>0</secure> field is the client being told the second hop is
plaintext -- independent corroboration of the plaintext Blaze finding, now
expressed in code.

NUCLEUS IS NOT PORTED, and that is a finding rather than an omission.
Instrumented across every live session:

  listener bound            YES  0.0.0.0:42131 since 00:21:32
  handler logs on connect   YES  unconditional, before any parsing
  client received the URL   YES  OSDK_NUCLEUS fetched 10+ times
  client connected          NO   zero requests, including 4 full FUT flows

So the long-standing nucleusConnect=0.0.0.0 anomaly is explained: FIFA never
follows that URL on this path. The invalid address has never mattered because
nothing dials it. Porting the stub would add an untested component for no
parity gain.

TLS CONSTRAINT RECORDED, NOT RESOLVED. All 9 observed handshakes negotiated
AES256-GCM-SHA384 = TLS 1.2 with STATIC RSA key exchange. rustls supports only
forward-secret (EC)DHE suites and cannot serve that. Whether the client also
OFFERS ECDHE is unknown -- OpenSSL follows client preference by default, so
preferring static RSA does not prove it is the only option. This must be
instrumented from a real ClientHello before a TLS stack is chosen; the module
docs say so rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:48:12 +00:00
funman300 ed0ccb8c2b blaze-host: check client sessions in BOTH network namespaces
Host-side ss cannot see the Python backend's connections: the responders run in
a container, so a client session terminates at 172.20.0.2:42130 inside its
namespace and the host only sees the NAT'd flow. 'ss | grep <client>' on the
host therefore reports nothing while a session is very much alive.

That produced a wrong precondition: 'no .105 Blaze session -- closed' was
reported while FIFA was mid-session on Python, and gate 9 was armed against a
client that had never exited. Python's own log had the answer -- it logs closes
reliably and there was no close for that session.

client-state.sh looks in both namespaces, reports Rust and Python separately,
and exits non-zero while any session is live. An unreachable container counts
as 'cannot confirm', not as 'clear'.

Fourth measurement bug in this tooling, and the most consequential: the other
three mis-COUNTED, this one mis-STATED a precondition and caused an action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:34:46 +00:00
funman300 b40adac3fc gate-evidence: window FUT-action counts to the gate, not the whole log
'pack opens recorded: 45' appeared in the gate 8 report. The UTAS log is
cumulative across the entire deployment, so a bare count reads as if 45 packs
were opened during that gate; the real number was 1.

Now reports both, labelled, windowed from the sidecar's start time (it is
restarted per gate, so that is the gate boundary). A bare count in a gate
report will be read as belonging to that gate, so it has to be the one that
does.

Third counting bug in this tooling: the trace frame counter matched OPEN/CLOSE
markers, the capture and trace were read seconds apart during a live session,
and now this. Evidence tooling gets the same scrutiny as the code under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:31:55 +00:00
funman300 bfb7876ed4 gate-evidence: count trace frames correctly, archive the raw capture, observe FUT actions
Three fixes, all found while closing out gate 7.

1. Frame count was wrong. It counted lines matching '^conn-', which also
   matches the OPEN/CLOSE lifecycle markers, inflating the figure by one or
   two. Compared against the capture's record count that looked like a
   capture/trace divergence (89 vs 88) when there was none: read at the same
   instant, both report 99. Evidence tooling that miscounts is exactly what
   this project cannot afford.

2. The raw capture is now copied into the evidence bundle (0600), so a gate's
   forensic bytes travel with its report.

3. FUT actions are now observed on the UTAS side. 'Known FUT action succeeded'
   is a client-side fact, but FUT actions go over UTAS -- which is never
   switched -- so the UTAS log confirms them independently of anyone's
   recollection. Gate 7's pack open shows up as:
     STORE: opened pack Special Players Pack -> 11 items

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:28:27 +00:00
funman300 55b4e54d5f gate-evidence: add the Python-side positive/negative observation
'Rust did not receive it' is weaker than 'Python did'. The redirector always
runs on Python and is never switched, so it advertises the Blaze endpoint on
every run; whether Python then receives the Blaze CONNECT it just advertised
says where the hop actually went.

This is already visible in the existing logs and settles gate 5-6 more firmly
than the sidecar record alone:

  02:02:13  Python REDIR SENT -> 10.10.0.120:42130  (to .105)
  02:02:13  Rust  conn-0005 CONNECT from 10.10.0.105
            Python received NO Blaze CONNECT

Same second, both sides: Python advertised the endpoint and did not get the
connection; Rust did. It is also the mechanism gate 8 needs in reverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:21:21 +00:00
funman300 fafa2f1858 blaze-host: document capture, sanitization, and what the tests do not cover 2026-08-11 02:16:11 +00:00
funman300 c84fd14cac blaze-host: make the build stamp trustworthy for evidence attribution
Committing updates refs/heads/<branch>, not the HEAD file, so watching HEAD
alone left the stamp one commit behind -- observed live, the banner read
a84a72e immediately after 2337431 was committed. build.rs now also watches the
resolved branch ref.

Belt and braces, since cargo still cannot see every source change: sidecar.sh
compares the binary's stamped commit against the tree's real HEAD at launch and
says so loudly on a mismatch. An evidence artefact that names the WRONG commit
is worse than one that names none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:15:11 +00:00
funman300 23374312bc blaze-host: opt-in raw frame capture + auditable sanitizer
Evidence infrastructure, not protocol functionality. Built before gates 7-10
because those sessions cannot be reproduced -- a later run is a different
session, and the migration-validation runs happen once. Gates 5-6 already went
past without their bytes being recorded.

TWO LAYERS

  live FIFA traffic
    ├── raw capture      exact RX/TX bytes, mode 0600, gitignored
    └── blaze-sanitize   → repository-safe, replayable fixtures

CAPTURE. Off unless OPENFUT_BLAZE_CAPTURE names a file. Deterministic
big-endian container: 20-byte file header, then per-frame records carrying
connection id, a global monotonic sequence, timestamp, direction and the EXACT
frame bytes. RX is recorded as received; TX only AFTER a successful write, so a
record means the bytes were sent rather than intended.

Component/command/msgNum/msgType/payload length are deliberately NOT stored
beside the frame: they are already in its 16-byte header, and a redundant copy
can disagree with the bytes, leaving a reader unable to tell which is true.
Record::header() derives them, so every field the requirements name is
available without duplicating it.

SANITIZER. Redacts only the named tags in SENSITIVE_TAGS (KEY, AUTH, SESS,
MAIL, PML) and reports every substitution with path, kind and length.
Replacement is LENGTH-PRESERVING, so the TDF varint, payload length and Fire2
header are unchanged and the sanitized frame is exactly the size of the
captured one -- asserted per frame, failing rather than emitting a subtly
different conversation. Frames with nothing sensitive keep their exact wire
bytes. Payloads that will not decode are passed through and REPORTED, so a
reader knows they were never inspected rather than assuming they were checked.

TESTS. 39 in this crate. All nine required cases: capture disabled produces no
artefact; RX and TX captured exactly; ordering preserved; fragmented input
(one byte at a time) reconstructs the same frames as a single write; coalesced
input is captured as separate frames, not per-read; capture does not alter wire
output; sanitization removes a real session key from a real captured login;
malformed/truncated/wrong-version captures fail clearly; every listed sensitive
tag is provably reachable.

MUTATION TESTED. Dropping TX capture, truncating captured frames to their
header, and removing KEY from the sensitive list were each verified to turn the
suite red. One mutation was NOT caught: moving the TX capture above the write.
It is indistinguishable while writes succeed and only diverges when one fails.
That invariant is held by code placement and a comment saying so, not by a
test, and the code says as much rather than implying coverage it does not have.

Python oracle unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:14:25 +00:00
funman300 a84a72e0c0 blaze-host: A/B the RPC routes a real FIFA session actually used
The gate 5-6 live run exercised 14 RPCs the recorded fixtures never covered --
Stats, Clubs, OSDKSettings, SponsoredEvents, Messaging::fetchMessages,
Util::getTelemetryServer, Util::userSettingsLoadAll, UserSessions cmd 0x0008,
and the transport PING -- every one taking the empty-reply fallback.

That Python does the same was an inference from reading its dispatch table.
This sends those exact routes to both backends and diffs the replies:
14/14 byte-identical.

Worth keeping: the fixtures were built from what the responder implements, so
they could never have covered what the client asks for and the responder does
not. Only a live session reveals that surface, and this makes it checkable
afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:05:18 +00:00
funman300 6c102f00c0 gitignore: gate-evidence bundles are run artefacts, not source
They contain live traces and logs from a specific run; they belong with the
run, not in the tree.
2026-08-11 02:01:18 +00:00
funman300 48aa955212 blaze-host: per-gate evidence capture, separating asserted from observed
For the live FIFA gates. Records switch rules, sidecar status, log, trace and
the Python contract result into a timestamped bundle, and reports CONFIGURED
and OBSERVED state as two distinct sections.

The separation is the whole point. 'blaze-switch.sh status = ON' is an
assertion produced by the same tooling that performs the switch, and that
tooling reported a successful rollback once when none had happened. The
observed half comes from an unrelated source: the sidecar's own record of
which peers connected to it. A non-loopback peer in that log proves the
client's Blaze traffic landed on Rust without depending on reading an iptables
rule correctly.

Verified both ways: loopback-only traffic reports 'a FIFA session did NOT land
here'; a non-loopback peer reports that it observably did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:00:31 +00:00
funman300 cf3ddde3a6 blaze-host: move the dirty-tree safeguard to launch and evidence time
The compiled-in dirty flag cannot be trusted for this job. Cargo does not
re-run a build script when another crate's source changes, so editing the
adapter and rebuilding the host leaves it reading 'clean' -- verified by
appending a line to the adapter and watching the flag not move.

So the stamp now only names the commit, and the real safeguards run at the
moment they matter and cannot go stale:

  * sidecar.sh checks the working tree at LAUNCH and warns.
  * check-live-parity.sh REFUSES on a dirty tree, since it produces the
    artefact a migration decision is made from. ALLOW_DIRTY=1 overrides for a
    throwaway check.

Both scope to the three migration crates, so unrelated submodule dirt does not
trigger them -- a warning that is always on is a warning nobody reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:56:43 +00:00
funman300 468b006008 blaze-host: scope the dirty-tree check to the crates the binary is built from
A whole-repo check read DIRTY permanently, because unrelated submodules carry
pre-existing modifications. A warning that is always on is a warning nobody
reads, which defeats the point: the flag exists so a mutated build announces
itself before it can be mistaken for parity evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:55:20 +00:00
funman300 e091921b18 blaze-host: safe sidecar lifecycle, Blaze switch, build identity
Prerequisites for the live FIFA A/B. Two safeguards here exist because the
corresponding failure actually happened, not because it was imagined.

BUILD IDENTITY. build.rs stamps commit + working-tree cleanliness; the host
prints commit, tree state, profile and a fingerprint of the bundled config
table at startup, into both the log and the trace. A dirty tree prints an
explicit "do NOT treat results from this binary as parity evidence" warning.
The previous step left four sidecars running, two serving mutated builds, and
nothing in their output said so.

SIDECAR LIFECYCLE (sidecar.sh). start/stop/status/check-orphans/with. Start
refuses when any sidecar is already running or the port is busy. Stop kills,
waits, then PROVES it: PID gone AND port free AND no stray processes, failing
if any check does not hold. `with -- CMD` traps EXIT/INT/TERM so cleanup runs
however the command exits.

  Bug found and fixed while testing it: orphan detection used `pgrep -f`,
  which matched any process whose command line merely mentioned the name --
  including the shell running the test script. It now matches the resolved
  executable via /proc/PID/exe. `pgrep -x` is unusable because Linux truncates
  the process name to "openfut-blaze-h".

BLAZE SWITCH (blaze-switch.sh). Redirects Blaze to the sidecar with a scoped
NAT rule instead of editing the frozen Python oracle, whose redirector
advertises a hardcoded BLAZE_PORT = 42130. Rules match only <LAN_IP>:42130;
127.0.0.1:42130 is deliberately left alone so Python stays reachable on
loopback and the A/B compares real Python against real Rust. Verified both
directions live: LAN->Rust with the switch on, LAN->Python with it off.

  Bug found and fixed: `off` reported success while two rules remained active
  and rollback had NOT happened. It matched `--comment "tag"` with quotes this
  iptables does not emit -- and the verification used the SAME broken matcher,
  so it confirmed its own failure. A rollback that lies is worse than one that
  fails. Now matched on the bare tag, verified with iptables-save plus a
  tag-independent check that nothing still redirects the port.

  Second flaw fixed: `sidecar.sh stop` originally warned about a live switch
  and then stopped anyway, creating the exact broken state it warned about. It
  now REFUSES, with --force as the deliberate override.

The general rule this all converges on, now stated in the README: a
verification must not share the failure mode of the thing it verifies.

116 tests still passing; clippy clean; Python backend untouched and contract
suite 446/446. NAT table left clean, no orphan processes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:54:40 +00:00
funman300 a9eb54ae9c openfut-blaze-host: thin Blaze sidecar, live-parity with Python
Third migration step, and the one that turns fixture parity into transport
parity. A TCP host that frames a Fire2 stream, keeps one Session per
connection, calls openfut-adapter-fifa17::dispatch(), and writes the returned
frames in order. It owns a socket, a buffer, a session and diagnostics --
that is the complete list. No coins, club, packs, profiles or UTAS logic:
those belong to Core, reached through the adapter later.

NO TLS, and that is evidence-based rather than an omission. The Blaze main
port is plaintext: sending a raw Fire2 Util::ping to the running backend
returns a plaintext PingResponse, blaze_handle uses the raw socket, and only
redir_handle wraps ssl. TLS belongs to the redirector phase.

LIVE A/B AGAINST THE RUNNING PYTHON BACKEND: 101 frames across three
conversations, identical normalized traces. This is the first result in the
migration that is not purely offline. check-live-parity.sh replays the
recorded conversations against both endpoints over real sockets and diffs
volatile-masked traces; session keys and clocks are masked, so anything that
differs is behavioural.

Transport tests cover what fixtures cannot: byte-for-byte replay over a
socket, requests dribbled one byte at a time, several requests in one write,
the four-frame login burst ordered on the wire, session state persisting
across frames and NOT leaking between connections, an absurd payload length
closing the connection instead of allocating, and an undecodable body still
getting a reply. 18 tests here, 116 across the three migration crates.

MUTATION TESTED, including the comparison itself. Dropping a post-login
notification is caught by the probe (frame count) AND the diff; a same-length
content change deep inside a notification body (CTY "US"->"GB", payload 116
both sides) is caught ONLY by the trace digest. So the probe's exit code is
not the test -- the diff is, and the README says so. check-live-parity.sh was
itself verified to exit 1 under mutation.

The listen port is required configuration with no default, so the sidecar
cannot silently collide with the working container. OPENFUT_BIND stays the
advertised-config bind (the adapter derives nucleusConnect from it,
reproducing the oracle) and the listener gets its own setting, so the two are
not conflated.

Gates 1-4 pass and are re-runnable. Gates 5-10 need a FIFA client and are
listed in the README, including the Python -> Rust -> Python -> Rust
back-and-forth that proves the rollback path rather than asserting it.

Python backend untouched and still the live runtime; contract suite 446/446
after this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:42:23 +00:00
funman300 cf961603fe openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested
The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.

  blaze/ids.rs           component/command/notification tables
  blaze/config.rs        injectable identity + endpoints, nothing hardcoded
  blaze/session.rs       per-connection state
  blaze/client_config.rs the fetchClientConfig tables
  blaze/responses.rs     16 Blaze::* response bodies
  blaze/dispatch.rs      (component, command) -> Vec<Frame>

Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.

98 tests green across both crates; clippy clean.

MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.

The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.

Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.

Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.

Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:18:29 +00:00
funman300 cc3ecddc06 openfut-protocol-blaze: pin advertise/bind so fixtures do not depend on the shell
The responder reads OPENFUT_ADVERTISE/OPENFUT_BIND at import time and several
live payloads embed the advertised address (Blaze redirect target, RS4/POW/
roster URLs). Without pinning, regenerating on a machine that exports
OPENFUT_ADVERTISE produces different bytes and --check goes red for a reason
that has nothing to do with the codec.

Pinned to 127.0.0.1 as a placeholder so no real LAN address is baked into a
committed fixture. Not a claim about deployment: remote mode still requires an
explicit advertised address and has no loopback fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:59:36 +00:00
funman300 a9a816e0ed openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested
First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.

Contents:
  * fire2   -- the proven 16-byte frame header, frame/stream splitting
  * heat2   -- tag packing, varints, all 11 TDF value types
  * message -- frame + decoded body, routed by NUMERIC component/command
  * diagnostics -- dumps for capture review

No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.

Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.

Supersedes two wrong framings, neither of which is removed yet:
  * fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
    length, nibble-packed type/options, an error field and a JUMBO flag.
    A documented guess at FIFA 23 predating the FIFA 17 recon.
  * heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
    msgType at [12]. Dead code, but its docstring still states that layout.

Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.

Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.

The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:53:59 +00:00
333 changed files with 196271 additions and 1279 deletions
+25
View File
@@ -0,0 +1,25 @@
# OpenFUT Docker stack configuration. Copy to .env and adjust.
# All values have sensible defaults in docker-compose.yml; override as needed.
# --- Container registry (Gitea) ---
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
# e.g. git.aleshym.co/openfut/openfut-core:latest
REGISTRY=git.aleshym.co
NAMESPACE=openfut
TAG=latest
# --- Networking ---
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
BRIDGE_PUBLISH=0.0.0.0
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
CORE_PUBLISH=127.0.0.1
# --- Behaviour ---
# Bridge returns placeholder JSON + captures unknown routes when true.
PLACEHOLDER_MODE=true
# --- Logging (RUST_LOG filters) ---
CORE_LOG=openfut_core=info,tower_http=info
BRIDGE_LOG=openfut_bridge=info,tower_http=info
+5 -5
View File
@@ -30,9 +30,9 @@ Thumbs.db
# Frozen baseline archives / inspects / manifests
/docker-backups/
gate-evidence/
# local dev screenshots (not versioned)
fifa17-recon/.screens/
# local hook backup
*.pre-storeguard.bak
# Raw Fire2 frame captures — forensic evidence, may contain session material.
# Sanitize with `blaze-sanitize` before anything leaves this machine.
*.ofcap
captures/
+163
View File
@@ -0,0 +1,163 @@
# AGENTS.md — OpenFUT
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
## Project
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
project does not bypass ownership checks — it only re-serves the dead online services locally.
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
Three moving parts, kept strictly separate:
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
## Repository map
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
| Path | What it is | Status |
|---|---|---|
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
a task explicitly targets the FIFA 23 port.
## Architecture (live path)
```
FIFA 17 client (Wine/Proton, base 0x140000000)
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
└─ roster :8081 → roster_server.py (FUT roster-update XML)
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
```
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
## Development commands (verified)
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
(sqlite://openfut.db), `DATA_DIR` (data).
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
## Coding conventions
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
`models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
design:** every service reads "the active profile" as the first DB row — intentional, don't
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
names). When changing a responder, preserve byte-exactness — the client is the oracle.
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
## AI-agent rules
1. Read this file before exploring the repo.
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
vault as canonical.
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
stale, update the vault, then its repo `docs/` mirror.
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
6. Don't refactor or rewrite unrelated working code.
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
confident guesses. The live client is the only oracle for whether a gate is satisfied.
9. Clearly separate discovered behavior from hypotheses; record findings in
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
tier — never silently promote a hypothesis to a fact.
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
prefer vault + repository evidence over them when they conflict.
## AI Session Bootstrap
Future agents should start with:
1. Read `AGENTS.md`.
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
Project State, Roadmap/Current Priorities, or Protocol Findings.
4. Inspect only the relevant source directories.
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
anything about FIFA/EA behavior.
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
7. Implement the smallest coherent change.
8. Run the narrowest relevant tests.
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
inaccurate.
Do not reread the entire repository during every session.
## OpenFUT Knowledge Base
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
vault wins on any disagreement. Consult it before starting substantial work and update it after
durable discoveries.
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
Canonical files:
- Dashboard: `00 Dashboard/OpenFUT.md`
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
- Direction history: `04 Decisions/Direction History.md`
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
- Current Priorities: `06 Agent Memory/Current Priorities.md`
- Known Issues: `06 Agent Memory/Known Issues.md`
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
When editing knowledge that exists in both places, edit the vault first, then update the matching
`docs/` mirror so they stay in sync.
+2
View File
@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
## Repository Layout
This is a monorepo containing three independent Rust crates as git submodules:
Generated
+251 -96
View File
@@ -457,6 +457,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.7.9"
@@ -613,19 +636,6 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "blaze-ssl-async"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de"
dependencies = [
"bytes",
"num_enum",
"rsa",
"tokio",
"x509-cert",
]
[[package]]
name = "block"
version = "0.1.6"
@@ -830,6 +840,15 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -1062,23 +1081,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1187,6 +1193,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecolor"
version = "0.29.1"
@@ -1457,12 +1469,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1547,6 +1553,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.33"
@@ -2113,9 +2125,9 @@ dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"rustls",
"rustls 0.21.12",
"tokio",
"tokio-rustls",
"tokio-rustls 0.24.1",
]
[[package]]
@@ -3126,11 +3138,34 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openfut-adapter-fifa17"
version = "0.1.0"
dependencies = [
"openfut-protocol-blaze",
"rand",
"serde",
"serde_json",
]
[[package]]
name = "openfut-autopatch"
version = "0.1.0"
[[package]]
name = "openfut-blaze-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-protocol-blaze",
"rand",
"serde_json",
]
[[package]]
name = "openfut-bridge"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"axum",
"bytes",
@@ -3141,13 +3176,13 @@ dependencies = [
"hyper-util",
"rcgen",
"reqwest",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-rustls",
"tokio-rustls 0.24.1",
"tokio-stream",
"tower 0.4.13",
"tower-http",
@@ -3175,6 +3210,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower 0.5.3",
@@ -3185,11 +3221,40 @@ dependencies = [
]
[[package]]
name = "openfut-hook"
name = "openfut-host-config"
version = "0.1.0"
dependencies = [
"openfut-common",
"windows-sys 0.59.0",
"openfut-adapter-fifa17",
]
[[package]]
name = "openfut-http"
version = "0.1.0"
[[package]]
name = "openfut-identity"
version = "0.1.0"
dependencies = [
"parking_lot",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "openfut-import-fifa17"
version = "0.1.0"
dependencies = [
"anyhow",
"openfut-adapter-fifa17",
"openfut-core",
"openfut-identity",
"serde",
"serde_json",
"sqlx",
"tempfile",
"tokio",
"uuid",
]
[[package]]
@@ -3201,11 +3266,73 @@ dependencies = [
"dirs",
"eframe",
"egui",
"openfut-common",
"parking_lot",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "openfut-lsx"
version = "0.1.0"
dependencies = [
"aes",
"parking_lot",
]
[[package]]
name = "openfut-protocol-blaze"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "openfut-redirector-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-host-config",
"openfut-http",
"openfut-tls",
"openssl",
]
[[package]]
name = "openfut-roster-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-host-config",
"openfut-http",
"openfut-tls",
]
[[package]]
name = "openfut-tls"
version = "0.1.0"
dependencies = [
"openssl",
]
[[package]]
name = "openfut-utas-host"
version = "0.1.0"
dependencies = [
"axum",
"openfut-adapter-fifa17",
"openfut-core",
"openfut-http",
"openfut-identity",
"parking_lot",
"rand",
"reqwest",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
name = "openssl"
version = "0.10.81"
@@ -3237,6 +3364,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
@@ -3245,6 +3381,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -3685,8 +3822,8 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3694,7 +3831,7 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-rustls 0.24.1",
"tower-service",
"url",
"wasm-bindgen",
@@ -3833,10 +3970,26 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring 0.17.14",
"rustls-webpki",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring 0.17.14",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3846,6 +3999,24 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -3856,6 +4027,18 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring 0.17.14",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -4042,15 +4225,17 @@ version = "0.1.0"
dependencies = [
"anyhow",
"blaze-proto",
"blaze-ssl-async",
"bytes",
"chrono",
"futures-util",
"hex",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"serde",
"serde_json",
"tdf",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"toml",
"tracing",
@@ -4068,6 +4253,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4334,8 +4525,8 @@ dependencies = [
"once_cell",
"paste",
"percent-encoding",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"sha2",
@@ -4789,27 +4980,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -4854,7 +5024,17 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls",
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.43",
"tokio",
]
@@ -5219,6 +5399,7 @@ dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -6157,18 +6338,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"spki",
"tls_codec",
]
[[package]]
name = "xcursor"
version = "0.3.11"
@@ -6393,20 +6562,6 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
+22 -1
View File
@@ -2,9 +2,30 @@
resolver = "2"
members = [
"openfut-core",
"openfut-protocol-blaze",
"openfut-adapter-fifa17",
"openfut-blaze-host",
"openfut-host-config",
"openfut-http",
"openfut-tls",
"openfut-redirector-host",
"openfut-roster-host",
"openfut-utas-host",
"openfut-identity",
"openfut-import-fifa17",
"openfut-bridge",
"openfut-launcher",
"openfut-launcher/openfut-hook",
# The two companion services the launcher used to shell out to Python for.
"openfut-lsx",
"openfut-autopatch",
"fifa-blaze/crates/blaze-proto",
"fifa-blaze/crates/server",
]
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
exclude = ["openfut-launcher/openfut-hook"]
-45
View File
@@ -263,48 +263,3 @@ Both matter beyond themselves, because they are the only two routes into a match
Useful framing: this project's failures have almost always come from proposing a fix
before testing the assumption under it. Hypotheses that come with a cheap way to
disconfirm them are worth far more than plausible ones.
## FIFA 17 network-redirect milestone (2026-08-09)
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
RESULT of live launch (client 105 -> server 120):
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
network". Redirect IS firing (progress).
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
Blaze-speaking bridge listener before expecting the error to clear.
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
and redirect_ip_str() helpers.
LIVE RESULT (client 105 -> server 120):
- resolver detours 3/3 installed.
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
listener exists. Clearing Unable to connect requires a Blaze redirector+main
server on the bridge side (real server build), not a hook change.
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
poller, not the game.
+4
View File
@@ -1,5 +1,9 @@
# OpenFUT
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
>
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
**Offline Ultimate Team — like SPT, but for FIFA 23.**
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
+93
View File
@@ -0,0 +1,93 @@
# ============================================================================
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
#
# Canonical server (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
#
# `core` (openfut-core) IS still the shared, game-independent backend and is
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
# is the retired FIFA 23 integration, kept for reference.
# Status source of truth: docs/PROJECT_STATE.md
# ============================================================================
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
#
# Bring up: docker compose up -d
# Tear down: docker compose down (keeps data/captures volumes)
# Wipe state: docker compose down -v (also drops volumes)
# Rebuild: docker compose build (or ./scripts/registry.sh build)
# Logs: docker compose logs -f
#
# Images are pulled from / pushed to the Gitea container registry. Override the
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
name: openfut
services:
core:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
build:
context: ./openfut-core
dockerfile: Dockerfile
restart: unless-stopped
environment:
LISTEN_ADDR: 0.0.0.0:8080
DATABASE_URL: sqlite:///app/db/openfut.db
DATA_DIR: /app/data
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
volumes:
- core-db:/app/db
# Bound to localhost by default — the bridge reaches core over the internal
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
# .env if you want to hit the REST API directly from other hosts.
ports:
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 10s
bridge:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
build:
context: ./openfut-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
core:
condition: service_healthy
environment:
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
CORE_URL: http://core:8080
CAPTURES_DIR: /app/captures
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
TLS_ENABLED: "true"
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
volumes:
- bridge-captures:/app/captures
# The FIFA client connects here — publish on all interfaces by default so
# LAN clients (e.g. 10.10.0.0/24) can reach it.
ports:
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 8s
networks:
openfut:
driver: bridge
volumes:
core-db:
bridge-captures:
-250
View File
@@ -1,250 +0,0 @@
# OpenFUT — Direction Document
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
---
## 1. Goal (revised)
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
progression — lives in a **custom app** (web UI or desktop) built on the
already-complete OpenFUT Core economy backend.
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
single-player match using the squad the app built. No FUT mode, no online, no
Blaze, no EA servers.
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
for a project that **converges** instead of being gated behind months of
backend reverse-engineering.
### Why this replaces the backend plan
The status review confirmed the backend route (faking EA's online stack) is
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
beyond it — realistically 36 months of expert RE that may not converge. The
app-centric route sidesteps **every** wall in that review by never making FIFA's
own FUT mode run.
---
## 2. Base mode: Career, not Kick-Off
**Career mode is the base.** Reasons:
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
work in career mode** and explicitly does NOT work in FUT/online modes.
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
persistent club, a fixture schedule, recorded results, progression across a
season.
- **Match results are written into the career DB**, making result capture a DB
read rather than a fragile live-memory grab.
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
works with nothing to corrupt (no save to break), then move the real loop onto
career. Run the foundational injection test in BOTH.
---
## 3. Core architecture: the bidirectional FLE bridge
The backbone is a **bidirectional channel between the app and a resident FLE Lua
script running inside the game.** Everything else is messages over this channel.
```
Custom App (FUT experience)
│ squad push ──────────────► ┌─────────────────────────────┐
│ │ Resident FLE Lua script │
│ ◄────────── game state │ (inside FIFA 23, career) │
│ ◄────────── match result │ - reads game state │
└────────────────────────────► │ - applies squad live │
(file-watch or local socket) │ - reads results from DB │
└─────────────────────────────┘
FIFA 23 plays the match
```
Three message types over the bridge:
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
field(s) are found by diffing, not assumed). No restart, no
file-copy-reload. (File-load remains a fallback.)
2. **Game → App: game state.** The resident script polls the game's current
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
Apply button in the app (see §5).
3. **Game → App: match result.** After full-time, the script reads the result
from the career DB and pushes score/scorers to the app, which awards
coins/progression. (Manual entry is the baseline fallback.)
The bridge transport can be a watched file the in-game Lua polls, or a local
socket — decided in build (see §7). Either way the *game keeps running*; a file,
if used, is just the message channel, not a reload.
---
## 4. Tiered mod scope
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
so it lands together once squad injection works.
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
underlying write, replicated via script — see §6).
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
expressed as written attribute values.
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
small stat bumps when writing players in (no in-game chem UI; that's in the app).
- **Appearance / identity** — kits, names, team assignment, so the club looks
like your club on the pitch.
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
### Tier 2 — Confirm-then-add
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
World Class". Settable in-game trivially; programmatic drive needs confirming.
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
### Tier 3 — Result capture (manual baseline + automated stretch)
- **Manual:** user enters the score in the app after the match. Zero RE, ships
first.
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
reads the in-match score from memory at full-time — precedent exists: the
CM cheat table's `export_season_stats.lua` already reads goals/cards from
memory via known offsets). Push to app → auto-award progression.
### Out of scope (stays in the app, by design)
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
presentation. The app is where it looks/feels like FUT.
---
## 5. The smart Apply button (state-aware)
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
for example, overwrites edits). So the bridge reads game state and gates applying:
- Resident Lua script polls the game's current-screen value (a few Hz),
classifies **safe / not safe**, reports to the app.
- App's **Apply button is enabled only when the script confirms a safe state**
(squad hub, main menu); greyed otherwise.
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
Expand the known-safe list incrementally as states are confirmed.
- **v2 (more seamless):** instead of greying, the app always lets you click and
the script **queues** the apply, executing the moment a safe state is entered,
then confirms back. Greying is v1; queue-and-apply is v2.
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
value→screen mapping is one-time reconnaissance (same technique as result reading).
---
## 6. What's confirmed vs what needs validating
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
- FLE live-edits the running career DB without restart, via `EditDBTableField`
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
with `.value` mutated first — not the table/index/field/value form an
earlier draft of this doc assumed).
- FLE reads game state via `IsInCM()`.
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
etc.) for arbitrary process memory — confirms the result-reading fallback
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
- `GetPlayersStats()` is a documented function returning per-player
goals/assists/cards/etc. — a better confirmed path for match-result capture
than raw memory offsets.
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
XI in career mode. This **replaces** "selection bias" below.
- OpenFUT Core (economy) is complete and tested.
**Walked back — not actually confirmed:**
- "Selection bias forces specific players into the starting XI" — no such
field appears anywhere in FLE's documented Lua API or its own example
scripts. This was an unverified assumption carried over from general FIFA
modding precedent (other titles), not anything checked against FLE/FIFA 23.
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
uses Freeze Lineup instead.
**Needs validating (the foundational tests — see §7):**
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
documents the feature but not a live-match test of it).
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
GUI-only and undocumented at that level; finding it is part of the
foundational test.
- Whether that write can be replicated by a script (`EditDBTableField`) well
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
UI — required for the app↔game bridge.
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
run setup.
- The screen-state address + safe/not-safe classification (FLE's `Events`
API page exists in the wiki index but its content is currently empty/
undocumented — this is more open than previously assumed).
- Result read-back from the career DB after a match.
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
game updates off; confirm neutralized state each session.
---
## 7. Build order / next steps
Each is a bounded, verifiable step. Do them in order; later ones depend on
earlier answers.
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
holds into a played match, reverse-engineer the DB write it makes, then
replicate that write from a script so it can be triggered externally
instead of through the Formation Editor UI. See
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
a script-driven write produces a match that fields the squad you
specified.* Everything rests on this.
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
messaging; implement the minimal app→game squad push. *Done = app sends a
squad, the resident script receives and applies it.*
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
when you enter a match/edit screen, enables in the squad hub.*
4. **Result read-back.** Read the career-DB match result post-game, push to app,
award progression. Manual entry ships alongside as the fallback. *Done = app
updates coins from a played match.*
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
formation (same write mechanism). *Done = the club looks and plays like the
app's build.*
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
pack → SBC → squad → match → reward loop closed end-to-end.
### Decision still open
- **App form factor:** web UI vs desktop app. This affects the bridge transport
(a desktop app can hold a local socket more naturally; a web UI leans toward a
small local helper/file-watch). Decide before step 2.
---
## 8. Provenance
Clean-room throughout. This route relies on FLE's documented public API and the
game's own supported career mode — no EA backend, no Blaze, and nothing derived
from leaked EA source. The earlier backend RE remains clean-room and is preserved
as a spec artifact; it is simply no longer the primary path.
---
## 9. One-paragraph summary
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
app owns the entire FUT experience; a resident FLE Lua script in career mode
applies the app's squad live (no restart), reports game state to drive a safe
Apply button, and reads match results back to feed progression. This sidesteps
every backend wall, runs on confirmed FLE capabilities, builds on the finished
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
actual goal.
-108
View File
@@ -1,108 +0,0 @@
# FIFA 23 PC Startup Flow (Offline / Proton)
Observed via FLE log, hook log, and file inspection on 2026-06-26.
## Launch chain
```
umu-run / Steam → FIFA23.exe (via Proton/Wine)
├─ DLL load order (before entry point)
│ ntdll.dll, kernel32.dll, ws2_32.dll …
│ version.dll ← our hook DLL slot (loads here)
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
├─ anadius / LSX emulator (anadius64.dll)
│ Fakes EA App / Origin session
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
│ Spawns as child; checks EAAntiCheat.cfg
│ Not active in offline/cracked builds (FakeEAACLauncher present)
└─ FIFA23.exe entry point
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
Reads Data\initfs_Win32 ← Frostbite package manifest
Reads Data\layout.toc ← file-system layout
Reads Patch\initfs_Win32 ← patches on top of base
Reads Documents\FIFA 23\fifasetup.ini ← display settings
Reads Data\locale.ini ← language table
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
```
## Phase timing (observed, single machine)
| Phase | Time after launch | Trigger |
|------------------------------|-------------------|----------------------------------|
| DLL load + FLE injection | 0 0.3 s | OS loader |
| Engine + DirectX init | 0.3 5 s | FIFA23 entry point |
| "Press any key" splash | ~5 s | First rendered frame |
| Main menu | ~25 s | After key press |
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
## Files read at startup (observed)
| File | Format | Purpose |
|------|--------|---------|
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
| `Data/layout.toc` | Frostbite TOC | File layout index |
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
| `Data/locale.ini` | INI | String localisation |
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
| `Data/id_map.json` | JSON | Player/team ID→name map |
| `Data/char_conv.json` | JSON | Character conversion table |
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
| `anadius.cfg` | VDF | Fake EA persona config |
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
## Files written during a session (observed)
| File | When written | Content |
|------|-------------|---------|
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
## Save file formats
### FBCHUNKS (Frostbite chunk container)
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
- Byte 8: version (01 seen)
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
### fifasetup.ini
- Plain `KEY = VALUE` ini, fully human-readable
- Safe to edit (display resolution, locale, vsync)
## Network calls at FUT entry (observed with iptables redirect)
Traffic pattern captured before changing strategy:
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
- No SNI sent (DirtySDK does not set `server_name` extension)
- Connections originate from Wine/Proton network stack via Linux kernel TCP
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
- `fut.ea.com` (FUT API)
- `accounts.ea.com` (auth)
- `gateway.ea.com` (entitlements)
- `pin-river.data.ea.com` (telemetry)
## Key FLE Lua API hooks
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
- Read any in-memory DB table via `GetDBTableRows(tableName)`
- Write any cell via `EditDBTableField`
- Query career mode state via `IsInCM()`
- Get player/team names via `GetPlayerName`, `GetTeamName`
This is the primary safe integration path (see `fut-integration-options.md`).
-191
View File
@@ -1,191 +0,0 @@
# Foundational test — live custom XI via Freeze Lineup
**Status: PENDING — test has not yet been run.**
This is build-order step 1 from `docs/direction.md`: the test everything else
in the direction pivot depends on.
## What changed since the first draft of this doc
The first version of this test guessed at a "selection bias" DB field and a
candidate squad/lineup table name, based on general FIFA-modding precedent
that turned out not to hold for FLE's documented API — no such field appears
anywhere in FLE's actual Lua API docs or its own example scripts. While
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
mechanism for forcing a starting XI turned up instead: the **Formation
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
> This feature can be used in player career mode if you want to manage the
> starting lineup of your team. Can be also used in manager career mode to
> manually manage your next opponent's starting lineup.
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
on the pitch → tick **Freeze Lineup**`Data → Save`.
This is real and documented, but it's GUI-only — there is no Lua function for
it, and what DB write it actually performs under the hood is undocumented.
This test is now two phases: confirm the GUI feature works at all, then
reverse the DB write it makes so it can be replicated programmatically
(required for the app→game bridge in build-order step 2, which needs this
driven from outside the game, not from a person clicking checkboxes).
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
as originally (incorrectly) written into the first draft of the injector
script.
## What this test settles
Whether a *specific, externally-chosen* 11 players can be forced into a
career (or Kick-Off) match's starting lineup, live, with no restart — and
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
be driven by a script instead of a person clicking through the Formation
Editor UI.
If Freeze Lineup itself doesn't actually hold under match start (the wiki
doesn't show it being tested against a live match, only "you should be able
to see... when you play against them"), the whole bridge architecture in
`docs/direction.md` §3 needs rethinking — there is no other documented
mechanism for forcing a lineup.
## Prerequisites
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
as `track-c-fut-table-test.md`)
- A career save loaded (Freeze Lineup is documented for career mode
specifically — confirm separately whether it does anything in Kick-Off,
don't assume it does)
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
output, `playerid` field) that are NOT currently your starting XI
## Phase 1 — confirm Freeze Lineup actually holds into a match
This has zero scripting and should be done first since everything else is
wasted effort if it fails.
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
hotkey note below).
2. `Features → Teams` → find your team → `Edit`.
3. `Team → Formation` to open the Formation Editor.
4. Swap players around on the pitch so the XI differs from your current
actual starting XI in some checkable way (e.g. swap two outfield players'
positions, or bench/start a specific player).
5. Tick **Freeze Lineup**.
6. `Data → Save`.
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
your main save in case this corrupts something), exit to main menu, reload
that save, and check the team's lineup screen / play a match and watch who
starts.
**Record in the Results table below whether the frozen lineup actually took
the pitch.** If not, stop here — Phase 2 is moot.
## Phase 2 — find the underlying DB write
Only proceed if Phase 1 confirmed Freeze Lineup works.
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
This dumps every DB table whose name contains `squad`, `lineup`,
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
filename — this is your **before** snapshot.
2. Without restarting or reloading, repeat the Formation Editor steps from
Phase 1 (steps 26 only — open Formation Editor, change the lineup, tick
Freeze Lineup, `Data → Save`). Don't save/reload the career between
snapshot and this step — keep it to a single live session so the diff
isn't polluted by other state changes.
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
4. Copy both JSON files out of the Wine prefix (same path pattern as
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
Editor/`) and run:
```bash
python3 tools/squad-injector/diff_snapshots.py before.json after.json
```
5. The output shows exactly which table(s) and field(s) changed. This is the
real, confirmed write Freeze Lineup performs — record it in the Results
table below.
## Phase 3 — replicate the write via script
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
```json
{
"team_id": 12345,
"xi": [
{ "player_id": 111111, "position": 0 },
{ "player_id": 222222, "position": 5 }
]
}
```
Use 11 entries. Position codes are **confirmed numeric 027**
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
repo, not a guess.
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
4. Repeat the save-to-new-slot / reload / check-lineup verification from
Phase 1, but this time without ever opening the Formation Editor — the
write was made entirely from the script.
## Classification criteria
### "Confirmed — full mechanism works"
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
the same in-match result as the manual GUI path.
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
`docs/direction.md`.
### "GUI works, script doesn't"
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
diffed fields matched what changed in Phase 2.
**Verdict:** Freeze Lineup likely does more than a single DB field write
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
write the diff missed because it happened in a table outside the `KEYWORDS`
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
### "Freeze Lineup doesn't hold at all"
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
the bridge architecture as designed in `direction.md` §3 and needs a return
to first principles — there is no fallback documented anywhere in FLE's wiki
for this specific case.
## A note on the umu/Wine F9/F11 hotkey issue
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
low-level hook that doesn't translate cleanly through Wine's input layer) —
not something documented anywhere in FLE's own troubleshooting docs, which
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
function** — F9 is the only documented toggle. Workaround: click directly
into the FLE overlay window (it should still be visible/clickable even if the
hotkey doesn't fire) and use its own menu bar instead of relying on the
hotkey.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| Phase 1: Freeze Lineup holds into a match? | — |
| Phase 2: table(s)/field(s) changed | — |
| Phase 3: scripted write reproduces Phase 1 result? | — |
| **Classification** | **PENDING** |
-136
View File
@@ -1,136 +0,0 @@
# FUT Integration Options
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
## Option A — FLE Lua scripting (RECOMMENDED)
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
database tables at runtime. FLE is already injected; no additional hooking needed.
**Why it's the right path:**
- Fully offline, no EA servers touched
- FLE is already trusted by the user (it's the launch mechanism)
- `GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
- Scripts run inside the game process; no IPC complexity
- Same mechanism used by modders for career mode edits today
**Integration design:**
```
openfut-core (SQLite)
│ HTTP REST (localhost)
openfut-bridge (port 8080, plain HTTP, no TLS)
│ pulls club/squad/player data as JSON
FLE Lua bridge script
│ calls GetDBTableRows, EditDBTableField
FIFA 23 in-memory DB (Frostbite)
```
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
and writes simulator data (coins, items, squad) into the appropriate DB tables.
**Tables likely involved (to verify with export_squad.lua):**
| Table | Expected FUT content |
|-------|---------------------|
| `players` | Player attributes (OVR, potential, stats) |
| `teams` | Club identity, stadium, colors |
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
| `fut_items` | Card inventory (if in memory) |
| `fut_squads` | Active squad (if in memory) |
**Steps to implement:**
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
2. Map openfut-core's data model to the discovered table fields
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
**Limitations:**
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
- Only works while FLE is running (always true in our setup)
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
---
## Option B — Local save file injection (career mode proxy)
**What it does:** Generate or modify offline career mode save files that contain FUT-like
squad/player data, using Frostbite's FBCHUNKS format.
**Feasibility:** Medium
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
- Career saves are 16 MB — large and complex
- Changes take effect only after a game restart
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
**Steps:**
1. Use Frosty Tool Suite to open a career save and map the schema
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
3. Test: replace the career save, launch FIFA, verify squad is correct
---
## Option C — Local companion web UI
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
---
## Option D — Local proxy for non-secured local calls only
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
**Feasibility:** Low value in isolation
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
- All FUT API calls go to EA's servers over TLS
- Intercepting those would require the approach we explicitly ruled out
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
---
## Option E — Memory bridge (Cheat Engine / FLE offsets)
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
**Feasibility:** Medium — FLE already does this for career mode
- FLE's `offset_cache.json` contains addresses for many game structures
- FUT in-memory structs are separate from career structs and may not be mapped yet
- This is fragile (offsets change with game updates)
**Not recommended** unless Options A and B both fail — too brittle.
---
## Recommendation
**Start with Option A (FLE Lua scripting).**
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
4. Map findings back to openfut-core's data model
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
---
## Safety boundary
The following are out of scope and must not be implemented:
- Decrypting or inspecting EA's TLS traffic
- Spoofing EA domain names or impersonating EA servers
- Sending modified clients to EA's production services
- Bypassing EA App login or account verification
- Anything that could constitute online cheating or violate EA's ToS for online play
All integration must remain local/offline/single-player.
-208
View File
@@ -1,208 +0,0 @@
# OpenFUT Status Review
*Generated 2026-06-30 — read-only stocktake, no code changed.*
---
## Executive Summary
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
However the project is currently blocked before any Blaze traffic is ever seen.
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
a push that current code never sends. Every approach tried so far (flipping poll
return values, forcing the state flags, read-only probes) confirms the gate is
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
is synthesized and delivered correctly, Milestones 27 are all waiting on the same
single wall.
---
## 1. Proven vs Assumed
| Claim | Status | Evidence |
|---|---|---|
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
---
## 2. Milestone Status
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|---|---|---|---|
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
**Note on roadmap.md wording:** Under M2M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
### M4 is the first hard wall in detail
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
1. **Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
2. **ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
3. **Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
---
## 3. Blockers, Risks, Unknowns
### Blockers (stop progress now)
1. **ONLINE_STATUS_EVENT push not synthesized** *(M2 wall)*
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
2. **Bridge LSX server delivery unconfirmed** *(architectural risk converted to blocker)*
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
### Risks (could derail later)
3. **Fire2 framing wrong** *(M4 risk)*
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
4. **Secondary auth-token gate** *(M5 risk)*
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
5. **EAAC not fully neutralized** *(persistent risk)*
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
6. **FUT REST response shapes wrong** *(M6 risk)*
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
### Unknowns (open questions)
7. **ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
8. **GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
9. **Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
10. **Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
11. **openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
---
## 4. Track Comparison
### Track A — Full EA-backend fake (M1M7, playable FUT vs AI)
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 12 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 36 months of expert RE work.
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
---
### Track B — Clean-room spec deliverable (M1M5 documented)
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
**Effort:** Medium. M1 is done. M2M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
---
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
---
### Recommendation
**Start Track C immediately as a parallel, low-cost validation.**
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
---
## 5. Architecture and Provenance Sanity-Check
### Hook + Brain coherence
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
### Clean-room status
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
---
## 6. If You Read Only This
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
-93
View File
@@ -1,93 +0,0 @@
# Track C — FUT DB table viability test
**Status: PENDING — test has not yet been run.**
## What this test settles
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
It is only viable for FUT (not just career mode) if FUT-specific tables — card
inventory, squad composition with FUT fields, coins — are accessible in memory when
the game is in the FUT area.
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
can read/write. This test settles that question directly.
## Test procedure
**Prerequisites:**
- FIFA 23 launched normally via umu-run/Steam
- FLE (FIFA Live Editor) injected and active (normal launch path)
- EAAC in offline/neutralized state
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
furthest FUT screen before the gate blocks it)
**Run the exporter:**
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
3. Retrieve the output file from the Wine prefix:
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
**What to inspect in the output:**
- `all_db_tables` array — the complete list of table names visible to FLE right now
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
`market` (the script auto-extracts these)
- `is_career_mode` — confirms whether FUT or career mode was active
## Classification criteria
### "FUT tables present"
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
- e.g., `fut_items` with card-type / rating / chemistry fields
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
- e.g., a coins or points balance field
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
### "only base player tables"
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
in `all_db_tables`), OR those tables exist but contain only base player attributes
(OVR, potential, position, pace, …) — the same fields visible in career mode.
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
only on EA's servers (not in the in-memory DB in offline mode).
### "FUT area unreachable to test"
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
Record which tables were visible and at what screen the test was run.
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
handling the entry check stub.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| FIFA screen at test time | — |
| `is_career_mode` | — |
| Total tables in `all_db_tables` | — |
| FUT-specific table names found | — |
| Key FUT fields present | — |
| **Classification** | **PENDING** |
## Honest prior
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
analogy with career mode (which does store club/squad in the DB). FUT's data model is
architecturally different — it is account-bound server-side. The expectation may be
wrong. This test is the oracle.
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
assumed names), so it will surface any FUT tables that actually exist, regardless of
what name they use.
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
+30 -4
View File
@@ -1,11 +1,37 @@
# Copy to .env in this directory. Required for remote deployment.
#
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
OPENFUT_ADVERTISE=10.10.0.120
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# OPENFUT_BIND — address the listeners bind inside the container.
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
# uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
# Advertise the certificate's DNS identity, then resolve that one hostname to
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
# Default (unset) = the server-side set: "blaze roster utas pow".
#
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
# name here so the two never serve the same role at once.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (Rust utas-host still proxies its non-/club routes here for now)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
# on this server. Leave it OUT unless client and server share one box.
#
# Example — Rust already owns roster, so Docker should not also serve it:
# OPENFUT_SERVERS=blaze utas pow
# When you drop a component, also stop advertising / DNAT'ing its port to this
# container so the client is routed to the Rust host instead.
#OPENFUT_SERVERS=blaze roster utas pow
+11 -7
View File
@@ -37,17 +37,21 @@ RUN set -eu; \
COPY data/ /app/data/
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
# without extra steps.
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
# entrypoint validates this stable certificate; it never reissues it for an IP
# SAN that the verifier ignores.
#
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
# checkout and to validate the configured DNS identity at startup.
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
apt-get update && apt-get install -y --no-install-recommends openssl && \
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
rm -rf /var/lib/apt/lists/*; \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
fi
# Bake a dataset manifest so every image is self-identifying.
@@ -3,9 +3,9 @@
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
# certificate DNS identity handed out for roster HTTPS.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
@@ -24,7 +24,10 @@ services:
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
# resolve this hostname to OPENFUT_ADVERTISE.
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
@@ -36,10 +39,14 @@ services:
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
# Which Python responders this SERVER runs. Default excludes lsx (that is
# a client-side responder — see below). Drop a name once it is migrated to
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX (Origin bootstrap)
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
@@ -8,25 +8,39 @@
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 10.10.0.120)
# Address behaviour is driven by three env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 10.10.0.120)}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
# POW keys advertised by blaze must also point at the server, not loopback.
export POW_HOST="${POW_HOST:-$ADV:8094}"
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
echo "[openfut] bind=$BIND advertise=$ADV"
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
# if the configured roster hostname is not already one of its DNS identities.
CERT=redir_cert.pem
ROSTER_NAME="${ROSTER_HOST%%:*}"
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
exit 1
fi
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
# name script extra-env
declare -a SERVERS=(
@@ -37,10 +51,40 @@ declare -a SERVERS=(
"pow|pow_server.py|-"
)
# ── Component selection ──────────────────────────────────────────────────────
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
# This container is the SERVER side (.120). It serves ONLY components that have
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
# Docker during migration), drop its name so the two never serve the same role.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (the Rust utas-host currently reverse-proxies its non-/club routes
# back here, so keep this enabled until UTAS is fully migrated)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
# NOT on the server. Excluded by default; enable ONLY for an
# all-on-one-box dev setup where client and server share a host.
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
want=" ${OPENFUT_SERVERS//,/ } "
known=" lsx blaze roster utas pow "
for w in $want; do
case "$known" in
*" $w "*) ;;
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
esac
done
echo "[openfut] servers=$OPENFUT_SERVERS"
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
case "$want" in
*" $name "*) ;;
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
esac
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
@@ -49,6 +93,11 @@ for entry in "${SERVERS[@]}"; do
names+=("$name")
done
if [ "${#pids[@]}" -eq 0 ]; then
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
exit 2
fi
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
+153 -21
View File
@@ -347,10 +347,17 @@ The client's own dialog names the class: "Search Type: Consumables Search".
times a session with the PLAYER stat set, so the panel read seven zeros and never
proceeded. Two rounds of item-shape work sat unrequested for want of a counter.
2. THE ROUTE IS GET club/consumables/<category>. Not club?type=, which a previous
round shipped four arms for, and not the "/consumables/%s" template in .rdata,
which the client has still never used. Worse, that path is a /club PREFIX, so it
fell through to the generic route and the consumables screen was answered with the
194-card player list.
round shipped four arms for. That path is a /club PREFIX, so a naive router
falls it through to the generic route and answers the consumables screen with
the 194-card player list.
**CORRECTED 2026-08-21.** This item used to add "and not the
`/consumables/%s` template in .rdata, which the client has still never used".
That is false, and the same sentence is in commit `ccb736f`. It IS exactly
that template: action row 9 `ConsumablesSearch` carries base index 3 =
`ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was
`ut/%s/club` all along, which is why the observed URL and the template look
like different things and are not.
3. THE ELEMENT IS A STACK WRAPPER, NOT AN ITEM. FutConsumablesSearchServerResponse
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
0x180130d10, 6873 chars) reads itemData(0x16b) at the root like the club list, but
@@ -402,21 +409,40 @@ the same mapping: balls 37, kits 35, stadium 36, badges 39, league logos 40.
# Club items: what the research established, 2026-08-05
Researched after a guessed field crashed the client. Facts first, and the one thing
still unknown is named as unknown.
> **SUPERSEDED 2026-08-21 in part.** `docs/plan-2026-08-06-card-subsystem.md` is
> the authority for club items and for the `itemState` vocabulary; where this
> file and that one disagree, that one wins. The corrections are applied inline
> below and marked. The subtype question this section calls UNKNOWN is ANSWERED.
Researched after a guessed field crashed the client. Facts first.
## VERIFIED IN BINARY
1. THE CARDTYPE MAP IS EXACT. FUN_1800d8330 (714 chars, read in full) returns cardtype
9 for cardsubtypeid 0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9 and 0xec, and nothing else.
fcc_misccards carries cardsubtype 231 = 0xe7, which anchors the 0xe7..0xe9 block to
misc cards. That leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls
and league logos.
2. ITEMSTATE CARRIES THE EQUIPPED STATE. The enum table at 0x180229d20 (stride 0x10)
is: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active. So an EQUIPPED club item is not a
misc cards.
**CORRECTED 2026-08-21.** The first half is right; the inference that followed
it was wrong. It read "that leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits,
stadia, balls and league logos". In fact `0x91..0x96` are TROPHIES, and three
of the five club families are **cardtype 7, not 9**`FUN_1800d8330` contains
`case 9: case 10: case 0xb: return 7;`. Only ball (0x1e) and league logo
(0x1f) are cardtype 9.
2. ITEMSTATE CARRIES THE EQUIPPED STATE. So an EQUIPPED club item is not a
different subtype, it is the same item with itemState set to one of those five.
"free" is correct for owned-but-not-equipped, which is what we send.
**CORRECTED 2026-08-21.** The table starts at **`0x180229cc0`**, not
`0x180229d20` — the recorded address points into the MIDDLE of it, which is why
only ten rows were seen. The full vocabulary is TWELVE rows; the six missing
from the reading below are `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`,
`forSale` and `offered`. Two further consequences the ten-row reading hid:
`WAITING_FOR_GAME` and `inGame` are genuine ALIASES (both decode to 2), and
OMITTING the key yields `0` = `invalid`, which is NOT the same as `free` — an
item left at 0 fails the squad builder's `state == 1 || state == 2` test. The
match is also CASE-SENSITIVE (measured 2026-08-21: the comparator is
`msvcr120.dll+0x3c330`, a plain `strncmp` with no case folding), so the casing
in the table is a contract. See `openfut-adapter-fifa17/src/fut/item_state.rs`.
3. CLUB ITEMS HAVE NO CATEGORY GROUP TABLE. Consumables have one at 0x180203260 (seven
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
position) and staff have one at 0x180203310 (five codes). There is no equivalent
@@ -427,16 +453,30 @@ still unknown is named as unknown.
type=ball, type=equippables (the combined customisation view). Not the plural stat
names, and not a club/<family> path.
## STILL UNKNOWN, AND NOT GUESSED
## ANSWERED 2026-08-06 (was "STILL UNKNOWN, AND NOT GUESSED")
Which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus badge versus kit.
It is in none of the 149 dumped tables, there is no group table, and cardtype 9 has NO
arm in the merge, so a wrong subtype cannot announce itself the way a coach's "DB
Error" does. Two ways to settle it, in order of preference:
a. more RE: find the consumer that switches on subtype for a club item, most likely
in the equip path that writes itemState = activeBadge and friends;
b. FUT_CLUBITEMS=probe:<family>, which serves ONE family as eight items, one per
candidate subtype, so the screen names the right one.
The question was "which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus
badge versus kit". It was the wrong candidate set — three of the families are not
in it at all. The settled map:
| family | cardsubtypeid | cardtype | how the caption resolves |
|---|---|---|---|
| kit | **9** | 7 | `TeamName_Abbr15_<teamid>` |
| stadium | **10** | 7 | `StadiumName_<assetId>` |
| badge | **11** | 7 | `TeamName_Abbr15_<teamid>` |
| ball | **30** (0x1e) | 9 | no DB resolver; `FUT_UC_BALL` caption only |
| league logo | **31** (0x1f) | 9 | by elimination |
`0x91..0x96` are TROPHIES, not club items. Route (a) of the two proposals above is
what paid off — the consumer is the manager vtable slot `+0x498` =
`FUN_180119bd0`, dispatched when `item+0x4c == 7`. Route (b),
`FUT_CLUBITEMS=probe:<family>`, would have FAILED for three of the five families,
because its candidate set never contained 9, 10 or 11.
Kit, badge and stadium are served by OpenFUT today. Ball and league logo are
withheld: cardtype 9 has no database name resolver, so their name could only come
from `localizedName` on the wire, and that is not established as safe to send.
One residual probe remains, specified in `plan-2026-08-06-card-subsystem.md` §3.
## WHY THE CRASH HAPPENED, recorded so it is not repeated
@@ -447,3 +487,95 @@ taking its time and then dies. None of the three was needed to draw a card. Comp
it, the response that crashed was type=equippables carrying 30 items across FIVE
unverified subtypes at once, so even the crash taught us nothing about which subtype
was wrong. Both are fixed: no extras, equippables withheld, one family per test.
---
# Field-map corrections (dated)
This file's earlier field notes predate the deserializer frame arithmetic. Where
they disagree with the table in `plan-2026-08-06-card-subsystem.md` §2, that
table wins — it is derived structurally (`FUN_18013fe00` builds the record as a
stack struct and hands `&local_188` to the merge, so `record_offset = 0x188 - X`)
rather than inferred backwards from an accessor.
```
CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X):
+0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR
+0x48 owners (atom 0x207, u8; constructor default 0)
+0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1)
+0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum
+0x5c itemState (atom 0x172 via FUN_180166660, u32)
+0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17)
+0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY
+0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles)
+0xbf amount (atom 0x1b, u8) for the other consumable classes
+0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7
+0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7
+0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field
+0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm
itemType (atom 0x173) is parsed into a heap string and never stored
definitionId is NOT AN ATOM
```
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
right. The pre-match kit selector gates on `+0x60 == 4` at `0x1801c34f2`, and no
instruction in CardsDLL stores that constant immediately (29 stores, constants
`{-2,0,1,908,0x3f800000}`), nor does FIFA17.exe across 79 MB.
Tool: `fifa17-recon/tools/kit_gate_probe.py`.
**CORRECTED 2026-08-23 (live, pid 8793, read-only `/proc/PID/mem`).** The
2026-08-21 entry went on to call the kit selector "a client dead end, not a
missing wire field", on the grounds that "every OTHER input to that gate is
already served". That conclusion is WITHDRAWN. It rested on two mistakes.
1. **`+0x60 == 4` does occur.** A live record reached the art-clone driver
`FUN_1801c3480` holding `+0x4c == 2`, `+0x60 == 4`. So the value arrives by
some path the immediate-store scan cannot see (register copy or computed),
and "nothing can ever satisfy the gate" is false. What the static scan
actually licenses is the narrower claim above.
2. **cardtype 7 was never verified to be produced at all.** The probe annotates
`cmp [rdi+0x4c], 7` with "<- we produce this". Nothing measured that. Its own
live half showed `{1: players, 0: staff}` -- i.e. zero cardtype-7 records --
and that was read as "the only thing missing is +0x60".
**What is actually measured now.** With the client parked on the kit selector,
scanning all 3047 MiB of readable process memory for the exact u32 values the
server sent:
```
resident (record-shaped, sane fields):
player resourceId 83906881 -> cardtype 1, itemState 1, teamid 243, +0x60 1
staff resourceId 9000081 -> cardtype 2
staff resourceId 3000083 -> cardtype 4, subtype 8
staff resourceId 1000509 -> cardtype 2, subtype 4, teamid 241
NOT resident, by resourceId AND by instance id, zero hits each:
kit 6300006 / 100004874 (cardsubtypeid 9)
kit 6400003 / 100004873 (cardsubtypeid 9)
badge 6000005 / 100004875 (cardsubtypeid 11)
stadium 6200000 / 100004876 (cardsubtypeid 10)
```
The client fetched `?type=kit` at 17:50:09 this session and the host logged
`total=2 emitted=2`. Both kits were delivered and NEITHER produced a record.
Every cardtype-7 family is absent while cardtype 1/2/4 are resident.
So the blocker is upstream of the `+0x60` gate: no cardtype-7 record is ever
created, therefore the club scan `FUN_1800d73d0` (`+0x4c==7 && +0x50==9 &&
`+0x5c in {101,102}`) has nothing to match, `KIT_DESC` never fires, and
`KITS_AVAILABLE` reads 0. Whether that is a bad wire shape (the cardtype-7 parse
arm wants `name`/`localizedName`/`description`, which OpenFUT does not send) or
cardtype-7 items being transient by design is NOT yet settled -- do not record
either as fact.
**Method note.** `kit_gate_probe.py`'s live half is unreliable as written: on
pid 8793 it printed "CardsDb is empty (no FUT session loaded)" while a byte scan
found 1966 resident players. Its structural chain is stale, so its record counts
(including the original "27 resident records") understate reality. Prefer the
value scan until the chain is re-derived.
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
`cardsubtypeid`, `itemState`, `assetId`, `cardassetid`, `rareflag`, `owners`,
`contract`, `discardValue`, `localizedName` — while `definitionId` is absent
entirely. It is still sent on the live-proven player path; it is inert, not
harmful, and has not been removed.
+406
View File
@@ -0,0 +1,406 @@
# The client's complete UTAS route surface
Read out of the running client's own `.rdata` on 2026-08-21 (pid 6580) with
`fifa17-recon/tools/url_template_probe.py`, then each route probed against
staging. This bounds the server: FIFA 17 cannot ask for a route that is not in
this list.
Staging's Python upstream is deliberately dead, so a `502` there means the Rust
host does not own the route — which makes the coverage column a measurement
rather than an audit of the source.
## Route templates in CardsDLL
`%s` is the sku segment, built from `game/%s` (`0x18021fac8`) → `game/fifa17`.
```
ut/auth ut/delete/auth
ut/%s/user ut/delete/%s/user ut/%s/user/list
ut/%s/club ut/%s/clubUser
ut/%s/item ut/%s/item/resource ut/delete/%s/item
ut/%s/defid
ut/%s/squad ut/delete/%s/squad ut/%s/squad/mode
ut/%s/purchased ut/%s/store ut/v2/%s/store
ut/%s/trade ut/delete/%s/trade
ut/%s/tradePile ut/%s/watchList ut/delete/%s/watchList
ut/%s/auctionhouse ut/%s/marketdata
ut/%s/match ut/%s/sbs
ut/%s/season ut/%s/season/user ut/%s/season/%%s/user
ut/%s/season/%%s/reset ut/%s/season/friendly
ut/%s/tournament ut/%s/tournament/user ut/delete/%s/tournament/user
ut/%s/champion ut/%s/draft/mode
ut/%s/leaderboards ut/%s/leaderboards/options
ut/%s/activeMessage ut/%s/livemessage
ut/%s/clientdata ut/%s/phishing ut/%s/captcha ut/%s/tfa
```
Suffixes appended to the above, not standalone routes:
`/consumables/%s`, `/items`, `/purchasegroup`, `/squadBuildingSets`,
`/challenge/%d/squad`, `/choices/manager`, `/purchase/mode/%d/draft`,
`/transfermarket?type=%s&start=%d&num=%d`.
## THE TRAP when reading this list
A literal in `.rdata` is a **fragment**, not necessarily a callable path. Probing
fragments bare manufactures fake gaps. Every one of these looked unserved and was
not:
| looked missing | actually |
|---|---|
| `clientdata` | real route is `clientdata/<key>`; served (`clientdata/userHubData` → 200) |
| `purchasegroup` | a suffix of `store`; `store/purchasegroup/all` is served |
| `sbs/challenges` | not a route; the real ones are `sbs/sets`, `sbs/setId/<n>/challenges`, `sbs/challenge/<n>` — all served |
| `squadBuildingSets` | not a route in the oracle either |
| `club/items` | `items/...` literals are ART ASSET paths, not UTAS |
| `item` | only ever PUT (move/pile) and DELETE (quick-sell) |
Check a candidate gap against `tools/utas_server.py`'s regex table before
believing it.
## Genuinely unserved, and why that is correct
* `squad/mode` — bare form is never used. The oracle only has Draft sub-paths
(`squad/mode/draft/state`, `squad/mode/<n>/draft/choices/*`). Draft is out of
scope, so this correctly stays on Python.
## Fixed by this measurement
Four handlers existed and were unreachable because `classify` never produced
their route, so every request fell through to Python. This is a **recurring
defect class** in `openfut-utas-host``season/list` and `watchList` were the
first two, and their fix comments are still in the file:
| route | handler | was |
|---|---|---|
| `captcha` | `handle_static_ack`, returns the oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` | fell to Python |
| `tfa` / `livemessage` / `activeMessage` | `handle_static_ack`, `{}` | fell to Python |
| `tournament/user` | `FeatureOffEmpty`, `{}` — the oracle's answer with `FUT_MODES` off | fell to Python |
`Route`'s own doc comment already claimed the first four as "Rust-owned
UNCONDITIONAL", so the documentation had been wrong rather than the intent. All
five are byte-identical to the oracle, so claiming them is parity, not new
behaviour. Invisible in production (the upstream answers); a 502 on staging.
Two regression tests now pin the vocabularies —
`every_static_ack_tail_is_actually_routed` and
`the_disabled_mode_reads_are_all_claimed` — so a handler cannot go unreachable a
fifth time.
## No consumable apply endpoint exists
Support level L5 for consumables was open, with an inherited note saying there is
"no training/position/chemistry/manager-league endpoint at all". **The route
table confirms it from the binary**: there is no apply/training/position/
chemistry route anywhere in CardsDLL. The only owned-item mutations the client
can express are:
```
PUT ut/%s/item move / pile
DELETE ut/%s/item/<id> quick sell
POST ut/delete/%s/item bulk quick sell
PUT ut/%s/squad squad write
```
So applying a consumable is **not** a dedicated server route. If it reaches the
server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by
capturing that PUT's payload while applying a card — not by looking for an
endpoint that does not exist.
## FUT task vocabulary (2026-08-21, live)
The client drives UTAS through named TASKS, not just URLs. The task-name table
lives in CardsDLL `.rdata` as 0x20-byte inline slots holding MixedCase/UPPERCASE
pairs (`tools/apply_route_search.py`, controls `tradePile`/`ut/%s/item`/`squad`
all FOUND):
```
ViewCards AssingCard(sic) ApplyCard ApplyCardByRes
ActivateCard ConsumeCard DiscardCard DiscardCardByRes
DiscardACard MoveCard MoveCardByRes SwapCard
CreateMatch MatchReady DestroyMatch PlayGame ResetMatch KeepAlive
LoadCategoryDetails LoadSetChallenges StartChallenge LoadSquadChallenge
SaveSquadChallenge SubmitChallenge TagSets SetSbcData
TournamentList TournamentTeams SetUserInfo GetHistorical SetTutData ...
```
A descriptor table in `.data` pairs each name with a task id and a small setter
thunk, e.g. `ApplyCard` id **0x0d** at `0x1802cb170`, `ApplyCardByRes` id **0x0e**
at `0x1802cb1a0`. The thunks are `mov [rip+flag], cl; ret` (a per-task flag), NOT
request builders, so the request is assembled elsewhere keyed by task id.
**So consumable application IS a first-class client action (`ApplyCard` /
`ApplyCardByRes` / `ConsumeCard`), even though no `/apply` URL exists.** It
therefore rides an existing route. Which one is a one-capture question, and the
host now names every unclaimed request:
```
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
```
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
Captured end to end on staging, operator applying a bronze player contract:
```
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
```
| element | value | where |
|---|---|---|
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
| verb | `POST` | |
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
we already serve for **GET** (item-definition lookup). The **POST** verb on that
path is the mutation, and nothing claimed it, so it fell through to Python. This
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
res**ource" -- which is why the source is a definition id rather than an instance
id.
`apply` is an ARRAY, so one consumable resource can name several targets in a
single request. Whether the client ever batches is unobserved.
Corroborating UI evidence from the same session: applying to a PLAYER offered
only the subtype-201 card and withheld both subtype-202 manager contracts,
independently confirming the `201 = player_contract / 202 = manager_contract`
split.
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
EXACTLY unchanged (coins, owned count, and the source card all identical).
### Not yet known
* the **response shape** the client expects on success;
* the **effect** -- how many matches a contract grants. Our own catalog carries
`contract: 7` for `5001004`, documented as "the number of matches the card
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
effect is implemented on that basis.
## Consumables category `development` is unmapped (client really asks)
The new passthrough/route logging caught the client requesting
```
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
```
`consumable_families_for_category` has no `development` arm, so the screen is
served empty. The client demonstrably asks for it, which is exactly the condition
that function's own doc says should add an arm. Which families it should map to
is NOT guessed here.
### Success contract — STATIC_REVERSED (2026-08-22)
The apply completion handler is `0x180035520`:
```asm
0x180035529 mov ecx,DWORD PTR [rdx+0x1c] ; the ONLY field tested
0x18003552c test ecx,ecx
0x18003552e jne 0x18003555c ; nonzero -> FAILURE
0x18003553c lea rdx,[EVENT_CARDS_APPLY_CARD_SUCCESS] ; 0x1801f37f0
0x180035569 lea rdx,[EVENT_CARDS_APPLY_CARD_FAILURE] ; 0x1801f3810
```
It tests exactly one 32-bit field — the transport code — and **never inspects
the body**. `EVENT_CARDS_APPLY_CARD_SUCCESS` has precisely one reference in the
module, so this is the whole verdict path.
This does NOT resemble the move ack (`0x180128600`), which builds per-item
verdict records and reports FAILURE on an EMPTY vector. The "`{}` is
known-broken" precedent is specific to that route and does not transfer here.
Supporting structure: the response object's constructor `0x1800a4ce0` installs
vtable `0x1801fb5b0` and initialises its record vector at `+0x50`/`+0x58`/`+0x60`
EMPTY (0x20-byte elements); `0x1800682b0` is the matching destructor, freeing
that range with a 0x20 stride. An empty result is therefore a legal parsed state
for this response, unlike the move.
Registration site: `0x1800357da` installs the completion handler and
`0x1800357e5` the response factory, back to back.
**Probe response**: `{"itemData":[]}` — an object root (matching how the oracle's
method-agnostic `item/resource` route answers this path) containing an empty
vector (legal per the constructor). Labelled a PROBE. The client's SUCCESS only
requires transport code 0.
## Consumables categories — nine, not seven (2026-08-22)
Correcting the earlier claim that the two formation-modifier families "have no
group code, so no segment can reach them — the client's own gap". The client's
own switch says otherwise. Literal table at `0x1801f5a38` (under
`MyClubAdapterClass` / `CONSUMABLE_TYPE`); switch at `0x180048820` indexing by
`enum + 1` through the byte table at `0x180048a90` into the case table at
`0x180048a6c`:
| CONSUMABLE_TYPE | segment |
|---|---|
| **-1 (unset)** | `development` |
| 1, 2 | `contracts` |
| 3 | `healing` |
| 4 | `fitness` |
| **16** | `formation` |
| 17 | `position` |
| 23 | `playStyle` |
| 24 | `managerLeagueModifier` |
| 0, 5..15, 18..22 | `training` (switch default) |
`formation` was a SERVER gap, not a client one. `development` is the type-unset
bucket — index 0 of an `enum + 1` table — i.e. the unfiltered view; the eight
typed segments already reach all thirteen families exactly once, so it owns no
family privately and maps to their union.
## Contract effect — the `contract: 7` inference is REFUTED at the source
Do not implement a contract effect from the catalog's `contract: 7`.
`fifa17-recon/tools/fut_store.py:232` — the generic `_item()` factory that builds
EVERY item the oracle serves — hardcodes:
```python
"playStyle": 250,
"contract": 7,
"fitness": 99,
```
These are blanket placeholders on every item, players and consumables alike. The
staging squad's GK reads back `contract 7 / fitness 99 / playStyle 250`: the same
three constants. So the `contract: 7` carried in the production catalog for
resource 5001004 is **our own oracle placeholder round-tripped through an
observed profile**, not an EA value. Its evidence level is not INFERRED; it is
KNOWN-BOGUS as a source of the effect.
### What the client's own table does say
`fcc_contractcards` (13 rows) is NOT amount-less, contrary to an earlier note
here. Columns: `carddbid, cardsubtype, weightrare, cardassetid, gold, rating,
bronze, silver`.
| rating | player (201) | manager (202) | gold | silver | bronze |
|---|---|---|---|---|---|
| 50 | 5001001 | 5001007 | 1 | 2 | 8 |
| 65 | 5001002 | 5001008 | 8 | 10 | 10 / 8 |
| 80 | 5001003 | 5001009 | 13 | 11 | 15 / 11 |
| 60 | 5001004 | 5001010 | 3 | 6 | 15 |
| 70 | 5001005 | 5001011 | 18 | 24 | 20 / 18 |
| 90 | 5001006 | 5001012 | 28 | 24 | 28 / 24 |
| 90 | 5001013 | — | 99 | 99 | 99 |
Compare the sibling `fcc_healingcards`, which shares `carddbid, cardsubtype,
weightrare, cardassetid, rating` and differs only by carrying a single `amount`.
So `weightrare` is the drop weight and the differing column(s) are the effect
payload — which would make gold/silver/bronze a per-target-tier amount.
AGAINST that reading: the values are not monotonic across tiers (5001005 is gold
18, silver 24, bronze 20; 5001003 is gold 13, silver 11, bronze 15), which is
odd for an amount and unremarkable for a weight. Note also that **no column of
5001004 equals 7**, so nothing here explains the placeholder either way.
Unresolved, and NOT to be guessed: the fcc tables are loaded by `FIFA17.exe`, not
CardsDLL (the table-name and column literals are absent from the DLL), so the
reader that would settle amount-vs-weight lives in the EXE. Status stays
**EFFECT_UNKNOWN**.
## Post-ACK behaviour — OUTCOME B, LIVE_PROVEN (2026-08-22)
Captured with the staging probe answering `200 {"itemData":[]}` and mutating
nothing:
```
T0 POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]}
T1 200 {"itemData":[]}
T2 callback -> SUCCESS (no failure event; ZERO ut/delete/auth; session alive)
T4 GET club/consumables/contracts <- refresh of the SOURCE list
T5 GET club/consumables/development
T6 GET squad/active <- refresh of the TARGET
T7 no second mutation of any kind
```
So of the candidate protocols:
```
B) POST resource -> ACK -> client performs GET refresh
-> the SERVER is expected to have mutated state
```
Ruled out by observation: (A) the response carries the modified state — the body
was empty and the client was satisfied; (C) a follow-up generic PUT/item — none
was sent; (D) another route performs the mutation — nothing else was called.
Three consequences.
1. **The success verdict is transport-only, confirmed live.** The static read of
`0x180035520` said the body is never inspected; an empty `itemData` produced a
clean success and a surviving session, which is that prediction holding.
2. **The server owns the effect entirely.** The client does not compute one; it
re-reads. This is the good failure mode: a wrong server-side effect cannot be
masked by client-side optimism, and the refresh will always show server truth.
Here the refresh correctly showed `contracts copies=3` and an unchanged squad,
because the probe consumed nothing.
3. **There is no client-side amount to harvest.** Since the client never renders
an optimistic "+N games" of its own, the live path cannot reveal the grant
size. The number the client DISPLAYS on a contract card comes from the wire
`contract` atom (0xb8 -> record+0x8c; see `fut_consumables.py`, which notes
categories 2 and 3 ignore `amount` and read `contract`) — i.e. the server
tells the client what the card is worth.
That last point matters for honesty: our oracle has been sending the placeholder
`7` for that atom, so every contract card this project has ever shown a player
said "7" because WE said 7. Recovering EA's real value is not reachable from the
client's behaviour; it needs the `FIFA17.exe` reader of `fcc_contractcards`, or
it becomes an explicit design decision. Status: **EFFECT_UNKNOWN**.
### Boundary status
| aspect | status |
|---|---|
| route, method, source encoding, target encoding | LIVE_PROVEN |
| success condition (`[obj+0x1c] == 0`, body ignored) | STATIC_REVERSED + LIVE_CONFIRMED |
| response shape accepted by the client | LIVE_PROVEN (`{"itemData":[]}`, session survived) |
| post-ACK protocol | LIVE_PROVEN — outcome B |
| batching | UNPROVEN — refused, never guessed |
| contract effect / grant size | UNKNOWN (placeholder source refuted) |
| source instance selection with multiple copies | UNDETERMINED (only 1 copy owned) |
## Consumable QUICK-SELL is PUT item/resource — LIVE_PROVEN (2026-08-22)
Captured on staging when the operator quick-sold a Position Modifier from the
consumables screen:
```
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
```
So `ut/<sku>/item/resource/<resourceId>` carries THREE verbs, and this is the
third:
| verb | meaning |
|---|---|
| `GET` | item-definition lookup (`defs_route` parity) |
| `POST` | apply the consumable (`ApplyCardByRes`, body `{"apply":[{"id":N}]}`) |
| `PUT` | **quick-sell the consumable**, EMPTY body |
Note it is keyed by **resourceId**, i.e. the STACK, not by an owned instance
id — unlike the player quick-sell, which is `DELETE ut/<sku>/item/<instanceId>`
and is retail-proven in production. That asymmetry follows the consumables
screen's own model: the UI entity there is a stack, not a card.
Neither stack has ever served this route. The Python oracle maps
`item/resource` method-agnostically to `defs_route`, so a PUT would get a
definition list and HTTP 200 while nothing was sold — the client would believe
the sale succeeded. On staging the oracle is deliberately dead, so it 502'd and
Core was left untouched (coins 29843976, owned 1993, consumables 17).
### Consequence for production
Production's oracle IS alive, so today a consumable quick-sell there would reach
Python, return 200 from `defs_route`, and mutate nothing — the client would show
a successful sale that never happened. That is a second, independent reason not
to quick-sell consumables in production until this route is implemented in Rust.
### UNKNOWN, not to be guessed
* Does an empty-body PUT sell ONE copy or the WHOLE stack? The request carries no
quantity, and both readings fit. A stack of 2 at 38 is either +38 or +76.
* Which owned instance is consumed when several share the resourceId.
* What response the client requires (the player path's ack shape may not apply).
+103 -4
View File
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
> confirmed consumer of these fields is the requirement-display string builder at
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
> the empty `elgReq`.
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
@@ -920,16 +939,96 @@ freezes any of these — GAPs are "feature missing", not "crash".
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/<rid>` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH |
> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex
> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item`
> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and
> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture:
> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`.
> This row previously read `PUT ut/%s/item` for both, and that conflation is what
> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive
> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every
> observed `PUT ut/%s/item` is a pile MOVE, never an apply.
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item` (move by res) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool (+ 2 str/1 int minor) | GAP | HIGH / extra-fields MED |
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/item?type=…` (GetFilteredConsumableSearchResults) | `itemData`(0x16b) → **array[consumable-item]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | GAP | deser HIGH / scalars MED |
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/…` (CardsGetStaffBonuses) | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | GAP | MED |
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/club/consumables/<cat>` (ConsumablesSearch) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[consumable-stack]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | SERVED (Rust host) | deser HIGH / scalars MED |
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/club/stats/staff` (StaffStats, thunk `0x18012b080`) **[CORRECTED 2026-08-21]** | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | SERVED (`{}`, the oracle body) | MED |
| 14 | FutGetAvailableLoanPlayers | `0x18014e030` → sub `0x18013a1c0` | GET `ut/%s/item` (FUT_AVAILABLE_LOAN_PLAYERS_DP) | `loans`(0x19b) → **array** [FREEZE-RISK]; `itemData`(0x16b) → **array[card-item]** [FREEZE-RISK]; `default`(0xcd) → int | GAP | deser HIGH / fields MED |
| 15 | FutSignLoanPlayer | `0x1801642c0` | PUT `ut/%s/item` (sign loan) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/…` (stickerbook search) | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/club?<query>` (ClubSearch, `FUN_18012ddf0`) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | SERVED (Rust host) | HIGH |
### The four `ut/%s/club` routes are a TABLE, not an inference (2026-08-21)
The URLs for rows 12, 13 and 16 above were previously guessed as `ut/%s/item?…`
or left as `ut/%s/…`. The binding is exact: the 125-row action table at
`0x1802caa20` indexes the 48-entry URL-base table at `0x18021df80` through column
1, and **base index 3 = `ut/%s/club` is carried by exactly four rows** — so the
client can emit exactly four request families on that base and no others.
```
| ClubSearch | FUN_18012ddf0 | GET ut/%s/club?<query> | FutStickerBookSearchServerResponse |
| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/<f>[/<id>] | FutStickerBookStats2ServerResponse |
| StaffStats | thunk 0x18012b080 | GET ut/%s/club/stats/staff | FutStaffBonusServerResponse |
| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/<cat> | FutConsumablesSearchServerResponse |
```
**Club query grammar**, complete and ordered: `?year=2017` (always, hardcoded),
then `type`, `start` (omitted at 0), `count` (omitted at 100), `filter`, then
EITHER the filter block (`position, formation, state, level, rare, nation,
country, league, playStyle, team, sort`) OR a comma-joined `defId=` list, never
both. Live control from the log:
`GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`
matches the predicted order and every suppression rule.
Sub-vocabularies: `filter` = available/base/exact/any; `level` =
bronze/silver/gold/any; `sort` = asc/desc; `rare` = the literal string `SP`, not
a boolean; `state` = the itemState names plus `any` — and note the REQUEST spells
it `onSale` where the RESPONSE value is `forSale`.
`?type=` has 30 values. Decoded 2026-08-21 from the jump table itself rather
than from a case count: `FUN_18012ec50` is `cmp ecx,0x1d` + a 30-entry table at
`0x18012ed9c`, and each case is `mov ecx,<atom>; jmp 0x180180cd0` (atom → string).
Resolving those atoms against `fut_atoms.tsv` gives the vocabulary in table order:
```
0 any 1 player 2 manager 3 headcoach
4 fitnesscoach 5 physio 6 development 7 custom
8 unlocks 9 gkcoach 10 staff 11 badge
12 kit 13 stadium 14 ball 15 equippables
16 leaguelogos 17 offlinetrophy 18 onlinetrophy 19 featuredofflinetrophy
20 featuredonlinetrophy 21 allofflinetrophy
22 allonlinetrophy 23 healing 24 contract
25 training 26 misc 27 playerdefender
28 playermidfielder 29 playerforward
```
Notes worth having: there is **no `playergoalkeeper`** — the client has only
DEF/MID/FWD tabs, so goalkeepers belong to `playerdefender`, and a GK appearing
there is correct rather than a filter bug. `healing`, `contract` and `training`
exist here as `?type=` arms even though consumables have their own
`club/consumables/<cat>` route. Six of the thirty are trophy arms.
`openfut-utas-host`'s `club_type_filter` implements all 30 with no extras; a unit
test pins the list so a missing arm (an empty real tab) or an invented one (dead
code that looks like coverage) fails the build.
**`/club/stats` has exactly seven forms**: `club`, `year`, `country/<id>`,
`league/<id>`, `newcards`, `consumables`, and the separately-dispatched `staff`.
**There is no `/club/stats/team/<id>`** — verified twice (the switch has six cases
with no such arm, and an exhaustive PE string scan finds no literal containing
`stats/team`). Any handling of a `team` stats mode is dead code.
**Two holes in the base table**, recorded so nobody re-derives them as findings:
base index 43 = `ut/v2/%s/store` is carried by no action row and has zero
references in `.text`, yet `ut/v2/store` is live-proven; base index 9 =
`ut/%s/activeMessage` is a second hole of the same kind. So at least one route is
composed OUTSIDE CardsDLL, most likely in the packed exe — every "the table bounds
it" statement here is bounded to CardsDLL only.
Notes:
- **`0x1801642c0`** is a shared no-op deserializer (function body = `ret`). Three responses
@@ -607,6 +607,62 @@ cardtype 6, live-confirmed on the two resident consumables, so for exactly the
items the warning was aimed at, the server's rating and rare flag are
authoritative.
**APPLIED (2026-08-21), behind a default-off flag.** The table and the formula
above are now in Rust as `openfut-adapter-fifa17::fut::discard`:
`cardtype_for_subtype` is the decode, `discard_level` the 3/2/1 ladder,
`table_price` the 141-row lookup (`0` for an absent key) and `discard_value` the
`round_half_up(rating * price / 100)` formula. `DISCARD_COINS` is generated from
`fifa17-recon/data/tables/fcc_discardcoins.json` and a test re-reads that file
and asserts they still agree row for row, so the two cannot drift. The four
worked examples above (`8 * rating`, `4 * rating`, the 50-rated bronze at 15,
and an absent key paying 0) are tests.
Wire and wallet are now ONE method. `ItemIdentityResolver::discard_value` both
stamps the card's `discardValue` and prices the sale, because a non-zero
`discardValue` suppresses the client's local computation — so whatever is sent
is what the player is promised. The host's separate `quick_sell_value` ladder is
deleted (it was a second copy that could drift), and a test with a resolver
double returning an impossible price proves the credit follows the wire.
`OPENFUT_FIFA17_DISCARD_TABLE=1` turns the table on; the default keeps the old
placeholder ladder because switching revalues an existing club by **10.5x**
(measured over the real 1991-item club: 1,820,400 -> 19,128,955 coins if wholly
liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables
move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x).
STAFF: CLOSED, and the `value`-is-the-rating question is now SETTLED against the
running client rather than inferred. A staff wire record carries no `rating`, no
`rareflag` and no `discardValue`, so the displayed price had to be read back out
of memory. `tools/coach_probe.py` grades the four resident staff records HIT,
which requires record `+0xb4` == the table's `value` and `+0x58` == its `rare`;
`tools/discard_probe.py` (new) then reads the two discard slots directly —
`+0x38` is what we sent, `+0x3c` is what the client computed:
```
resource sub ct rat lvl rar sent+38 calc+3c predicted
1000509 4 2 88 3 1 0 282 282 AGREES (manager)
9000081 6 10 66 2 0 0 36 36 AGREES (gk coach)
3000083 8 4 66 2 0 0 36 36 AGREES (fitness)
```
4 of 4 agree, 0 disagree, and 36 on the `value`-66 GK coach was the stated
falsifier. `openfut-import-fifa17::Entities::enrich_staff` now carries `value` ->
rating and `rare` -> rareflag for the five families, so the catalog holds what
the client re-rates to; verified on staging, a GK coach quick-sells for 36 rather
than the 150 floor. The catalog diff is exactly the two coach entries.
The same probe shows what production is doing to PLAYERS today: all 23 resident
player records carry `sent+38 = 1500`, which suppresses the local computation, so
the client displays 1500 for every one of them — against its own table's 688..752
for a gold rare, 11,102..11,468 for the 21/23/24 specials, 22,080..23,280 for
rareflag 11, and 72,800 / 74,400 for the two rareflag 5/6 legends. A 50x underpay
at the top and a 2x overpay at the bottom.
STILL OPEN, and NOT a discard problem: the manager `fifa17_1000509` is owned in
Core but has no catalog entry and no card definition (it reaches the client
through the opaque squad extension), so pricing declines for it and falls back to
the ladder — 150 against the client's 282. That is definition coverage.
### 3.7 `duplicateItemIdList`
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
@@ -304,8 +304,44 @@ elimination:**
| kit | **9** | 7 | `FUN_180119bd0``FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` | `teamid` |
| stadium | **10** | 7 | `FUN_180119bd0``Stadium` + `StadiumName_<assetId>` | `assetId` |
| badge | **11** | 7 | `FUN_180119bd0``Badge` + `TeamName_Abbr15_<teamid>` | `teamid` |
| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` |
| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably |
| ball | **30** (0x1e) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
| league logo | **31** (0x1f) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
**MEASURED 2026-08-21 against the running client (`tools/cardtype_dispatch_probe.py`,
pid 6580): no cardtype-9 family can be named, and no server change can alter that.**
Four independent reads, each with a passing positive control:
1. The merge switch's jump table at rva `0x141eb4` is indexed by `cardtype - 1`
and has exactly 10 entries. Cardtypes 15 and 10 each get their own DB-merge
arm; **cardtypes 6, 7, 8 and 9 all land on the shared tail `0x180141e8a`**,
which issues no query and writes no name — it only derives the discard level
from the rating.
2. Census of every `cmp [reg+0x4c], imm` (cardtype): 0 → 1 site, 1 → 13, 6 → 1,
7 → 6, **9 → ZERO**.
3. Census of every `cmp [reg+0x50], imm` (cardsubtypeid), which is what actually
selects a club-item caption: kit 9, stadium 10 and badge 11 all present
(control), **ball 30 → ZERO sites, league logo 31 → ZERO sites**. The only
cardtype-9 subtypes that appear at all are `fcc_misccards` 231/232/233/236,
and all four sites are one boolean predicate near `0x1801a72da` that returns
FALSE for them — an exclusion, not a resolver. (That predicate's identity is
NOT established; it reads `+0x49`, `+0x145` and a vtable slot `+0x270`.)
4. The cardtype-7 resolver is reached only under `cmp DWORD PTR [rax+0x4c], 0x7`
at `0x1800f6f04`, so a cardtype-9 item can never arrive there. Its `jne` path
formats `AWARD_LABEL_%i` (`0x1801fd5a0`) — the TROPHY path, not a fallback
that would name a ball.
So the earlier "`localizedName`, probably" for these two rows was optimistic:
there is no code that would read it for a caption. Withholding ball and league
logo from the projection is a measured limit of the client, not caution.
CORRECTION, same measurement: `FUN_180119bd0` was recorded elsewhere as having
"zero refs in CardsDLL → almost certainly an export, its caller is in
FIFA17.exe". It is **not** an export. Its address occurs exactly ONCE in the
whole process, at `0x18021c738` in CardsDLL's own `.rdata`, and nothing in
FIFA17.exe references it. It is a virtual function: vtable base `0x18021c2a0`,
slot **+0x498**, index 147 (ctor LEAs at `0x18010ce10` / `0x18011111b`) — which
independently reproduces the "manager vtable slot +0x498" recorded below, by a
different method. It has 7 distinct `call [reg+0x498]` sites.
The premise that all five live in cardtype 9 is wrong, and the root fact is not an
inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two
@@ -406,6 +442,91 @@ from an accessor. So: send `localizedName` and expect it to show; send
`description` and do not be surprised if nothing changes. The same `+0xba` also
holds the unresolved kit-variant selector, so these two gaps may be one gap.
### The cardtype-9 name gap is ONE gap, not three (2026-08-21)
Worth stating plainly, because it was being tracked as three separate holes.
Everything OpenFUT still refuses to project is cardtype 9, and for exactly the
same reason:
| family | subtype(s) | definition table | why withheld |
|---|---|---|---|
| ball | 30 | `fcc_balls` (42) | no DB name resolver |
| league logo | 31 | `fcc_leaguelogos` (44) | no DB name resolver |
| misc | 231, 232, 233, 236 | `fcc_misccards` (42) | no DB name resolver |
The cardtype-7 families (kit 9, badge 11, stadium 10) all resolve their caption
from the client's own tables through `FUN_180119bd0`, so the server sends only
identity and the name takes care of itself — which is why all three now project.
Cardtype 9 has no such resolver, so the displayed name can ONLY come from
`localizedName` on the wire, and that single unproven step gates all three
families at once.
Closing it closes the last of the ownable taxonomy. It needs the launch-driven
probe in "The one probe still outstanding" above — one item, one family — and
nothing else. Ownership, `content_kind`, club/stats counting and restart
durability are already in place for all three, so the probe is the only
remaining work: the projection arm is a two-line change once the name is proven.
#### A lead on league logos: a `LeagueName_Abbr_15_%d` path DOES exist
`FUN_180098f20` (named above as the league-logo function, hedged "localizedName,
probably") was read in full on 2026-08-21. It builds a real database query, and
the literals settle what it does:
```
table 'fcc_leaguelogos'
where 'leagueid' '==' %d ; the id arrives in r9d
columns 'carddbid' 'value' 'cardassetid'
caption 'LeagueName_Abbr_15_%d' ; a localisation key built from the league id
domain 'FUT String'
```
So a database-backed league NAME demonstrably exists in the client, keyed on
`leagueid`, in exactly the shape kits use (`TeamName_Abbr15_<teamid>`). That
makes the blanket claim "cardtype 9 has no DB name resolver" too strong for
league logos specifically.
WHAT THIS DOES NOT YET SHOW, stated plainly because the obvious next step is a
trap. Its ONLY caller is `0x180098da3`, and the `[rbx+0x20]` it passes as the
league id is NOT the item record: `rbx` is reloaded from `[rsp+0x48]` and
compared against an end pointer, i.e. it is a cursor over a list of small
elements (int at `+0x20`, double at `+0x24`, int at `+0x2c`), not the 0x158-byte
card record. So this is a CATALOG/BROWSE builder, and it is not established that
the owned-item render path reaches it at all. Reading `+0x20` as the record's
`assetId` and concluding "send the leagueid as assetId" would be exactly the
kind of inference this document exists to prevent.
The lead worth following: find whether the owned cardtype-9 render path reaches
this resolver, and if so which field feeds the league id. If it does, league
logos need no `localizedName` at all and separate from the ball/misc gap.
#### Where to look next, and where NOT to (2026-08-21)
The lead above was chased and stopped at a useful boundary. `FUN_180119bd0`
the cardtype-7 caption resolver this whole section rests on — has **zero
references anywhere in CardsDLL**: no `call`, no `jmp`, and its address is never
taken in `.text`, `.rdata` or `.data`. It is nonetheless a genuine function
(clean `mov rax,rsp` entry after `int3` padding).
A real, unreferenced function in a DLL is almost certainly an **export**, which
puts its caller in FIFA17.exe. That matches the shape of everything else here:
CardsDLL owns the card model and the database, and the EXE owns the UI that asks
for captions. `FUN_180098f20`'s only caller likewise iterates a small list
element, not a card record — a browse/catalog builder, not the owned-item path.
So the practical guidance is: **stop looking for the owned cardtype-9 caption
path inside CardsDLL.** It is not there. Closing this by static reading means
parsing CardsDLL's export table and following the callers in FIFA17.exe's 79 MB,
which is a much larger job than the launch probe in "The one probe still
outstanding" — one item, one family, and the answer is visible on screen.
Method note for whoever does dump memory here: CardsDLL's sections are
`.text` at image `0x180001000`, `.rdata` at `0x1801e5000`, `.data` at
`0x18028a000`. Confusing a LIVE mapping offset with an IMAGE offset silently
reads the wrong section and produces false negatives — every atom-name lookup
came back ABSENT until the region was corrected, including controls like
`resourceId`. Always validate a memory scan against a key known to be present.
---
## 4. The card lifecycle
@@ -503,13 +624,6 @@ effects move in the permissive direction. There is also a second escape hatch in
that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if
squad submission behaves oddly afterwards, that is where to look.
**"List on Transfer Market" as a separate menu entry was not found.** The eight
flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes
`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but
whether it has its own enable predicate was not chased. The likely explanation is
that listing is only reachable from the trade pile, so both entries share one root
cause, but that is an inference and it is not established.
### Equipping club items
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
@@ -528,22 +642,60 @@ will not change the kit.
### Needs decompiling only
**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
instructions depending on method. The narrower anchor is the `/club` and
`/purchased` response handlers -- find the list-insert that assigns it, read the
constants. This is the single blocker between "we can mark a kit equipped" and "we
can equip a kit".
**Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit
swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60`
returns 1688 and 4144 instructions) because it is a common struct offset. Two
filters cut it to a readable set: only an IMMEDIATE store can introduce a
constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c`
nearby. Measured with `fifa17-recon/tools/kit_gate_probe.py` against pid 6580:
| evidence | result |
|---|---|
| live `+0x60`, all 27 resident records | `{1: 23 players, 0: 4 staff}` — never 4 |
| `cmp dword [reg+0x60], imm8` in CardsDLL | 4 sites: `0`, `0`, `1`, `4`; the `4` is the gate and is UNIQUE in the process |
| immediate stores to `[reg+0x60]`, CardsDLL | 29; constants `{-2, 0, 1, 908, 0x3f800000}` — no 4 |
| immediate stores of 4, FIFA17.exe (79 MB) | 0; also 0 comparisons against 4 |
| xrefs to the gate function | 1 (`jmp` from `0x1801a5329`); address never taken |
| register stores to `+0x60`, CardsDLL | all struct copies or inits to 0/1/-2 |
So the blocker is not a wire field we have not learned to send: the value the
gate demands is never produced by anything. Every OTHER input to the gate is
already served — `+0x4c == 7` (subtype 9), `+0x5c` 101/102
(`activeHomeKit`/`activeAwayKit`), `+0x94` teamid — leaving only the `+0xba`
variant selector below it. A client-side patch is therefore the only remaining
avenue, and a small one; it is not proposed here.
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
byte). Which wire atom sets it is unknown, so we cannot serve a specific kit
deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle.
**`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is
claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no
action today because we send no `statsList`, but it is two minutes of work and it
would close the chain.
**`FUN_1801aa190`. CLOSED 2026-08-21.** The one unopened link inside the
eight-flag chain. It is eleven instructions, and it resolves TWO parallel arrays
rather than the one the earlier claim described:
```
mov rax, [rcx+0x10] ; the ITEM record (same +0x10 hop the kit gate uses)
test r8b, r8b
jz .low
mov eax, [rax + rdx*4 + 0x124] ; array B
ret
.low:
mov eax, [rax + rcx*4 + 0x104] ; array A <- the claimed statsList
ret
```
So the signature is `f(self, int idx, bool which)`: `+0x104 + idx*4` when the
flag is clear, `+0x124 + idx*4` when it is set. The two arrays are 0x20 apart,
i.e. eight ints each (`+0x104..+0x123`, `+0x124..+0x143`).
LIVE (pid 6580, production-served records): BOTH arrays read all zeros on every
resident record, players included — e.g. resourceId 20801 rating 94 has
`A = [0]*8`, `B = [0]*8`. That confirms "changes no action today because we send
no statsList", and extends it: the sibling array at `+0x124` is equally empty.
Any action flag derived from either is reading 0 in production, so neither can
be the reason an action is greyed.
**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved.
What remains is whether the field is visible anywhere worth populating.
@@ -553,24 +705,73 @@ depend on it (`FUN_180108c00` carries the same mapping independently), but the
dispatch table that reaches it was not identified, and trophies are a whole
unimplemented family.
**Case sensitivity of the `itemState` string match.** Almost certainly
unresolvable statically: `FUN_180008190` is a single indirect call through
`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact
casing from the table and do not experiment on the live save.
**Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 —
CASE-SENSITIVE.** It was expected to be unresolvable statically, because
`FUN_180008190` is nothing but a forwarding stub through a runtime-populated
slot:
```
mov rax, [DAT_1802ddfd8] ; service object, handed to CardsDLL by the host
mov r9, [rax + 0x248]
jmp r9
```
Resolved read-only against the running client (pid 6580) with
`fifa17-recon/tools/service_ptr_probe.py`, which follows the chain and
attributes each hop to a module (Wine maps PE sections anonymously, so the
module comes from the nearest preceding named mapping):
```
*(service + 0x248) = 0x146d1c020 FIFA17.exe+0x20f9020 e9 … jmp rel32
→ 0x145e27fe0 FIFA17.exe+0x1204fe0 ff 25 jmp [rip+…]
→ 0x6ffffd11c330 msvcr120.dll+0x3c330 function body
```
The body is `strncmp`: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al`
(NUL stop) / `cmp al,[rcx+rdx]`, then MSVC's 8-byte fast path with the
`0x8080808080808080` and `0xfefefefefefefeff` NUL-detect constants. There is no
`or ..,0x20` and no folding table anywhere in the body, so the compare is raw
bytes.
CONSEQUENCE: a mis-cased token does not degrade, it matches nothing —
`FUN_180166660` returns `0xffffffff`, the record keeps `0` = `invalid`, and the
item fails the squad builder's `state == 1 || state == 2` test. The casing in
the table at `0x180229cc0` is a contract. Send it verbatim; do not experiment on
the live save.
### Needs a live probe (read-only, no launch)
**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string
comparator. That answers the casing question without a launch.
**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and
"sequence counter". Low value; nothing we send reaches it.
**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result
proves `g_db` is loaded and complete; it says nothing about the separate database
behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in
FIFA17.exe and not in CardsDLL. These are different databases and they should stop
being conflated.
**Confirm the FUT roster database is loaded. PARTLY ANSWERED 2026-08-21 — the
two databases are now definitively distinct; the load FLAG is still unlocated.**
The `fcc_discardcoins` result proves `g_db` is loaded and complete; it says
nothing about the separate database behind `LoadFUTDatabase` / `.dbFUTVer` /
`DL_FUT_LIVEDB`. Scanning FIFA17.exe's 79 MB of code+data in the live process
(pid 6580) recovers the whole API name set, and it settles the distinction:
```
SetFUTDatabaseUnloaded UpdateFUTDBVersion StartFUTRosterDownload
LoadFUTDatabase UnLoadFUTDatabase GetFUTDBCRC
CancelRosterDownload DL_FUT_LIVEDB APPLY_FUT_LIVEDB
RosterXMLDownloadedFail .dbFUTVer .dbMajor .dbMinor .dbMajorCRC .dbMinorCRC
```
Every one of those lives in FIFA17.exe; none is in CardsDLL. So the FUT roster
DB is a DOWNLOADED, versioned, CRC-checked live database with its own
download -> apply -> load/unload lifecycle (and its own failure state,
`RosterXMLDownloadedFail`), which is a different kind of thing from the shipped
card tables CardsDLL reads. They should stop being conflated, and this is the
evidence for saying so.
What is NOT answered: whether it is loaded right now. The process holds no
separate database file open — only Frostbite bundles (`.sb` / `.cas`) — which is
consistent with the roster DB living inside a bundle or in memory, so absence of
a file handle proves nothing either way. The `SetFUTDatabaseUnloaded` state
implies a boolean somewhere; that global was not located, so "is it loaded"
remains open and needs the flag found before it can be answered honestly.
### Needs a launch the user must drive -- ranked, and short
@@ -870,10 +1071,29 @@ be misrouted onto another field. **Freeze risk: none** -- removing a key the par
skips strictly reduces executed code. Low value, zero cost, and it removes a field
that three documents describe as if it did something.
**Fourth verification, 2026-08-21 (independent method).** Searched CardsDLL's
own `.rdata` in the running client for the literal key names. Every real atom is
present exactly once — `resourceId` `0x18022a3a8`, `cardsubtypeid` `0x180230520`,
`itemState` `0x180231490`, `assetId` `0x180230178`, `cardassetid` `0x180204200`,
`rareflag`, `untradeable`, `owners`, `contract`, `discardValue`, and notably
`localizedName` at `0x1802316d0` — while **`definitionId` is ABSENT entirely**.
The client has no string for it, so no arm can exist. That is a different method
from the three above (string table rather than key dictionary) and it agrees.
NOT applied all the same. The player path that carries `definitionId` is
live-proven in production, the saving is payload only, and this project's house
rule is that a flag defaults to the live-proven value. "Provably inert" is a good
reason to stop documenting it as meaningful; it is not on its own a reason to
change a working wire. Bundle it with the next change that needs a launch.
---
## 7. Proposed corrections to existing documents
> **APPLIED 2026-08-21.** Every correction below has been made in the named file
> and marked there with a dated note. This section is kept as the rationale and
> the audit trail, not as an outstanding to-do.
### `docs/CARD_SYSTEM.md`
**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered.
+697
View File
@@ -0,0 +1,697 @@
#!/usr/bin/env python3
"""Decoder for EA APT (compiled ActionScript) as shipped in FIFA 17.
Clean-room implementation. The byte-level format facts (opcode numbers, operand
widths, alignment rule, branch base, DefineFunction2 field order) were taken from
a written specification derived from OpenSAGE, which is GPL-3.0 with EA
additional terms. No OpenSAGE code was copied or transliterated; only the format
description -- an interface specification -- was used. Reference read at
OpenSAGE/OpenSAGE commit 588ac477367a0022adf29f20a084e8873014e6ce and
OpenSAGE/AptEditor commit 09f73c655c45a781f883b623a93d2e8f5b065a6c.
FIFA 17 ships a 64-BIT variant of the format. Differences from the 32-bit SAGE
layout described by the reference, all established by measurement against
futSelectTeam and asserted by --selftest:
* Container pointers and counts are u64, not u32.
* Parameterised instructions align their operand block to 8 bytes, not 4.
Proven by the ConstantPool at 0xd38: aligning to 4 yields garbage, aligning
to 8 yields count=401 with an index array that ends exactly on the
parameter-list region.
* The constant pool lives in a separate "Apt1" container member rather than a
".const" sibling file. Entries are 16 bytes: {u64 type, u64 value}; type 1
is a string whose value is an absolute offset inside that same member.
* DefineFunction2's operand block is 48 bytes rather than 28, and the
0x1234567898765432 trailer is stored as two u64 halves.
* Branch displacements remain i32 and remain relative to the end of the
branch record, exactly as in the 32-bit format.
"""
from __future__ import annotations
import argparse
import struct
import sys
from dataclasses import dataclass, field
APT1_MAGIC = b"Apt1"
APTDATA_MAGIC = b"Apt Data:1:7:8\x1a\x00"
# Trailer sentinel on DefineFunction/DefineFunction2, stored as two u64 halves.
FUNC_SENTINEL_LO = 0x98765432
FUNC_SENTINEL_HI = 0x12345678
ALIGN = 8
# Operand kinds.
NONE = "none" # no operand block
U8REG = "u8reg" # 1 raw byte, register index
U8CONST = "u8const" # 1 raw byte, constant-pool index
U16CONST = "u16const" # 2 raw bytes, constant-pool index
U8LIT = "u8lit" # 1 raw byte, literal integer
U16LIT = "u16lit" # 2 raw bytes, literal integer
BRANCH = "branch" # aligned i32, relative to end of record
U32 = "u32" # aligned u32
F32 = "f32" # aligned f32
STR64 = "str64" # aligned u64 absolute offset to NUL-terminated string
POOL = "pool" # aligned u64 count + u64 array offset (array of u64 ids)
FUNC2 = "func2" # aligned DefineFunction2 record
FUNC1 = "func1" # aligned DefineFunction record
# opcode -> (mnemonic, operand kind)
OPCODES: dict[int, tuple[str, str]] = {
0x00: ("End", NONE),
0x04: ("NextFrame", NONE),
0x06: ("Play", NONE),
0x07: ("Stop", NONE),
0x0A: ("Add", NONE),
0x0B: ("Subtract", NONE),
0x0C: ("Multiply", NONE),
0x0D: ("Divide", NONE),
0x12: ("Not", NONE),
0x13: ("StringEquals", NONE),
0x17: ("Pop", NONE),
0x18: ("ToInteger", NONE),
0x1C: ("GetVariable", NONE),
0x1D: ("SetVariable", NONE),
0x21: ("StringConcat", NONE),
0x22: ("GetProperty", NONE),
0x23: ("SetProperty", NONE),
0x26: ("Trace", NONE),
0x30: ("Random", NONE),
0x3A: ("Delete", NONE),
0x3B: ("Delete2", NONE),
0x3C: ("DefineLocal", NONE),
0x3D: ("CallFunction", NONE),
0x3E: ("Return", NONE),
0x3F: ("Modulo", NONE),
0x40: ("NewObject", NONE),
0x41: ("Var", NONE),
0x42: ("InitArray", NONE),
0x43: ("InitObject", NONE),
0x44: ("TypeOf", NONE),
0x47: ("Add2", NONE),
0x48: ("LessThan2", NONE),
0x49: ("Equals2", NONE),
0x4A: ("ToNumber", NONE),
0x4B: ("ToString", NONE),
0x4C: ("PushDuplicate", NONE),
0x4E: ("GetMember", NONE),
0x4F: ("SetMember", NONE),
0x50: ("Increment", NONE),
0x51: ("Decrement", NONE),
0x52: ("CallMethod", NONE),
# 0x53 appears in the reference enum as NewMethod but the reference never
# parses it. Standard AVM1 ActionNewMethod carries no operand block;
# decoding it as zero-length keeps this artifact synchronised with every
# branch still landing on an instruction boundary, which is the check that
# would break first if the width were wrong.
0x53: ("NewMethod", NONE),
0x54: ("InstanceOf", NONE),
0x55: ("Enumerate2", NONE),
0x56: ("PushThis", NONE),
0x59: ("PushZero", NONE),
0x5A: ("PushOne", NONE),
0x5B: ("CallFuncPop", NONE),
0x5C: ("CallFunc", NONE),
0x5D: ("CallMethodPop", NONE),
0x62: ("BitwiseXOr", NONE),
0x66: ("StrictEqual", NONE),
0x67: ("Greater", NONE),
0x69: ("Extends", NONE),
0x70: ("PushThisVar", NONE),
0x71: ("PushGlobalVar", NONE),
0x72: ("ZeroVar", NONE),
0x73: ("PushTrue", NONE),
0x74: ("PushFalse", NONE),
0x75: ("PushNull", NONE),
0x76: ("PushUndefined", NONE),
0x87: ("SetRegister", U32),
0x88: ("ConstantPool", POOL),
0x8C: ("GotoLabel", STR64),
0x8E: ("DefineFunction2", FUNC2),
0x96: ("PushData", POOL),
0x99: ("BranchAlways", BRANCH),
0x9B: ("DefineFunction", FUNC1),
0x9D: ("BranchIfTrue", BRANCH),
0x9F: ("GotoFrame2", U32),
0xA1: ("PushString", STR64),
0xA2: ("PushConstantByte", U8CONST),
0xA3: ("PushConstantWord", U16CONST),
0xA4: ("GetStringVar", STR64),
0xA5: ("GetStringMember", STR64),
0xA6: ("SetStringVar", STR64),
0xA7: ("SetStringMember", STR64),
0xAE: ("PushValueOfVar", U8CONST),
0xAF: ("GetNamedMember", U8CONST),
0xB0: ("CallNamedFuncPop", U8CONST),
0xB1: ("CallNamedFunc", U8CONST),
0xB2: ("CallNamedMethodPop", U8CONST),
0xB3: ("CallNamedMethod", U8CONST),
0xB4: ("PushFloat", F32),
0xB5: ("PushByte", U8LIT),
0xB6: ("PushShort", U16LIT),
0xB8: ("BranchIfFalse", BRANCH),
0xB9: ("PushRegister", U8REG),
}
ALIGNED_KINDS = {BRANCH, U32, F32, STR64, POOL, FUNC2, FUNC1}
class DecodeError(Exception):
"""Raised when the stream cannot be decoded without guessing."""
@dataclass
class Instr:
offset: int
opcode: int
mnemonic: str
length: int # opcode byte through end of operand block, incl. padding
operands: dict
raw: bytes
target: int | None = None # resolved branch destination
comment: str = ""
def render(self, width: int = 22) -> str:
ops = self.comment or ""
return f" {self.offset:#07x} {self.mnemonic:<{width}} {ops}"
@dataclass
class Function:
name: str
record_offset: int # offset of the DefineFunction* opcode byte
body_start: int
body_end: int
n_params: int
n_registers: int
flags: int
params: list = field(default_factory=list)
@property
def anonymous(self) -> bool:
return not self.name
PRELOAD_FLAGS = [
(0x010000, "PreloadExtern"),
(0x008000, "PreloadParent"),
(0x004000, "PreloadRoot"),
(0x002000, "SupressSuper"),
(0x001000, "PreloadSuper"),
(0x000800, "SupressArguments"),
(0x000400, "PreloadArguments"),
(0x000200, "SupressThis"),
(0x000100, "PreloadThis"),
(0x000001, "PreloadGlobal"),
]
# Registers preloaded by the VM, in flag order, starting at index 1.
PRELOAD_ORDER = [
(0x000100, "this"),
(0x000400, "arguments"),
(0x001000, "super"),
(0x004000, "_root"),
(0x008000, "_parent"),
(0x000001, "_global"),
(0x010000, "extern"),
]
def flag_names(flags: int) -> str:
got = [n for bit, n in PRELOAD_FLAGS if flags & bit]
return "|".join(got) if got else "0"
def register_map(fn: Function) -> dict[int, str]:
"""Reproduce the VM's register preload order, then bound parameters."""
regs: dict[int, str] = {}
idx = 1
for bit, name in PRELOAD_ORDER:
if fn.flags & bit:
regs[idx] = name
idx += 1
for reg, pname in fn.params:
if reg:
regs[reg] = pname
return regs
class ConstPool:
"""The 'Apt1' container member: header, 16-byte entries, string table."""
def __init__(self, data: bytes):
if data[:4] != APT1_MAGIC:
raise DecodeError(f"not an Apt1 member: {data[:4]!r}")
self.data = data
self.count = struct.unpack_from("<Q", data, 0x20)[0]
self.first = struct.unpack_from("<Q", data, 0x28)[0]
self.entries: list[tuple[int, int, str | None]] = []
for i in range(self.count):
off = self.first + i * 16
if off + 16 > len(data):
raise DecodeError(f"const entry {i} at {off:#x} runs past end")
etype, value = struct.unpack_from("<QQ", data, off)
text = None
if etype == 1:
if not (0 < value < len(data)):
raise DecodeError(
f"const entry {i}: string offset {value:#x} outside member"
)
end = data.find(b"\0", value)
if end < 0:
raise DecodeError(f"const entry {i}: unterminated string")
text = data[value:end].decode("latin1")
self.entries.append((etype, value, text))
def string(self, index: int) -> str:
if not (0 <= index < len(self.entries)):
raise DecodeError(f"const index {index} out of range (0..{len(self.entries)-1})")
etype, _, text = self.entries[index]
if etype != 1 or text is None:
raise DecodeError(f"const index {index} is type {etype}, not a string")
return text
def find(self, needle: str) -> list[int]:
return [i for i, (_, _, t) in enumerate(self.entries) if t == needle]
class AptData:
"""The 'Apt Data' container member: movie structures plus action streams."""
def __init__(self, data: bytes, pool: ConstPool):
if not data.startswith(APTDATA_MAGIC[:8]):
raise DecodeError(f"not an Apt Data member: {data[:16]!r}")
self.data = data
self.pool = pool
self.scope: list[str] = [] # installed by ConstantPool
self.functions: list[Function] = []
# -- helpers ---------------------------------------------------------
def cstr(self, off: int) -> str:
if not (0 <= off < len(self.data)):
raise DecodeError(f"string offset {off:#x} outside Apt Data")
end = self.data.find(b"\0", off)
if end < 0:
raise DecodeError(f"unterminated string at {off:#x}")
return self.data[off:end].decode("latin1")
def const(self, index: int) -> str:
"""Resolve through the scope pool installed by the most recent 0x88."""
if self.scope:
if not (0 <= index < len(self.scope)):
raise DecodeError(
f"scope-pool index {index} out of range (0..{len(self.scope)-1})"
)
return self.scope[index]
return self.pool.string(index)
def install_pool(self, ids: list[int]) -> None:
self.scope = [self.pool.string(i) for i in ids]
# -- instruction decoding --------------------------------------------
def decode_one(self, pos: int) -> Instr:
d = self.data
if pos >= len(d):
raise DecodeError(f"position {pos:#x} past end of stream")
op = d[pos]
entry = OPCODES.get(op)
if entry is None:
raise DecodeError(
f"unknown opcode {op:#04x} at {pos:#07x} "
f"(raw {d[pos:pos+8].hex(' ')}) - refusing to guess its length"
)
mnem, kind = entry
p = pos + 1
if kind in ALIGNED_KINDS:
p = (p + ALIGN - 1) & ~(ALIGN - 1)
ops: dict = {}
comment = ""
target = None
def need(n: int) -> None:
if p + n > len(d):
raise DecodeError(f"{mnem} at {pos:#07x} truncated: needs {n} bytes")
if kind == NONE:
pass
elif kind in (U8REG, U8LIT):
need(1)
ops["value"] = d[p]
p += 1
comment = f"r{ops['value']}" if kind == U8REG else str(ops["value"])
elif kind == U8CONST:
need(1)
ops["index"] = d[p]
p += 1
comment = f"{ops['index']:#04x} -> {self.const(ops['index'])!r}"
elif kind == U16CONST:
need(2)
ops["index"] = struct.unpack_from("<H", d, p)[0]
p += 2
comment = f"{ops['index']:#06x} -> {self.const(ops['index'])!r}"
elif kind == U16LIT:
need(2)
ops["value"] = struct.unpack_from("<H", d, p)[0]
p += 2
comment = str(ops["value"])
elif kind == U32:
need(4)
ops["value"] = struct.unpack_from("<I", d, p)[0]
p += 4
comment = str(ops["value"])
elif kind == F32:
need(4)
ops["value"] = struct.unpack_from("<f", d, p)[0]
p += 4
comment = repr(ops["value"])
elif kind == BRANCH:
need(4)
disp = struct.unpack_from("<i", d, p)[0]
p += 4
ops["displacement"] = disp
target = p + disp # base = end of record
comment = f"{disp:+d} -> {target:#07x}"
elif kind == STR64:
need(8)
off = struct.unpack_from("<Q", d, p)[0]
p += 8
ops["offset"] = off
ops["text"] = self.cstr(off)
comment = f"{ops['text']!r}"
elif kind == POOL:
need(16)
count, arr = struct.unpack_from("<QQ", d, p)
p += 16
if arr + count * 8 > len(d):
raise DecodeError(f"{mnem} at {pos:#07x}: array {arr:#x}[{count}] overruns")
ids = list(struct.unpack_from(f"<{count}Q", d, arr))
ops["count"], ops["array"], ops["ids"] = count, arr, ids
comment = f"count={count} array={arr:#x}"
elif kind in (FUNC2, FUNC1):
if kind == FUNC2:
need(48)
name_off, n_params = struct.unpack_from("<QI", d, p)
n_reg = d[p + 12]
flags = int.from_bytes(d[p + 13:p + 16], "little")
plist, body = struct.unpack_from("<QQ", d, p + 16)
lo, hi = struct.unpack_from("<QQ", d, p + 32)
p += 48
else:
need(40)
name_off, n_params, plist, body = struct.unpack_from("<QQQQ", d, p)
n_reg, flags = 4, 0
lo, hi = struct.unpack_from("<QQ", d, p + 32)
p += 40
if (lo, hi) != (FUNC_SENTINEL_LO, FUNC_SENTINEL_HI):
raise DecodeError(
f"{mnem} at {pos:#07x}: bad trailer {lo:#x}/{hi:#x}, "
"record layout is wrong"
)
name = self.cstr(name_off)
params = []
for i in range(n_params):
e = plist + i * 16
if e + 16 > len(d):
raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns")
reg, pn = struct.unpack_from("<QQ", d, e)
params.append((reg, self.cstr(pn)))
ops.update(name=name, n_params=n_params, n_registers=n_reg,
flags=flags, params=params, body_size=body)
comment = (f"{name or '<anonymous>'}({', '.join(n for _, n in params)}) "
f"nRegs={n_reg} flags={flag_names(flags)} bodySize={body}")
ops["body_start"] = p
ops["body_end"] = p + body
else:
raise DecodeError(f"internal: unhandled kind {kind}")
return Instr(pos, op, mnem, p - pos, ops, d[pos:p], target, comment)
def decode_stream(self, start: int, limit: int | None = None) -> list[Instr]:
"""Linear decode using the reference termination rule.
Stops when the last instruction was End AND we are past every branch
destination seen so far. A stream may legitimately continue past an End.
"""
out: list[Instr] = []
pos = start
furthest = start
while True:
if limit is not None and pos >= limit:
break
ins = self.decode_one(pos)
out.append(ins)
if ins.target is not None:
furthest = max(furthest, ins.target)
if ins.mnemonic == "ConstantPool":
self.install_pool(ins.operands["ids"])
if ins.mnemonic in ("DefineFunction2", "DefineFunction"):
fn = Function(
name=ins.operands["name"],
record_offset=ins.offset,
body_start=ins.operands["body_start"],
body_end=ins.operands["body_end"],
n_params=ins.operands["n_params"],
n_registers=ins.operands["n_registers"],
flags=ins.operands["flags"],
params=ins.operands["params"],
)
self.functions.append(fn)
furthest = max(furthest, fn.body_end)
pos = ins.offset + ins.length
if ins.mnemonic == "End" and pos > furthest:
break
return out
def load(apt1_path: str, aptdata_path: str) -> tuple[ConstPool, AptData]:
pool = ConstPool(open(apt1_path, "rb").read())
movie = AptData(open(aptdata_path, "rb").read(), pool)
return pool, movie
def find_streams(movie: AptData) -> list[int]:
"""Seed stream starts: every ConstantPool record that validates."""
seeds = []
d = movie.data
for p in range(len(d)):
if d[p] != 0x88:
continue
try:
ins = movie.decode_one(p)
except DecodeError:
continue
if ins.operands.get("count", 0) and ins.operands["ids"] == list(
range(ins.operands["count"])
):
seeds.append(p)
return seeds
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apt1", default="fifa17-recon/data/apt/futSelectTeam_Apt1.bin")
ap.add_argument("--aptdata", default="fifa17-recon/data/apt/futSelectTeam_AptData.bin")
ap.add_argument("--stream", type=lambda s: int(s, 0), help="decode one stream at offset")
ap.add_argument("--function", help="decode the named function's body")
ap.add_argument("--list-functions", action="store_true")
ap.add_argument("--report", action="store_true", help="structural validation report")
ap.add_argument("--strings", action="store_true", help="dump the constant pool")
ap.add_argument("--selftest", action="store_true")
args = ap.parse_args(argv)
pool, movie = load(args.apt1, args.aptdata)
if args.selftest:
return selftest(pool, movie)
if args.strings:
for i, (t, v, s) in enumerate(pool.entries):
print(f" #{i:3d} type={t} @{v:#07x} {s!r}")
return 0
seeds = find_streams(movie)
if args.stream is not None:
seeds = [args.stream]
all_instrs: list[Instr] = []
for s in seeds:
all_instrs.extend(movie.decode_stream(s))
if args.list_functions:
for fn in movie.functions:
regs = register_map(fn)
rs = " ".join(f"r{k}={v}" for k, v in sorted(regs.items()))
print(f" {fn.body_start:#07x}-{fn.body_end:#07x} "
f"{fn.name or '<anonymous>':<34} {rs}")
return 0
if args.function:
for fn in movie.functions:
if fn.name == args.function:
print(f"; {fn.name} body {fn.body_start:#x}..{fn.body_end:#x} "
f"flags={flag_names(fn.flags)} nRegs={fn.n_registers}")
regs = register_map(fn)
for k, v in sorted(regs.items()):
print(f"; r{k} = {v}")
for ins in movie.decode_stream(fn.body_start, fn.body_end):
print(ins.render())
return 0
print(f"function {args.function!r} not found", file=sys.stderr)
return 1
if args.report:
return report(movie, seeds, all_instrs)
for ins in all_instrs:
print(ins.render())
return 0
def report(movie: AptData, seeds: list[int], instrs: list[Instr]) -> int:
import collections
hist = collections.Counter(i.mnemonic for i in instrs)
covered = set()
for i in instrs:
covered.update(range(i.offset, i.offset + i.length))
branches = [i for i in instrs if i.target is not None]
boundaries = {i.offset for i in instrs}
bad = [i for i in branches if i.target not in boundaries]
print(f" streams decoded : {len(seeds)} {[hex(s) for s in seeds]}")
print(f" instructions : {len(instrs)}")
print(f" bytes covered : {len(covered)} of {len(movie.data)}")
print(f" functions : {len(movie.functions)}")
print(f" branches : {len(branches)}")
print(f" invalid branch targets: {len(bad)}")
for i in bad[:10]:
print(f" {i.offset:#07x} {i.mnemonic} -> {i.target:#07x}")
print(f" distinct opcodes : {len(hist)}")
for m, n in hist.most_common():
print(f" {m:<22} {n}")
return 1 if bad else 0
def selftest(pool: ConstPool, movie: AptData) -> int:
"""Assertions that pin the measured format facts."""
ok = True
def check(label: str, cond: bool, detail: str = "") -> None:
nonlocal ok
print(f" [{'PASS' if cond else 'FAIL'}] {label}{(' - ' + detail) if detail else ''}")
ok = ok and cond
check("Apt1 entry count", pool.count == 414, f"{pool.count}")
check("Apt1 all entries are strings",
all(t == 1 for t, _, _ in pool.entries))
check("Apt1 entry array abuts string table",
pool.first + pool.count * 16 == min(v for t, v, _ in pool.entries if t == 1))
# Phase 3: exact pointer -> string resolution for known symbols.
for name in ("CheckIsKitLocked", "KITS_AVAILABLE", "FUT_GET_MATCH_KITS_DP",
"mcLockHome"):
idx = pool.find(name)
check(f"string resolves: {name}", len(idx) == 1 and pool.string(idx[0]) == name,
f"index {idx}")
# Bad pointers must raise, not fuzzy-match.
for bad in (-1, 10 ** 6):
try:
pool.string(bad)
check(f"bad const index {bad} rejected", False)
except DecodeError:
check(f"bad const index {bad} rejected", True)
# Phase 4 fixtures for the two EA opcodes.
movie.scope = ["alpha", "beta"] + [f"c{i}" for i in range(2, 300)]
fixtures = [
(bytes([0xB9, 0x00]), "PushRegister", 2, "r0"),
(bytes([0xB9, 0x05]), "PushRegister", 2, "r5"),
(bytes([0xB9, 0xFF]), "PushRegister", 2, "r255"),
(bytes([0xAF, 0x00]), "GetNamedMember", 2, "'alpha'"),
(bytes([0xAF, 0x01]), "GetNamedMember", 2, "'beta'"),
(bytes([0xA2, 0x01]), "PushConstantByte", 2, "'beta'"),
]
for raw, mnem, length, needle in fixtures:
probe = AptData(APTDATA_MAGIC + raw.ljust(16, b"\0"), pool)
probe.scope = movie.scope
ins = probe.decode_one(16)
check(f"fixture {raw.hex()} -> {mnem}",
ins.mnemonic == mnem and ins.length == length and needle in ins.comment,
f"{ins.mnemonic} len={ins.length} {ins.comment}")
# Truncated records must fail closed.
for raw in (bytes([0xB9]), bytes([0xAF]), bytes([0xA3, 0x01])):
probe = AptData(APTDATA_MAGIC + raw, pool)
probe.scope = movie.scope
try:
probe.decode_one(16)
check(f"truncated {raw.hex()} fails closed", False)
except DecodeError:
check(f"truncated {raw.hex()} fails closed", True)
# Out-of-range pool index must fail closed, not silently clamp.
probe = AptData(APTDATA_MAGIC + bytes([0xAF, 0x10]), pool)
probe.scope = ["only-one"]
try:
probe.decode_one(16)
check("out-of-range scope index rejected", False)
except DecodeError:
check("out-of-range scope index rejected", True)
# Unknown opcode must refuse rather than resynchronise.
probe = AptData(APTDATA_MAGIC + bytes([0xEE, 0x00]), pool)
try:
probe.decode_one(16)
check("unknown opcode refuses to guess length", False)
except DecodeError as e:
check("unknown opcode refuses to guess length", "refusing to guess" in str(e))
# Whole-artifact decode.
movie.scope = []
movie.functions = []
seeds = find_streams(movie)
instrs: list[Instr] = []
try:
for s in seeds:
instrs.extend(movie.decode_stream(s))
check("whole artifact decodes", True, f"{len(instrs)} instructions")
except DecodeError as e:
check("whole artifact decodes", False, str(e))
return 1
boundaries = {i.offset for i in instrs}
bad = [i for i in instrs if i.target is not None and i.target not in boundaries]
check("every branch lands on an instruction boundary", not bad,
f"{len(bad)} bad")
# CheckIsKitLocked is CALLED here, never defined here: it is a method on the
# mcSelectTeam child clip, whose class lives in another asset. Assert the
# call site is bound exactly, and that this asset defines no such function.
called = [i for i in instrs if i.comment and "CheckIsKitLocked" in i.comment]
check("CheckIsKitLocked referenced exactly once", len(called) == 1,
f"{[hex(i.offset) for i in called]}")
check("CheckIsKitLocked reference is PushConstantWord (pool index > u8)",
bool(called) and called[0].mnemonic == "PushConstantWord")
check("CheckIsKitLocked is not defined in this asset",
"CheckIsKitLocked" not in {f.name for f in movie.functions})
# The gate contract the native DP builder must satisfy.
gate = [i for i in instrs if i.comment and "KITS_AVAILABLE" in i.comment]
check("KITS_AVAILABLE read exactly once", len(gate) == 1)
check("KITS_AVAILABLE read via GetNamedMember on the DP header",
bool(gate) and gate[0].mnemonic == "GetNamedMember")
# 8-byte alignment is load-bearing: prove 4 would break the pool record.
p4 = (0xD38 + 1 + 3) & ~3
c4 = struct.unpack_from("<Q", movie.data, p4)[0]
check("alignment is 8 not 4", c4 != 401, f"align4 count would be {c4:#x}")
print(f"\n {'ALL PASS' if ok else 'FAILURES PRESENT'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them.
WHY THIS EXISTS
---------------
CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer
table at .data 0x1802d2760), then a per-response-family mapper converts that index
into an internal field id with a chain of integer compares and jump tables.
A previous attempt to recover each mapper's accepted atoms by scanning for
`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it
reported that no mapper accepts atom 424 (`manager`), while a live client plainly
holds a resident manager record. Pattern scanning cannot see control flow, so it
cannot tell which compares are actually reachable.
This module executes the mappers instead. The modelled subset is exactly what these
functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed
jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`.
Anything outside that subset raises Unsupported, so a wrong field id is never
returned silently.
TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE
---------------------------------------------------
1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5
is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses.
Covered by test_rbp_relative_is_not_rip_relative.
2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so
searching for an immediate-to-memory store misses it. The interpreter tracks
register values, so propagated constants are followed.
Covered by test_constant_propagated_through_register.
Run `--selftest` to execute the positive controls. Negative results from this tool
are only admissible when the selftest passes.
"""
from __future__ import annotations
import argparse
import bisect
import struct
import sys
from pathlib import Path
REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15")
ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors()
ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax
ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1
class Unsupported(Exception):
"""The mapper used an instruction or address outside the modelled subset."""
def s32(v: int) -> int:
v &= 0xFFFFFFFF
return v - 0x100000000 if v & 0x80000000 else v
class Image:
"""A parsed PE, with VA<->file mapping and .pdata function bounds."""
def __init__(self, path: Path):
self.buf = path.read_bytes()
b = self.buf
pe = struct.unpack_from("<I", b, 0x3C)[0]
if b[pe:pe + 4] != b"PE\0\0":
raise ValueError(f"{path} is not a PE image")
nsec = struct.unpack_from("<H", b, pe + 6)[0]
optsz = struct.unpack_from("<H", b, pe + 20)[0]
self.base = struct.unpack_from("<Q", b, pe + 24 + 24)[0]
self.sections = []
for i in range(nsec):
o = pe + 24 + optsz + 40 * i
name = b[o:o + 8].rstrip(b"\0").decode(errors="replace")
vsz, va, rsz, raw = struct.unpack_from("<IIII", b, o + 8)
self.sections.append((name, va, vsz, raw, rsz))
self._funcs = None
def va2off(self, va: int):
rva = va - self.base
for _name, sva, vsz, raw, rsz in self.sections:
if sva <= rva < sva + max(vsz, rsz):
off = raw + (rva - sva)
if off < len(self.buf):
return off
return None
def rd8(self, va: int) -> int:
o = self.va2off(va)
if o is None:
raise Unsupported(f"unmapped byte read 0x{va:x}")
return self.buf[o]
def rd32(self, va: int) -> int:
o = self.va2off(va)
if o is None:
raise Unsupported(f"unmapped dword read 0x{va:x}")
return struct.unpack_from("<I", self.buf, o)[0]
def cstr(self, va: int, maxlen: int = 96):
o = self.va2off(va)
if o is None:
return None
end = self.buf.find(b"\0", o, o + maxlen)
if end < 0:
return None
try:
return self.buf[o:end].decode("ascii")
except UnicodeDecodeError:
return None
# ---- .pdata gives exact function bounds; never guess a prologue ----
def functions(self):
if self._funcs is None:
sec = next(s for s in self.sections if s[0] == ".pdata")
_n, _va, vsz, raw, _rsz = sec
out = []
for i in range(vsz // 12):
beg, end, _unw = struct.unpack_from("<III", self.buf, raw + 12 * i)
if beg or end:
out.append((self.base + beg, self.base + end))
out.sort()
self._funcs = out
return self._funcs
def function_of(self, va: int):
fs = self.functions()
starts = [f[0] for f in fs]
i = bisect.bisect_right(starts, va) - 1
if i >= 0 and fs[i][0] <= va < fs[i][1]:
return fs[i]
return None
def atom(self, index: int):
ptr = struct.unpack_from("<Q", self.buf, self.va2off(ATOM_TABLE_BASE) + 8 * index)[0]
return self.cstr(ptr)
def atom_index(self, name: str):
off = self.va2off(ATOM_TABLE_BASE)
for i in range(4096):
ptr = struct.unpack_from("<Q", self.buf, off + 8 * i)[0]
if self.cstr(ptr) == name:
return i
return None
class Mapper:
"""Executes one dispatch function for a given atom index."""
def __init__(self, image: Image, resolver: int = ATOM_RESOLVER):
self.img = image
self.resolver = resolver
def _ea(self, k: int, rex: int, r: dict):
"""Decode ModRM[+SIB][+disp].
Returns (nbytes, dst_reg, addr, src_reg). addr is an int, or the marker
("rip", disp) which the caller resolves once it knows the instruction
length, or None for a register-form operand.
TRAP 1: rm==5 is RIP-relative ONLY when mod==0. With mod 1 or 2 it is
[rbp+disp] and must be resolved from rbp.
"""
b = self.img.buf
modrm = b[k]
mod, rm = modrm >> 6, modrm & 7
dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15]
n = 1
if mod == 3:
return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15]
base_v = idx_v = disp = 0
if rm == 4:
sib = b[k + 1]
n += 1
scale = 1 << (sib >> 6)
ir = ((sib >> 3) & 7) | ((rex & 2) << 2)
br = (sib & 7) | ((rex & 1) << 3)
if (ir & 15) != 4:
idx_v = r[REGS[ir & 15]] * scale
if (sib & 7) == 5 and mod == 0:
disp = struct.unpack_from("<i", b, k + n)[0]
n += 4
else:
base_v = r[REGS[br & 15]]
elif rm == 5 and mod == 0:
disp = struct.unpack_from("<i", b, k + 1)[0]
return n + 4, dst, ("rip", disp), None
else:
base_v = r[REGS[(rm | ((rex & 1) << 3)) & 15]]
if mod == 1:
disp = struct.unpack_from("<b", b, k + n)[0]
n += 1
elif mod == 2:
disp = struct.unpack_from("<i", b, k + n)[0]
n += 4
return n, dst, (base_v + idx_v + disp) & 0xFFFFFFFFFFFFFFFF, None
@staticmethod
def _cond(cc: int, last) -> bool:
a, b = last
sa, sb = s32(a), s32(b)
ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF
if cc == 0x4: return sa == sb
if cc == 0x5: return sa != sb
if cc == 0xF: return sa > sb
if cc == 0xD: return sa >= sb
if cc == 0xC: return sa < sb
if cc == 0xE: return sa <= sb
if cc == 0x7: return ua > ub
if cc == 0x3: return ua >= ub
if cc == 0x2: return ua < ub
if cc == 0x6: return ua <= ub
if cc == 0x8: return sa < sb
if cc == 0x9: return sa >= sb
raise Unsupported(f"condition code 0x{cc:x}")
def run(self, start: int, atom: int, limit: int = 5000) -> int:
b = self.img.buf
r = {k: 0 for k in REGS}
last = (0, 0)
va = start
for _ in range(limit):
i0 = self.img.va2off(va)
if i0 is None:
raise Unsupported(f"pc unmapped 0x{va:x}")
j = i0
while b[j] in (0x66, 0x67, 0xF2, 0xF3):
j += 1
rex = 0
if 0x40 <= b[j] <= 0x4F:
rex = b[j]
j += 1
op = b[j]
pre = j - i0
if op == 0xC3:
return r["rax"] & 0xFFFFFFFF
if op == 0xCC:
raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function")
if op == 0xE8:
tgt = va + pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
if tgt != self.resolver:
raise Unsupported(f"call to non-resolver 0x{tgt:x} at 0x{va:x}")
r["rax"] = atom & 0xFFFFFFFF # resolver returns the atom index
va += pre + 5
continue
if op == 0xE9:
va += pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
continue
if op == 0xEB:
va += pre + 2 + struct.unpack_from("<b", b, j + 1)[0]
continue
if 0x70 <= op <= 0x7F:
nxt = va + pre + 2
rel = struct.unpack_from("<b", b, j + 1)[0]
va = nxt + rel if self._cond(op & 0xF, last) else nxt
continue
if op == 0x0F and 0x80 <= b[j + 1] <= 0x8F:
nxt = va + pre + 6
rel = struct.unpack_from("<i", b, j + 2)[0]
va = nxt + rel if self._cond(b[j + 1] & 0xF, last) else nxt
continue
if 0xB8 <= op <= 0xBF:
r[REGS[((op - 0xB8) | ((rex & 1) << 3)) & 15]] = struct.unpack_from("<I", b, j + 1)[0]
va += pre + 5
continue
if op in (0x05, 0x2D, 0x3D):
# accumulator short forms: add/sub/cmp eax, imm32
imm = struct.unpack_from("<i", b, j + 1)[0]
cur = r["rax"] & 0xFFFFFFFF
if op == 0x3D:
last = (cur, imm & 0xFFFFFFFF)
elif op == 0x2D:
r["rax"] = (cur - imm) & 0xFFFFFFFF
last = (r["rax"], 0)
else:
r["rax"] = (cur + imm) & 0xFFFFFFFF
last = (r["rax"], 0)
va += pre + 5
continue
if op in (0x81, 0x83):
w = 4 if op == 0x81 else 1
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
imm = struct.unpack_from("<i" if w == 4 else "<b", b, j + 2)[0]
ext = (modrm >> 3) & 7
cur = r[reg] & 0xFFFFFFFF
if ext == 7:
last = (cur, imm & 0xFFFFFFFF)
elif ext == 5:
r[reg] = (cur - imm) & 0xFFFFFFFF
last = (r[reg], 0)
elif ext == 0:
r[reg] = (cur + imm) & 0xFFFFFFFF
last = (r[reg], 0)
else:
raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}")
va += pre + 2 + w
continue
if op == 0xFF and b[j + 1] >> 6 == 3:
ext = (b[j + 1] >> 3) & 7
reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15]
if ext == 1:
r[reg] = (r[reg] - 1) & 0xFFFFFFFF
last = (r[reg], 0)
va += pre + 2
continue
if ext == 4:
va = r[reg]
continue
raise Unsupported(f"ff /{ext} at 0x{va:x}")
if op == 0x0F and b[j + 1] == 0xB6:
n, dst, addr, src = self._ea(j + 2, rex, r)
end = va + pre + 2 + n
if isinstance(addr, tuple):
addr = end + addr[1]
r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF
va = end
continue
if op in (0x8B, 0x8D):
n, dst, addr, src = self._ea(j + 1, rex, r)
end = va + pre + 1 + n
if isinstance(addr, tuple):
addr = end + addr[1]
if op == 0x8D:
if addr is None:
raise Unsupported(f"lea with register operand at 0x{va:x}")
r[dst] = addr
else:
if addr is None:
# register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax)
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
else:
r[dst] = self.img.rd32(addr)
va = end
continue
if op == 0x89:
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"89 memory store at 0x{va:x}")
src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
va += pre + 2
continue
if op == 0x63:
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"63 memory form at 0x{va:x}")
src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF
va += pre + 2
continue
if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B,
0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85):
modrm = b[j + 1]
if modrm >> 6 != 3:
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF
if op == 0x01:
r[a] = (r[a] + r[c]) & m
elif op == 0x03:
r[c] = (r[c] + r[a]) & m
elif op == 0x29:
r[a] = (r[a] - r[c]) & m
last = (r[a] & 0xFFFFFFFF, 0)
elif op == 0x2B:
r[c] = (r[c] - r[a]) & m
last = (r[c] & 0xFFFFFFFF, 0)
elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33):
fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y,
0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y,
0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op]
if op in (0x09, 0x21, 0x31):
r[a] = fn(r[a], r[c]) & m
last = (r[a] & 0xFFFFFFFF, 0)
else:
r[c] = fn(r[c], r[a]) & m
last = (r[c] & 0xFFFFFFFF, 0)
elif op == 0x85:
last = ((r[a] & r[c]) & 0xFFFFFFFF, 0)
elif op == 0x39:
last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF)
else:
last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF)
va += pre + 2
continue
if op == 0x90:
va += pre + 1
continue
if op == 0x0F and b[j + 1] == 0x1F:
n, _d, _a, _s = self._ea(j + 2, rex, r)
va += pre + 2 + n
continue
raise Unsupported(f"opcode {op:02x} at 0x{va:x}")
raise Unsupported("instruction limit reached")
def find_mappers(img: Image, resolver: int = ATOM_RESOLVER):
"""Every function containing a direct call to the atom resolver."""
sec = next(s for s in img.sections if s[0] == ".text")
_n, tva, _vsz, traw, trsz = sec
out = {}
for i in range(traw, traw + trsz - 5):
if img.buf[i] != 0xE8:
continue
va = img.base + tva + (i - traw)
if va + 5 + struct.unpack_from("<i", img.buf, i + 1)[0] == resolver:
f = img.function_of(va)
if f:
out.setdefault(f[0], []).append(va)
return out
# --------------------------------------------------------------------------
# selftest: the two decoder traps plus the live-verified positive controls
# --------------------------------------------------------------------------
def test_atom_anchors(img: Image) -> list:
"""The atom table base must reproduce known anchors, or every index is wrong."""
anchors = {11: "actives", 363: "itemData", 376: "kicktakers",
424: "manager", 568: "players", 718: "squadActives"}
fails = []
for idx, want in anchors.items():
got = img.atom(idx)
if got != want:
fails.append(f"atom[{idx}] = {got!r}, expected {want!r}")
return fails
def test_rbp_relative_is_not_rip_relative(img: Image) -> list:
"""TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative.
Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101).
A decoder that treats rm==5 as RIP-relative computes a wildly different
address and silently reads the wrong memory.
"""
m = Mapper(img)
r = {k: 0 for k in REGS}
r["rbp"] = 0x140000000
saved = img.buf
try:
img.buf = bytes.fromhex("8b4d20")
n, dst, addr, _src = m._ea(1, 0, r)
finally:
img.buf = saved
fails = []
if isinstance(addr, tuple):
fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]")
elif addr != 0x140000020:
fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020")
if dst != "rcx":
fails.append(f"destination decoded as {dst}, expected rcx")
if n != 2:
fails.append(f"modrm+disp8 consumed {n} bytes, expected 2")
return fails
def test_constant_propagated_through_register(img: Image) -> list:
"""TRAP 2. A constant reaching a use through a register must be followed.
Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is
only observable if register values propagate. Scanning for an immediate
store would see nothing.
"""
m = Mapper(img)
saved = img.buf
prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3")
try:
img.buf = prog
img_va2off = img.va2off
img.va2off = lambda va: va if 0 <= va < len(prog) else None
got = m.run(0, 0)
finally:
img.buf = saved
img.va2off = img_va2off
return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"]
def test_item_mapper_controls(img: Image) -> list:
"""Live/disassembly-verified behaviour of the item mapper."""
m = Mapper(img)
fails = []
got = m.run(ITEM_MAPPER, 568)
if got != 1:
fails.append(f"item mapper atom 568 'players' -> {got}, expected 1")
got = m.run(ITEM_MAPPER, 11)
if got != 0:
fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0")
return fails
def test_manager_424_is_accepted_somewhere(img: Image) -> list:
"""MANDATORY control. A live client holds a resident manager record, so some
mapper must map atom 424 to a non-zero field id. The previous pattern-scan
method failed exactly here, and any replacement must not."""
m = Mapper(img)
accepting = []
for start in find_mappers(img):
try:
if m.run(start, 424):
accepting.append(start)
except Unsupported:
continue
if not accepting:
return ["no mapper maps atom 424 'manager' to a non-zero field id, "
"which contradicts the live resident manager record"]
return []
def selftest(img: Image) -> int:
checks = [
("atom table anchors", test_atom_anchors),
("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative),
("trap 2: constant via register", test_constant_propagated_through_register),
("item mapper positive controls", test_item_mapper_controls),
("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere),
]
bad = 0
for name, fn in checks:
try:
fails = fn(img)
except Exception as exc: # noqa: BLE001 - report, don't mask
fails = [f"raised {type(exc).__name__}: {exc}"]
if fails:
bad += 1
print(f" FAIL {name}")
for f in fails:
print(f" {f}")
else:
print(f" ok {name}")
print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible")
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--atom", type=int, action="append", default=[],
help="atom index to resolve through every mapper")
ap.add_argument("--name", action="append", default=[],
help="atom name to resolve through every mapper")
args = ap.parse_args()
img = Image(args.image)
if args.selftest:
return selftest(img)
atoms = list(args.atom)
for nm in args.name:
idx = img.atom_index(nm)
if idx is None:
print(f" atom {nm!r} not found in the table")
return 2
atoms.append(idx)
if not atoms:
ap.error("give --atom/--name, or --selftest")
m = Mapper(img)
mappers = find_mappers(img)
print(f" {len(mappers)} mapper function(s) found\n")
for a in atoms:
print(f" === atom {a} ({img.atom(a)!r}) ===")
rows, unsup = [], 0
for start in sorted(mappers):
try:
fid = m.run(start, a)
except Unsupported:
unsup += 1
continue
if fid:
rows.append((start, fid))
for start, fid in rows:
print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})")
print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Canonical FIFA 17 kit map, joined from the extracted client tables.
Authority for every kit question that a table can answer, so nobody has to
reverse a binary for a fact that is sitting in a JSON row. Reads only:
fifa17-recon/data/tables/fcc_kitcards.json the FUT KIT CARD definitions
fifa17-recon/data/tables/teamkits.json the ENGINE kit rows
Everything printed is TABLE_PROVEN unless the line says otherwise: it is a
direct count over the full table, not a sample.
Usage:
python3 audit_fifa17_kits.py human report
python3 audit_fifa17_kits.py --json machine-readable, for tests/tools
python3 audit_fifa17_kits.py --team 21 drill into one team
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
# TABLE_PROVEN, established by this script's own discriminating test (see
# category_type_evidence): a kit CARD's `category` selects the engine kit ROW's
# `teamkittypetechid` at the same (team, year).
CATEGORY_TO_KIT_TYPE = {2: 0, 3: 1, 5: 2}
KIT_TYPE_NAME = {0: "HOME", 1: "AWAY", 2: "THIRD", 3: "FOURTH", 5: "GK", 6: "SPECIAL6", 7: "SPECIAL7"}
def load(name):
with open(os.path.join(TABLES, name), "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, list) else data.get("rows", data)
def band(carddbid: int) -> int:
"""The 6_300_000 / 6_400_000 id band."""
return (carddbid // 100_000) * 100_000
def category_type_evidence(cards, kits):
"""The DISCRIMINATING test behind CATEGORY_TO_KIT_TYPE.
Asserting "category 3 means away" because away kits usually exist is not
evidence -- types 0/1/2 are present for most teams, so the claim is true by
construction. What discriminates is the teams that LACK a type: if category 3
really means type 1, then no category-3 card may exist for a (team, year)
that has no type-1 row. Same for category 5 and type 2.
"""
kits_by = defaultdict(set)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].add(r["teamkittypetechid"])
cards_by = defaultdict(list)
for r in cards:
cards_by[(r["teamid"], r["year"])].append(r)
out = {}
for cat, want in CATEGORY_TO_KIT_TYPE.items():
# keys that HAVE teamkits rows but not the wanted type
lacking = [k for k, t in kits_by.items() if t and want not in t]
counterexamples = [
r["carddbid"] for k in lacking for r in cards_by.get(k, []) if r["category"] == cat
]
out[cat] = {
"kit_type": want,
"name": KIT_TYPE_NAME[want],
"keys_lacking_type": len(lacking),
"counterexamples": counterexamples,
}
return out
def audit():
cards = load("fcc_kitcards.json")
kits = load("teamkits.json")
kits_by = defaultdict(list)
for r in kits:
kits_by[(r["teamtechid"], r["year"])].append(r)
rows = []
for c in cards:
key = (c["teamid"], c["year"])
want = CATEGORY_TO_KIT_TYPE.get(c["category"])
match = next((k for k in kits_by.get(key, []) if k["teamkittypetechid"] == want), None)
rows.append(
{
"carddbid": c["carddbid"],
"band": band(c["carddbid"]),
"teamid": c["teamid"],
"year": c["year"],
"category": c["category"],
"kit_type": want,
"kit_type_name": KIT_TYPE_NAME.get(want, "?"),
"assetid": c["assetid"],
"cardassetid": c["cardassetid"],
"value": c["value"],
"weightrare": c["weightrare"],
# These are BYTE OFFSETS into the table's string blob, not ids.
# The blob is not among the extracted tables, so a kit's own
# name string is NOT recoverable from data/tables alone.
"name_offset": c["name"],
"header_offset": c["header"],
"description_offset": c["description"],
"teamkitid": match["teamkitid"] if match else None,
"teamkit_islocked": match["islocked"] if match else None,
"teamkit_embargoed": match["isembargoed"] if match else None,
}
)
dupes = [k for k, n in Counter((r["teamid"], r["year"], r["category"]) for r in rows).items() if n > 1]
return {
"counts": {"fcc_kitcards": len(cards), "teamkits": len(kits)},
"bands": dict(sorted(Counter(r["band"] for r in rows).items())),
"band_x_assetid": {f"{b}/{a}": n for (b, a), n in
sorted(Counter((r["band"], r["assetid"]) for r in rows).items())},
"band_x_category": {f"{b}/{c}": n for (b, c), n in
sorted(Counter((r["band"], r["category"]) for r in rows).items())},
"category_counts": dict(sorted(Counter(r["category"] for r in rows).items())),
"cardassetid": sorted({r["cardassetid"] for r in rows}),
"category_type_evidence": category_type_evidence(cards, kits),
"unmatched": [r["carddbid"] for r in rows if r["teamkitid"] is None],
"duplicate_team_year_category": dupes,
"teamkits_islocked": dict(Counter(r["islocked"] for r in kits)),
"teamkits_embargoed": dict(Counter(r["isembargoed"] for r in kits)),
"teamkits_types": dict(sorted(Counter(r["teamkittypetechid"] for r in kits).items())),
"rows": rows,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--team", type=int)
args = ap.parse_args()
a = audit()
if args.json:
json.dump(a, sys.stdout, indent=2)
return
print("FIFA 17 kit map — TABLE_PROVEN from the extracted client tables")
print(f" fcc_kitcards rows : {a['counts']['fcc_kitcards']}")
print(f" teamkits rows : {a['counts']['teamkits']}")
print("\nid bands")
for b, n in a["bands"].items():
print(f" {b}: {n}")
print("\nband/assetid (assetid is fully determined by band)")
for k, n in a["band_x_assetid"].items():
print(f" {k}: {n}")
print("\nband/category")
for k, n in a["band_x_category"].items():
print(f" {k}: {n}")
print(f"\ncardassetid values: {a['cardassetid']} (the FUT card frame, not the kit art)")
print("\ncategory -> engine kit type, with the discriminating test")
for cat, ev in a["category_type_evidence"].items():
verdict = "HOLDS" if not ev["counterexamples"] else f"FAILS ({len(ev['counterexamples'])})"
print(f" category {cat} -> type {ev['kit_type']} {ev['name']:6s} "
f"| {ev['keys_lacking_type']:4d} (team,year) keys lack that type, "
f"{len(ev['counterexamples'])} counterexample(s) -> {verdict}")
print("\nengine kit types present in teamkits")
for t, n in a["teamkits_types"].items():
print(f" type {t} {KIT_TYPE_NAME.get(t,'?'):8s}: {n}")
print(f"\nteamkits islocked : {a['teamkits_islocked']} <- every row, so NOT the selector lock")
print(f"teamkits embargoed : {a['teamkits_embargoed']}")
print(f"\nanomalies")
print(f" cards with no matching teamkits row : {len(a['unmatched'])}")
print(f" duplicate (team,year,category) : {len(a['duplicate_team_year_category'])}")
if args.team is not None:
print(f"\n=== team {args.team} ===")
print(f" {'carddbid':10s} {'cat':4s} {'type':7s} {'year':6s} {'assetid':8s} {'teamkitid':10s} locked")
for r in sorted((r for r in a["rows"] if r["teamid"] == args.team), key=lambda r: r["carddbid"]):
print(f" {r['carddbid']:<10} {r['category']:<4} {r['kit_type_name']:<7} {r['year']:<6} "
f"{r['assetid']:<8} {str(r['teamkitid']):<10} {r['teamkit_islocked']}")
if __name__ == "__main__":
main()
+12 -11
View File
@@ -147,14 +147,13 @@ def refresh_account_identity():
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
# Client/server split support (OpenFUT dev-container): bind and advertise default
# to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
# and (unless overridden) the roster service.
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
# Use a certificate dNSName and resolve it on the client.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
@@ -563,9 +562,11 @@ OSDK_TICKER = []
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "%s:8081" % _ADVERTISE
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
# deployment can advertise a certificate DNS name without changing other hosts.
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Recover the kit caption/localisation vocabulary from the UNPACKED CardsDLL.
Why CardsDLL and not FIFA17.exe: CardsDLL is not packed, so a MISS here is
meaningful. FIFA17.exe is Denuvo-packed and only partially readable -- a hit
there is useful, a miss proves nothing. Every run therefore prints a positive
control first; if the control fails, the run is void and no negative may be
quoted from it.
Usage: python3 cardsdll_kit_strings.py [path-to-CardsDLL]
"""
from __future__ import annotations
import os
import re
import sys
DEFAULT = os.path.expanduser(
"~/.cache/openfut-investigation/bin/CardsDLL_Win64_retail.dll"
)
# Strings that MUST be present. If any is missing the search is broken.
CONTROLS = [b"activeHomeKit", b"cardsubtypeid", b"resourceId", b"activeAwayKit"]
# The kit caption vocabulary this project has referred to, plus neighbours worth
# knowing about either way.
PROBES = [
b"FUT_UC_KITS", b"TeamName_Abbr15_", b"TeamName_Abbr15", b"TeamName_",
b"FUT_UC_", b"StadiumName_", b"Badge", b"Stadium",
b"activeBadge", b"activeBall", b"activeStadium",
b"kit", b"Kit", b"KIT",
b"home", b"Home", b"HOME", b"away", b"Away", b"AWAY",
b"locked", b"Locked", b"LOCKED", b"unlock",
b"category", b"year", b"teamid", b"teamId",
b"DataProvider", b"itemData", b"itemType", b"itemState",
]
def ascii_strings(data, minlen=4):
for m in re.finditer(rb"[ -~]{%d,}" % minlen, data):
yield m.start(), m.group()
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
data = open(path, "rb").read()
print(f"{os.path.basename(path)} {len(data)} bytes")
print("\n-- positive control (a miss voids every negative below) --")
ok = True
for c in CONTROLS:
n = data.count(c)
print(f" {c.decode():16s} {n}")
if n == 0:
ok = False
if not ok:
print(" CONTROL FAILED — do not quote negatives from this run.")
return 1
print("\n-- probe counts --")
for p in PROBES:
print(f" {p.decode():18s} {data.count(p)}")
# Whole-string table: every standalone string containing kit-ish substrings.
print("\n-- standalone strings matching kit/team/caption vocabulary --")
pat = re.compile(rb"(?i)(kit|teamname|abbr|stadiumname|fut_uc|locked|unlock)")
seen = set()
for off, s in ascii_strings(data, 5):
if pat.search(s) and s not in seen:
seen.add(s)
print(f" @{off:#08x} {s.decode('latin1')[:110]}")
print(f" ({len(seen)} distinct)")
return 0
if __name__ == "__main__":
sys.exit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Prove, from the live client, which cardtypes CardsDLL can NAME -- and that
cardtype 9 (ball / league logo / fcc_misccards) is not one of them.
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
WHY
---
Serving an owned ball or league logo was blocked on one question: where does a
cardtype-9 item's caption come from? Three independent reads here say: nowhere.
MEASURED 2026-08-21, pid 6580, CardsDLL live base 0x6ffffc0f0000
(module-relative offsets below are stable; live addresses are not).
1. THE CLUB-ITEM CAPTION RESOLVER IS A VTABLE SLOT, NOT AN EXPORT.
An earlier note recorded FUN_180119bd0 as "zero refs in CardsDLL -> almost
certainly an export, its caller is in FIFA17.exe". That is WRONG and this
tool corrects it. Its address occurs exactly ONCE in the entire process, at
image 0x18021c738, inside CardsDLL's own .rdata -- a vtable entry. Nothing in
FIFA17.exe references it.
Walking backwards over "qwords pointing into .text" overshoots the vtable
boundary (it runs 826 slots through several adjacent vtables). The reliable
discriminator is that a vtable's START is referenced by its constructor via a
RIP-relative LEA while interior slots never are:
vtable base image 0x18021c2a0 (ctor LEAs at 0x18010ce10, 0x18011111b)
FUN_180119bd0 slot +0x498, index 147
which independently reproduces the previously recorded "manager vtable slot
+0x498". There are 7 distinct `call [reg+0x498]` sites.
2. THE CAPTION CALL IS GATED ON cardtype == 7, AND THE ELSE IS TROPHIES.
At 0x1800f6f04:
cmp DWORD PTR [rax+0x4c], 0x7 ; cardtype
jne 0x1800f6f82
...
mov r9d, [rdx+0x94]
mov r8d, [rdx+0x50] ; cardsubtypeid
mov ecx, [rdx+0x20] ; assetid
call QWORD PTR [r10+0x498] ; FUN_180119bd0
The jne path formats [rdi+0x8] into 'AWARD_LABEL_%i' (0x1801fd5a0) and
localises it -- that is the TROPHY path (subtypes 0x91..0x96), not a fallback
that would name a ball.
3. NO CARDTYPE-9 HANDLING EXISTS, BY TWO INDEPENDENT MEASURES.
a) Census of every `cmp [reg+0x4c], imm8` in .text:
cardtype 0 : 2 sites
cardtype 1 : 14 sites
cardtype 6 : 1 site
cardtype 7 : 6 sites
cardtype 9 : 0 sites
b) The merge switch's jump table at rva 0x141eb4, indexed by cardtype-1,
10 entries:
idx 0..4 -> cardtypes 1..5 distinct DB-merge arms
idx 5..8 -> cardtypes 6..9 ALL to the shared tail 0x180141e8a
idx 9 -> cardtype 10 distinct arm (gkcoach)
The shared tail does no DB query and writes no name: it only derives the
discard level from the rating.
A cmp census alone would miss a jump-table switch, and a jump table alone
would miss an explicit compare. Both say the same thing.
CONSEQUENCE
-----------
A cardtype-9 item cannot receive a client-resolved caption: it has no merge arm
to fill a name and it can never reach the cardtype-7 resolver. Withholding ball
and league logo from the projection is therefore an evidence-backed limit of the
client, not caution -- and no server-side change can lift it.
BONUS, and it validates the discard work: the shared tail at 0x180141e8a IS the
discard level ladder, live --
movzx eax,[rdi+0xb4] ; cmp al,0x4b ; -> 3
cmp al,0x41 ; sbb eax,eax ; add eax,2 ; -> 2 or 1
mov [rdi+0x54], eax
which is `discard::discard_level` instruction for instruction.
Usage:
python3 cardtype_dispatch_probe.py
"""
import collections
import struct
import sys
import watch_club_model as W
TEXT_LO, TEXT_HI = 0x180001000, 0x1801E5000
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
CAPTION_FN = 0x180119BD0
JUMP_TABLE = 0x180141EB4
SHARED_TAIL = 0x180141E8A
REGS = {0x78: "rax", 0x79: "rcx", 0x7A: "rdx", 0x7B: "rbx",
0x7D: "rbp", 0x7E: "rsi", 0x7F: "rdi"}
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
dll = W.dll_base(pid)
if dll is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
live = lambda i: dll + (i - W.IMG_BASE)
print("pid=%d CardsDLL live base %#x" % (pid, dll))
text, bad = mem.read_pages(live(TEXT_LO), TEXT_HI - TEXT_LO)
text = bytes(text)
print("read %#x bytes .text (%d bad pages)" % (len(text), len(bad)))
# --- 1. locate the caption fn's single reference, and its vtable base ----
target = live(CAPTION_FN)
rdata, _ = mem.read_pages(live(RDATA_LO), RDATA_HI - RDATA_LO)
rdata = bytes(rdata)
slots = []
needle = struct.pack("<Q", target)
i = rdata.find(needle)
while i != -1:
slots.append(RDATA_LO + i)
i = rdata.find(needle, i + 1)
print("\n[1] FUN_%x referenced from .rdata at: %s"
% (CAPTION_FN, [hex(s) for s in slots]) or "nowhere")
lea_t = set()
for i in range(len(text) - 7):
if text[i] in (0x48, 0x4C) and text[i + 1] == 0x8D and text[i + 2] in (
0x05, 0x0D, 0x15, 0x1D, 0x25, 0x2D, 0x35, 0x3D):
tgt = TEXT_LO + i + 7 + struct.unpack_from("<i", text, i + 3)[0]
if RDATA_LO <= tgt < RDATA_HI:
lea_t.add(tgt)
for slot in slots:
base = max((t for t in lea_t if t <= slot), default=None)
if base is not None:
print(" vtable base %#x -> slot +%#x (index %d)"
% (base, slot - base, (slot - base) // 8))
# --- 2. cardtype compare census -----------------------------------------
hits = collections.defaultdict(list)
for i in range(len(text) - 4):
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x4C:
hits[text[i + 3]].append(TEXT_LO + i)
print("\n[2] cardtype tests `cmp [reg+0x4c], imm`:")
for ct in sorted(hits):
print(" cardtype %2d : %3d site(s) e.g. %s"
% (ct, len(hits[ct]), ", ".join("%#x" % v for v in hits[ct][:4])))
ok_control = 7 in hits and 1 in hits
print(" CONTROL (cardtypes 1 and 7 must both appear): %s"
% ("OK" if ok_control else "WRONG REGION -- results are meaningless"))
print(" cardtype 9 sites: %d" % len(hits.get(9, [])))
# --- 3. merge jump table -------------------------------------------------
jt, _ = mem.read_pages(live(JUMP_TABLE), 0x40)
jt = bytes(jt)
print("\n[3] merge jump table at %#x (index = cardtype - 1):" % JUMP_TABLE)
tail_types = []
for n in range(16):
rva = struct.unpack_from("<I", jt, n * 4)[0]
if not (0x1000 <= rva < 0x1E5000):
break
va = W.IMG_BASE + rva
ct = n + 1
mark = " <- SHARED TAIL (no DB query, no name)" if va == SHARED_TAIL else ""
print(" cardtype %2d -> %#x%s" % (ct, va, mark))
if va == SHARED_TAIL:
tail_types.append(ct)
# --- 4. cardsubtypeid census -------------------------------------------
# The club-item CAPTION is chosen by subtype (+0x50), not cardtype, so the
# cardtype census alone does not settle whether a ball or logo is nameable.
sub = collections.defaultdict(list)
for i in range(len(text) - 8):
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x50:
sub[text[i + 3]].append(TEXT_LO + i)
elif text[i] == 0x81 and text[i + 1] in REGS and text[i + 2] == 0x50:
sub[struct.unpack_from("<I", text, i + 3)[0]].append(TEXT_LO + i)
print("\n[4] cardsubtypeid tests `cmp [reg+0x50], imm`:")
for st in sorted(k for k in sub if k <= 400):
print(" subtype %3d : %2d site(s) e.g. %s"
% (st, len(sub[st]), ", ".join("%#x" % v for v in sub[st][:4])))
print(" CONTROL (kit 9 / stadium 10 / badge 11 must appear): %s"
% ("OK" if all(s in sub for s in (9, 10, 11)) else "WRONG REGION"))
print(" ball(30)=%d leaguelogo(31)=%d misc(231/232/233/236)=%d"
% (len(sub.get(30, [])), len(sub.get(31, [])),
sum(len(sub.get(s, [])) for s in (231, 232, 233, 236))))
print(" NOTE: the misc sites are all one boolean predicate near"
" 0x1801a72da that returns FALSE for them -- an exclusion, not a"
" caption. Its identity is NOT established.")
print("\nVERDICT: cardtypes with no merge arm: %s" % tail_types)
print(" cardtype 9 named by CardsDLL: %s"
% ("NO -- no merge arm and no compare site" if 9 in tail_types
and not hits.get(9) else "reconsider"))
return 0
if __name__ == "__main__":
sys.exit(main())
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Classify call sites of the 130000/130001 provider stubs.
A call whose result is COMPARED implements a predicate ("is this the FUT custom
club?"). Only a call whose result is STORED can assign a team id. This turns an
unreadable 81-site list into the handful that could actually introduce 130000
into a struct.
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
"""
import re
import sys
asm = sys.argv[1]
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
lines = []
for l in open(asm, errors="replace"):
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
if m:
lines.append((int(m.group(1), 16), m.group(3).strip()))
idx = {a: i for i, (a, _t) in enumerate(lines)}
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
for tgt in targets:
print(f"\n ===== callers of 0x{tgt} =====")
stores, cmps, other = [], [], []
for i, (a, txt) in enumerate(lines):
if not txt.startswith("call") or tgt not in txt:
continue
# look at the next few instructions for the fate of eax
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
verdict, detail = "other", window[0] if window else ""
for w in window:
if STORE.match(w):
verdict, detail = "STORE", w
break
if CMP.match(w):
verdict, detail = "compare", w
break
if MOVREG.match(w):
verdict, detail = "movreg", w
break
rec = (a, detail)
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
print(f" STORE (can assign) : {len(stores)}")
for a, d in stores:
print(f" 0x{a:x} {d}")
print(f" compare (predicate) : {len(cmps)}")
print(f" other/moved to reg : {len(other)}")
for a, d in other[:14]:
print(f" 0x{a:x} {d}")
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Read-only probe v3: discriminate "kits never ingested" from "ingested then freed".
Staff was refetched by the client at 18:40:38, four minutes before the scan, and
players are resident. If staff/badge/stadium records are resident but the two
kits are not, the kits are being dropped specifically.
"""
import re
import struct
import subprocess
import sys
NEEDLES = {
"PLAYER resourceId 83906881 (control, resident)": 83906881,
"STAFF resourceId 9000081 (headcoach-ish)": 9000081,
"STAFF resourceId 3000083 (x2)": 3000083,
"STAFF resourceId 1000509": 1000509,
"STAFF instance 100004870": 100004870,
"BADGE resourceId 6000005": 6000005,
"BADGE instance 100004875": 100004875,
"STADIUM resourceId 6200000": 6200000,
"STADIUM instance 100004876": 100004876,
"KIT resourceId 6300006 (home)": 6300006,
"KIT resourceId 6400003 (away)": 6400003,
"KIT instance 100004874 (home)": 100004874,
"KIT instance 100004873 (away)": 100004873,
"KIT cardassetid 35": 35,
}
def find_pid():
out = subprocess.run(["pgrep", "-f", "FIFA17.exe"], capture_output=True, text=True).stdout.split()
for p in out:
try:
with open(f"/proc/{p}/maps") as fh:
if "CardsDLL" in fh.read():
return int(p)
except OSError:
continue
return int(out[0]) if out else None
def main():
pid = find_pid()
if not pid:
sys.exit("FIFA17.exe not running")
print(f"pid={pid}")
regs = []
with open(f"/proc/{pid}/maps") as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
if not m:
continue
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
if "r" in perms and not path.startswith("/dev/") and (hi - lo) <= (512 << 20):
regs.append((lo, hi))
hits = {k: [] for k in NEEDLES}
pats = {k: struct.pack("<I", v) for k, v in NEEDLES.items()}
mib = 0
with open(f"/proc/{pid}/mem", "rb", buffering=0) as mem:
for lo, hi in regs:
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
if not buf:
continue
mib += len(buf)
for k, needle in pats.items():
start = 0
while len(hits[k]) < 5000:
i = buf.find(needle, start)
if i < 0:
break
hits[k].append(lo + i)
start = i + 4
print(f"read {mib/(1<<20):.0f} MiB\n" + "=" * 66)
def rd(base, off, size=4):
try:
mem.seek(base + off)
raw = mem.read(size)
return int.from_bytes(raw, "little") if len(raw) == size else None
except (OSError, ValueError, OverflowError):
return None
for k in NEEDLES:
addrs = hits[k]
# count how many look like real item records (plausible cardtype)
recs = []
for a in addrs[:3000]:
base = a - 0x18
ct = rd(base, 0x4C)
if ct in (1, 2, 3, 4, 5, 6, 7, 9):
recs.append((base, ct))
flag = "" if addrs else " <-- ZERO"
print(f" {len(addrs):6d} raw / {len(recs):4d} record-shaped {k}{flag}")
for base, ct in recs[:3]:
print(f" @{base:#x} cardtype={ct} subtype={rd(base,0x50)} "
f"itemState={rd(base,0x5c)} +0x60={rd(base,0x60)} "
f"teamid={rd(base,0x94)} cat={rd(base,0xb8)} year={rd(base,0xba,2)}")
print("=" * 66)
if __name__ == "__main__":
main()
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
The generated GDB program uses hardware breakpoints, only reads registers and
client memory, logs, and continues. Seven breakpoints are rotated so no more
than four are enabled. It never calls client functions, writes client memory,
emits events, or drives input.
command_128_trace.py [pid] [--output PATH]
command_128_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
SCREEN_VTABLE_RVA = 0x03B3ECC0
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
def addresses(base: int) -> dict[str, int]:
return {
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
"scheduler": base + SCENARIO_SCHEDULER_RVA,
"manager_start": base + SCENARIO_MANAGER_START_RVA,
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
"screen_vtable": base + SCREEN_VTABLE_RVA,
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_VTABLE_RVA,
}
def gdb_prelude(pid: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted off
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
"""
def build_script(pid: int, fifa_base: int, output: str) -> str:
address = addresses(fifa_base)
return (
gdb_prelude(pid, output)
+ f"""define snapshot_gameplay
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
set $snap_listener_manager = 0
set $snap_listener_table = 0
set $snap_listener_index = -1
set $snap_free_roam = 0
set $snap_free_state = -1
set $snap_free_111 = -1
set $snap_free_112 = -1
set $snap_free_124 = -1
set $snap_selected = 0
set $snap_selected_vtable = 0
set $snap_selected_mode = -1
if $snap_gameplay_global != 0
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
end
if $snap_listener_manager != 0
set $snap_listener_table = *(void**)$snap_listener_manager
end
if $snap_listener_table != 0
set $snap_free_roam = *(void**)$snap_listener_table
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
if $snap_listener_index >= 0 && $snap_listener_index < 3
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
end
end
if $snap_free_roam != 0
set $snap_free_state = *(int*)($snap_free_roam+0x30)
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
set $snap_free_124 = *(int*)($snap_free_roam+0x124)
end
if $snap_selected != 0
set $snap_selected_vtable = *(void**)$snap_selected
set $snap_selected_mode = *(int*)($snap_selected+0x18)
end
end
set $action_count = 0
hbreak *0x{address['manager_select_action']:x}
commands
silent
set $action_count = $action_count+1
set $provider = $rbx
snapshot_gameplay
if $action_count <= 128
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
end
if $eax == 0x30
bt 16
end
continue
end
hbreak *0x{address['instructions_screen']:x}
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
commands
silent
set $screen = $rcx
set $screen_owner = *(void**)($screen+0x140)
set $screen_owner_vtable = 0
if $screen_owner != 0
set $screen_owner_vtable = *(void**)$screen_owner
end
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
bt 16
continue
end
hbreak *0x{address['command_dispatch']:x}
condition 3 $edx == 0x128
commands
silent
set $command_dispatcher = $rcx
set $command_table = *(void**)$command_dispatcher
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 1
disable 2
disable 3
enable 5
continue
end
hbreak *0x{address['free_roam_case']:x}
commands
silent
set $owner = $rbx
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
continue
end
hbreak *0x{address['scheduler']:x}
disable 5
commands
silent
set $owner = $rcx
set $manager = *(void**)($owner+0x168)
set $manager_vtable = 0
set $manager_mode = -1
set $child = 0
set $child_vtable = 0
if $manager != 0
set $manager_vtable = *(void**)$manager
set $manager_mode = *(int*)($manager+0x50)
set $child = *(void**)($manager+0x8)
end
if $child != 0
set $child_vtable = *(void**)$child
end
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
disable 4
disable 5
enable 6
continue
end
hbreak *0x{address['manager_start']:x}
disable 6
commands
silent
set $manager = $rcx
set $child = *(void**)($manager+0x8)
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
disable 6
enable 7
continue
end
hbreak *0x{address['scenario_start']:x}
disable 7
commands
silent
set $ctx = $rcx
snapshot_gameplay
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 7
continue
end
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']:x}\\n"
continue
"""
)
def effective_environment(pid: int) -> dict[str, str]:
values: dict[str, str] = {}
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
if not item.startswith(b"OPENFUT_FIFA17_"):
continue
key, _, value = item.decode("utf-8", errors="replace").partition("=")
values[key] = value
return values
def selftest() -> None:
address = addresses(0x140000000)
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
assert address["manager_select_source"] == 0x14705A620
assert address["manager_select_action"] == 0x147CDC4A6
assert address["instructions_screen"] == 0x147DCA400
assert address["command_dispatch"] == 0x147A8F6C0
assert address["free_roam_case"] == 0x147A92B0F
assert address["scheduler"] == 0x147AC3A40
assert address["manager_start"] == 0x147B1C2B0
assert address["scenario_start"] == 0x147B1C190
assert script.count("hbreak *") == 7
assert "set $action_count = 0" in script
assert "MANAGER_SELECT_ACTION" in script
assert "condition 2 $edx == 0x30" in script
assert "condition 3 $edx == 0x128" in script
assert "disable 4" in script
assert "disable 5" in script and "enable 5" in script
assert "disable 6" in script and "enable 6" in script
assert "disable 7" in script and "enable 7" in script
assert "set *(" not in script
print("command_128_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
cards_base = 0
cards_path = "<not-loaded>"
try:
cards_base, cards_path = transition.cards_mapping(pid)
except RuntimeError:
pass
else:
transition.validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
script = build_script(pid, fifa_base, output)
environment = effective_environment(pid)
print(
"COMMAND128 PREPARED "
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
f"cards_path={cards_path} "
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-command-128-{pid}.gdb"
Path(script_path).write_text(script, encoding="utf-8")
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back the DISCARD (quick-sell) value the live client holds for every
resident card, and check it against the client's own `fcc_discardcoins` table.
READ-ONLY. Walks the same CardsDb node tree as card_identity_probe / coach_probe
via /proc/PID/mem; there is no write path in this file.
WHAT THE TWO SLOTS MEAN (FUN_18013fe00 / FUN_180141660)
-------------------------------------------------------
item+0x38 the `discardValue` WE sent (atom 0xd7), stored verbatim.
item+0x3c the value the CLIENT computed for itself.
At 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` SKIPS the whole local
computation when +0x38 is non-zero. So:
* +0x38 non-zero -> the client displays OUR number and +0x3c is not filled.
* +0x38 zero -> the client computes, and +0x3c is what the player sees.
The local computation is
SELECT price FROM fcc_discardcoins WHERE cardtype==? AND level==? AND rare==?
value = round_half_up(rating * price / 100)
with `level` = 3 if rating >= 0x4b, 2 if >= 0x41, else 1 (item+0x54), and
cardtype derived from cardsubtypeid by FUN_1800d8330.
WHY THIS TOOL EXISTS
--------------------
For cardtypes 2/3/4/5/10 (the five staff families) the client OVERWRITES the
rating and rare flag we send with values from its own card database before
computing. The server therefore cannot know the displayed price from what it
sent -- it has to be read back. +0x3c is that read-back, and it is the ground
truth for what the server must credit on a quick sell.
Usage:
python3 discard_probe.py # table of every resident card
python3 discard_probe.py --kind staff # only the staff families
python3 discard_probe.py --json out.json
"""
import argparse
import json
import os
import sys
import card_identity_probe as P
import watch_club_model as W
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
F_SERVER_DISCARD = 0x38
F_CLIENT_DISCARD = 0x3C
F_LEVEL = 0x54
F_RARE = 0x58
F_RATING = 0xB4
def cardtype_for_subtype(sub):
"""FUN_1800d8330, read out of its raw two-level jump table."""
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 236) or 145 <= sub <= 150 or 231 <= sub <= 233:
return 9
if 51 <= sub <= 136 or 201 <= sub <= 220 or 250 <= sub <= 273 or 300 <= sub <= 341:
return 6
return 0
def load_prices():
"""{(cardtype, level, rare): price} from the client's own dumped table."""
path = os.path.join(TABLES, "fcc_discardcoins.json")
if not os.path.isfile(path):
return None
doc = json.load(open(path))
rows = doc["rows"] if isinstance(doc, dict) else doc
return {(r["cardtype"], r["level"], r["rare"]): r["price"] for r in rows}
def predict(prices, cardtype, rating, rare):
"""The client's formula, reproduced. An absent key pays 0, never a floor."""
if prices is None or cardtype == 0 or rating is None:
return None
level = 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1)
price = prices.get((cardtype, level, rare), 0)
if price == 0:
return 0
return (rating * price + 50) // 100
STAFF_SUBTYPES = (4, 5, 6, 7, 8)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--kind", choices=("all", "staff", "player", "other"), default="all")
ap.add_argument("--json", metavar="PATH")
a = ap.parse_args()
prices = load_prices()
if prices is None:
print("WARNING: no fcc_discardcoins.json under %s -- predictions disabled\n" % TABLES)
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
out = []
for n in ns:
buf = mem.read(n + P.REC, P.REC_LEN)
if buf is None or len(buf) < P.REC_LEN:
continue
sub = P.u32(buf, P.F_SUBTYPE)
ct = P.u32(buf, P.F_CARDTYPE)
rating = P.u8(buf, F_RATING)
rare = P.u32(buf, F_RARE)
rec = {
"resourceId": P.u32(buf, P.F_RESOURCE),
"subtype": sub,
"cardtype": ct,
"decoded_cardtype": cardtype_for_subtype(sub),
"rating": rating,
"level": P.u32(buf, F_LEVEL),
"rare": rare,
"server_discard": P.u32(buf, F_SERVER_DISCARD),
"client_discard": P.u32(buf, F_CLIENT_DISCARD),
"predicted": predict(prices, ct, rating, rare),
}
if a.kind == "staff" and sub not in STAFF_SUBTYPES:
continue
if a.kind == "player" and ct != 1:
continue
if a.kind == "other" and (ct == 1 or sub in STAFF_SUBTYPES):
continue
out.append(rec)
out.sort(key=lambda r: (r["cardtype"], r["subtype"], r["resourceId"]))
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
% ("resource", "sub", "ct", "rat", "lvl", "rar", "sent+38", "calc+3c",
"predict", "verdict"))
agree = disagree = notcomputed = 0
for r in out:
if r["server_discard"]:
verdict = "SERVER-SHOWN (local calc skipped)"
notcomputed += 1
elif r["predicted"] is None:
verdict = "?"
elif r["client_discard"] == r["predicted"]:
verdict = "AGREES"
agree += 1
else:
verdict = "DISAGREES"
disagree += 1
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
% (r["resourceId"], r["subtype"], r["cardtype"], r["rating"],
r["level"], r["rare"], r["server_discard"], r["client_discard"],
r["predicted"], verdict))
print("\nAGREES=%d DISAGREES=%d server-shown=%d total=%d"
% (agree, disagree, notcomputed, len(out)))
if a.json:
json.dump(out, open(a.json, "w"), indent=2)
print("wrote %s" % a.json)
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Find an APT/ActionScript symbol inside the FIFA 17 Frostbite .cas archives.
Frosty is a GUI-only tool and its Legacy Explorer is the documented way to reach
these assets, but the chunks holding APT ActionScript are stored plainly enough to
grep so a screen can be identified, and its whole symbol table recovered,
without driving the GUI at all.
ALWAYS passes a control first: `KitAssignmentPopup` is a string from an
already-exported BIG, so if it misses, the archives are packed differently than
assumed and no negative from this tool may be quoted.
python3 find_apt_in_cas.py FUT_GET_MATCH_KITS_DP
python3 find_apt_in_cas.py --dump 0x3707ecd7 fifa_installpackage_01/cas_01.cas
"""
import argparse
import glob
import os
import re
import sys
ROOT = "/mnt/games/FIFA 17"
CONTROL = b"KitAssignmentPopup"
def cas_files():
return sorted(glob.glob(os.path.join(ROOT, "**", "*.cas"), recursive=True))
def find(needle: bytes):
control_total = 0
hits = []
for p in cas_files():
d = open(p, "rb").read()
control_total += d.count(CONTROL)
start = 0
while True:
i = d.find(needle, start)
if i < 0:
break
hits.append((p, i))
start = i + 1
return control_total, hits
def dump(path, off, span=90000):
with open(path, "rb") as f:
f.seek(max(0, off - span // 2))
d = f.read(span)
seen = []
for m in re.finditer(rb"[ -~]{4,}", d):
t = m.group().decode("latin1")
if t not in seen:
seen.append(t)
return seen
def main():
ap = argparse.ArgumentParser()
ap.add_argument("needle", nargs="?")
ap.add_argument("--dump", metavar="OFFSET")
ap.add_argument("--file")
args = ap.parse_args()
if args.dump:
path = args.file if os.path.isabs(args.file or "") else os.path.join(
ROOT, "Data/Win32/superbundlelayout", args.file or "")
for s in dump(path, int(args.dump, 0)):
print(s)
return 0
if not args.needle:
ap.error("needle required")
ctl, hits = find(args.needle.encode())
print(f"control {CONTROL.decode()}: {ctl} hit(s)")
if ctl == 0:
print("CONTROL FAILED — archives not greppable this way; no negative is valid.")
return 1
print(f"{args.needle}: {len(hits)} hit(s)")
for p, i in hits[:20]:
print(f" {os.path.relpath(p, ROOT)} @ {i:#x}")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -4
View File
@@ -299,10 +299,16 @@ def player_item(item_id, player, special=False):
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
# client runs its own fcc_discardcoins lookup and the price register stays 0.
#
# CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup
# returns no row for our cards" and "WHY its lookup misses is still UNKNOWN" --
# are both FALSE. The lookup does not miss; real rows exist for both rare values
# on (cardtype 6, level, rare). The tile reads a DIFFERENT property, which is why
# the wallet and the screen disagreed. Sending a non-zero value still fixes the
# display, for the reason below -- it bypasses the local computation entirely --
# but do not carry the "missing row" story forward: it sent one round of work
# looking for a table defect that was never there.
#
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
@@ -0,0 +1,126 @@
"""Hardware-only trace of the engine-local overwrite wrapper entry.
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
caller return address and R8D is the team ID later written to the final match
record. This closes the one frame Wine PE unwinding could not recover.
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
WRAPPER_VA = 0x147CE47E0
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, path: str):
self.path = path
self.index = 0
def log(self, kind: str, **payload):
self.index += 1
thread = _thread()
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": thread,
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class WrapperBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
super().__init__(
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
stack = _reg("rsp")
caller_return = _u64(stack)
self.state.log(
"engine_overwrite_wrapper_entry",
wrapper_va=WRAPPER_VA,
caller_return_address=caller_return,
source_team_id=_reg("r8") & 0xFFFFFFFF,
side_argument=_reg("rdx") & 0xFFFFFFFF,
registers=_registers(),
caller_disassembly=(
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
if caller_return else None
),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
traceback=traceback.format_exc()
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
breakpoint = WrapperBreakpoint(_STATE)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,226 @@
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
READ-ONLY hardware debug only:
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
At the first team-like source value, derives both fixed-stride record fields
from live RCX and arms 4-byte WRITE watchpoints on:
teamId A = rcx + 0x44
teamId B = rcx + 0x44 + 0x45c
The execute breakpoint records the intended source value before every call. The
watchpoints then capture both the expected write and any later overwrite, even
if the overwrite comes from a different function.
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
WRITER_VA = 0x147C652CE
POST_WRITER_VA = 0x147C652D3
SIDE_STRIDE = 0x45C
TEAM_FIELD_OFF = 0x44
RECORD_FIELD_OFF = 0x14
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, log_path: str):
self.log_path = log_path
self.event_index = 0
self.engine_base = None
self.watch_a = None
self.watch_b = None
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def arm_fields(self, engine_base: int):
if self.engine_base == engine_base and self.watch_a and self.watch_b:
return
for watchpoint in (self.watch_a, self.watch_b):
if watchpoint is not None:
try:
watchpoint.delete()
except gdb.error:
pass
self.engine_base = engine_base
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
self.watch_b = TeamFieldWatchpoint(
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
)
self.log(
"team_field_watchpoints_armed",
engine_base=engine_base,
team_id_a_address=self.watch_a.address,
team_id_b_address=self.watch_b.address,
watchpoint_a=self.watch_a.number,
watchpoint_b=self.watch_b.number,
)
class TeamFieldWatchpoint(gdb.Breakpoint):
def __init__(self, state: State, side: int, address: int):
self.state = state
self.side = side
self.address = address
super().__init__(
f"*(int*)0x{address:x}",
type=gdb.BP_WATCHPOINT,
wp_class=gdb.WP_WRITE,
internal=False,
)
self.silent = True
def stop(self):
try:
pc = _reg("rip")
writer = WRITER_VA if pc == POST_WRITER_VA else None
record_start = self.address - RECORD_FIELD_OFF
record = _read(record_start, 0x7C)
self.state.log(
"final_team_field_write_post",
side=self.side,
watch_address=self.address,
value=_i32(self.address),
stopped_pc=pc,
writer_va=writer,
record_start=record_start,
record_hex=record.hex() if record else None,
registers=_registers(),
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error",
where="team_field_watchpoint",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class FinalWriterBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
super().__init__(
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
engine_base = _reg("rcx")
side_offset = _reg("rdx")
source_value = _reg("r8") & 0xFFFFFFFF
if source_value in TEAM_LIKE:
self.state.arm_fields(engine_base)
destination = engine_base + side_offset + TEAM_FIELD_OFF
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
self.state.log(
"final_writer_pre",
instruction_va=WRITER_VA,
engine_base=engine_base,
side_offset=side_offset,
side=side,
destination=destination,
record_start=destination - RECORD_FIELD_OFF,
source_register="r8d",
source_value=source_value,
prior_value=_i32(destination),
team_id_a_address=engine_base + TEAM_FIELD_OFF,
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
registers=_registers(),
disassembly=gdb.execute("x/6i $pc", to_string=True),
backtrace=gdb.execute("bt 32", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error",
where="final_writer",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
writer = FinalWriterBreakpoint(_STATE)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={
"final_writer": {"number": writer.number, "va": WRITER_VA},
},
side_stride=SIDE_STRIDE,
team_field_offset=TEAM_FIELD_OFF,
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,225 @@
"""Hardware-only origin trace for the exact SetTeam team context.
Matches the typed integer context pointer selected by SetTeam to the constructor
invocation that produced it. No client memory writes.
"""
from __future__ import annotations
from collections import deque
import json
import os
import struct
import time
import traceback
import gdb
CONTEXT_REUSE = 0x1477C17FC
CONTEXT_ALLOCATED = 0x1477C18C1
SET_TEAM_STUB = 0x147060A80
LOCKED_SETTER_RETURN = 0x1477C2415
CONTEXT_STACK_COUNT = 0x144BCEDA0
CONTEXT_STACK_ARRAY = 0x144BCEDA8
INTERESTING = {73, 130000, 130001}
_STATE = None
def _reg(name):
return int(gdb.parse_and_eval(f"${name}"))
def _read(address, size):
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address):
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address):
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread():
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
class State:
def __init__(self, path):
self.path = path
self.index = 0
self.total_constructor_hits = 0
self.interesting_constructor_hits = 0
self.pending_allocations = {}
self.origins = deque(maxlen=4096)
def log(self, kind, **payload):
self.index += 1
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def thread_key(self):
return tuple(_thread().get("ptid", ()))
def remember_origin(self, context, origin):
if context:
self.origins.append({**origin, "context": context})
def find_origin(self, context):
return next((origin for origin in reversed(self.origins)
if origin["context"] == context), None)
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state, address):
self.state = state
self.address = address
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class ContextReuseBreakpoint(HardwareBreakpoint):
def stop(self):
self.state.total_constructor_hits += 1
try:
value = _reg("rcx") & 0xFFFFFFFF
if value not in INTERESTING:
return False
self.state.interesting_constructor_hits += 1
rsp = _reg("rsp")
direct_return = _u64(rsp + 0x28)
origin = {
"value": value,
"direct_return_address": direct_return,
"upstream_return_address": (
_u64(rsp + 0x68)
if direct_return == LOCKED_SETTER_RETURN
else direct_return
),
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
"constructor_hit": self.state.total_constructor_hits,
}
context = _reg("rax")
if context:
self.state.remember_origin(context, origin)
else:
self.state.pending_allocations[self.state.thread_key()] = origin
except Exception as exc:
self.state.log(
"trace_error",
where="context_reuse",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class ContextAllocatedBreakpoint(HardwareBreakpoint):
def stop(self):
try:
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
if origin is not None:
self.state.remember_origin(_reg("rdx"), origin)
except Exception as exc:
self.state.log(
"trace_error",
where="context_allocated",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
class SetTeamStubBreakpoint(HardwareBreakpoint):
def stop(self):
try:
count = _i32(CONTEXT_STACK_COUNT)
array = _u64(CONTEXT_STACK_ARRAY)
team_context = (
_u64(array + (count - 2) * 8)
if array and count is not None and count >= 2
else None
)
side_context = (
_u64(array + (count - 1) * 8)
if array and count is not None and count >= 1
else None
)
rsp = _reg("rsp")
self.state.log(
"set_team_stub_entry",
context_stack_count=count,
team_context=team_context,
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
team_value=_i32(team_context + 0x10) if team_context else None,
side_context=side_context,
side_value=_i32(side_context + 0x10) if side_context else None,
matched_origin=self.state.find_origin(team_context),
caller_return_address=_u64(rsp),
entry_registers={
name: _reg(name)
for name in ("rcx", "rdx", "r8", "r9")
},
backtrace=gdb.execute("bt 32", to_string=True),
total_constructor_hits=self.state.total_constructor_hits,
interesting_constructor_hits=self.state.interesting_constructor_hits,
)
except Exception as exc:
self.state.log(
"trace_error",
where="set_team_stub",
error=str(exc),
traceback=traceback.format_exc(),
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log(
"inferior_exited",
detail=str(event),
total_constructor_hits=_STATE.total_constructor_hits,
interesting_constructor_hits=_STATE.interesting_constructor_hits,
)
def start_trace(log_path, _cards_base):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
points = {
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={
name: {"number": point.number, "va": point.address}
for name, point in points.items()
},
hardware_only=True,
client_memory_writes=False,
matching="exact_context_pointer",
)
@@ -0,0 +1,167 @@
"""Hardware-only trace of engine game-setup context selection.
Captures the function that requests team/side, selector indices 1/0, selected
transient context objects, and the typed value getter. No client writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
DISPATCH = 0x147060D00
SELECT_VALUE = 0x147572C50
CONTEXT_SELECTED = 0x1477C845D
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _printable_pointers(address: int, data: bytes) -> dict:
found = {}
for offset in range(0, len(data) - 7, 8):
pointer = struct.unpack_from("<Q", data, offset)[0]
raw = _read(pointer, 128)
if not raw:
continue
value = raw.split(b"\0", 1)[0]
try:
text = value.decode("utf-8")
except UnicodeDecodeError:
continue
if len(text) >= 3 and all(char.isprintable() for char in text):
found[hex(offset)] = {"pointer": pointer, "text": text}
return found
class State:
def __init__(self, path: str):
self.path = path
self.index = 0
self.requested_indices = {}
def log(self, kind: str, **payload):
self.index += 1
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
"thread": _thread(), **payload}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush(); os.fsync(handle.fileno())
def key(self):
return tuple(_thread().get("ptid", ()))
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
self.address = address
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class DispatchBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
caller = _u64(rsp)
self.state.log(
"game_setup_dispatch_entry",
caller_return_address=caller,
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
if caller else None),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
return False
class SelectValueBreakpoint(HardwareBreakpoint):
def stop(self):
try:
index = _reg("rcx") & 0xFFFFFFFF
self.state.requested_indices[self.state.key()] = index
self.state.log("context_value_request", index=index)
except Exception as exc:
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
return False
class ContextSelectedBreakpoint(HardwareBreakpoint):
def stop(self):
try:
index = _reg("rdi") & 0xFFFFFFFF
context = _reg("rbx")
data = _read(context, 0x80) or b""
self.state.log(
"context_selected",
requested_index=self.state.requested_indices.get(self.state.key()),
selector_index=index,
context=context,
type_flags=_i32(context + 8),
value_i32=_i32(context + 0x10),
value_qword=_u64(context + 0x10),
context_hex=data.hex(),
printable_pointers=_printable_pointers(context, data),
)
except Exception as exc:
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, _cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path)
points = {
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,264 @@
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
Breakpoints:
FUN_180031340 entry incoming teamId/side/context
0x18003148f pre-call query id, selector, output/count pointers
0x180031495 post-call complete 48-byte records and count
0x180031861 submit original incoming teamId sent to engine
This proves whether query 13 influences the overwrite. No INT3/software
breakpoints, client writes, or game input.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
ENTRY = 0x180031340
QUERY_PRE = 0x18003148F
QUERY_POST = 0x180031495
SUBMIT = 0x180031861
MAX_RECORDS = 100
RECORD_SIZE = 48
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0 or size < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
def _printable_pointer(pointer: int) -> str | None:
data = _read(pointer, 96)
if not data:
return None
raw = data.split(b"\0", 1)[0]
if len(raw) < 3:
return None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return None
return text if all(char.isprintable() for char in text) else None
def _decode_record(data: bytes, address: int) -> dict:
words = list(struct.unpack("<12i", data))
qwords = list(struct.unpack("<6Q", data))
strings = {}
for index, pointer in enumerate(qwords):
text = _printable_pointer(pointer)
if text:
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
interesting = {
str(value): [index * 4 for index, word in enumerate(words) if word == value]
for value in (73, 240, 241, 243, 130000, 130001)
if value in words
}
return {
"address": address,
"hex": data.hex(),
"i32": words,
"u32": [value & 0xFFFFFFFF for value in words],
"f32": list(struct.unpack("<12f", data)),
"qwords": qwords,
"strings": strings,
"interesting_values": interesting,
}
class State:
def __init__(self, path: str, cards_base: int):
self.path = path
self.cards_base = cards_base
self.index = 0
self.calls = {}
def log(self, kind: str, **payload):
self.index += 1
event = {
"event": kind,
"event_index": self.index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
def thread_key(self):
return tuple(_thread().get("ptid", ()))
class HardwareBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, image_va: int):
self.state = state
self.image_va = image_va
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
class EntryBreakpoint(HardwareBreakpoint):
def stop(self):
try:
self.state.log(
"game_setup_entry",
incoming_context=_reg("rcx"),
incoming_side=_reg("rdx") & 0xFFFFFFFF,
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
incoming_r9=_reg("r9"),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
return False
class QueryPreBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
adapter = _reg("rcx")
vtable = _u64(adapter)
count_pointer = _u64(rsp + 0x20)
state = {
"adapter": adapter,
"adapter_vtable": vtable,
"query_target": _u64(vtable + 0xE0) if vtable else None,
"query_id": _reg("rdx") & 0xFFFFFFFF,
"selector": _reg("r8") & 0xFFFFFFFF,
"output_buffer": _reg("r9"),
"count_pointer": count_pointer,
"sixth_argument": _u64(rsp + 0x28),
"count_before": _i32(count_pointer) if count_pointer else None,
"saved_incoming_team_id": _i32(rsp + 0x34),
"saved_side": _i32(rsp + 0x50),
"saved_engine_context": _u64(rsp + 0x68),
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
}
self.state.calls[self.state.thread_key()] = state
self.state.log(
"query13_pre",
**state,
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
return False
class QueryPostBreakpoint(HardwareBreakpoint):
def stop(self):
try:
state = self.state.calls.get(self.state.thread_key(), {})
count_pointer = state.get("count_pointer")
output = state.get("output_buffer")
count = _i32(count_pointer) if count_pointer else None
safe_count = min(max(count or 0, 0), MAX_RECORDS)
records = []
for index in range(safe_count):
address = output + index * RECORD_SIZE
data = _read(address, RECORD_SIZE)
if data and len(data) == RECORD_SIZE:
records.append(_decode_record(data, address))
self.state.log(
"query13_post",
query_state=state,
count_after=count,
records=records,
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
saved_side_after=_i32(_reg("rsp") + 0x50),
registers=_registers(),
)
except Exception as exc:
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
return False
class SubmitBreakpoint(HardwareBreakpoint):
def stop(self):
try:
rsp = _reg("rsp")
self.state.log(
"game_setup_submit",
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
submitted_side=_reg("rdx") & 0xFFFFFFFF,
engine_context=_reg("rcx"),
saved_incoming_team_id=_i32(rsp + 0x34),
saved_side=_i32(rsp + 0x50),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
points = {
"entry": EntryBreakpoint(_STATE, ENTRY),
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
"submit": SubmitBreakpoint(_STATE, SUBMIT),
}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
record_size=RECORD_SIZE,
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,388 @@
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
memory.
Breakpoints (CardsDLL image VAs):
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
The dynamic watchpoint catches the exact write establishing pair[1], whether it
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
ENTRY_RVA = 0x0FC500
LOOKUP_TO_TEAM1_RVA = 0x0FC595
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
TEAM1_POST_PC_TO_WRITER = {
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
}
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0 or size < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _u8(address: int) -> int | None:
data = _read(address, 1)
return data[0] if data else None
def _u32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<I", data)[0] if data else None
def _i32(address: int) -> int | None:
data = _read(address, 4)
return struct.unpack("<i", data)[0] if data else None
def _u64(address: int) -> int | None:
data = _read(address, 8)
return struct.unpack("<Q", data)[0] if data else None
def _cstring(address: int, maximum: int = 256) -> str | None:
data = _read(address, maximum)
if not data:
return None
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
def _rtti_name(vtable: int, cards_base: int) -> str | None:
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
PE RVAs in the locator are module-relative. Failure is evidence-free and is
logged as null; no pointer is named from an offset coincidence.
"""
locator = _u64(vtable - 8) if vtable else None
if not locator:
return None
raw = _read(locator, 24)
if not raw:
return None
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
"<IIIiii", raw
)
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
return None
image_base = locator - self_rva
if abs(image_base - cards_base) > 0x100000:
return None
return _cstring(image_base + type_rva + 16)
def _object(address: int, cards_base: int) -> dict:
vtable = _u64(address) if address else None
return {
"address": address,
"vtable": vtable,
"vtable_image_va": (
CARDS_IMAGE_BASE + (vtable - cards_base)
if vtable and cards_base <= vtable < cards_base + 0x400000
else None
),
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
}
def _registers() -> dict:
names = (
"rax",
"rbx",
"rcx",
"rdx",
"rsi",
"rdi",
"rbp",
"rsp",
"r8",
"r9",
"r10",
"r11",
"r12",
"r13",
"r14",
"r15",
"rip",
)
return {name: _reg(name) for name in names}
def _provenance(state, destination: int | None = None) -> dict:
"""Recover the candidate's live input chain without naming the objects."""
regs = _registers()
context = regs["rbx"]
output_pair = regs["r14"]
obj = regs["rbp"]
nested = _u64(obj + 0xB0) if obj else None
field_2e8 = nested + 0x2E8 if nested else None
source_base = _u64(field_2e8) if field_2e8 else None
participant_holder = regs["r12"]
participant = _u64(participant_holder) if participant_holder else None
index_70 = _u8(participant + 0x70) if participant else None
source_address = (
source_base + index_70 * 16
if source_base is not None and index_70 is not None
else None
)
source_bytes = _read(source_address, 16) if source_address else None
decoded = None
if source_bytes and len(source_bytes) == 16:
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
decoded = {
"team_id": team_id,
"byte_4": byte4,
"byte_5": byte5,
"pad_6": pad,
"word_8": word8,
"word_c": wordc,
}
pair_bytes = _read(output_pair, 8) if output_pair else None
return {
"destination": destination,
"context": _object(context, state.cards_base),
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
"output_pair": output_pair,
"entry_output_pair": state.current_entry.get("output_pair"),
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
"output_team_id_0": _i32(output_pair) if output_pair else None,
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
"obj": _object(obj, state.cards_base),
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
"field_plus_2e8_address": field_2e8,
"source_array_base": source_base,
"participant_holder": participant_holder,
"participant": _object(participant or 0, state.cards_base),
"participant_plus_70": index_70,
"source_record_address": source_address,
"source_record_hex": source_bytes.hex() if source_bytes else None,
"source_record": decoded,
"registers": regs,
}
class State:
def __init__(self, log_path: str, cards_base: int):
self.log_path = log_path
self.cards_base = cards_base
self.current_entry: dict = {}
self.watchpoint = None
self.event_index = 0
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class Team1Watchpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
self.address = address
super().__init__(
f"*(int*)0x{address:x}",
type=gdb.BP_WATCHPOINT,
wp_class=gdb.WP_WRITE,
internal=False,
)
self.silent = True
def stop(self):
try:
pc = _reg("rip")
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
source_value = None
if writer == 0x1800FC595:
source_value = _reg("rcx") & 0xFFFFFFFF
elif writer == 0x1800FC5A0:
source_value = _reg("rax") & 0xFFFFFFFF
self.state.log(
"team1_write_post",
watch_address=self.address,
value=_i32(self.address),
stopped_pc=pc,
stopped_image_va=image_pc,
writer_image_va=writer,
source_value=source_value,
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
provenance=_provenance(self.state, self.address),
)
if writer is not None:
# The output pair is a short-lived stack buffer. Leaving the
# watchpoint active after the candidate's exact write produced
# 114k unrelated events when that stack memory was reused.
# The two hardware lookup breakpoints remain armed, so disabling
# only this completed one-shot watch loses no provenance.
self.enabled = False
self.state.log(
"team1_watchpoint_disabled",
watch_address=self.address,
reason="candidate exact write captured",
)
except Exception as exc: # GDB must continue even if evidence rendering fails.
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
traceback=traceback.format_exc())
return False
class EntryBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int):
self.state = state
super().__init__(
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
context, output_pair = _reg("rcx"), _reg("rdx")
self.state.current_entry = {
"context": context,
"output_pair": output_pair,
"entry_thread": _thread(),
}
if self.state.watchpoint is not None:
try:
self.state.watchpoint.delete()
except gdb.error:
pass
initial = _i32(output_pair + 4)
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
self.state.log(
"candidate_entry",
entry_image_va=0x1800FC500,
context=_object(context, self.state.cards_base),
output_pair=output_pair,
team_id_1_address=output_pair + 4,
team_id_1_initial=initial,
watchpoint_number=self.state.watchpoint.number,
backtrace=gdb.execute("bt 24", to_string=True),
registers=_registers(),
)
self.state.log(
"team1_watchpoint_armed",
watch_address=output_pair + 4,
watchpoint_number=self.state.watchpoint.number,
)
except Exception as exc:
self.state.log("trace_error", where="candidate_entry", error=str(exc),
traceback=traceback.format_exc())
return False
class LookupStoreBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
self.state = state
self.image_va = image_va
self.destination_offset = destination_offset
super().__init__(
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
)
self.silent = True
def stop(self):
try:
destination = _reg("r14") + self.destination_offset
self.state.log(
"opponent_lookup_store_pre",
writer_image_va=self.image_va,
destination=destination,
destination_offset=self.destination_offset,
source_register="ecx",
source_value=_reg("rcx") & 0xFFFFFFFF,
disassembly=gdb.execute("x/5i $pc", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
provenance=_provenance(self.state, destination),
)
except Exception as exc:
self.state.log("trace_error", where="lookup_store", error=str(exc),
traceback=traceback.format_exc())
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
"""Called from the supervisor's gdb command file after attach."""
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
lookup_team1 = LookupStoreBreakpoint(
_STATE,
cards_base + LOOKUP_TO_TEAM1_RVA,
0x1800FC595,
4,
)
lookup_team0 = LookupStoreBreakpoint(
_STATE,
cards_base + LOOKUP_TO_TEAM0_RVA,
0x1800FC5B8,
0,
)
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
cards_base=cards_base,
breakpoints={
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
"lookup_to_team1": {
"number": lookup_team1.number,
"image_va": 0x1800FC595,
},
"lookup_to_team0": {
"number": lookup_team0.number,
"image_va": 0x1800FC5B8,
},
},
hardware_only=True,
client_memory_writes=False,
)
@@ -0,0 +1,170 @@
"""Hardware-only origin trace for CardsDLL team-pair submissions.
Distinguishes the three callers of the engine team-id service that can submit a
full two-team pair, plus the mode-76 builder that prepares its pair:
0x1800c7583 correct fixture pair control
0x1800c6c23 generic pair submitter
0x1800c8dc1 mode-76 pair submitter
0x1800c8bf0 mode-76 pair builder entry
No INT3/software breakpoints. No client memory writes.
"""
from __future__ import annotations
import json
import os
import struct
import time
import traceback
import gdb
CARDS_IMAGE_BASE = 0x180000000
SITES = {
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
}
MODE76_BUILDER = 0x1800C8BF0
_STATE = None
def _reg(name: str) -> int:
return int(gdb.parse_and_eval(f"${name}"))
def _thread() -> dict:
thread = gdb.selected_thread()
if thread is None:
return {}
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
def _read(address: int, size: int) -> bytes | None:
if not address or address < 0:
return None
try:
return bytes(gdb.selected_inferior().read_memory(address, size))
except gdb.error:
return None
def _pair(address: int) -> list[int] | None:
data = _read(address, 8)
return list(struct.unpack("<2i", data)) if data else None
def _registers() -> dict:
names = (
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
)
return {name: _reg(name) for name in names}
class State:
def __init__(self, log_path: str, cards_base: int):
self.log_path = log_path
self.cards_base = cards_base
self.event_index = 0
def log(self, kind: str, **payload):
self.event_index += 1
event = {
"event": kind,
"event_index": self.event_index,
"time_unix": time.time(),
"thread": _thread(),
**payload,
}
with open(self.log_path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
class PairSubmitBreakpoint(gdb.Breakpoint):
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
self.state = state
self.image_va = image_va
self.name = name
self.pointer_reg = pointer_reg
self.index_reg = index_reg
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
def stop(self):
try:
pointer = _reg(self.pointer_reg)
index = _reg(self.index_reg) & 0xFFFFFFFF
pair_base = pointer - index * 4
self.state.log(
self.name,
instruction_image_va=self.image_va,
source_value=_reg("r8") & 0xFFFFFFFF,
side=_reg("rdx") & 0xFF,
engine_base=_reg("rcx"),
pair_pointer=pointer,
pair_index=index,
pair_base=pair_base,
pair=_pair(pair_base),
registers=_registers(),
disassembly=gdb.execute("x/5i $pc", to_string=True),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where=self.name, error=str(exc),
traceback=traceback.format_exc()
)
return False
class Mode76BuilderBreakpoint(gdb.Breakpoint):
def __init__(self, state: State):
self.state = state
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
self.silent = True
def stop(self):
try:
self.state.log(
"mode76_builder_entry",
instruction_image_va=MODE76_BUILDER,
object=_reg("rcx"),
registers=_registers(),
backtrace=gdb.execute("bt 24", to_string=True),
)
except Exception as exc:
self.state.log(
"trace_error", where="mode76_builder", error=str(exc),
traceback=traceback.format_exc()
)
return False
def _on_exit(event):
if _STATE is not None:
_STATE.log("inferior_exited", detail=str(event))
def start_trace(log_path: str, cards_base: int):
global _STATE
open(log_path, "w", encoding="utf-8").close()
_STATE = State(log_path, cards_base)
breakpoints = {}
for image_va, (name, pointer_reg, index_reg) in SITES.items():
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
breakpoints[name] = {"number": bp.number, "image_va": image_va}
builder = Mode76BuilderBreakpoint(_STATE)
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
gdb.events.exited.connect(_on_exit)
_STATE.log(
"trace_armed",
breakpoints=breakpoints,
hardware_only=True,
client_memory_writes=False,
)
+29 -11
View File
@@ -15,16 +15,27 @@ reimplementations (the `tdf` crate cloned in this scratchpad), which were used
only as a cross-check of *structure*, never copied.
NO EA/FIFA leaked source was consulted.
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
--------------------------------------------------------------------
Fire2 frame header, 16 bytes big-endian:
[0:4] u32 payload length (bytes after the header)
[4:6] u16 always 0 (observed)
[6:8] u16 component
[8:10] u16 command
[10:12]u16 error / msgId
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
[13:16]3 reserved bytes (observed 00 00 00)
VALIDATED RULES (the TDF body; byte-exact round-trip against the 219-byte capture)
---------------------------------------------------------------------------------
Fire2 frame header, 16 bytes big-endian.
!!! SUPERSEDED the [10:16] FIELD SEMANTICS below are WRONG for FIFA 17. !!!
The "byte-exact round-trip" only proves the payload length and the TDF body
encoding: decoding then re-encoding with the SAME (mis)labelled header layout
trivially reproduces the capture, so it never tested the header's field
boundaries. The authoritative, live-driven layout is
`openfut-protocol-blaze::fire2` / `blaze_responder_v3b.py::fire2`:
[0:4] u32 payload length (bytes after header + metadata)
[4:6] u16 metadata length (0 when absent what this file called "always 0")
[6:8] u16 component
[8:10] u16 command
[10:13] u24 msgNum (this file WRONGLY split it as [10:12] msgId + [12] msgType)
[13] u8 (msgType << 5) | (userIndex & 0x1F)
[14] u8 options
[15] u8 reserved
There is NO error field in Fire2 (that is Fire v1) and NO jumbo escape the
length is already a full u32. `build_fire2_frame`/`decode_fire2` below keep the
old wrong `>IHHHHB3s` layout; they are dead and retained only for history.
Heat2 field = 3-byte packed tag + 1 type byte + value.
@@ -359,7 +370,14 @@ MSG_ERROR = 0x05 # UNVERIFIED
def build_fire2_frame(component: int, command: int, msgType: int,
msgId: int, tdf_bytes: bytes) -> bytes:
"""16-byte big-endian Fire2 header + TDF payload."""
"""16-byte big-endian Fire2 header + TDF payload.
WRONG HEADER (dead code): the ``>IHHHHB3s`` layout mislabels [10:16] it
puts a u16 msgId at [10:12] and msgType at [12]. FIFA 17's real Fire2 header
is [10:13] u24 msgNum, [13] (msgType<<5)|userIndex, [14] options, [15]
reserved, and has no error field. Use ``openfut-protocol-blaze::fire2`` or
``blaze_responder_v3b.py::fire2``; this is retained only for history.
"""
tdf_bytes = bytes(tdf_bytes)
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
immstore.py <imm_dec> [disp_hex|any] [--exe]
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
ldis.py. Read-only.
"""
import glob
import os
import re
import struct
import sys
CARDS_IMG = 0x180000000
EXE_IMG = 0x140000000
def pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
raise SystemExit("FIFA17.exe not running")
P = pid()
def module_base(n):
for l in open(f"/proc/{P}/maps"):
if n.lower() in l.lower():
return int(l.split("-")[0], 16)
raise SystemExit(f"{n} not mapped")
def text_spans(base):
out = []
started = False
for l in open(f"/proc/{P}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
if not m:
continue
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
if lo == base:
started = True
continue
if started:
if not path.strip() and "x" in perms:
out.append((lo, hi))
elif out:
break
return out
args = [a for a in sys.argv[1:] if a != "--exe"]
exe = "--exe" in sys.argv
imm = int(args[0], 0)
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
img = EXE_IMG if exe else CARDS_IMG
base = module_base("FIFA17.exe" if exe else "CardsDLL")
mem = open(f"/proc/{P}/mem", "rb", 0)
immb = struct.pack("<i", imm)
hits = 0
for lo, hi in text_spans(base):
mem.seek(lo)
buf = mem.read(hi - lo)
img_lo = img + (lo - base)
i = buf.find(b"\xc7", 0)
while i >= 0:
modrm = buf[i + 1] if i + 1 < len(buf) else 0
if (modrm & 0x38) == 0: # /0
mod, rm = modrm >> 6, modrm & 7
if mod == 1 and i + 7 <= len(buf): # disp8
disp, ib = buf[i + 2], i + 3
sz = 7
elif mod == 2 and i + 10 <= len(buf): # disp32
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
sz = 10
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
disp, ib = 0, i + 2
sz = 6
else:
disp = None
if disp is not None and buf[ib:ib + 4] == immb:
if want_disp is None or disp == want_disp:
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
hits += 1
i = buf.find(b"\xc7", i + 1)
print(f" {hits} immediate store(s) of {imm}"
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Settle the pre-match kit selector gate: who, if anyone, writes item `+0x60`.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`plan-2026-08-06-card-subsystem.md` section 5 calls `+0x60` "the single blocker
between 'we can mark a kit equipped' and 'we can equip a kit'", and records that
two attempts to find its writer drowned: scanning for the offset returned 1688
and 4144 instructions depending on method.
The scan drowns because `+0x60` is a common struct offset. Two cheap filters cut
it to something a person can read:
* only IMMEDIATE stores can introduce a constant (a register store propagates
one from somewhere else), and
* item-record code is recognisable by touching `+0x4c` (cardtype) or `+0x5c`
(itemState) within a few instructions.
WHAT IT REPORTS
---------------
1. The live `+0x60` distribution over every resident CardsDb record.
2. Every `cmp dword [reg+0x60], imm8` in CardsDLL .text -- the readers.
3. Every immediate store to `[reg+0x60]` and the constants they use.
4. Which of those stores sit next to item-record code.
MEASURED 2026-08-21 (pid 6580, 27 resident records):
live +0x60 : {1: 23 (players), 0: 4 (staff)} -- never 4
readers : 4 total; exactly ONE compares against 4, at 0x1801c34f2,
which is the kit gate in FUN_1801c3480
immediate stores: 27 total; constants {-2, 0, 1, 908, 0x3f800000} -- NO 4
FIFA17.exe : 0 immediate stores of 4 to +0x60 across its 79MB of code,
and 0 comparisons against 4
gate xrefs : 1 (a jmp from 0x1801a5329); address never taken
The gate at 0x1801c34f2 decodes as:
cmp [rdi+0x4c], 7 cardtype 7 = kit/stadium/badge <- we produce this
cmp [rdi+0x60], 4 <- THE BLOCKER
mov eax, [rdi+0x5c] itemState
cmp eax, 0x65 / 0x66 101 activeHomeKit / 102 activeAwayKit <- we produce
mov r8d, [rdi+0x94] teamid <- we produce
mov r9d, [rdi+0xba] kit variant selector (unresolved)
So every input EXCEPT `+0x60` is already satisfied by what OpenFUT serves, and
no instruction in either module ever stores the constant 4 there.
Usage: python3 kit_gate_probe.py
"""
import collections
import struct
import sys
import watch_club_model as W
try:
import card_identity_probe as P
except Exception: # pragma: no cover - probe is optional for the static half
P = None
TEXT_START = 0x180001000
FIELD = 0x60
REGS = ["rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi"]
REC_SIZE = 0x158
F_SUBTYPE = 0x50
def live_distribution(mem, base):
"""(+0x60 histogram, (subtype,+0x60) histogram) over resident records."""
if P is None:
return None, None
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
return None, None
by_value = collections.Counter()
by_pair = collections.Counter()
for node in P.nodes(mem, obj):
buf = mem.read(node + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
subtype = struct.unpack_from("<I", buf, F_SUBTYPE)[0]
value = struct.unpack_from("<i", buf, FIELD)[0]
by_value[value] += 1
by_pair[(subtype, value)] += 1
return by_value, by_pair
def scan_text(text):
"""(readers, immediate stores, item-record markers) over a .text image."""
readers, stores, markers = [], [], set()
for i in range(len(text) - 8):
op, modrm = text[i], text[i + 1]
mod, reg, rm = modrm >> 6, (modrm >> 3) & 7, modrm & 7
if mod != 1 or rm == 4:
continue
disp = text[i + 2]
if disp in (0x4C, 0x5C) and op in (0x8B, 0x89, 0x83, 0x39, 0x3B, 0xC7, 0x0F):
markers.add(TEXT_START + i)
if disp != FIELD:
continue
if op == 0x83 and reg == 7: # cmp dword [reg+0x60], imm8
readers.append((TEXT_START + i, REGS[rm], text[i + 3]))
elif op == 0xC7 and reg == 0: # mov dword [reg+0x60], imm32
stores.append((TEXT_START + i, REGS[rm], struct.unpack_from("<i", text, i + 3)[0], "dword"))
elif op == 0xC6 and reg == 0: # mov byte [reg+0x60], imm8
stores.append((TEXT_START + i, REGS[rm], text[i + 3], "byte"))
return readers, stores, markers
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
print()
by_value, by_pair = live_distribution(mem, base)
print("── live records ──")
if by_value is None:
print(" CardsDb is empty (no FUT session loaded); static half still runs.")
else:
print(" +0x60 distribution : %s" % dict(by_value))
print(" (cardsubtypeid, +0x60) : %s" % dict(by_pair))
print(" holds the gate value 4 : %s" % ("YES" if 4 in by_value else "NO"))
print()
# .text is the second CardsDLL mapping; read it whole and scan.
size = 0x1E4000
buf, bad = mem.read_pages(base + 0x1000, size)
if bad:
print(" WARNING: %d unreadable page(s); the scan is incomplete." % len(bad))
text = bytes(buf)
readers, stores, markers = scan_text(text)
print("── readers: cmp dword [reg+0x60], imm8 ──")
for va, reg, imm in readers:
flag = " <-- THE KIT GATE" if imm == 4 else ""
print(" %#x cmp [%s+0x60], %d%s" % (va, reg, imm, flag))
print()
print("── immediate stores to [reg+0x60] ──")
consts = collections.Counter(s[2] for s in stores)
print(" %d store(s); constants %s" % (len(stores), dict(sorted(consts.items()))))
near = [s for s in stores if any(abs(m - s[0]) <= 96 for m in markers)]
print(" %d of them sit within 96B of item-record code (+0x4c/+0x5c):" % len(near))
for va, reg, imm, width in near:
print(" %#x mov %s [%s+0x60], %d" % (va, width, reg, imm))
print()
print("=" * 70)
if any(s[2] == 4 for s in stores):
print("A store of 4 EXISTS -- the gate is reachable. Follow the sites above.")
return 0
print("NO instruction in CardsDLL stores the constant 4 into +0x60.")
print("Combined with the live records (never 4) and the fact that every OTHER")
print("gate input is already served, the pre-match kit selector cannot be")
print("opened by anything the server sends. This is a CLIENT-side dead end,")
print("not a missing wire field.")
print()
print("Scope of the claim: immediate stores, all widths, disp8 form. A value")
print("could still arrive by register copy -- but in CardsDLL every register")
print("store to +0x60 is a field-by-field struct copy or an init to 0/1/-2.")
print("=" * 70)
return 0
if __name__ == "__main__":
sys.exit(main())
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
ldis.py --bytes <image_va_hex> [nbytes] hexdump
ldis.py --map show module bases
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
but this is recomputed and printed so the offset trap stays visible.
"""
import re
import subprocess
import sys
import tempfile
PID = None
CARDS_IMG = 0x180000000
EXE_IMG = 0x140000000
def pid():
global PID
if PID is None:
import glob, os
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
PID = int(os.path.basename(d))
break
except OSError:
pass
if PID is None:
raise SystemExit("FIFA17.exe not running")
return PID
def module_base(needle):
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
anonymously, so never trust the mapping that merely CONTAINS an address)."""
for l in open(f"/proc/{pid()}/maps"):
if needle.lower() in l.lower():
return int(l.split("-")[0], 16)
raise SystemExit(f"module {needle} not mapped")
def live(va, exe=False):
if exe:
return module_base("FIFA17.exe") + (va - EXE_IMG)
return module_base("CardsDLL") + (va - CARDS_IMG)
def read(va, n, exe=False):
la = live(va, exe)
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
m.seek(la)
return la, m.read(n)
def main():
a = sys.argv[1:]
if not a or a[0] == "--map":
print(f" pid = {pid()}")
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
return
hexdump = a[0] == "--bytes"
if hexdump:
a = a[1:]
exe = "--exe" in a
a = [x for x in a if x != "--exe"]
va = int(a[0], 16)
n = int(a[1]) if len(a) > 1 else 160
la, buf = read(va, n, exe)
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
if hexdump:
for i in range(0, len(buf), 16):
c = buf[i:i + 16]
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
return
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
f.write(buf)
f.flush()
out = subprocess.run(
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
f"--adjust-vma=0x{va:x}", f.name],
capture_output=True, text=True).stdout
for line in out.splitlines():
if re.match(r"\s+[0-9a-f]+:", line):
print(" " + line.strip())
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Read-only census of FIFA 17's RESIDENT club-item vector.
Chain, every link from CardsDLL static RE:
[CardsDLL+0x2e6398] -> owner object (FUN_18011a830)
owner->vtable[0x4e8] -> getter returning mgr (call *0x4e8(%rdx))
mgr+0x108 .. mgr+0x110 -> club-item vector, stride 24
element+0x10 -> the item record pointer (FUN_1800d73d0)
record+0x4c cardtype (derived from cardsubtypeid by FUN_1800d8330: 9/10/11 -> 7)
record+0x50 cardsubtypeid
record+0x5c itemState (101 activeHomeKit, 102 activeAwayKit)
record+0x60 category (clone driver FUN_1801c3480 requires 4)
record+0x94 teamid
record+0xba teamkittypetechid (u16)
Offsets not in that list are labelled UNVERIFIED and only dumped raw.
No writes. Ever.
"""
import re, struct, sys, collections
PID = int(sys.argv[1]) if len(sys.argv) > 1 else 44405
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
def i32(b, o):
return struct.unpack_from("<i", b, o)[0]
# locate CardsDLL by its NEAREST PRECEDING NAMED mapping (Wine maps PE sections anon)
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m: named.append((int(m.group(1),16), m.group(3).strip()))
named.sort()
base = None
for s, p in named:
if p.endswith("CardsDLL_Win64_retail.dll"):
base = s; break
if base is None:
print(" CardsDLL mapping not found"); sys.exit(1)
print(f" CardsDLL base = {base:#x}")
def live(static): return base + (static - 0x180000000)
# sanity: the 0x7575 sender immediate must be where static RE says
probe = rd(live(0x180026fea), 6)
print(f" sanity @0x180026fea: {probe.hex(' ')} (expect ba 75 75 00 00)")
if probe[:5] != bytes.fromhex("ba75750000"):
print(" SANITY FAILED - base wrong, aborting"); sys.exit(1)
owner = q(live(0x1802e6398))
print(f" owner object = {owner:#x}")
vt = q(owner)
getter = q(vt + 0x4e8)
print(f" vtable = {vt:#x}")
print(f" vtable[0x4e8] = {getter:#x} bytes: {rd(getter,12).hex(' ')}")
# expect: mov rax,[rcx+off] ; ret -> 48 8b 81 off32 c3 or 48 8b 41 off8 c3
b = rd(getter, 12)
mgr = None
if b[0:3] == bytes.fromhex("488d81"):
off = struct.unpack_from("<I", b, 3)[0]; mgr = owner + off
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488d41"):
off = b[3]; mgr = owner + off
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488b81"):
off = struct.unpack_from("<I", b, 3)[0]; mgr = q(owner + off)
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
elif b[0:3] == bytes.fromhex("488b41"):
off = b[3]; mgr = q(owner + off)
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
elif b[0:2] == bytes.fromhex("488b") and b[2] == 0xc1:
mgr = owner; print(" getter returns owner itself")
else:
print(" getter shape unrecognised; trying owner as mgr")
mgr = owner
for label, mgr_try in (("resolved", mgr), ("owner", owner)):
try:
beg, end = q(mgr_try + 0x108), q(mgr_try + 0x110)
except OSError:
print(f" [{label}] +0x108/0x110 unreadable"); continue
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24*100000:
print(f" [{label}] vector implausible: {beg:#x}..{end:#x}")
continue
n = (end - beg) // 24
print(f"\n === club-item vector via {label}: {beg:#x}..{end:#x} {n} slot(s) ===")
hist = collections.Counter(); rows = []
for k in range(n):
try:
rec = q(beg + k*24 + 0x10)
except OSError:
continue
if not rec:
hist[("<null slot>", None)] += 1; continue
try:
r = rd(rec, 0xC0)
except OSError:
continue
if len(r) < 0xC0: continue
ct, sub, st, cat = i32(r,0x4c), i32(r,0x50), i32(r,0x5c), i32(r,0x60)
team = i32(r,0x94); kt = struct.unpack_from("<H", r, 0xba)[0]
hist[(ct, sub)] += 1
rows.append((rec, ct, sub, st, cat, team, kt))
print(f" (cardtype, cardsubtypeid) histogram:")
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
tag = " <== KIT (selector needs this)" if key == (7, 9) else ""
print(f" {str(key):<18} x{c}{tag}")
print(f" cardtype 7 records: {sum(c for (ct,_),c in hist.items() if ct==7)}")
print(f"\n first 12 records:")
print(f" {'ptr':>14} {'ctype':>5} {'subtype':>7} {'state':>5} {'cat':>4} {'team':>5} {'kittype':>7}")
for rec, ct, sub, st, cat, team, kt in rows[:12]:
print(f" {rec:#14x} {ct:>5} {sub:>7} {st:>5} {cat:>4} {team:>5} {kt:>7}")
break
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
The pre-match selector draws each kit from a clone query keyed on the record's
own fields, so if both tiles render identically the question is precisely: which
bytes of the home record differ from the away record? This prints every differing
offset with the known field names attached, and dumps the fields the decoded
clone query consumes.
Read-only. Never writes to the process.
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
teamtechid == record+0x94
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
year == record+0xba
"""
import re
import struct
import sys
PID = int(sys.argv[1])
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a)
return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
if base is None:
sys.exit("CardsDLL mapping not found")
def live(static):
return base + (static - 0x180000000)
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED - wrong base")
print(f" CardsDLL base = {base:#x} (sanity ok)")
owner = q(live(0x1802E6398))
sentinel = owner + 0x160C8
root = q(owner + 0x160D8)
# Known record fields, offset -> (name, width)
FIELDS = {
0x08: ("id", 8),
0x18: ("resourceId/definitionId", 4),
# Offsets per club_items.json `_record_map`, which is authoritative:
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
0x1C: ("cardassetid", 4),
0x20: ("assetId", 4),
0x38: ("discardValue", 4),
0x4C: ("cardtype", 4),
0x50: ("cardsubtypeid", 4),
0x5C: ("itemState", 4),
0x60: ("category(club slot)", 4),
0x8C: ("contract", 4),
0x94: ("teamid", 4),
0xB4: ("rating", 4),
0xB8: ("wire category", 1),
0xBA: ("year", 2),
0x148: ("nation", 4),
0x154: ("leagueId", 4),
}
def walk(node, out):
if not node or node == sentinel:
return
walk(q(node + 0x00), out)
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
walk(q(node + 0x08), out)
nodes = []
walk(root, nodes)
recs = {k: v for k, v in nodes}
found = [(w, recs[w]) for w in WANT if w in recs]
if len(found) < 2:
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
a = rd(ptr_a, 0x180)
b = rd(ptr_b, 0x180)
print(f" A = {id_a} @ {ptr_a:#x}")
print(f" B = {id_b} @ {ptr_b:#x}")
print("\n --- fields the clone query consumes ---")
for off in (0x94, 0x5C, 0xBA):
name = FIELDS[off][0]
w = FIELDS[off][1]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
flag = "" if va != vb else " <== IDENTICAL"
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
print("\n --- every differing byte range ---")
diffs = [i for i in range(0x180) if a[i] != b[i]]
runs = []
for i in diffs:
if runs and i == runs[-1][1] + 1:
runs[-1][1] = i
else:
runs.append([i, i])
for s, e in runs:
named_field = next(
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
)
va = int.from_bytes(a[s : e + 1], "little")
vb = int.from_bytes(b[s : e + 1], "little")
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
print("\n --- known fields, side by side ---")
for off in sorted(FIELDS):
name, w = FIELDS[off]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
mark = " DIFFERS" if va != vb else ""
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Remove the port-8081 DNAT rule that hijacks FIFA 17's roster/squad-update TLS.
#
# Why: the FUT squad update is https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml
# (TLS on port 8081). A DNAT rule rewriting dport 8081 -> 8299 sends that TLS
# handshake to the plain-HTTP staging UTAS host, which closes the connection.
# Proven: a probe to 10.10.0.120:8081 from this box arrives at the server as
# dport 8299. Result: "An error occurred downloading the FUT squad update."
#
# The rule also never redirected UTAS, which lives on :8443, not :8081.
#
# Read-only until it deletes; deletes only nat rules whose target port is 8299.
set -u
echo "== nat OUTPUT rules mentioning 8081 or 8299 =="
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
echo
echo "== deleting DNAT rules that redirect to port 8299 =="
removed=0
# Delete by spec, repeatedly, until no matching rule remains.
while :; do
rule=$(iptables -t nat -S OUTPUT 2>/dev/null | grep -m1 -E '\-\-dport 8081 .*8299|to-destination [0-9.]+:8299')
[ -z "$rule" ] && break
spec=$(printf '%s' "$rule" | sed 's/^-A /-D /')
# shellcheck disable=SC2086
if iptables -t nat $spec 2>/dev/null; then
echo " removed: $rule"
removed=$((removed + 1))
else
echo " FAILED to remove: $rule" >&2
break
fi
done
[ "$removed" -eq 0 ] && echo " (no matching rule found)"
echo
echo "== remaining nat OUTPUT rules mentioning 8081 or 8299 =="
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
echo
echo "== verifying the roster endpoint now presents the correct certificate =="
python3 - <<'PY'
import socket, ssl
host, port, sni = "10.10.0.120", 8081, "winter15.gosredirector.ea.com"
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), 8) as s:
with ctx.wrap_socket(s, server_hostname=sni) as t:
der = t.getpeercert(True)
cn = dict(x[0] for x in t.getpeercert().get("subject", ()))
print(f" PASS {host}:{port} sni={sni} {t.version()} der={len(der)}B subject={cn}")
except Exception as e:
print(f" FAIL {host}:{port} sni={sni} -> {type(e).__name__}: {e}")
print(" The roster path is still broken; do not relaunch yet.")
PY
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
Answers whether a served item was materialised into a record at all, versus
materialised but not attached to a collection. A record is recognised by its
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
Read-only. Never writes.
usage: probe_hunt.py PID id [id ...]
"""
import re, struct, sys
PID = int(sys.argv[1])
IDS = [int(a) for a in sys.argv[2:]]
if not IDS:
sys.exit("give at least one wire id")
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
regions = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln)
if not m:
continue
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
if "w" not in perms:
continue
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
continue
regions.append((lo, hi, perms, path))
total = sum(hi - lo for lo, hi, _, _ in regions)
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
needles = {struct.pack("<I", i): i for i in IDS}
hits = {i: [] for i in IDS}
CHUNK = 8 << 20
scanned = 0
for lo, hi, perms, path in regions:
a = lo
while a < hi:
n = min(CHUNK, hi - a)
try:
mem.seek(a)
data = mem.read(n)
except OSError:
a += n
continue
if not data:
a += n
continue
scanned += len(data)
for nd, wid in needles.items():
start = 0
while True:
j = data.find(nd, start)
if j < 0:
break
start = j + 1
va = a + j
# a record would place this id at +0x08
rec = va - 0x08
try:
mem.seek(rec)
r = mem.read(0x100)
except OSError:
continue
if len(r) < 0x100:
continue
ct = struct.unpack_from("<i", r, 0x4c)[0]
res = struct.unpack_from("<I", r, 0x18)[0]
sub = struct.unpack_from("<i", r, 0x50)[0]
cat = struct.unpack_from("<i", r, 0x60)[0]
looks = 0 <= ct <= 32 and res > 1000
hits[wid].append((va, rec, ct, sub, cat, res, looks))
a += n
print(f" scanned {scanned/2**20:.0f} MiB\n")
for wid in IDS:
hs = hits[wid]
recs = [h for h in hs if h[6]]
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
for va, rec, ct, sub, cat, res, _ in recs[:6]:
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
if not recs:
print(" NOT MATERIALISED as a record anywhere in writable memory")
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Identify every resident record by its wire instance id.
Record layout established from known wire values:
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
+0x5c itemState +0x60 category +0x94 teamid
+0xb4 rating +0xba teamkittypetechid (u16)
Walks the contiguous 0x180-stride pool around the manager slot record so records
that are resident but not in any collection are still seen. Read-only.
usage: probe_ids.py PID [expected_id ...]
"""
import re, struct, sys
PID = int(sys.argv[1])
WANT = {int(a) for a in sys.argv[2:]}
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
live = lambda s: base + (s - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
RECSZ = 0x180
def dec(rec):
r = rd(rec, 0x180)
g = lambda o: struct.unpack_from("<i", r, o)[0]
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
rating=struct.unpack_from("<I", r, 0xb4)[0],
kt=struct.unpack_from("<H", r, 0xba)[0])
mgr_rec = q(mgr + 0xc0 + 0x10)
print(f" manager-slot record = {mgr_rec:#x}")
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
# walk backwards to the start of the contiguous run, then forwards
lo = anchor
for _ in range(64):
prev = lo - RECSZ
try:
d = dec(prev)
except OSError:
break
if not (0 < d["ct"] < 64) or d["id"] == 0:
break
lo = prev
print(f" pool run starts at {lo:#x}\n")
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
found = {}
k = 0
addr = lo
while k < 48:
try:
d = dec(addr)
except OSError:
break
if d["id"] == 0 and d["ct"] == 0:
break
tag = ""
if d["ct"] == 7:
tag = " <== CARDTYPE 7"
if d["id"] in WANT:
tag += " <== WANTED"
found[d["id"]] = addr
slot = " [manager slot]" if addr == mgr_rec else ""
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
addr += RECSZ
k += 1
if WANT:
print(f"\n wanted ids: {sorted(WANT)}")
for w in sorted(WANT):
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
v2: identifies each record by its wire instance id (large, unique) and only
accepts a field mapping when the value is distinctive (>= 16) and the same
offset holds the right value for EVERY identified record. This avoids the v1
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
Read-only. Never writes.
usage: probe_layout2.py PID squad_active.json
"""
import re, struct, sys, json, collections
PID = int(sys.argv[1])
SQUAD = json.load(open(sys.argv[2]))
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
live = lambda s: base + (s - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
RECSZ = 0x180
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
wire = {}
for p in SQUAD["players"]:
it = p.get("itemData") or {}
if it.get("id"):
wire[it["id"]] = it
# --- identify each record by its wire instance id ---
ident = {}
for rec in recs:
r = rd(rec, RECSZ)
for off in range(0, RECSZ - 4, 4):
v = struct.unpack_from("<I", r, off)[0]
if v in wire:
ident.setdefault(rec, (v, off))
break
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
id_offs = collections.Counter(o for _, o in ident.values())
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
"discardValue", "cardsubtypeid", "owners", "rareflag")
# --- for every offset, does it hold field F for every identified record? ---
consistent = {}
for off in range(0, RECSZ - 4, 4):
for f in FIELDS:
ok = 0; total = 0; distinct = set()
for rec, (wid, _) in ident.items():
it = wire[wid]
v = it.get(f)
if not isinstance(v, int) or v < 16: # require distinctive values
continue
total += 1
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
if got == v:
ok += 1; distinct.add(v)
if total >= 5 and ok == total and len(distinct) >= 2:
consistent.setdefault(off, []).append((f, total, len(distinct)))
print(f"\n === offsets consistently holding a distinctive wire field ===")
for off in sorted(consistent):
for f, total, nd in consistent[off]:
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
# --- dump the manager and the three club staff for comparison ---
print(f"\n === cardtype-2 slot (manager) ===")
h = q(mgr + 0xc0 + 0x10)
if h:
r = rd(h, RECSZ)
for off in sorted(consistent):
f = consistent[off][0][0]
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
("itemState", 0x5c, 4), ("category", 0x60, 4)):
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Enumerate FIFA 17's resident item map authoritatively.
Layout recovered from the lower_bound at 0x180119640:
owner+0x160c8 sentinel / end marker
owner+0x160d8 root
owner+0x160e8 count
node+0x00, node+0x08 children
node+0x20 key = wire instance id (qword)
node+0x28 the item record
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
Read-only. usage: probe_map2.py PID [id ...]
"""
import re, struct, sys, collections
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a): return struct.unpack("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m: named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED")
owner = q(base + (0x1802e6398 - 0x180000000))
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
nodes, seen, stack = [], set(), [ROOT]
while stack:
n = stack.pop()
if not n or n == SENT or n in seen or len(seen) > 5000:
continue
seen.add(n)
try:
h = rd(n, 0x30)
except OSError:
continue
if len(h) < 0x30:
continue
nodes.append(n)
stack.append(struct.unpack_from("<Q", h, 0)[0])
stack.append(struct.unpack_from("<Q", h, 8)[0])
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
hist = collections.Counter(); found = {}
rows = []
for n in nodes:
key = q(n + 0x20)
rec = n + 0x28
try: r = rd(rec, 0x180)
except OSError: continue
if len(r) < 0x180: continue
g = lambda o: struct.unpack_from("<i", r, o)[0]
rid = struct.unpack_from("<I", r, 0x8)[0]
res = struct.unpack_from("<I", r, 0x18)[0]
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
hist[ct] += 1
if rid in WANT: found[rid] = rec
rows.append((key, rec, rid, res, ct, sub, st, cat))
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
for w in sorted(WANT):
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Read-only scan of the record pool embedded in the club-model owner object.
The 18 resident player records sit at a fixed stride of 0x180 inside the owner
object, below the embedded manager subobject at owner+0x1f9d8. This walks that
pool to see whether storage for the five club items exists and what it holds.
Read-only. Never writes.
"""
import re, struct, sys
PID = int(sys.argv[1])
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
def live(s):
return base + (s - 0x180000000)
owner = q(live(0x1802e6398))
mgr = owner + 0x1f9d8
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
first = None
for k in range((end - beg) // 24):
r = q(beg + k * 24 + 0x10)
if r:
first = r; break
if first is None:
sys.exit("no populated player record to anchor the pool")
print(f" owner = {owner:#x} mgr = {mgr:#x} first record = {first:#x}")
print(f" record - owner = {first - owner:#x} pool room to mgr = {(mgr - first) // 0x180} slots of 0x180")
print()
hdr = f" {'idx':>3} {'addr':>12} {'ctype':>6} {'subtyp':>6} {'state':>6} {'cat':>4} {'team':>5} {'kittyp':>6} set"
print(hdr)
n = (mgr - first) // 0x180
for k in range(min(n, 40)):
a = first + k * 0x180
try:
r = rd(a, 0xC0)
except OSError:
print(f" {k:>3} {a:#12x} unreadable"); break
if len(r) < 0xC0:
break
ct, sub, st, cat, team = (struct.unpack_from("<i", r, o)[0] for o in (0x4c, 0x50, 0x5c, 0x60, 0x94))
kt = struct.unpack_from("<H", r, 0xba)[0]
nz = sum(1 for b in r if b)
flag = ""
if ct == 7:
flag = " <== CARDTYPE 7"
elif nz == 0:
flag = " (all zero)"
print(f" {k:>3} {a:#12x} {ct:>6} {sub:>6} {st:>6} {cat:>4} {team:>5} {kt:>6} {nz:>3}/192{flag}")
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Read-only dump of RESIDENT record fields, for both the player and club-item vectors.
Purpose: the kit clone driver FUN_1801c3480 gates on record+0x60 (category) == 4.
No instruction in CardsDLL writes immediate 4 there, so this reads what value a
genuinely resident record actually carries. Read-only. Never writes.
mgr+0x0c0 cardtype-2 single slot
mgr+0x0d8..0x0e0 cardtype-1 (player) vector
mgr+0x108..0x110 club-item vector
record+0x4c cardtype +0x50 cardsubtypeid +0x5c itemState
record+0x60 category +0x94 teamid +0xba teamkittypetechid (u16)
"""
import re, struct, sys, collections
PID = int(sys.argv[1])
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
def rd(a, n):
mem.seek(a); return mem.read(n)
def q(a):
return struct.unpack("<Q", rd(a, 8))[0]
def i32(b, o):
return struct.unpack_from("<i", b, o)[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
if base is None:
sys.exit("CardsDLL mapping not found")
def live(static):
return base + (static - 0x180000000)
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED - wrong base")
print(f" CardsDLL base = {base:#x} (sanity ok)")
owner = q(live(0x1802e6398))
b = rd(q(owner) + 0x4e8, 12)
b = rd(struct.unpack("<Q", struct.pack("<Q", q(q(owner) + 0x4e8)))[0], 12)
getter = q(q(owner) + 0x4e8)
gb = rd(getter, 12)
if gb[0:3] == bytes.fromhex("488d81"):
mgr = owner + struct.unpack_from("<I", gb, 3)[0]
elif gb[0:3] == bytes.fromhex("488d41"):
mgr = owner + gb[3]
else:
sys.exit(f"unexpected getter shape {gb.hex(' ')}")
print(f" owner = {owner:#x} mgr = {mgr:#x}")
FIELDS = ("ctype", "subtype", "state", "cat", "team", "kittype")
def decode(rec):
r = rd(rec, 0xC0)
if len(r) < 0xC0:
return None
return (i32(r, 0x4c), i32(r, 0x50), i32(r, 0x5c), i32(r, 0x60),
i32(r, 0x94), struct.unpack_from("<H", r, 0xba)[0])
for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
("club items", mgr + 0x108, mgr + 0x110)):
try:
beg, end = q(vbeg), q(vend)
except OSError:
print(f"\n {label}: vector unreadable")
continue
span = end - beg
print(f"\n === {label}: {beg:#x}..{end:#x} span={span} ===")
if not (0 < beg <= end) or span > 24 * 100000:
print(" implausible vector, skipping")
continue
# resolve stride: the element must contain a plausible heap pointer
for stride, ptr_off in ((24, 0x10), (16, 0x08), (8, 0x00)):
if span % stride:
continue
n = span // stride
recs, nulls = [], []
ok = True
for k in range(n):
try:
rec = q(beg + k * stride + ptr_off)
except OSError:
ok = False; break
if not rec:
nulls.append(k); continue
d = decode(rec)
if d is None:
ok = False; break
recs.append((k, rec, d))
if not ok:
continue
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
if not recs and len(nulls) != n:
continue
hist = collections.Counter(d[0:2] for _, _, d in recs)
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
print(f" (cardtype,subtype)={key} x{c}")
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
# that other atom handlers clobber. Which slots are filled therefore
# reveals the index the parse actually started from.
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
for k, rec, d in recs[:8]:
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
if nulls:
print(f" empty slots: {nulls[:16]}")
cats = collections.Counter(d[3] for _, _, d in recs)
if cats:
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
break
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
# cardtype-7 records, and are the served kit ids among them?
#
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
#
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
# 4:2, 10:1} and both kit ids ABSENT.
set -u
PID=$(for p in /proc/[0-9]*; do
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
done | head -1)
if [ -z "$PID" ]; then
echo " FIFA17.exe is not running - launch the game and enter FUT first"
exit 1
fi
echo " live FIFA17 pid = $PID"
echo
cd "$(dirname "$0")" || exit 1
python3 probe_map2.py "$PID" 100004873 100004874 100004870
echo
echo " ================ squad survival + slot indices ================"
# The kit milestone is only real if the REST of the squad survives with it.
# A populated `squad.actives` was once seen to leave the map holding just the
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
# the player-vector fill below is a PASS/FAIL gate, not decoration.
#
# The club-item slot indices are the other half: the parser writes element i to
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
# somewhere other than slots 0 and 1 means the index did not start at zero.
python3 probe_resident_fields.py "$PID"
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
manager is reallocated across logins), and appends one line per CHANGE so the
output can be aligned against the staging host's route log by wall clock.
Purpose: answer "after which response does a resident club item first appear?"
without reversing the constructor first. Pair with
journalctl -u openfut-staging-host --since <start> -o short-iso
and compare timestamps.
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
"""
from __future__ import annotations
import argparse
import collections
import os
import re
import struct
import sys
import time
CARDS_DLL = "CardsDLL_Win64_retail.dll"
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
SANITY_VA = 0x180026FEA # mov edx,0x7575
SANITY_BYTES = bytes.fromhex("ba75750000")
IMAGE_BASE = 0x180000000
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
"category": 0x60, "teamid": 0x94}
OFF_KITTYPE_U16 = 0xBA
class Target:
"""One live FIFA17.exe, with the store chain resolved."""
def __init__(self, pid: int):
self.pid = pid
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
self.base = self._cards_base()
if self.base is None:
raise RuntimeError("CardsDLL mapping not found")
probe = self.rd(self.live(SANITY_VA), 5)
if probe != SANITY_BYTES:
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
owner = self.q(self.live(OWNER_GLOBAL))
if not owner:
raise RuntimeError("owner object is null (not logged in yet)")
vt = self.q(owner)
getter = self.q(vt + 0x4E8)
b = self.rd(getter, 8)
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
if b[0:3] == bytes.fromhex("488d81"):
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
elif b[0:3] == bytes.fromhex("488d41"):
self.mgr = owner + b[3]
elif b[0:3] == bytes.fromhex("488b81"):
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
else:
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
# -- raw access ------------------------------------------------------
def rd(self, a: int, n: int) -> bytes:
self.mem.seek(a)
return self.mem.read(n)
def q(self, a: int) -> int:
return struct.unpack("<Q", self.rd(a, 8))[0]
def live(self, static: int) -> int:
return self.base + (static - IMAGE_BASE)
def _cards_base(self):
named = []
for ln in open(f"/proc/{self.pid}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
# the Wine heap is also rwx, so permissions cannot identify a module.
for start, path in sorted(named):
if path.endswith(CARDS_DLL):
return start
return None
# -- the store -------------------------------------------------------
def vector(self, off_begin: int):
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
return None, 0
return beg, (end - beg) // 24
def records(self, off_begin: int):
beg, n = self.vector(off_begin)
out = []
if beg is None:
return out
for k in range(n):
try:
rec = self.q(beg + k * 24 + 0x10)
except OSError:
continue
if not rec:
out.append(None)
continue
try:
r = self.rd(rec, 0xC0)
except OSError:
out.append(None)
continue
if len(r) < 0xC0:
out.append(None)
continue
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
f["ptr"] = rec
out.append(f)
return out
def snapshot(self) -> dict:
club = self.records(0x108)
players = self.records(0xD8)
hist = collections.Counter(
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
)
return {
"club_slots": len(club),
"club_filled": sum(1 for r in club if r),
"club_hist": dict(hist),
"club_records": [r for r in club if r],
"player_slots": len(players),
"player_filled": sum(1 for r in players if r),
}
def find_pid() -> int | None:
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
with open(f"/proc/{d}/comm") as f:
if f.read().strip() == "FIFA17.exe":
return int(d)
except OSError:
continue
return None
def fmt(snap: dict) -> str:
parts = [
f"club={snap['club_filled']}/{snap['club_slots']}",
f"players={snap['player_filled']}/{snap['player_slots']}",
]
if snap["club_hist"]:
parts.append("hist=" + ",".join(
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
for r in snap["club_records"]:
parts.append(
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
f"state={r['itemState']} cat={r['category']} "
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
return " ".join(parts)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--interval", type=float, default=1.0)
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
ap.add_argument("--once", action="store_true")
a = ap.parse_args()
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
def emit(msg: str) -> None:
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
print(line, file=sink)
if sink is not sys.stdout:
print(line, flush=True)
emit("watch: start")
target = None
last = None
while True:
if target is None:
pid = find_pid()
if pid is None:
if a.once:
emit("watch: no FIFA17.exe"); return 1
time.sleep(a.interval); continue
try:
target = Target(pid)
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
f"mgr={target.mgr:#x}")
last = None
except (OSError, RuntimeError) as e:
# not logged in yet, or the process died mid-resolve
if a.once:
emit(f"watch: not ready: {e}"); return 1
target = None
time.sleep(a.interval); continue
try:
snap = target.snapshot()
except (OSError, struct.error) as e:
emit(f"watch: detached ({e})")
target = None
if a.once:
return 1
continue
key = fmt(snap)
if key != last:
emit(key)
last = key
if a.once:
return 0
time.sleep(a.interval)
if __name__ == "__main__":
sys.exit(main())
-14
View File
@@ -162,19 +162,6 @@ def log(*a):
print("[lsx]", *a, flush=True)
def spawn_parent_watchdog():
parent = os.getppid()
def _watch():
while True:
time.sleep(1)
if os.getppid() != parent:
log(f"launcher pid {parent} exited; stopping lsx")
os._exit(0)
threading.Thread(target=_watch, daemon=True).start()
_SECRET_ATTR_RE = re.compile(
r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"')
_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"')
@@ -585,7 +572,6 @@ def serve(sock, addr):
def main():
spawn_parent_watchdog()
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back the MANAGER-ONLY chemistry slots the client resolved, and prove
whether the server's `nation`/`leagueId` actually land in the record.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`card_identity_probe` reads the PLAYER slots (F_NATION = 0x148, F_LEAGUE =
0x154). A manager does not use those, so grading a manager with that tool
reports nation=0 / leagueId=0 and looks like a server bug when it is only the
wrong offsets.
`fifa17-recon/tools/fut_staff.py` records the manager layout from Ghidra:
rec+0x94 teamid (read by the card view-model)
rec+0xde nation MANAGER-ONLY slot, u16
rec+0xe0 leagueId MANAGER-ONLY slot, u16
rec+0xe2 talkrating written by the managercards merge
rec+0xe3 negotiation written by the managercards merge
The merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, so whatever sits there
came from OUR JSON and nowhere else. That makes those two u16s a direct,
unambiguous test of the server's manager chemistry fields: if they read back as
the values we served, the wire contract is PROVEN rather than inferred; if they
read zero, the client discarded them and manager chemistry cannot be rendering.
Usage: python3 manager_chem_probe.py # grade every manager in the map
"""
import sys
import watch_club_model as W
import card_identity_probe as P
MANAGER_CARDTYPE = 2 # FUN_1800d8330: cardsubtypeid 4 -> cardtype 2
F_CARDTYPE = 0x4C
F_RESOURCE = 0x18
F_TEAMID = 0x94
F_NATION_MGR = 0xDE
F_LEAGUE_MGR = 0xE0
F_TALKRATING = 0xE2
F_NEGOTIATION = 0xE3
REC_SIZE = 0x158
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d" % (pid, obj, len(ns)))
print()
print("%-10s %-8s %-8s %-8s %-10s %-10s %s"
% ("resource", "teamid", "nation", "league", "talkrating", "negot", "verdict"))
found = 0
for n in ns:
rec = n + 0x28
buf = mem.read(rec, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
if P.u8(buf, F_CARDTYPE) != MANAGER_CARDTYPE:
continue
found += 1
resource = P.u32(buf, F_RESOURCE)
teamid = P.u32(buf, F_TEAMID)
nation = P.u16(buf, F_NATION_MGR)
league = P.u16(buf, F_LEAGUE_MGR)
talk = P.u8(buf, F_TALKRATING)
negot = P.u8(buf, F_NEGOTIATION)
# +0xde and +0xe0 are never written by the merge, so a non-zero value
# can only have come from the server's JSON.
if nation and league:
verdict = "SERVER FIELDS LANDED"
elif nation or league:
verdict = "PARTIAL -- one slot empty"
else:
verdict = "EMPTY -- client kept nothing we sent"
print("%-10d %-8d %-8d %-8d %-10d %-10d %s"
% (resource, teamid, nation, league, talk, negot, verdict))
if not found:
print("(no manager record in the map -- the client has not been served one)")
return 0
if __name__ == "__main__":
sys.exit(main())
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
no input to the game, and never opens 'r+b'.
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
[--control WIRE:RESOURCE ...]
Defaults describe the staging profile used to close the manager milestone; pass
the flags for any other profile.
WHAT THIS DECIDES
-----------------
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
two different routes:
players : atom 568 -> per-element atoms 355 index / 363 itemData /
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
on the NESTED itemData object.
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
CONTROLS
--------
manager wire id the instance id. Present even when BROKEN, because `id` is
an item atom the parser reads at element level. Its
presence proves the element was parsed and therefore proves
nothing about registration -- do not use it as the verdict.
manager resourceId THE VERDICT. Resident => the merge key survived the load.
player wire id and positive controls. Players demonstrably render, so if their
player resourceId resourceIds are absent the squad simply is not loaded yet
and the run is INCONCLUSIVE, not a failure.
RESIDENT-MANAGER HIT
--------------------
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
a readable private mapping. Corroborate with the record context printed below:
a real item record carries resourceId eight words ahead of its wire id, which
is the layout the player controls exhibit. Hits without that shape are usually
id lists or unrelated integers -- the layout, not the raw count, is the proof.
LAYOUT ASSUMPTION (the only one)
--------------------------------
Item records place resourceId 0x20 bytes before the wire id. Measured, both
sides:
before b91e707 (pid 126936) -- manager parsed, merge key absent
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
after b91e707 (pid 134118) -- same layout, key present
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
Addresses shift every session and are recorded only as provenance; nothing here
depends on them. The tool re-derives everything by scanning.
EXIT CODES (fail-closed)
------------------------
0 PASS manager resourceId resident, controls present
1 FAIL controls present, manager resourceId absent
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
squad screen. Deliberately NOT 0: absent controls mean the
probe proved nothing.
"""
import argparse
import glob
import os
import re
import struct
import sys
# Staging profile defaults (override on the command line).
DEF_MANAGER_WIRE = 100004870
DEF_MANAGER_RESOURCE = 1000509
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
# Item record layout: resourceId sits this far BEFORE the wire id.
RESOURCE_BACK_OFF = 0x20
def find_pid():
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
for d in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(d, "comm")) as fh:
if fh.read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
continue
return None
def regions(pid):
"""Readable private mappings worth scanning.
Skips device/memfd mappings and anything over 512 MiB (the big reserved
ranges are not where parsed records live and dominate the runtime).
"""
out = []
with open(f"/proc/{pid}/maps") as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
if not m:
continue
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
perms, path = m.group(3), m.group(4)
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
continue
if hi - lo > 512 * 1024 * 1024:
continue
out.append((lo, hi))
return out
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
found = {n: [] for n in needles}
pats = {n: struct.pack("<i", n) for n in needles}
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
for lo, hi in regions(pid):
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue # torn-down or unreadable mapping; not a failure
for n, pat in pats.items():
i = buf.find(pat)
while i >= 0:
if i % 4 == 0:
found[n].append(
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
)
i = buf.find(pat, i + 4)
return found
def words(blob, centre, before=8, after=4):
cells = []
for k in range(-before, after):
o = centre + k * 4
if 0 <= o <= len(blob) - 4:
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
return " ".join(cells)
def record_shaped(blob, centre, resource):
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
item-record layout, as opposed to an incidental integer match."""
o = centre - RESOURCE_BACK_OFF
if o < 0 or o > len(blob) - 4:
return False
return struct.unpack_from("<i", blob, o)[0] == resource
def main():
ap = argparse.ArgumentParser(description="read-only manager registration probe")
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
ap.add_argument(
"--control",
action="append",
metavar="WIRE:RESOURCE",
help="player positive control; repeatable (default: the staging pair)",
)
args = ap.parse_args()
controls = DEF_CONTROLS
if args.control:
try:
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
except ValueError:
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
return 2
pid = args.pid or find_pid()
if not pid:
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
return 2
if not os.access(f"/proc/{pid}/mem", os.R_OK):
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
return 2
print(f" pid={pid}")
needles = [args.manager_wire, args.manager_resource]
for w, r in controls:
needles += [w, r]
try:
res = scan(pid, sorted(set(needles)))
except OSError as e:
print(f" cannot read /proc/{pid}/mem: {e}")
return 2
print("\n ===== hit counts =====")
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
f"hits={len(res[args.manager_resource])}")
for w, r in controls:
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
print("\n ===== record context (8 words before the id, then the id) =====")
shaped = {"manager": 0}
for tag, wire, resource in (
[("manager", args.manager_wire, args.manager_resource)]
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
):
marked = 0
for addr, blob, centre in res[wire]:
ok = record_shaped(blob, centre, resource)
if ok:
marked += 1
if marked <= 2 or ok:
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
f"{words(blob, centre)}")
if marked >= 2:
break
shaped[tag] = marked
ctl_keys = sum(len(res[r]) for _w, r in controls)
mgr_keys = len(res[args.manager_resource])
print("\n ===== verdict =====")
if ctl_keys == 0:
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
return 3
if mgr_keys == 0:
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
f"{ctl_keys} player")
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
return 1
print(f" PASS: manager resourceId {args.manager_resource} is resident "
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
print(" The merge key survived the load; the broken projection had 0.")
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
payload; neither register is a screen key.
The generated GDB program uses hardware-assisted execution breakpoints only.
It never writes client memory and never drives game input.
match_advance_trace.py [pid] [--output PATH]
match_advance_trace.py --print-script [pid]
match_advance_trace.py --selftest
"""
from __future__ import annotations
import argparse
import hashlib
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_transition_trace as transition
FIFA_MODULE = "FIFA17.exe"
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
def module_mapping(pid: int, module: str) -> tuple[int, str]:
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
for line in handle:
fields = line.split(maxsplit=5)
path = fields[5].rstrip() if len(fields) == 6 else ""
if not path.endswith(module):
continue
return int(fields[0].split("-", 1)[0], 16), path
raise RuntimeError(f"{module} is not mapped in PID {pid}")
def validate_file(path: str, expected: str, label: str) -> None:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if actual != expected:
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
}
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
hbreak *0x{address['provider']:x}
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['global_dispatch']:x}
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['controller']:x}
condition 3 $edx == 0x7546
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
continue
end
hbreak *0x{address['bridge']:x}
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
continue
end
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"provider": 0x1801A4CD0,
"global_dispatch": 0x1480D1070,
"controller": 0x1800BF950,
"bridge": 0x1801A4D41,
}
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "CREATE_MATCH_CONTROLLER" in script
assert "PROVIDER_BRIDGE" in script
assert "set *(" not in script
print("match_advance_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
The probe correlates:
* the select-team action handler for UIF action IDs 0x7574..0x757b;
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
* the concrete FutCreateMatchServerResponse data-source request method;
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
Static decoding identifies action 0x7577 as the branch that constructs the
create-match request and calls DataManager for source 0x7546. The trace proves
whether that authentic trigger executes in the failing flow. It uses four
hardware-assisted execution breakpoints, never writes client memory, and never
drives game input.
match_create_action_trace.py [pid] [--output PATH]
match_create_action_trace.py --print-script [pid]
match_create_action_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
DATA_MANAGER_REQUEST_RVA = 0x80D2340
DATA_SOURCE_REQUEST_RVA = 0x120270
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
FIRST_SELECT_TEAM_ACTION = 0x7574
LAST_SELECT_TEAM_ACTION = 0x757B
ACTION_CREATE_MATCH = 0x7577
CREATE_DATA_SOURCE = 0x7546
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
}
def build_gdb_script(
pid: int, cards_base: int, fifa_base: int, output: str
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $create_action_seen = 0
set $manager_request_seen = 0
set $data_source_request_seen = 0
hbreak *0x{address['action_handler']:x}
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
commands
silent
if $edx == 0x{ACTION_CREATE_MATCH:x}
set $create_action_seen = 1
end
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
bt 10
continue
end
hbreak *0x{address['manager_request']:x}
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
commands
silent
set $manager_request_seen = 1
set $tree_sentinel = $rcx + 0x10
set $tree_cursor = *(void**)($rcx+0x20)
set $data_node = $tree_sentinel
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
set $data_node = $tree_cursor
set $tree_cursor = *(void**)($tree_cursor+0x08)
else
set $tree_cursor = *(void**)$tree_cursor
end
end
set $data_source = 0
set $request_method = 0
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
set $data_source = *(void**)($data_node+0x28)
if $data_source != 0
set $request_method = *(void**)(*(void**)$data_source+0x18)
end
end
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
bt 10
continue
end
hbreak *0x{address['data_source_request']:x}
commands
silent
set $data_source_request_seen = 1
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
bt 10
continue
end
hbreak *0x{address['ui_dispatch']:x}
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
bt 10
continue
end
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"action_handler": 0x1800BFCC0,
"manager_request": 0x1480D2340,
"data_source_request": 0x180120270,
"ui_dispatch": 0x1480D1070,
}
script = build_gdb_script(
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
)
assert script.count("hbreak *") == 4
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "request_method" in script
assert "set *(" not in script
print("match_create_action_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
vslot +0x38 call -> create gate return -> returned target -> UI bridge
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
vtable, concrete lookup function, event service, readiness-gate implementation,
every register input, returned target, and whether the native-to-UI bridge
executes. No post-event object identity is used.
The generated GDB program uses hardware-assisted execution breakpoints only.
It never writes client memory and never drives game input.
match_delivery_lifecycle_trace.py [pid] [--output PATH]
match_delivery_lifecycle_trace.py --print-script [pid]
match_delivery_lifecycle_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
CREATE_GATE_RETURN_RVA = 0x0BE647
def trace_addresses(cards_base: int) -> dict[str, int]:
return {
"lookup_call": cards_base + LOOKUP_CALL_RVA,
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
"bridge": cards_base + BRIDGE_CALL_RVA,
}
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $current_provider = 0
set $current_payload = 0
set $current_controller = 0
set $current_vtable = 0
set $current_lookup = 0
set $current_service = 0
set $current_service_vtable = 0
set $current_gate = 0
hbreak *0x{address['lookup_call']:x}
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
set $current_provider = $edi
set $current_payload = $rbp
set $current_controller = $rcx
set $current_vtable = *(void**)$rcx
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
set $current_service = *(void**)($rcx+0x18)
set $current_service_vtable = *(void**)$current_service
set $current_gate = *(void**)($current_service_vtable+0x58)
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
continue
end
hbreak *0x{address['gate_return']:x}
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
continue
end
hbreak *0x{address['lookup_return']:x}
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
continue
end
hbreak *0x{address['bridge']:x}
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
continue
end
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000)
assert address == {
"lookup_call": 0x1801A4CFC,
"gate_return": 0x1800BE647,
"lookup_return": 0x1801A4CFF,
"bridge": 0x1801A4D41,
}
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
assert script.count("hbreak *") == 4
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "LOOKUP_CALL" in script
assert "CREATE_GATE_RETURN" in script
assert "LOOKUP_RETURN" in script
assert "gate_fn" in script
assert "BRIDGE" in script
assert "set *(" not in script
print("match_delivery_lifecycle_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
script = build_gdb_script(pid, cards_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
select-team deleting destructor with its real caller, records entry to the
Gameplay::ScenarioModeStart consumer with the state it would advance, and
identifies the first TestingGame update after the boundary.
The generated GDB program uses four hardware-assisted execution breakpoints.
It never writes client memory, calls client functions, drives input, emits
actions, or changes timing deliberately.
match_drill_transition_trace.py [pid] [--output PATH]
match_drill_transition_trace.py --print-script [pid]
match_drill_transition_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SAVE_KIT_ACTION = 0x7576
SAVE_ACTION_RVA = 0x0BFCC0
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
TESTING_GAME_UPDATE_RVA = 0x05A410C8
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
TESTING_GAME_VTABLE_RVA = 0x035C58A8
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
OWNER_STATE_OFFSET = 0x1958
STATE_GAME_DATABASE_OFFSET = 0x17450
STATE_PHASE_OFFSET = 0x27BEC
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"save_action": cards_base + SAVE_ACTION_RVA,
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
}
def build_gdb_script(
pid: int,
cards_base: int,
fifa_base: int,
output: str,
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $save_count = 0
set $current_controller = 0
set $engine_seen = 0
hbreak *0x{address['save_action']:x}
commands
silent
if $edx == 0x{SAVE_KIT_ACTION:x}
set $save_count = $save_count + 1
set $current_controller = $rcx
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
if $save_count == 2
disable 1
end
end
continue
end
hbreak *0x{address['deleting_destructor']:x}
condition 2 $save_count >= 2 && $rcx == $current_controller
commands
silent
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
x/16gx $rsp
bt 12
disable 2
continue
end
hbreak *0x{address['scenario_mode_start_handler']:x}
commands
silent
set $scenario_wrapper = $rcx
set $scenario_state = *(void**)($scenario_wrapper+0x30)
set $scenario_payload = $r9
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $scenario_state != 0
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
if $scenario_database != 0
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
else
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
end
else
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
end
bt 12
disable 3
continue
end
hbreak *0x{address['testing_game_update']:x}
condition 4 $save_count >= 2 && $engine_seen == 0
commands
silent
set $owner = $rsi
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
if $database != 0
set $engine_seen = 1
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
bt 12
disable 4
end
end
continue
end
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"save_action": 0x1800BFCC0,
"deleting_destructor": 0x1800BE020,
"testing_game_update": 0x145A410C8,
"scenario_mode_start_handler": 0x145A58EC0,
"testing_game_vtable": 0x1435C58A8,
"testing_game_state_vtable": 0x1435C2EE0,
}
script = build_gdb_script(
49938,
0x180000000,
0x140000000,
"/tmp/drill-transition.log",
)
assert script.count("hbreak *") == 4
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
assert "SCENARIO_MODE_START" in script
assert "wrapper=%p state=%p payload=%p" in script
assert "alternate_gate=%d" in script
assert "skill_game_start_constructor" not in script
assert "ENGINE_HANDOFF" in script
assert "GameplayGameDatabase.IsSkillGame" not in script
assert "set *(" not in script
print("match_drill_transition_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Trace FIFA17's post-kit boundary without changing client behavior.
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
native save call, the action-handler return, and select-team provider teardown.
The second 0x7576 action is the temporal boundary for later drill/game-loader
instrumentation.
The generated GDB program uses four hardware-assisted execution breakpoints. It
never writes client memory, calls client functions, drives input, emits actions,
or alters timing deliberately.
match_post_kit_trace.py [pid] [--output PATH]
match_post_kit_trace.py --print-script [pid]
match_post_kit_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
SAVE_KIT_ACTION = 0x7576
ACTION_HANDLER_RVA = 0x0BFCC0
SAVE_CALL_RVA = 0x0BFF25
ACTION_RETURN_RVA = 0x0C00AE
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
def trace_addresses(cards_base: int) -> dict[str, int]:
return {
"action": cards_base + ACTION_HANDLER_RVA,
"save_call": cards_base + SAVE_CALL_RVA,
"action_return": cards_base + ACTION_RETURN_RVA,
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
}
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $save_count = 0
set $current_action = 0
set $current_controller = 0
set $current_payload = 0
set $second_save_epoch = 0
hbreak *0x{address['action']:x}
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
commands
silent
set $save_count = $save_count + 1
set $current_action = $edx
set $current_controller = $rcx
set $current_payload = $r8
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
if $save_count == 2
set $second_save_epoch = $event_epoch
end
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
bt 10
continue
end
hbreak *0x{address['save_call']:x}
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
commands
silent
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
x/12gx $rdx
bt 10
continue
end
hbreak *0x{address['action_return']:x}
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
commands
silent
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
set $current_action = 0
bt 10
continue
end
hbreak *0x{address['destructor']:x}
condition 4 $save_count >= 2 && $rcx == $current_controller
commands
silent
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
bt 12
continue
end
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000)
assert address == {
"action": 0x1800BFCC0,
"save_call": 0x1800BFF25,
"action_return": 0x1800C00AE,
"destructor": 0x1800BDEC0,
}
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
assert "second_boundary" in script
assert "NATIVE_SAVE_CALL" in script
assert "SELECT_TEAM_DESTRUCTOR" in script
assert "set *(" not in script
print("match_post_kit_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
script = build_gdb_script(pid, cards_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
The probe correlates four concrete lifecycle boundaries:
* FutCreateMatchServerResponse data-source request;
* the POST /match network response callback;
* the response readiness/completion callback;
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
This distinguishes network completion from the separate DataManager readiness
lifecycle without assuming any screen or heap-object identity. The generated GDB
program uses hardware-assisted execution breakpoints only. It never writes
client memory and never drives game input.
match_provider_producer_trace.py [pid] [--output PATH]
match_provider_producer_trace.py --print-script [pid]
match_provider_producer_trace.py --selftest
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
DATA_SOURCE_REQUEST_RVA = 0x120270
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
CREATE_COMPLETE_RVA = 0x120000
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
CREATE_RESPONSE_OFFSET = 0xA0
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
ACTIVE_CALLBACK_OFFSET = 0x47D0
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
return {
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
"network_response": cards_base + NETWORK_RESPONSE_RVA,
"create_complete": cards_base + CREATE_COMPLETE_RVA,
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
}
def build_gdb_script(
pid: int, cards_base: int, fifa_base: int, output: str
) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(cards_base, fifa_base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
set $last_central = 0
set $last_response = 0
set $last_data_source = 0
set $last_descriptor = 0
hbreak *0x{address['data_source_request']:x}
commands
silent
set $request_data_source = $rcx
set $request_response = $rcx - 0x50
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
bt 10
continue
end
hbreak *0x{address['network_response']:x}
commands
silent
set $last_central = $rcx
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
set $last_descriptor = $rdx
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx == 0
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
else
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
end
bt 10
continue
end
hbreak *0x{address['create_complete']:x}
commands
silent
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx == 0
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
else
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
end
bt 10
continue
end
hbreak *0x{address['ui_dispatch']:x}
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $last_response == 0
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
else
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
end
bt 10
continue
end
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
continue
"""
def selftest() -> None:
address = trace_addresses(0x180000000, 0x140000000)
assert address == {
"data_source_request": 0x180120270,
"network_response": 0x180114D90,
"create_complete": 0x180120000,
"ui_dispatch": 0x1480D1070,
}
script = build_gdb_script(
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
)
assert script.count("hbreak *") == 4
assert "DATA_SOURCE_REQUEST" in script
assert "NETWORK_RESPONSE" in script
assert "CREATE_COMPLETE" in script
assert "UI_DISPATCH" in script
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
assert "set *(" not in script
print("match_provider_producer_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
script = build_gdb_script(pid, cards_base, fifa_base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
The tracer covers the client-local path after POST /match:
response callback -> deserializer -> controller event 0x7546
-> FUT_CREATE_MATCH_DP 0x7563
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
same native dispatcher. The generated GDB program uses only hardware-assisted
execution breakpoints. It never writes client memory and never drives game
input.
match_transition_trace.py [pid] [--output PATH]
match_transition_trace.py --print-script [pid]
match_transition_trace.py --selftest
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import os
import shutil
import sys
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
RESPONSE_CALLBACK_RVA = 0x114D90
DESERIALIZE_SUCCESS_RVA = 0x118940
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
PROVIDER_DISPATCH_RVA = 0x1A4CD0
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
FUT_CREATE_MATCH_DP = 0x7563
FUT_GET_MATCH_KITS_DP = 0x7565
def find_pid() -> int | None:
found = []
for directory in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
if handle.read().strip() != "FIFA17.exe":
continue
pid = int(os.path.basename(directory))
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
resident_pages = int(handle.read().split()[1])
found.append((resident_pages, pid))
except (OSError, ValueError, IndexError):
continue
return max(found)[1] if found else None
def parse_cards_mapping(lines) -> tuple[int, str]:
for line in lines:
fields = line.split(maxsplit=5)
path = fields[5].rstrip() if len(fields) == 6 else ""
if not path.endswith(CARDS_MODULE):
continue
start = int(fields[0].split("-", 1)[0], 16)
return start, path
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
def cards_mapping(pid: int) -> tuple[int, str]:
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
try:
return parse_cards_mapping(handle)
except RuntimeError as error:
raise RuntimeError(f"{error} in PID {pid}") from error
def sha256_file(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_cards(path: str) -> None:
actual = sha256_file(path)
if actual != PINNED_CARDS_SHA256:
raise RuntimeError(
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
)
def trace_addresses(base: int) -> dict[str, int]:
return {
"response": base + RESPONSE_CALLBACK_RVA,
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
"provider": base + PROVIDER_DISPATCH_RVA,
}
def build_gdb_script(pid: int, base: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
address = trace_addresses(base)
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted on
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
hbreak *0x{address['response']:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
if $rdx != 0
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
else
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
end
continue
end
hbreak *0x{address['deserialize']:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
continue
end
hbreak *0x{address['controller']:x}
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
continue
end
hbreak *0x{address['provider']:x}
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
commands
silent
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
continue
end
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
continue
"""
def selftest() -> None:
base = 0x180000000
address = trace_addresses(base)
assert address == {
"response": 0x180114D90,
"deserialize": 0x180118940,
"controller": 0x1800BF950,
"provider": 0x1801A4CD0,
}
mapping = parse_cards_mapping(
[
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
]
)
assert mapping == (
0x6FFFFC0F0000,
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
)
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
assert script.count("hbreak *") == 4
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
assert "T3_RESPONSE_CALLBACK" in script
assert "T4_DESERIALIZE_SUCCESS" in script
assert "T5_CREATE_MATCH_CONTROLLER" in script
assert "T6_PROVIDER_DISPATCH" in script
assert "set *(" not in script
print("match_transition_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
base, cards_path = cards_mapping(pid)
validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
script = build_gdb_script(pid, base, output)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
Never relies on heap addresses or allocator handles. It identifies:
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
sequence (teamId/difficulty/roundId/rewardMult/coins),
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
from the transient +0x18 handle,
* the match-config team pair from structural fields around it, not its team ids.
offline_match_locator.py [pid] [--fixture-index 0] [--json]
offline_match_locator.py --selftest
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import struct
import sys
from dataclasses import asdict, dataclass
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
F01 = 0x3DCCCCCD
@dataclass
class Fixture:
address: int
selected_address: int
selected_index: int
selected_team_id: int
records: list[dict[str, int]]
@dataclass
class MatchTeam:
address: int
team_id: int
marker_18: int
marker_1c: int
xi: list[int]
substitutes: list[int]
@dataclass
class MatchConfig:
pair_address: int
team_id_0: int
team_id_1: int
player_count_0: int
player_count_1: int
def find_pids() -> list[int]:
"""All live FIFA17.exe processes, largest resident set first.
The UMU/Proton launch chain briefly creates a small process with the same
comm before the real game. Returning the first /proc glob match attached
the trace supervisor to that short-lived process and missed the match.
"""
found = []
for directory in glob.glob("/proc/[0-9]*"):
try:
with open(os.path.join(directory, "comm")) as handle:
if handle.read().strip() != "FIFA17.exe":
continue
pid = int(os.path.basename(directory))
with open(os.path.join(directory, "statm")) as handle:
resident_pages = int(handle.read().split()[1])
found.append((resident_pages, pid))
except (OSError, ValueError, IndexError):
continue
return [pid for _resident, pid in sorted(found, reverse=True)]
def find_pid() -> int | None:
pids = find_pids()
return pids[0] if pids else None
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
return b"".join(
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
for round_id, team_id in enumerate(teams)
)
def readable_regions(pid: int, *, writable_anon_only: bool = False):
with open(f"/proc/{pid}/maps") as maps:
for line in maps:
match = re.match(
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
line,
)
if not match:
continue
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
perms, path = match.group(3), match.group(4).strip()
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
continue
if hi - lo > 512 * 1024 * 1024:
continue
if writable_anon_only and (perms[1] != "w" or path):
continue
yield lo, hi, perms, path
def _i32(buf: bytes, offset: int) -> int:
return struct.unpack_from("<i", buf, offset)[0]
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
pattern = fixture_bytes()
found = []
offset = buf.find(pattern)
while offset >= 0:
records = []
for round_id in range(len(DEFAULT_TEAMS)):
at = offset + round_id * 16
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
"<iBBHii", buf, at
)
records.append(
{
"team_id": team_id,
"difficulty": difficulty,
"round_id": parsed_round,
"reward_mult": reward_mult,
"coins": coins,
}
)
found.append(
Fixture(
address=base + offset,
selected_address=base + offset + selected_index * 16,
selected_index=selected_index,
selected_team_id=records[selected_index]["team_id"],
records=records,
)
)
offset = buf.find(pattern, offset + 4)
return found
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
found = []
offset = buf.find(MATCH_HEADER)
while offset >= 0:
if offset + 0x7C <= len(buf):
found.append(
MatchTeam(
address=base + offset,
team_id=_i32(buf, offset + 0x14),
marker_18=_i32(buf, offset + 0x18),
marker_1c=_i32(buf, offset + 0x1C),
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
)
)
offset = buf.find(MATCH_HEADER, offset + 4)
return found
def _valid_config(buf: bytes, pair: int) -> bool:
required = pair + 0x50
if pair < 0 or required > len(buf):
return False
return (
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
and _i32(buf, pair + 0x38) == 11
and _i32(buf, pair + 0x3C) == 11
and _i32(buf, pair + 0x40) == 0
and _i32(buf, pair + 0x44) == 5
)
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
found = []
offset = buf.find(PARTICIPANT_PREFIX)
while offset >= 0:
pair = offset + len(PARTICIPANT_PREFIX)
if _valid_config(buf, pair):
found.append(
MatchConfig(
pair_address=base + pair,
team_id_0=_i32(buf, pair),
team_id_1=_i32(buf, pair + 4),
player_count_0=_i32(buf, pair + 0x38),
player_count_1=_i32(buf, pair + 0x3C),
)
)
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
return found
def scan_process(
pid: int,
selected_index: int,
*,
include_fixture: bool = True,
writable_anon_only: bool = False,
) -> dict[str, list]:
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
for lo, hi, _perms, _path in readable_regions(
pid, writable_anon_only=writable_anon_only
):
try:
memory.seek(lo)
buf = memory.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
if include_fixture:
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
return result
def selftest() -> None:
fixture = fixture_bytes()
team = bytearray(0x7C)
team[:20] = MATCH_HEADER
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
struct.pack_into("<11i", team, 0x20, *range(11))
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
config = bytearray(0x20 + 0x50)
config[:0x20] = PARTICIPANT_PREFIX
pair = 0x20
struct.pack_into("<ii", config, pair, 130000, 130000)
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
teams = scan_match_team_buffer(buf, 0x1000)
configs = scan_match_config_buffer(buf, 0x1000)
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
assert len(teams) == 1 and teams[0].team_id == 130000
assert len(configs) == 1 and configs[0].team_id_1 == 130000
# The transient handle is never part of the anchor.
struct.pack_into("<i", team, 0x18, -1)
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
print("offline_match_locator selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--fixture-index", type=int, default=0)
parser.add_argument("--json", action="store_true")
parser.add_argument("--selftest", action="store_true")
parser.add_argument("--writable-anon-only", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
print("--fixture-index must be 0..9", file=sys.stderr)
return 2
result = scan_process(
pid,
args.fixture_index,
writable_anon_only=args.writable_anon_only,
)
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
serial["pid"] = pid
if args.json:
print(json.dumps(serial, sort_keys=True))
return 0
print(f"pid={pid}")
for fixture in result["fixtures"]:
print(
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
)
for config in result["match_configs"]:
print(
f"match config pair @0x{config.pair_address:x}: "
f"[{config.team_id_0}, {config.team_id_1}]"
)
for team in result["match_teams"]:
print(
f"match team @0x{team.address:x}: teamId={team.team_id} "
f"handles=[{team.marker_18}, {team.marker_1c}]"
)
print(
f"counts: fixtures={len(result['fixtures'])} "
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+18 -3
View File
@@ -32,11 +32,26 @@ c() { printf ' %s\n' "$*"; }
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
ensure_cert() {
[ -s "$CERT" ] && [ -s "$KEY" ] && return 0
echo "[*] generating self-signed TLS cert (redirector MITM; ProtoSSL verify is patched)"
# The cert MUST carry the address the client dials in its SAN, or the roster
# HTTPS handshake is rejected with fatal certificate_unknown and the FUT hub
# fails to load (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md): the client dials the
# roster/redirector by IP and that path validates the SAN against it. Default to
# this host's primary LAN IP so a client on another machine works;
# OPENFUT_ADVERTISE overrides. Reissue when absent OR when the current cert lacks
# that IP, so this self-heals rather than serving a stale DNS-only cert.
local adv ip_sans regen=0
adv="${OPENFUT_ADVERTISE:-$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')}"
ip_sans="IP:127.0.0.1"; [ -n "$adv" ] && ip_sans="IP:$adv,IP:127.0.0.1"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
regen=1
elif [ -n "$adv" ] && ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$adv"; then
regen=1
fi
[ "$regen" = 0 ] && return 0
echo "[*] issuing self-signed TLS cert (SAN includes $ip_sans; redirector MITM; ProtoSSL verify is patched)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,$ip_sans" \
>/dev/null 2>&1
}
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
The generated GDB program uses hardware breakpoints, only reads registers and
client memory, logs, and continues. Breakpoints are rotated so no more than four
are enabled. It never calls client functions, writes client memory, emits an
event, or drives input.
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
pma_producer_trace.py --selftest
"""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import match_advance_trace as advance
import match_transition_trace as transition
VARIANTS = {
"mode0": {
"scenario_rva": 0x07B1C190,
"writer_rva": 0x07B1C26B,
"register_rva": 0x07B1C282,
"writer_context": "$rsi",
"writer_async_requested": "1",
"arm_condition": "1",
},
"alternate": {
"scenario_rva": 0x07B1C050,
"writer_rva": 0x07B1C12F,
"register_rva": 0x07B1C146,
"writer_context": "$rbp",
"writer_async_requested": "$sil",
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
},
}
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
ASYNC_COMPLETION_RVA = 0x07B046C0
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
def addresses(base: int, variant: str) -> dict[str, int]:
config = VARIANTS[variant]
return {
"scenario": base + config["scenario_rva"],
"writer": base + config["writer_rva"],
"register": base + config["register_rva"],
"arm": base + PMA_COMPLETION_ARM_RVA,
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
"completion": base + ASYNC_COMPLETION_RVA,
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
}
def gdb_prelude(pid: int, output: str) -> str:
if any(character in output for character in "\n\r"):
raise ValueError("output path cannot contain a newline")
return f"""set pagination off
set confirm off
set print thread-events off
set breakpoint always-inserted off
set logging file {output}
set logging overwrite on
set logging redirect off
set logging enabled on
handle SIGSEGV nostop noprint pass
handle SIGILL nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGALRM nostop noprint pass
handle SIGUSR1 nostop noprint pass
handle SIGUSR2 nostop noprint pass
attach {pid}
"""
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
address = addresses(fifa_base, variant)
config = VARIANTS[variant]
return (
gdb_prelude(pid, output)
+ f"""define snapshot_pma_context
set $snap_ctx = $arg0
set $snap_flag40 = -1
set $snap_callback_vtable = 0
set $snap_callback_owner = 0
set $snap_dispatcher = 0
set $snap_dispatcher_vtable = 0
set $snap_pma = 0
set $snap_pma_flag18 = -1
set $snap_pma_parent = 0
set $snap_pma_machine = 0
set $snap_pma_current = 0
if $snap_ctx != 0
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
set $snap_dispatcher = $snap_ctx+0x80
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
set $snap_sentinel = $snap_ctx+0x88
set $snap_node = *(void**)$snap_sentinel
set $snap_scan = 0
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
set $snap_candidate = *(void**)($snap_node+0x10)
if $snap_candidate != 0
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
set $snap_pma = $snap_candidate
end
end
set $snap_node = *(void**)$snap_node
set $snap_scan = $snap_scan+1
end
if $snap_pma != 0
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
set $snap_pma_parent = *(void**)($snap_pma+0x8)
if $snap_pma_parent != 0
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
end
if $snap_pma_machine != 0
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
end
end
end
end
define snapshot_gameplay
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
set $snap_listener_manager = 0
set $snap_listener_table = 0
set $snap_listener_index = -1
set $snap_free_roam = 0
set $snap_free_roam_state = -1
set $snap_free_roam_111 = -1
set $snap_free_roam_112 = -1
set $snap_free_roam_124 = -1
set $snap_selected = 0
set $snap_selected_vtable = 0
set $snap_selected_mode = -1
if $snap_gameplay_global != 0
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
end
if $snap_listener_manager != 0
set $snap_listener_table = *(void**)$snap_listener_manager
end
if $snap_listener_table != 0
set $snap_free_roam = *(void**)$snap_listener_table
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
if $snap_listener_index >= 0 && $snap_listener_index < 3
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
end
end
if $snap_free_roam != 0
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
end
if $snap_selected != 0
set $snap_selected_vtable = *(void**)$snap_selected
set $snap_selected_mode = *(int*)($snap_selected+0x18)
end
end
set $tracked_ctx = 0
hbreak *0x{address['scenario']:x}
commands
silent
set $ctx = $rcx
set $tracked_ctx = $ctx
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 1
enable 2
continue
end
hbreak *0x{address['writer']:x}
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
disable 2
commands
silent
set $ctx = {config['writer_context']}
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 2
enable 3
continue
end
hbreak *0x{address['register']:x}
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
disable 3
commands
silent
set $callback = $rdx
set $ctx = $callback-0x48
snapshot_pma_context $ctx
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
disable 3
continue
end
hbreak *0x{address['arm']:x}
condition 4 {config['arm_condition']}
commands
silent
set $ctx = $rcx
if $tracked_ctx == 0
set $tracked_ctx = $ctx
end
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 4
enable 5
continue
end
hbreak *0x{address['arm_writer']:x}
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
disable 5
commands
silent
set $ctx = $rsi
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 5
continue
end
hbreak *0x{address['completion']:x}
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
commands
silent
set $callback = $rcx
set $ctx = *(void**)($callback+0x30)
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
continue
end
hbreak *0x{address['dispatcher']:x}
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
commands
silent
set $ctx = $rcx-0x80
snapshot_pma_context $ctx
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 7
enable 8
continue
end
hbreak *0x{address['instructions']:x}
disable 8
commands
silent
set $listener = $rcx
set $parent = *(void**)($listener+0x8)
set $machine = 0
set $current = 0
if $parent != 0
set $machine = *(void**)($parent+0x8)
end
if $machine != 0
set $current = *(void**)($machine+0x10)
end
snapshot_gameplay
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
disable 6
continue
end
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
continue
"""
)
def effective_environment(pid: int) -> dict[str, str]:
values: dict[str, str] = {}
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
if not item.startswith(b"OPENFUT_FIFA17_"):
continue
key, _, value = item.decode("utf-8", errors="replace").partition("=")
values[key] = value
return values
def selftest() -> None:
mode0 = addresses(0x140000000, "mode0")
alternate = addresses(0x140000000, "alternate")
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
assert mode0["scenario"] == 0x147B1C190
assert mode0["writer"] == 0x147B1C26B
assert mode0["register"] == 0x147B1C282
assert alternate["scenario"] == 0x147B1C050
assert alternate["writer"] == 0x147B1C12F
assert alternate["register"] == 0x147B1C146
assert mode0["completion"] == 0x147B046C0
assert mode0["dispatcher"] == 0x147AC87B0
assert mode0["instructions"] == 0x147AC91E0
assert mode0["arm"] == 0x147B1AE60
assert mode0["arm_writer"] == 0x147B1AF33
assert script.count("hbreak *") == 8
assert "condition 7 $tracked_ctx != 0" in script
assert "disable 2" in script and "enable 2" in script
assert "disable 3" in script and "enable 3" in script
assert "disable 5" in script and "enable 5" in script
assert "disable 8" in script and "enable 8" in script
assert "set *(" not in script
print("pma_producer_trace selftest: PASS")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pid", nargs="?", type=int)
parser.add_argument("--output")
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
parser.add_argument("--print-script", action="store_true")
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
selftest()
return 0
pid = args.pid or transition.find_pid()
if not pid:
print("FIFA17.exe not found", file=sys.stderr)
return 2
try:
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
advance.validate_file(
fifa_path,
advance.PINNED_FIFA_SHA256,
advance.FIFA_MODULE,
)
cards_base, cards_path = transition.cards_mapping(pid)
transition.validate_cards(cards_path)
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
script = build_script(pid, fifa_base, output, args.variant)
environment = effective_environment(pid)
print(
"PMAPRODUCER PREPARED "
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
)
except (OSError, RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 2
if args.print_script:
print(script, end="")
return 0
if not shutil.which("gdb"):
print("gdb not found", file=sys.stderr)
return 2
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
Path(script_path).write_text(script, encoding="utf-8")
import os
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
return 127
if __name__ == "__main__":
raise SystemExit(main())
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Dump the CLASSIFICATION fields the client stored for every card it holds, so
the subtype->cardtype map and the itemState runtime values are read from the
running game instead of inferred.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
Two things this project has repeatedly had to treat as INFERRED:
1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra
(0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio,
8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480`
branches on cardtype == 7. Serving a subtype whose cardtype we guessed
wrong fails SILENTLY, because cardtype 9 has no arm in the merge.
2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME
values the strings deserialize to (notably activeHomeKit/activeAwayKit ->
101/102) have been carried as inferred.
Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the
derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading
those back for every record turns the pair into measurements.
rec+0x18 resourceId
rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid)
rec+0x50 cardsubtypeid (as sent)
rec+0x5c itemState (decoded enum value)
Usage: python3 record_vocab_probe.py
"""
import collections
import sys
import watch_club_model as W
import card_identity_probe as P
F_RESOURCE = 0x18
F_CARDTYPE = 0x4C
F_SUBTYPE = 0x50
F_ITEMSTATE = 0x5C
REC_SIZE = 0x158
# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud.
EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4,
9: 7, 10: 7, 11: 7}
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
pairs = collections.Counter()
states = collections.Counter()
rows = []
for n in ns:
buf = mem.read(n + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
resource = P.u32(buf, F_RESOURCE)
cardtype = P.u8(buf, F_CARDTYPE)
subtype = P.u8(buf, F_SUBTYPE)
state = P.u8(buf, F_ITEMSTATE)
pairs[(subtype, cardtype)] += 1
states[state] += 1
rows.append((resource, subtype, cardtype, state))
print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState"))
for r in sorted(rows):
print("%-12d %-9d %-9d %d" % r)
print("\n--- MEASURED cardsubtypeid -> cardtype ---")
for (sub, ct), n in sorted(pairs.items()):
want = EXPECTED_CARDTYPE.get(sub)
if want is None:
verdict = "no Ghidra prediction for this subtype"
elif want == ct:
verdict = "agrees with FUN_1800d8330"
else:
verdict = "DISAGREES -- Ghidra said %d" % want
print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict))
print("\n--- MEASURED itemState runtime values ---")
for st, n in sorted(states.items()):
print(" %-5d %d record(s)" % (st, n))
print("\nNOTE: a runtime value only appears here if the client was actually")
print("served an item in that state. Absence is not evidence of absence.")
return 0
if __name__ == "__main__":
sys.exit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Dump CardsDLL's 45-row route table from the ON-DISK PE. READ-ONLY, static.
The transfer-market analysis locates the table at .rdata 0x18021df80 as
{char*, char*} rows. This resolves VA->file offset properly through the PE section
table rather than assuming a single .text mapping, then prints every row so we can
see whether any route other than `tradePile` could own a trade-pile ITEM list.
"""
import struct, sys
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
TABLE_VA = 0x18021DF80
MAX_ROWS = 64
pe = open(DLL, "rb").read()
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
assert pe[e_lfanew:e_lfanew + 4] == b"PE\0\0", "not a PE"
coff = e_lfanew + 4
nsec, opt_size = struct.unpack_from("<HH", pe, coff + 2), None
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from("<H", pe, opt)[0]
assert magic == 0x20B, "expected PE32+"
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
sec_off = opt + opt_size
sections = []
for i in range(num_sections):
b = sec_off + i * 40
name = pe[b:b + 8].rstrip(b"\0").decode("ascii", "replace")
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
sections.append((name, vaddr, vsize, rawptr, rawsize))
print("image_base=%#x sections=%d" % (image_base, num_sections))
for s in sections:
print(" %-8s rva=%#010x vsize=%#x rawptr=%#010x rawsize=%#x" % s)
def va2off(va):
rva = va - image_base
for name, vaddr, vsize, rawptr, rawsize in sections:
if vaddr <= rva < vaddr + max(vsize, rawsize):
off = rva - vaddr + rawptr
if off < len(pe):
return off
return None
def cstr(va, limit=96):
off = va2off(va)
if off is None:
return None
end = pe.find(b"\0", off, off + limit)
if end < 0:
return None
try:
return pe[off:end].decode("ascii")
except UnicodeDecodeError:
return None
base = va2off(TABLE_VA)
print("\nroute table VA %#x -> file offset %s" % (TABLE_VA, hex(base) if base else None))
assert base, "table VA did not resolve"
print("\n%-4s %-34s %s" % ("#", "field A", "field B"))
rows = 0
for i in range(MAX_ROWS):
a_va, b_va = struct.unpack_from("<QQ", pe, base + i * 16)
a, b = cstr(a_va), cstr(b_va)
if a is None and b is None:
print("-- table ends after %d rows --" % rows)
break
print("%-4d %-34s %s" % (i, repr(a), repr(b)))
rows += 1
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Scan for FIFA17 match-team records by the invariant header prefix.
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
per-record marker whose value varies between sessions (-1 on 2026-08-24,
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
scan_mt.py [pid]
"""
import glob
import os
import re
import struct
import sys
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
return int(os.path.basename(d))
except OSError:
pass
return None
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
if not pid:
print(" no FIFA17.exe")
raise SystemExit(2)
mem = open(f"/proc/{pid}/mem", "rb", 0)
found = []
for line in open(f"/proc/{pid}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
if not m or m.group(3)[0] != "r":
continue
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
continue
try:
mem.seek(lo)
buf = mem.read(hi - lo)
except (OSError, ValueError, OverflowError):
continue
i = buf.find(PAT)
while i >= 0:
rec = buf[i:i + 0x80]
if len(rec) >= 0x80:
tid = struct.unpack_from("<i", rec, 0x14)[0]
m18 = struct.unpack_from("<i", rec, 0x18)[0]
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
xi = list(struct.unpack_from("<11i", rec, 0x20))
subs = list(struct.unpack_from("<12i", rec, 0x4c))
found.append((lo + i, tid, m18, m1c, xi, subs))
i = buf.find(PAT, i + 4)
print(f" pid={pid} {len(found)} match-team record(s)")
for addr, tid, m18, m1c, xi, subs in found:
print(f"\n @0x{addr:x}")
print(f" +0x14 teamId = {tid}")
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
print(f" XI = {xi}")
print(f" subs = {subs}")
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Resolve the runtime string comparator behind `DAT_1802ddfd8 + 0x248`, and
settle whether the `itemState` match is case-sensitive.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`itemState` arrives on the wire as a STRING ("free", "activeHomeKit", ...) and
the client turns it into its runtime enum by comparing that string against its
own table. The compare goes through `FUN_180008190`, whose whole body is:
mov rax, [DAT_1802ddfd8] ; the service object, populated at runtime
mov r9, [rax + 0x248] ; slot 0x248
jmp r9 ; tail-jump
The slot is empty on disk, so `plan-2026-08-06-card-subsystem.md` section 5
recorded the casing question as "almost certainly unresolvable statically" and
listed this as a read-only live probe. It is worth answering: every shaper in
openfut-adapter-fifa17 emits these tokens, and if the comparator folded case then
our table's casing would be a convention rather than a contract.
WHAT IT DOES
------------
Reads the slot in the live process and follows the forwarding chain
(`e9` rel32 thunk -> `ff 25` IAT jump -> body), attributing each hop to a module.
Wine maps PE images as anonymous, so a mapping's own path is usually empty; the
module is recovered from the nearest PRECEDING named mapping, which is the PE
header page.
At the body it decides case sensitivity from the instruction stream rather than
from a symbol name: a case-insensitive comparator MUST fold case, so it carries
an `or ..,0x20` / lowercase-table lookup. A byte compare with no folding is
case-SENSITIVE.
MEASURED 2026-08-21 (pid 6580):
slot -> 0x146d1c020 (thunk) -> 0x145e27fe0 (IAT) -> msvcr120.dll + 0x3c330
body is strncmp: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` (NUL) /
`cmp al,[rcx+rdx]` with NO case folding, plus the MSVC NUL-detect constants
0x8080808080808080 and 0xfefefefefefefeff.
=> the itemState match is CASE-SENSITIVE. Emit the table's exact casing.
Usage: python3 service_ptr_probe.py
"""
import re
import struct
import sys
import watch_club_model as W
DAT_SERVICE = 0x1802DDFD8
SLOT = 0x248
MAX_HOPS = 8
# A case-insensitive comparator has to fold case somewhere. These are the two
# ways MSVC does it; neither appears in a plain strcmp/strncmp/memcmp.
FOLD_OR_IMM8 = b"\x0c\x20" # or al, 0x20
FOLD_OR_EAX = b"\x83\xc8\x20" # or eax, 0x20
def mappings(pid):
out = []
with open("/proc/%d/maps" % pid) as fh:
for line in fh:
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
if m:
out.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)))
return out
def attribute(maps, va):
"""(module_path, perms, offset_from_module_base) for `va`.
Wine maps PE sections anonymously, so the owning mapping usually has no
path; the module is the nearest preceding NAMED mapping (its header page).
"""
named = None
for start, end, perms, path in maps:
if path:
named = (start, path)
if start <= va < end:
if named:
return named[1], perms, va - named[0]
return path or "[anonymous]", perms, None
return None, None, None
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
maps = mappings(pid)
glob = base + (DAT_SERVICE - W.IMG_BASE)
svc = mem.q(glob)
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
print("DAT_1802ddfd8 @ %#x -> service %#x" % (glob, svc or 0))
if not svc:
print("service pointer is NULL; the host has not handed CardsDLL its table yet.")
return 2
va = mem.q(svc + SLOT)
print("*(service + %#x) = %#x" % (SLOT, va or 0))
if not va:
print("slot %#x is empty." % SLOT)
return 2
print()
body = None
for hop in range(MAX_HOPS):
buf = mem.read(va, 16)
if not buf or len(buf) < 6:
print("hop %d: %#x unreadable" % (hop, va))
return 2
path, perms, off = attribute(maps, va)
where = "%s+%#x" % (path, off) if off is not None else str(path)
print("hop %d: %#x [%s] %s %s" % (hop, va, perms, where, buf[:8].hex()))
if buf[0] == 0xE9: # jmp rel32
va = va + 5 + struct.unpack("<i", buf[1:5])[0]
elif buf[0] == 0xFF and buf[1] == 0x25: # jmp [rip+rel32]
nxt = mem.q(va + 6 + struct.unpack("<i", buf[2:6])[0])
if not nxt:
print(" IAT slot is empty.")
return 2
va = nxt
else:
body = (va, path, off)
print(" -> function body")
break
if body is None:
print("chain did not settle within %d hops." % MAX_HOPS)
return 2
addr, path, off = body
code = mem.read(addr, 256) or b""
folds = FOLD_OR_IMM8 in code or FOLD_OR_EAX in code
print()
print("=" * 70)
print("COMPARATOR: %s+%#x (%#x)" % (path, off if off is not None else 0, addr))
print("case folding in first %d bytes: %s" % (len(code), "YES" if folds else "NO"))
if folds:
print("VERDICT: case-INSENSITIVE. itemState casing is a convention, not a contract.")
else:
print("VERDICT: case-SENSITIVE. A byte compare with no folding means the")
print(" wire token must match the table's casing EXACTLY -- a")
print(" mis-cased token silently resolves to itemState 0 (invalid).")
print(" openfut-adapter-fifa17's fut::item_state table is therefore")
print(" a contract: emit its casing verbatim.")
print("=" * 70)
return 0
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Pure unit test for the empty-My-Packs store resolver guard in autopatch.py.
Covers the fail-closed guard decision (original -> PATCH, already-patched -> NOOP,
unknown -> SKIP) and pins the guarded patch table to the exact RVA/bytes proven on
the tested FIFA 17 build (JNZ 0x14869 -> JG 0x14869 at CardsDLL RVA 0x14858).
Run: python3 test_autopatch_guard.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import autopatch # importable: runtime loop is guarded by `if __name__ == "__main__"`
GUARD_VA = 0x180014858
ORIG = bytes.fromhex("750f") # JNZ 0x14869
PATCH = bytes.fromhex("7f0f") # JG 0x14869
def test_table_exact():
assert autopatch.STORE_PATCHES_GUARDED == {GUARD_VA: (ORIG, PATCH)}, \
autopatch.STORE_PATCHES_GUARDED
# Byte-level pin so a bad hex literal cannot slip through.
assert ORIG == b"\x75\x0f" and PATCH == b"\x7f\x0f"
def test_decision():
assert autopatch.guarded_action(ORIG, ORIG, PATCH) == "patch" # apply
assert autopatch.guarded_action(PATCH, ORIG, PATCH) == "noop" # already patched
assert autopatch.guarded_action(b"\x00\x00", ORIG, PATCH) == "skip" # build mismatch
assert autopatch.guarded_action(b"\x90", ORIG, PATCH) == "skip" # wrong length
def test_guard_state_after():
# already patched (7f0f) -> VERIFIED (guarded_action "noop"); write args irrelevant.
assert autopatch.guard_state_after(PATCH, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
# original (750f) + write ok + reread 7f0f -> VERIFIED (guarded_action "patch").
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
# original + write FAILS -> WRITE_FAILED.
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, False, ORIG) == autopatch.GUARD_WRITE_FAILED
# original + write ok but reread != 7f0f -> VERIFY_FAILED.
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, ORIG) == autopatch.GUARD_VERIFY_FAILED
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, b"") == autopatch.GUARD_VERIFY_FAILED
# unknown bytes -> UNSUPPORTED_BUILD (guarded_action "skip"); write args irrelevant.
assert autopatch.guard_state_after(b"\x00\x00", ORIG, PATCH, True, PATCH) == autopatch.GUARD_UNSUPPORTED_BUILD
def test_capability_constants():
assert autopatch.EMPTY_MYPACKS_RESOLVER_VERSION == 1
assert autopatch.EMPTY_MYPACKS_RESOLVER_CAPABILITY == "fifa17.empty_mypacks_resolver"
# State constant values are the exact tokens carried in the emitted status line.
assert autopatch.GUARD_VERIFIED == "VERIFIED"
assert autopatch.GUARD_UNSUPPORTED_BUILD == "UNSUPPORTED_BUILD"
assert autopatch.GUARD_WRITE_FAILED == "WRITE_FAILED"
assert autopatch.GUARD_VERIFY_FAILED == "VERIFY_FAILED"
assert autopatch.GUARD_NOT_ATTEMPTED == "NOT_ATTEMPTED"
if __name__ == "__main__":
test_table_exact()
test_decision()
test_guard_state_after()
test_capability_constants()
print("OK: autopatch guard table + fail-closed decision + guard-state function + capability constants")
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
The additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is
suppressed for ONE FIFA session only when the launcher has registered a verified
resolver capability (v1) that binds to THAT process's UTAS session (keyed by the
per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure /
unknown / late / cross-process / cross-session case is fail-closed to the sentinel.
The initial prototype keyed by source IP alone; this suite proves the hardened
per-session binding, including two sessions that SHARE a source IP.
Matrix (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md):
A no-capability, zero packs -> sentinel
B verified v1, zero packs -> clean (no 65534)
C real unopened pack + no capability -> genuine pack, no sentinel
D real unopened pack + capability -> genuine pack, no sentinel
E unsupported version / capability -> endpoint 400 AND mode sentinel
F late capability after sentinel freeze -> stays sentinel
G capability disappears after clean freeze -> stays clean (immutable)
H two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
I new session after reset -> fresh unpatched -> sentinel
J autopatch mismatch => never registers -> sentinel
K SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
L SAME IP+persona relaunch (old ok, new not) -> new session sentinel
M SAME IP, failed-patch second session -> first clean, second sentinel
N late registration when sessions are frozen -> does not modify active sessions
O session cleanup / TTL expiry -> capability gone, sentinel
P duplicate registration for a session -> idempotent; no post-freeze change
Q register-before-login (pending consumed) -> clean
R topology freeze immutable per SID -> no flip either way; new SID fresh
Standalone unit test in the project style: `python3 test_capability_negotiation.py`.
"""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
SENTINEL_ID = 65534
REAL_PACK_ID = 1
PERSONA = 111001
class _H:
"""Minimal request-handler stand-in: peer IP, optional X-UT-SID, optional body."""
def __init__(self, ip, body=None, sid=None):
self.client_address = (ip, 54321)
self.headers = {"X-UT-SID": sid} if sid is not None else {}
self._body = json.dumps(body).encode("utf-8") if body is not None else b""
def _ids(catalog):
return [p["id"] for p in catalog["purchase"]]
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
us = utas_server
CLEAN, SENT = us.FIFA17_MODE_CLEAN, us.FIFA17_MODE_SENTINEL
_orig_visible = us.visible_unopened_packs
def set_zero_packs():
us.visible_unopened_packs = lambda: []
def set_real_pack():
us.visible_unopened_packs = lambda: [REAL_PACK_ID]
def reset_state():
us._FIFA17_SESSIONS.clear()
us._FIFA17_PENDING.clear()
def auth(sid, ip, persona=PERSONA):
"""Simulate /ut/auth opening a per-login session with a chosen sid."""
us.fifa17_open_session(sid, ip, persona)
def register(ip, version, persona=PERSONA, pid=4242):
return us.fifa17_capability_route(_H(ip, {
"capability": "empty_mypacks_resolver", "version": version,
"personaId": persona, "fifaPid": pid,
}))
def store(sid, ip):
status, cat = us.store_catalog(_H(ip, sid=sid))
assert status == 200, status
return _ids(cat)
def mode_of(sid):
return us._FIFA17_SESSIONS[sid]["mode"]
try:
# ---- A. no capability, zero packs -> sentinel ----------------------
reset_state(); set_zero_packs()
auth("sidA", "10.0.0.1")
assert SENTINEL_ID in store("sidA", "10.0.0.1")
assert mode_of("sidA") == SENT
print("A no-capability zero-packs -> sentinel: OK")
# ---- B. verified v1, zero packs -> clean ---------------------------
reset_state(); set_zero_packs()
auth("sidB", "10.0.0.2")
assert register("10.0.0.2", 1)[0] == 200
ids = store("sidB", "10.0.0.2")
assert SENTINEL_ID not in ids, ids
assert mode_of("sidB") == CLEAN
print("B verified-v1 zero-packs -> clean: OK")
# ---- C. real pack + no capability -> genuine, no sentinel ----------
reset_state(); set_real_pack()
auth("sidC", "10.0.0.3")
ids = store("sidC", "10.0.0.3")
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
print("C real-pack no-capability -> genuine, no sentinel: OK")
# ---- D. real pack + capability -> genuine, no sentinel -------------
reset_state(); set_real_pack()
auth("sidD", "10.0.0.4"); register("10.0.0.4", 1)
ids = store("sidD", "10.0.0.4")
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
print("D real-pack capability -> genuine, no sentinel: OK")
# ---- E. unsupported version / capability -> 400 + sentinel ---------
reset_state(); set_zero_packs()
auth("sidE", "10.0.0.5")
assert register("10.0.0.5", 2)[0] == 400
assert register("10.0.0.5", 99)[0] == 400
assert us.fifa17_capability_route(
_H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400
assert SENTINEL_ID in store("sidE", "10.0.0.5")
assert mode_of("sidE") == SENT
print("E unsupported version/capability -> 400 + sentinel: OK")
# ---- F. late capability after sentinel freeze -> sentinel ----------
reset_state(); set_zero_packs()
auth("sidF", "10.0.0.6")
assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel
assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late
assert SENTINEL_ID in store("sidF", "10.0.0.6")
assert mode_of("sidF") == SENT
print("F late capability after sentinel freeze -> sentinel: OK")
# ---- G. capability disappears after clean freeze -> clean ----------
reset_state(); set_zero_packs()
auth("sidG", "10.0.0.7"); register("10.0.0.7", 1)
assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean
us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes
assert SENTINEL_ID not in store("sidG", "10.0.0.7")
assert mode_of("sidG") == CLEAN
print("G capability disappears after clean freeze -> clean: OK")
# ---- H. two IPs (A verified, B none) -> no global leak -------------
reset_state(); set_zero_packs()
auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1)
auth("sidH2", "10.0.1.2")
assert SENTINEL_ID not in store("sidH1", "10.0.1.1")
assert SENTINEL_ID in store("sidH2", "10.0.1.2")
print("H two IPs (A clean, B sentinel) -> no global leak: OK")
# ---- I. new session after reset -> fresh unpatched -> sentinel -----
reset_state(); set_zero_packs()
auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1)
assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean
us.fifa17_clear_pending("10.0.1.3") # relaunch boundary
auth("sidI2", "10.0.1.3") # new SID, autopatch failed
assert SENTINEL_ID in store("sidI2", "10.0.1.3")
print("I new session after reset -> sentinel (no cross-process leak): OK")
# ---- J. autopatch mismatch => never registers -> sentinel ----------
reset_state(); set_zero_packs()
auth("sidJ", "10.0.1.4")
assert SENTINEL_ID in store("sidJ", "10.0.1.4")
print("J autopatch mismatch (never registers) -> sentinel: OK")
# ---- K. SAME IP, two sessions: patched A clean, unpatched B sent ---
reset_state(); set_zero_packs()
IP = "10.0.2.1"
auth("sidK_A", IP)
assert register(IP, 1)[0] == 200 # A sole candidate -> bound
auth("sidK_B", IP) # B joins, never registers
assert SENTINEL_ID not in store("sidK_A", IP)
assert SENTINEL_ID in store("sidK_B", IP)
print("K same-IP two sessions -> A clean, B sentinel: OK")
# ---- L. SAME IP+persona relaunch: old ok, new not -> new sentinel --
reset_state(); set_zero_packs()
IP = "10.0.2.2"
auth("sidL_old", IP, PERSONA); register(IP, 1, PERSONA)
assert SENTINEL_ID not in store("sidL_old", IP)
us.fifa17_clear_pending(IP)
auth("sidL_new", IP, PERSONA) # same persona, unverified
assert SENTINEL_ID in store("sidL_new", IP)
print("L same-IP+persona relaunch -> new session sentinel: OK")
# ---- M. SAME IP, failed-patch second session -----------------------
reset_state(); set_zero_packs()
IP = "10.0.2.3"
auth("sidM1", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidM1", IP)
auth("sidM2", IP) # autopatch failed
assert SENTINEL_ID in store("sidM2", IP)
print("M same-IP failed-patch second session -> sentinel: OK")
# ---- N. late reg when sessions frozen -> no active session change --
reset_state(); set_zero_packs()
IP = "10.0.2.4"
auth("sidN1", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidN1", IP) # N1 frozen clean
auth("sidN2", IP)
assert SENTINEL_ID in store("sidN2", IP) # N2 frozen sentinel
assert register(IP, 1)[0] == 200 # late: both frozen -> ignored
assert SENTINEL_ID not in store("sidN1", IP) # unchanged
assert SENTINEL_ID in store("sidN2", IP) # unchanged
print("N late registration does not modify active sessions: OK")
# ---- O. session cleanup / TTL expiry -> capability gone ------------
reset_state(); set_zero_packs()
IP = "10.0.2.5"
auth("sidO", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidO", IP) # clean while live
us._FIFA17_SESSIONS["sidO"]["last_seen"] = (
us._fifa17_now() - us.FIFA17_SESSION_TTL - 10.0)
store("sidUNKNOWN", IP) # any op triggers reap
assert "sidO" not in us._FIFA17_SESSIONS, "expired session not reaped"
assert SENTINEL_ID in store("sidO", IP) # gone -> sentinel
print("O session cleanup / TTL expiry -> sentinel: OK")
# ---- P. duplicate registration -> idempotent, no post-freeze change
reset_state(); set_zero_packs()
IP = "10.0.2.6"
auth("sidP", IP)
assert register(IP, 1)[0] == 200 # bound
assert register(IP, 1)[0] == 200 # duplicate -> ignored-late
assert SENTINEL_ID not in store("sidP", IP) # still clean
assert register(IP, 1)[0] == 200 # after freeze
assert SENTINEL_ID not in store("sidP", IP) # unchanged
assert mode_of("sidP") == CLEAN
print("P duplicate registration -> idempotent: OK")
# ---- Q. register-before-login: pending consumed at auth -> clean ---
reset_state(); set_zero_packs()
IP = "10.0.2.7"
assert register(IP, 1)[0] == 200 # no session yet -> pending
assert (IP, PERSONA) in us._FIFA17_PENDING
auth("sidQ", IP, PERSONA) # consumes pending
assert (IP, PERSONA) not in us._FIFA17_PENDING # single-use
assert SENTINEL_ID not in store("sidQ", IP)
assert mode_of("sidQ") == CLEAN
print("Q register-before-login pending consumed -> clean: OK")
# ---- R. topology freeze immutable per SID; new SID decides fresh ----
# F3 invariant: once a SID's store topology is decided it NEVER flips,
# in either direction, and a different SID may decide differently.
reset_state(); set_zero_packs()
IP = "10.0.2.8"
# frozen Sentinel never becomes Clean, even if a capability appears later
auth("sidR_s", IP)
assert SENTINEL_ID in store("sidR_s", IP) # freeze Sentinel
register(IP, 1)
us._FIFA17_SESSIONS["sidR_s"]["resolver"] = 1 # force-present capability
assert SENTINEL_ID in store("sidR_s", IP) # STILL Sentinel
assert mode_of("sidR_s") == SENT
# frozen Clean never becomes Sentinel, even if the capability is wiped
auth("sidR_c", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidR_c", IP) # freeze Clean
us._FIFA17_SESSIONS["sidR_c"]["resolver"] = None # capability vanishes
assert SENTINEL_ID not in store("sidR_c", IP) # STILL Clean
assert mode_of("sidR_c") == CLEAN
# a fresh SID (same IP) decides independently
auth("sidR_new", IP)
assert SENTINEL_ID in store("sidR_new", IP)
print("R topology freeze immutable per SID; new SID fresh: OK")
finally:
us.visible_unopened_packs = _orig_visible
print("capability negotiation matrix A-R: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c).
Pins the behavior store_catalog() now depends on:
- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534
- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown
- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting)
- normal store packs (1/5/6/7) are untouched by the empty-state behavior
See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
Standalone unit test in the project style: `python3 test_empty_mypacks.py`.
"""
import importlib
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
SENTINEL_ID = 65534
def _set_unopened(fut_store, ids):
"""Deterministically set the active profile's owned unopened packs."""
p = fut_store.STORE.load()
p["unopenedPackIds"] = list(ids)
fut_store.STORE._save()
def _mypacks(catalog):
return [p for p in catalog["purchase"]
if (p.get("displayGroup") or {}).get("value") == "mypacks"]
def _fake_request(command, body):
class _H:
pass
h = _H()
h.command = command
h._body = body
return h
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG]
# ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ----
_set_unopened(fut_store, [])
utas_server._OPENED_PACK_GRACE.clear()
status, cat = utas_server.store_catalog(None)
assert status == 200
myp = _mypacks(cat)
assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp
s = myp[0]
assert s["id"] == SENTINEL_ID, s
assert s["state"] == "active", s # the P2 fix: active, not inactive
assert (s.get("displayGroup") or {}).get("value") == "mypacks", s
assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG"
assert fut_store.pack_by_id(SENTINEL_ID) is None
print("A empty-state active placeholder: PASS")
# ---- D (empty half). Normal packs untouched in empty state ----
norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)}
assert set(norm) == {1, 5, 6, 7}, sorted(norm)
assert all(norm[i]["state"] == "active" for i in norm), norm
assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack"
# ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ----
_set_unopened(fut_store, [70])
utas_server._OPENED_PACK_GRACE.clear()
status, cat = utas_server.store_catalog(None)
assert status == 200
ids = [p["id"] for p in cat["purchase"]]
assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists"
myp = _mypacks(cat)
assert len(myp) == 1 and myp[0]["id"] == 70, myp
assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0]
# normal packs still intact alongside the owned pack
assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids)
print("B non-empty-state genuine pack: PASS")
# ---- C. Economy safety of the synthetic placeholder ----
_set_unopened(fut_store, [])
utas_server._OPENED_PACK_GRACE.clear()
coins0 = fut_store.STORE.coins()
items0 = len(fut_store.STORE.items())
next0 = fut_store.STORE.load()["nextItemId"]
assert fut_store.pack_by_id(SENTINEL_ID) is None
# store_buy: a confirmed-buy transaction for 65534 must be a no-op {}
status, body = utas_server.store_buy(
_fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}'))
assert status == 200 and body == {}, (status, body)
# purchased_items: POST buy for 65534 must not open/grant anything
status, body = utas_server.purchased_items(
_fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'))
assert status == 200, (status, body)
assert "createPackResponse" not in body, body
# 65534 cannot enter the owned-pack pile (not a catalog pack)
assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False
assert SENTINEL_ID not in fut_store.STORE.unopened_packs()
# nothing mutated
assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0)
assert len(fut_store.STORE.items()) == items0
assert fut_store.STORE.load()["nextItemId"] == next0
assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID
for i in fut_store.STORE.items())
print("C economy safety (65534 non-openable / non-granting): PASS")
print("empty My Packs compatibility: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Standalone contract test for Blaze roster-host advertisement."""
import importlib
import os
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
ADVERTISE = "192.0.2.10"
DNS_HOST = "winter15.gosredirector.ea.com:8081"
def assert_roster_config(blaze, host):
config = dict(blaze.OSDK_ROSTER)
assert blaze.ROSTER_HOST == host
assert config["ROSTERUPDATE_URL"] == (
f"https://{host}/fifa17/fut/rosterupdate.xml"
)
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
assert config["ROSTER_VER"] == "0"
assert config["ROSTER_CSUM"] == ""
def main():
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
try:
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ.pop("OPENFUT_ROSTER_HOST", None)
import blaze_responder_v3b as blaze
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
blaze = importlib.reload(blaze)
assert_roster_config(blaze, DNS_HOST)
os.environ["OPENFUT_ROSTER_HOST"] = ""
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
finally:
if old_advertise is None:
os.environ.pop("OPENFUT_ADVERTISE", None)
else:
os.environ["OPENFUT_ADVERTISE"] = old_advertise
if old_roster_host is None:
os.environ.pop("OPENFUT_ROSTER_HOST", None)
else:
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
print("PASS: roster host defaults, override, and URLs")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Supervise one hardware-only FIFA17 match-team writer capture.
This is the robust fresh-client entry point. It waits for the largest-RSS
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
can construct match teams, and loads a hardware-only GDB Python payload.
The concurrent read-only structural locator proves when the fixture and final
match-team records exist. A zero-hit result is trusted only if gdb is still
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
records pre-existed the trace, and two final records then appeared.
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
watchpoint from live RDX. Other payloads trace the final engine writer or its
caller; all expose the same `start_trace(log, cards_base)` entry point.
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
'rb'. The operator alone drives the game.
trace_match_team_writer.py --status /tmp/mt-status.json \
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
from dataclasses import asdict
from pathlib import Path
from offline_match_locator import find_pids, scan_process
CARDS_IMAGE_BASE = 0x180000000
DEFAULT_TIMEOUT = 45 * 60
def cards_base(pid: int) -> int | None:
try:
with open(f"/proc/{pid}/maps") as maps:
for line in maps:
if "CardsDLL_Win64_retail.dll" in line:
return int(line.split("-", 1)[0], 16)
except OSError:
pass
return None
def tracer_pid(pid: int) -> int | None:
try:
with open(f"/proc/{pid}/status") as status:
for line in status:
if line.startswith("TracerPid:"):
return int(line.split()[1])
except OSError:
pass
return None
def target_state(pid: int) -> str | None:
try:
with open(f"/proc/{pid}/status") as status:
for line in status:
if line.startswith("State:"):
return line.split()[1]
except OSError:
pass
return None
def read_events(path: Path) -> list[dict]:
if not path.exists():
return []
events = []
try:
with path.open(encoding="utf-8", errors="replace") as handle:
for line in handle:
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
return []
return events
def event_counts(events: list[dict]) -> dict[str, int]:
counts: dict[str, int] = {}
for event in events:
kind = event.get("event", "unknown")
counts[kind] = counts.get(kind, 0) + 1
return counts
class Status:
def __init__(self, path: Path, monitor_log: Path):
self.path = path
self.monitor_log = monitor_log
self.data: dict = {"started_unix": time.time(), "state": "starting"}
self.write()
def write(self, **updates):
self.data.update(updates)
self.data["updated_unix"] = time.time()
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
os.replace(temporary, self.path)
def log(self, message: str, **payload):
record = {"time_unix": time.time(), "message": message, **payload}
with self.monitor_log.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
print(message, flush=True)
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
# Wine uses these signals for thread suspension/runtime plumbing. They must
# pass through, or batch gdb stops and silently detaches.
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
f"SIG{number}" for number in range(32, 40)
]
lines = [
"set confirm off",
"set pagination off",
"set height 0",
"set width 0",
f"attach {pid}",
]
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
lines.extend(
[
f"source {payload}",
f'python start_trace({json.dumps(str(trace))}, {cards})',
"continue",
]
)
return "\n".join(lines) + "\n"
def serialise_locations(locations: dict) -> dict:
return {key: [asdict(value) for value in values] for key, values in locations.items()}
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=12)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
deadline = time.time() + 8
while time.time() < deadline and tracer_pid(pid):
time.sleep(0.25)
status.log(
"gdb detached",
gdb_returncode=process.returncode,
tracer_pid=tracer_pid(pid),
target_state=target_state(pid),
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--status", type=Path, required=True)
parser.add_argument("--trace", type=Path, required=True)
parser.add_argument("--gdb-log", type=Path, required=True)
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
parser.add_argument("--fixture-index", type=int, default=0)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--post-record-wait", type=int, default=12)
parser.add_argument(
"--arm-check-seconds",
type=int,
default=0,
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
)
parser.add_argument(
"--wait-for-record-clear",
action="store_true",
help="keep tracing through abandon; accept creation only after old records disappear",
)
parser.add_argument(
"--exclude-pid",
action="append",
type=int,
default=[],
help="ignore an existing FIFA process and attach only after process replacement",
)
parser.add_argument(
"--payload",
default="gdb_match_team_writer_trace.py",
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
)
args = parser.parse_args()
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
path.parent.mkdir(parents=True, exist_ok=True)
for path in (args.trace, args.gdb_log, args.monitor_log):
path.unlink(missing_ok=True)
status = Status(args.status, args.monitor_log)
payload = Path(__file__).with_name(args.payload).resolve()
if not payload.exists():
status.write(state="failed", error=f"missing gdb payload: {payload}")
return 2
deadline = time.time() + args.timeout
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
status.log(
"waiting for FIFA17.exe with CardsDLL",
excluded_pids=args.exclude_pid,
)
pid = None
cards = None
while time.time() < deadline:
# UMU/Proton creates a short-lived small FIFA17.exe before the real
# client. Never bind to the first comm match. Require CardsDLL and prefer
# the largest-RSS process (find_pids is ordered that way).
for candidate in find_pids():
if candidate in args.exclude_pid:
continue
candidate_cards = cards_base(candidate)
if candidate_cards:
pid, cards = candidate, candidate_cards
break
if pid:
break
time.sleep(0.25)
if not pid or not cards:
status.write(state="timed_out", phase="ready_process")
return 3
status.write(state="ready_process_found", pid=pid, cards_base=cards)
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
process = subprocess.Popen(
["gdb", "-q", "-nx", "-x", str(command_path)],
stdout=gdb_handle,
stderr=subprocess.STDOUT,
text=True,
)
status.write(
state="attaching",
pid=pid,
cards_base=cards,
cards_image_base=CARDS_IMAGE_BASE,
gdb_pid=process.pid,
gdb_command_file=str(command_path),
payload=args.payload,
hardware_only=True,
client_memory_writes=False,
)
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
armed = False
arm_deadline = min(deadline, time.time() + 60)
while time.time() < arm_deadline:
if process.poll() is not None:
break
events = read_events(args.trace)
if any(event.get("event") == "trace_armed" for event in events):
armed = True
break
time.sleep(0.25)
if not armed:
gdb_handle.close()
status.write(
state="failed",
phase="arm",
gdb_returncode=process.poll(),
tracer_pid=tracer_pid(pid),
trace_events=event_counts(read_events(args.trace)),
)
if process.poll() is None:
terminate_gdb(process, status, pid)
return 4
attached = tracer_pid(pid) == process.pid
status.write(
state="armed",
tracer_pid=tracer_pid(pid),
target_state=target_state(pid),
trace_events=event_counts(read_events(args.trace)),
execution_breakpoints_armed=True,
team1_watchpoint_armed=False,
)
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
if not attached:
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="failed", phase="attach_verification")
return 4
if args.arm_check_seconds > 0:
time.sleep(args.arm_check_seconds)
events = read_events(args.trace)
counts = event_counts(events)
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
terminate_gdb(process, status, pid)
gdb_handle.close()
passed = (
still_attached
and counts.get("trace_armed", 0) == 1
and counts.get("trace_error", 0) == 0
and tracer_pid(pid) == 0
and target_state(pid) != "T"
)
status.write(
state="arm_check_passed" if passed else "arm_check_failed",
trace_events=counts,
attached_before_detach=still_attached,
tracer_pid_after_detach=tracer_pid(pid),
target_state_after_detach=target_state(pid),
)
status.log("arm check complete", passed=passed, trace_events=counts)
return 0 if passed else 5
# A final record that already exists before arming cannot prove execution
# crossed creation under the debugger. Fail closed instead of converting an
# already-built match into a trusted zero-hit result.
initial_heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
records_preexisting = len(initial_heap["match_teams"]) >= 2
records_cleared = not records_preexisting
if records_preexisting and args.wait_for_record_clear:
status.write(
state="waiting_for_record_clear",
locations=serialise_locations(initial_heap),
target_crossed_match_team_creation=False,
)
status.log(
"trace armed; waiting for old match-team records to disappear",
team_ids=[team.team_id for team in initial_heap["match_teams"]],
)
while time.time() < deadline:
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="failed", phase="record_clear")
return 5
heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
if not heap["match_teams"]:
records_cleared = True
status.write(
state="records_cleared",
cleared_unix=time.time(),
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
target_state=target_state(pid),
)
status.log(
"old match-team records disappeared; next records are a fresh creation",
tracer_pid=tracer_pid(pid),
)
break
time.sleep(2)
if not records_cleared:
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(state="timed_out", phase="record_clear")
return 3
elif records_preexisting:
counts = event_counts(read_events(args.trace))
terminate_gdb(process, status, pid)
gdb_handle.close()
status.write(
state="armed_too_late",
phase="preexisting_records",
trace_events=counts,
locations=serialise_locations(initial_heap),
target_crossed_match_team_creation=False,
tracer_pid_after_detach=tracer_pid(pid),
target_state_after_detach=target_state(pid),
)
status.log(
"match-team records pre-existed trace; no writer claim",
team_ids=[team.team_id for team in initial_heap["match_teams"]],
)
return 6
fixture = None
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
last_fixture_scan = 0.0
records_seen_at = None
record_control = None
try:
while time.time() < deadline:
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
status.write(
state="failed",
phase="monitor",
gdb_returncode=process.poll(),
target_exists=Path(f"/proc/{pid}").exists(),
)
return 5
now = time.time()
if fixture is None and now - last_fixture_scan >= 8:
full = scan_process(pid, args.fixture_index, include_fixture=True)
last_fixture_scan = now
if full["fixtures"]:
fixture = full["fixtures"][0]
latest_locations["fixtures"] = full["fixtures"]
status.log(
"fixture located",
address=fixture.address,
selected_address=fixture.selected_address,
selected_index=fixture.selected_index,
selected_team_id=fixture.selected_team_id,
)
heap = scan_process(
pid,
args.fixture_index,
include_fixture=False,
writable_anon_only=True,
)
latest_locations["match_teams"] = heap["match_teams"]
latest_locations["match_configs"] = heap["match_configs"]
events = read_events(args.trace)
counts = event_counts(events)
is_attached = tracer_pid(pid) == process.pid
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
status.write(
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
target_state=target_state(pid),
trace_events=counts,
team1_watchpoint_armed=watch_armed,
locations=serialise_locations(latest_locations),
)
if len(heap["match_teams"]) >= 2:
if records_seen_at is None:
if not is_attached or process.poll() is not None:
status.write(
state="failed",
phase="record_creation_control",
tracer_pid=tracer_pid(pid),
gdb_alive=process.poll() is None,
trace_events=counts,
)
return 5
records_seen_at = now
record_control = {
"gdb_alive": process.poll() is None,
"tracer_pid": tracer_pid(pid),
"attached": is_attached,
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
"team1_watchpoint_armed": watch_armed,
}
status.log(
"two match-team records located",
team_ids=[team.team_id for team in heap["match_teams"]],
trace_events=counts,
**record_control,
)
if now - records_seen_at >= args.post_record_wait:
break
time.sleep(3)
finally:
terminate_gdb(process, status, pid)
gdb_handle.close()
events = read_events(args.trace)
counts = event_counts(events)
final = {
"state": "captured",
"pid": pid,
"cards_base": cards,
"fixture": asdict(fixture) if fixture else None,
"locations": serialise_locations(latest_locations),
"trace_events": counts,
"record_creation_control": record_control,
"gdb_alive_at_record_creation": bool(
record_control and record_control["gdb_alive"] and record_control["attached"]
),
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
"tracer_pid_after_detach": tracer_pid(pid),
"target_state_after_detach": target_state(pid),
"records_preexisting": records_preexisting,
"records_cleared_before_capture": records_cleared,
}
status.write(**final)
status.log("capture complete", **final)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Read the FIFA 17 TRADING gate byte out of the live client. READ-ONLY.
Extends tools/gate_byte_probe.py with vtable slot +0x270 (IS_TRADING_ENABLED,
displacement 0x1fd2e) plus the two pile-size dwords, which the transfer-market
analysis names as the market screen's CardsDLL-supplied inputs.
Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
"""
import os, struct
pid = None
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
pid = int(d)
break
except Exception:
pass
assert pid, "FIFA17.exe not running"
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16)
assert base, "CardsDLL not mapped (client has not reached Ultimate Team)"
slide = base - 0x180000000
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
def rd(va, n):
return os.pread(fd, n, va)
# Control: the FNV atom-hash prologue must match the on-disk PE before any other
# address is trusted.
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
def f(va):
return va - 0x180000000 - 0x1000 + 0x400
ok = pe[f(0x180180d00):f(0x180180d00) + 32] == rd(0x180180d00 + slide, 32)
print("pid=%d slide=%#x FNV control=%s" % (pid, slide, "MATCH" if ok else "MISMATCH"))
assert ok, "slide not proven; refusing to read further"
obj = struct.unpack('<Q', rd(0x1802e6398 + slide, 8))[0]
vt = struct.unpack('<Q', rd(obj, 8))[0]
print("model=%#x vtable(static)=%#x" % (obj, vt - slide))
SLOTS = [
(0x270, 'IS_TRADING_ENABLED '),
(0x2b0, 'IS_FRIENDLY_SEASON '),
(0x2c8, 'IS_DRAFT_MODE '),
(0x2e0, 'packOpeningAnimation '),
]
print("\n-- gate bytes decoded from their accessor stubs --")
for off, name in SLOTS:
slot = struct.unpack('<Q', rd(vt + off, 8))[0]
stub = rd(slot, 8)
if stub[:3] == b'\x0f\xb6\x81':
disp = struct.unpack('<I', stub[3:7])[0]
val = rd(obj + disp, 1)[0]
print(" slot +%#05x %s disp=%#x VALUE=%d" % (off, name, disp, val))
else:
print(" slot +%#05x %s NOT a movzx stub: %s" % (off, name, stub.hex()))
print("\n-- market screen inputs --")
for disp, name in [(0x1fd1c, 'TRADE_PILE_SIZE'), (0x1fd20, 'watchListSize '),
(0x1fd2e, 'tradingEnabled '), (0x1fd2f, 'storeEnabled ')]:
print(" model+%#x %s = %d" % (disp, name, rd(obj + disp, 1)[0]))
os.close(fd)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Enumerate every UTAS URL template CardsDLL can build, from live memory.
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
WHY
---
Support level L5 ("apply endpoint") for consumables was recorded as unreversed,
with an earlier note claiming there is "no training/position/chemistry/
manager-league endpoint at all" and that the only owned-item mutations upstream
are quick sell and move/pile. That claim is load-bearing -- if true, applying a
consumable is not a server route at all and L5/L6 cannot be implemented as one --
so it deserves to be checked against the binary rather than inherited.
This scans CardsDLL's .rdata for route-shaped strings and prints them, so the
full reachable surface can be read at once.
Positive control: known-live routes MUST appear (e.g. a 'item' path and a
'club' path). If the control is empty the region is wrong, not the game.
Usage:
python3 url_template_probe.py # route-shaped strings
python3 url_template_probe.py --all # every printable string >= 6 chars
python3 url_template_probe.py --grep pat # substring filter (case-insensitive)
"""
import argparse
import re
import sys
import watch_club_model as W
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
DATA_LO, DATA_HI = 0x18028A000, 0x1802F0000
# Route-ish: contains a slash and no spaces, or looks like a UTAS path fragment.
ROUTE_HINTS = ("ut/", "game/", "item", "club", "squad", "purchase", "consumable",
"apply", "training", "position", "chemistry", "contract",
"fitness", "healing", "playstyle", "manager", "pile", "delete",
"transfer", "market", "auction", "sbs", "pack", "store")
PRINTABLE = re.compile(rb"[\x20-\x7e]{6,}")
def strings(mem, lo, hi):
buf, bad = mem.read_pages(W_live(lo), hi - lo)
if not buf:
return [], bad
out = []
for m in PRINTABLE.finditer(bytes(buf)):
out.append((lo + m.start(), m.group().decode("ascii")))
return out, bad
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true")
ap.add_argument("--grep")
a = ap.parse_args()
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
global W_live
W_live = lambda i: base + (i - W.IMG_BASE)
print("pid=%d CardsDLL live base %#x" % (pid, base))
found = []
for lo, hi, name in ((RDATA_LO, RDATA_HI, ".rdata"), (DATA_LO, DATA_HI, ".data")):
ss, bad = strings(mem, lo, hi)
print(" %s: %d strings (%d bad pages)" % (name, len(ss), len(bad)))
found.extend(ss)
if a.grep:
pat = a.grep.lower()
sel = [(va, s) for va, s in found if pat in s.lower()]
elif a.all:
sel = found
else:
sel = [(va, s) for va, s in found
if "/" in s and " " not in s
and any(h in s.lower() for h in ROUTE_HINTS)]
print("\n%d matching string(s):" % len(sel))
for va, s in sel:
print(" %#x %s" % (va, s))
ctrl = [s for _, s in found if "ut/game" in s.lower()]
print("\nCONTROL ('ut/game' present): %s (%d)"
% ("OK" if ctrl else "EMPTY -> wrong region", len(ctrl)))
return 0
if __name__ == "__main__":
sys.exit(main())
+257 -21
View File
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import copy, datetime, json, os, random, re, sys, http.server
import copy, datetime, json, os, random, re, sys, threading, time, http.server
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -52,6 +52,173 @@ def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
# ---- FIFA17 empty-My-Packs capability negotiation (PER-SESSION, hardened) ----
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
# suppressed for ONE FIFA session only when the launcher has registered that THAT
# process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
#
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
# /store/purchasegroup (live-confirmed present on real store requests). The initial
# prototype keyed on source IP ALONE; that was rejected because two FIFA processes
# (concurrent or relaunched) share an IP, so an unverified process could inherit a
# verified one's clean topology and crash. IP + persona are retained only as
# auxiliary data: a fail-closed sid/ip sanity check and the (ip,persona) key for the
# short-lived launcher->session hand-off.
#
# The launcher verifies out-of-band (autopatch) and cannot know the SID, so its
# registration is staged as a SINGLE-USE, short-TTL PENDING keyed by (ip,persona)
# and bound to exactly one FIFA session (directly if that session already exists,
# else consumed at the session's login or its first store request). Fail-closed
# everywhere: unknown / expired / absent / ambiguous / late => sentinel.
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§Session binding).
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
FIFA17_MODE_SENTINEL = "sentinel"
FIFA17_MODE_CLEAN = "clean-v1"
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
FIFA17_PENDING_TTL = 120.0 # a launcher capability may await its session this long
# sid -> {"ip","persona","resolver": Optional[int],"mode": Optional[str],"created","last_seen"}
_FIFA17_SESSIONS = {}
# (ip, persona) -> {"resolver": int, "ts"}: single-use launcher->session hand-off.
_FIFA17_PENDING = {}
_FIFA17_LOCK = threading.Lock()
def _fifa17_now():
return time.monotonic()
def _fifa17_client_ip(h):
"""Peer IP for the handler, or None when unavailable (e.g. h is None)."""
try:
return h.client_address[0]
except Exception:
return None
def _fifa17_sid(h):
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
try:
return h.headers.get("X-UT-SID")
except Exception:
return None
def _fifa17_sidlog(sid):
"""A short, non-secret tag for correlating a session in logs."""
return ("\u2026" + sid[-6:]) if sid else "-"
def _fifa17_mint_sid():
"""A fresh, per-login-unique UTAS session id (same shape/length as the legacy
constant). Uniqueness -- not unpredictability -- is what the binding needs."""
return "OPENFUT-SID-%016X" % random.getrandbits(64)
def _fifa17_reap_locked(now):
for sid in [s for s, r in _FIFA17_SESSIONS.items()
if now - r["last_seen"] > FIFA17_SESSION_TTL]:
del _FIFA17_SESSIONS[sid]
for key in [k for k, p in _FIFA17_PENDING.items()
if now - p["ts"] > FIFA17_PENDING_TTL]:
del _FIFA17_PENDING[key]
def _fifa17_take_pending_locked(ip, persona, now):
"""Single-use: remove and return a fresh pending resolver for (ip,persona)."""
p = _FIFA17_PENDING.get((ip, persona))
if p is not None and now - p["ts"] <= FIFA17_PENDING_TTL:
del _FIFA17_PENDING[(ip, persona)]
return p["resolver"]
return None
def fifa17_session_known(sid):
"""True if sid is a live session (or the legacy constant, accepted by the
retired security-question gate ONLY -- never used to grant clean store mode)."""
if sid == SID:
return True
with _FIFA17_LOCK:
return sid in _FIFA17_SESSIONS
def fifa17_open_session(sid, ip, persona):
"""/ut/auth: open a per-login session and bind any pending launcher capability
for (ip,persona) that arrived before login."""
if not sid:
return
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
resolver = _fifa17_take_pending_locked(ip, persona, now)
_FIFA17_SESSIONS[sid] = {"ip": ip, "persona": persona, "resolver": resolver,
"mode": None, "created": now, "last_seen": now}
log("[fifa17-store] session opened %s (ip=%s persona=%s resolver=%s)"
% (_fifa17_sidlog(sid), ip, persona, resolver))
def fifa17_clear_pending(ip):
"""/openfut/account/sync hygiene: drop any stale pending for this machine so a
new launch's unverified session cannot inherit a leftover capability."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
for key in [k for k in _FIFA17_PENDING if k[0] == ip]:
del _FIFA17_PENDING[key]
def fifa17_register_capability(ip, persona, version):
"""Launcher registration. Returns one of:
"bound" exactly one live, unfrozen, unbound session for (ip,persona)
existed (registration after login -- the common case): bound now.
"pending" no session for (ip,persona) yet (before login): staged single-use.
"ignored-late" a session for (ip,persona) exists but is frozen or ambiguous
(>1 unbound): NOT staged, so no later/unverified process can
inherit it. Fail-closed.
Never authorizes more than one session."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
sessions = [r for r in _FIFA17_SESSIONS.values()
if r["ip"] == ip and r["persona"] == persona]
candidates = [r for r in sessions if r["mode"] is None and r["resolver"] is None]
if len(candidates) == 1:
candidates[0]["resolver"] = version
return "bound"
if sessions:
return "ignored-late"
_FIFA17_PENDING[(ip, persona)] = {"resolver": version, "ts": now}
return "pending"
def fifa17_empty_mypacks_mode(sid, ip):
"""Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
Freeze point = the first /store/purchasegroup of the session. Fail-closed: an
unknown session, or a sid presented from a different IP than it was opened on,
resolves to the sentinel."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
rec = _FIFA17_SESSIONS.get(sid)
if rec is None:
return FIFA17_MODE_SENTINEL
rec["last_seen"] = now
if rec["ip"] is not None and ip is not None and rec["ip"] != ip:
log("[fifa17-store] sid %s ip mismatch (session %s != request %s) -> sentinel"
% (_fifa17_sidlog(sid), rec["ip"], ip))
return FIFA17_MODE_SENTINEL
if rec["mode"] is None:
if rec["resolver"] is None:
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
rec["mode"] = (FIFA17_MODE_CLEAN
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
else FIFA17_MODE_SENTINEL)
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
% (_fifa17_sidlog(sid), rec["mode"]))
return rec["mode"]
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -102,7 +269,7 @@ def security_question_route(h):
well-formed value without retaining or comparing it. Account selection has
already initialized the server-owned verified compatibility state.
"""
if h.headers.get("X-UT-SID") != SID:
if not fifa17_session_known(h.headers.get("X-UT-SID")):
log("[FUT] security-question request has no matching OpenFUT session")
return 400, {"reason": "invalid_session"}
@@ -193,11 +360,19 @@ def auth_body(h=None):
except Exception as e: # adoption must never break auth
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
% (type(e).__name__, e, before[0], before[1]))
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
sid = _fifa17_mint_sid()
fifa17_open_session(sid, _fifa17_client_ip(h), ACCOUNT.persona_id)
return {"protocol": 1, "sid": sid, "serverTime": now(), "lastOnlineTime": now()}
def account_sync_route(h):
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
# Pre-launch hygiene: drop any stale launcher capability still pending for this
# machine so a new launch's unverified FIFA session cannot inherit it. The real
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
ip = _fifa17_client_ip(h)
fifa17_clear_pending(ip)
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
account = activate_account(body)
@@ -209,6 +384,33 @@ def account_sync_route(h):
return 200, {"account": account, "status": "OK"}
def fifa17_capability_route(h):
"""POST /openfut/fifa17/capability -- launcher registers a verified resolver
capability for the current FIFA process (bound to the peer IP). Fail-closed:
anything but capability==empty_mypacks_resolver && version==current is a 400
that records NOTHING (the session stays on the sentinel fallback)."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
return 400, {"error": "unsupported capability"}
if not isinstance(body, dict):
return 400, {"error": "unsupported capability"}
try:
version = int(body.get("version"))
except (TypeError, ValueError):
return 400, {"error": "unsupported capability"}
if (body.get("capability") != "empty_mypacks_resolver"
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
return 400, {"error": "unsupported capability"}
ip = _fifa17_client_ip(h)
persona = body.get("personaId")
fifa_pid = body.get("fifaPid", "?")
status = fifa17_register_capability(ip, persona, version)
log("[fifa17-store] capability empty_mypacks_resolver=%s ip=%s persona=%s "
"fifa_pid=%s -> %s" % (version, ip, persona, fifa_pid, status))
return 200, {"status": "OK"}
def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run.
@@ -1203,6 +1405,10 @@ ROUTES = [
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
# never calls it; launch is blocked unless this succeeds first.
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
# Launcher registers a verified per-FIFA-process resolver capability (bound to
# peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never
# calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog).
(re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)),
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
@@ -3426,24 +3632,54 @@ def store_catalog(h):
if owned:
packs.append(_pack_body(owned, idx, owned=True))
if not owned_ids:
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
# renders rows. If the group is absent FIFA falls back to Bronze and
# shows the empty-category dialog over the wrong tab. Retain an inactive
# zero-item sentinel so the destination resolves, while state != active
# keeps it out of the visible row list. Its id is deliberately absent
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "inactive"
empty["unopened"] = False
packs.append(empty)
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
# /store/purchasegroup and is immutable for the session thereafter.
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
if mode == FIFA17_MODE_CLEAN:
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
# dereferencing a null group. (append nothing)
pass
else:
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
#
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
# account owns zero unopened packs (the category is chosen client-side from
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
# guard, so if no `mypacks` group exists the client CRASHES
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
# minidump). We therefore MUST emit a `mypacks` group when empty.
#
# state="inactive" avoids the crash but makes the client report the pack
# unavailable immediately on Store entry and bounce to the Hub. state="active"
# keeps the group structurally valid AND lets the Store open normally; the
# empty tile renders as "0 items" and an explicit open is rejected
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
# request and mutates nothing.
#
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
# zero-pack UX requires a client-side fix -- see
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "active"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Dump CardsDLL's NULL-terminated {const char*, int} vocabulary tables from the
ON-DISK PE. READ-ONLY, static.
The transfer-market analysis records tradeState as decoding through a table walk at
0x180229e40 and lists sibling vocabularies (type/zone/lev/pos) as tables of the same
shape. This prints the exact token spellings and their integer codes, so the accepted
strings come from the client rather than from inference.
"""
import struct
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
TABLES = [
(0x180229E40, "tradeState (table walk)"),
(0x180229C30, "type"),
(0x1802296E0, "zone"),
(0x180229A60, "lev"),
(0x1802295C0, "pos"),
(0x180229AB0, "cat"),
(0x180229880, "form"),
]
MAX_ROWS = 64
pe = open(DLL, "rb").read()
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
coff = e_lfanew + 4
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
opt = coff + 20
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
sec_off = opt + opt_size
sections = []
for i in range(num_sections):
b = sec_off + i * 40
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
sections.append((vaddr, vsize, rawptr, rawsize))
def va2off(va):
rva = va - image_base
for vaddr, vsize, rawptr, rawsize in sections:
if vaddr <= rva < vaddr + max(vsize, rawsize):
off = rva - vaddr + rawptr
if 0 <= off < len(pe):
return off
return None
def cstr(va, limit=64):
off = va2off(va)
if off is None:
return None
end = pe.find(b"\0", off, off + limit)
if end < 0:
return None
try:
s = pe[off:end].decode("ascii")
except UnicodeDecodeError:
return None
return s if s.isprintable() else None
for table_va, name in TABLES:
base = va2off(table_va)
print("\n=== %s VA %#x -> off %s ===" % (name, table_va, hex(base) if base else None))
if base is None:
print(" (VA did not resolve)")
continue
for i in range(MAX_ROWS):
ptr, code = struct.unpack_from("<Qi", pe, base + i * 16)
if ptr == 0:
print(" -- NULL terminator after %d rows --" % i)
break
s = cstr(ptr)
if s is None:
print(" row %d: ptr %#x does not resolve to a string; stopping" % (i, ptr))
break
print(" %-28s = %d" % (repr(s), code))
+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()
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "openfut-adapter-fifa17"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "FIFA 17 game adapter: Blaze command tables, response bodies and dispatch"
publish = false
[dependencies]
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
# Reads the bundled fetchClientConfig table (227-243 rows per CFID), which is
# generated from the Python oracle rather than transcribed by hand. Unlike the
# protocol crate below it, this crate is ordinary server-side code, so a real
# JSON parser is the right call — hand-rolling one to preserve a zero-dependency
# streak would be reinventing a solved problem in the riskiest possible place.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Seeded RNG for the Store pack-content generator (`fut::pack_content`). The
# generator is pure over an injected `rand::Rng`, so packs are deterministic
# under a seeded `StdRng` in tests and reproducible in production.
rand = "0.8"
[dev-dependencies]
# Differential fixtures are JSONL; the runtime dependency already covers it.
+115
View File
@@ -0,0 +1,115 @@
# openfut-adapter-fifa17
The FIFA 17 game adapter. Everything true of *FIFA 17 specifically* lives here,
so that neither OpenFUT Core nor the generic protocol crates have to know about
it.
```
openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
openfut-adapter-fifa17 THIS: command tables, response bodies, dispatch order
OpenFUT Core game-independent FUT domain (not yet wired)
```
## Status
| Surface | Port | State |
|---|---|---|
| **Blaze / Fire2 RPC** | 42130 | **Implemented**, byte-for-byte parity-tested |
| Redirector (HTTPS + XML) | 42127 | Python only |
| Nucleus OAuth stub | 42131 | Python only |
| LSX / Origin | 4216 | Python only |
| Roster XML | 8081 | Python only |
| UTAS / RS4 | 8099 | Python only |
| POW / EASFC | 8094 / 8080 | Python only |
**Nothing here is wired into the running backend.** The crate answers frames; it
opens no socket, terminates no TLS and owns no runtime. The Python backend
remains the live service and the behavioural oracle.
## What the adapter owns, and what it must not
Owns: component/command/notification IDs, response body shapes, dispatch
ordering, session identity, the `fetchClientConfig` tables.
Must not own: FUT domain state. Blaze is an auth/session/config protocol — no
coins, packs, clubs or squads appear on this wire — so `Session` holds a session
key, a locale, a service name, an auth code and a flag, and that is all. When
UTAS is migrated that boundary will need active defending; here it comes free.
## Parity
```bash
./check-parity.sh # oracle freshness + byte-for-byte replay
./check-parity.sh --regen # after an intentional oracle change
```
`fixtures/blaze_transactions.jsonl` holds 49 request→response(s) transactions
produced by calling the real `blaze_responder_v3b.dispatch()`. They replay in
order against a shared session per connection, so ordering-dependent behaviour
is exercised rather than assumed: preAuth captures the locale that later `ALOC`
fields echo, and login sets the auth code `getAuthToken` returns afterwards.
Comparison is byte-for-byte including frame count and order — a missing
post-login notification or a reply where the oracle stays silent fails here.
The suite was **mutation-tested**: swapping two post-login notifications,
flipping one enum deep inside `AccountInfo`, and hardcoding an address in
`utas_base()`/`nucleus_base()` were each verified to turn it red. The third
initially did *not*, because the config templating had made those helpers dead
code; the table now templates on URL-level tokens so they are the single place a
URL shape is defined.
## Three behaviours that are easy to get wrong
* **Login answers with four frames, in order**: reply, then `UserAuthenticated`,
`UserSessionExtendedDataUpdate`, `UserAdded`.
* **An unimplemented RPC still gets an empty reply.** Silence makes the client
wait for a timeout; an empty reply lets every field fall back to a client-side
default and the boot continues.
* **Non-request message types get nothing at all.**
No error replies are emitted. `msgType` 3 exists, but the error-code placement
is UNRESOLVED — three clean-room sources disagree between `header[14:16]`, a
metadata `ERRC`, and a payload `CNTX`/`ERRC` — so emitting one would be a guess
on the wire.
## The client config table
`fixtures/client_config.json` carries 227243 rows per CFID, generated from the
Python oracle and templated on `{utas_base}`, `{nucleus_base}`,
`{pow_content_url}`, `{advertise}`, `{bind}`, `{pow_host}`. It is
reverse-engineered *data*, not logic, and deriving it mechanically removes a
class of transcription typo no reviewer could catch. The generator does not take
its own templating on trust: it substitutes real addresses back in and diffs
against the oracle for every section before writing the file.
The table must be *complete*, not representative. The client resolves a per-call
key (`FUT_RS4_URL_<CALL>`) before a per-module one, and any unresolved call falls
back to a real, dead EA host — that is what produced "there has been an error
connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was
served.
## Known defect reproduced deliberately
`nucleusConnect` and `nucleusConnectTrusted` are built from the **bind** address,
not the advertised one. On the live split deployment that means the backend
tells a client on another machine to reach Nucleus at `http://0.0.0.0:42131`,
which it cannot. Verified against the running container, not inferred.
This is reproduced exactly, because it is what the only proven-working
configuration does and changing it would break parity. It also implies the
Nucleus stub is not actually reached in the current remote flow. Fixing it is a
separate change that needs live validation — see the vault.
## Configuration
Nothing is hardcoded. `AdapterConfig` carries `Identity` (persona, ids, email,
namespace, entitlement group, …) and `Endpoints` (advertise, bind, POW hosts,
telemetry/ticker/QoS ports). `Default` gives the project's synthetic offline
identity on loopback; a remote deployment must override `advertise`.
Bind and advertise are deliberately distinct: an advertised URL must carry the
address the *client* can reach, which on a two-machine deployment is not the
address the server binds.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Differential check: the Rust FIFA 17 Blaze adapter vs the Python responder.
#
# 1. assert the committed fixtures still match what the Python oracle emits
# 2. replay every recorded transaction through the Rust adapter, byte-for-byte
#
# Read-only with respect to the running backend: the oracle is imported as a
# library, no responder is started, no port is bound, no live service is
# touched. Safe to run while the Python backend is serving a live FIFA client.
#
# Use --regen to rewrite the fixtures after an intentional oracle change.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
if [[ "${1:-}" == "--regen" ]]; then
echo "==> regenerating fixtures from the Python oracle"
python3 fixtures/generate.py
else
echo "==> checking committed fixtures against the Python oracle"
python3 fixtures/generate.py --check
fi
echo "==> replaying transactions through the Rust adapter"
cargo test -p openfut-adapter-fifa17
echo
echo "PARITY OK — the adapter reproduces the Python dispatcher byte-for-byte."
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
//! Emit the discard-pricing matrix for an entire FIFA 17 corpus, and audit it.
//!
//! Uses the SHIPPED implementation (`fut::discard::value_for_definition`) rather
//! than reimplementing the formula, so the matrix cannot drift from what the
//! server actually pays.
//!
//! ```text
//! cargo run -p openfut-adapter-fifa17 --example discard_matrix -- \
//! <catalog.json> <cards.json> [--csv out.csv]
//! ```
//!
//! Prints an audit summary and, with `--csv`, the full per-definition matrix.
use std::collections::{BTreeMap, HashMap};
use openfut_adapter_fifa17::fut::discard;
use openfut_adapter_fifa17::fut::item::legacy_discard_value;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: discard_matrix <catalog.json> <cards.json> [--csv <path>]");
std::process::exit(2);
}
let catalog: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&args[1]).expect("read catalog"))
.expect("parse catalog");
let cards: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&args[2]).expect("read cards"))
.expect("parse cards");
let csv_path = args
.iter()
.position(|a| a == "--csv")
.map(|i| args[i + 1].clone());
// Core's rating per definition id (non-players are 0, which is exactly why
// the catalog rating matters).
let mut core_rating: HashMap<String, u8> = HashMap::new();
if let Some(arr) = cards.as_array() {
for c in arr {
let id = c["id"].as_str().unwrap_or_default().to_string();
let r = c["overall"].as_i64().unwrap_or(0).clamp(0, 255) as u8;
core_rating.insert(id, r);
}
}
let entries = catalog
.get("cards")
.and_then(|c| c.as_object())
.expect("catalog has cards{}");
let mut rows: Vec<String> = Vec::new();
rows.push("definition,kind,subtype,cardtype,rareflag,rating_src,rating,level,legacy,recovered,verdict".into());
let mut by_kind: BTreeMap<String, (usize, usize, i64, i64)> = BTreeMap::new(); // n, declined, legacy, recovered
let (mut negatives, mut zero_priced, mut declined_total, mut overflow) =
(0usize, 0usize, 0usize, 0usize);
let mut boundary_probe_failures = Vec::new();
for (id, e) in entries {
let kind = e["kind"].as_str().unwrap_or("player").to_string();
let subtype = e["subtype"].as_i64().unwrap_or(0);
let rareflag = e["rareflag"].as_i64().unwrap_or(0);
let cat_rating = e["rating"].as_i64().map(|r| r.clamp(0, 255) as u8);
let core = *core_rating.get(id).unwrap_or(&0);
let cardtype = discard::cardtype_for_subtype(subtype);
let recovered = discard::value_for_definition(subtype, rareflag, cat_rating, core);
let effective_rating = cat_rating.unwrap_or(core);
let level = discard::discard_level(effective_rating);
let legacy = legacy_discard_value(core);
let verdict = match recovered {
None => {
declined_total += 1;
"DECLINES->legacy"
}
Some(v) if v < 0 => {
negatives += 1;
"NEGATIVE"
}
Some(0) => {
zero_priced += 1;
"ZERO"
}
Some(v) if v > 1_000_000 => {
overflow += 1;
"IMPLAUSIBLE"
}
Some(_) => "ok",
};
let ent = by_kind.entry(kind.clone()).or_insert((0, 0, 0, 0));
ent.0 += 1;
ent.2 += legacy;
match recovered {
Some(v) => ent.3 += v,
None => {
ent.1 += 1;
ent.3 += legacy; // declining means the legacy ladder is what pays
}
}
rows.push(format!(
"{id},{kind},{subtype},{cardtype},{rareflag},{},{effective_rating},{level},{legacy},{},{verdict}",
if cat_rating.is_some() { "catalog" } else { "core" },
recovered.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
));
}
// Rating-boundary audit against the client's own ladder (cmp 0x4b / 0x41).
for (rating, want) in [(0u8, 1u8), (64, 1), (65, 2), (74, 2), (75, 3), (99, 3)] {
let got = discard::discard_level(rating);
if got != want {
boundary_probe_failures.push(format!("rating {rating}: level {got}, expected {want}"));
}
}
println!("== DISCARD MATRIX AUDIT ==");
println!("definitions : {}", entries.len());
println!("declined -> legacy : {declined_total}");
println!("priced zero : {zero_priced}");
println!("negative : {negatives}");
println!("implausible (>1e6) : {overflow}");
println!(
"rating boundaries : {}",
if boundary_probe_failures.is_empty() {
"OK (1/2/3 at <65 / 65..74 / >=75)".to_string()
} else {
boundary_probe_failures.join("; ")
}
);
println!();
println!(
"{:<12} {:>6} {:>9} {:>14} {:>14}",
"kind", "n", "declined", "legacy", "recovered"
);
let (mut tl, mut tr) = (0i64, 0i64);
for (kind, (n, dec, legacy, rec)) in &by_kind {
println!("{kind:<12} {n:>6} {dec:>9} {legacy:>14} {rec:>14}");
tl += legacy;
tr += rec;
}
println!(
"{:<12} {:>6} {:>9} {:>14} {:>14}",
"TOTAL",
entries.len(),
declined_total,
tl,
tr
);
if tl > 0 {
println!("ratio recovered/legacy : {:.2}x", tr as f64 / tl as f64);
}
if let Some(path) = csv_path {
std::fs::write(&path, rows.join("\n") + "\n").expect("write csv");
println!("\nwrote {} rows to {path}", rows.len() - 1);
}
let fatal = negatives + overflow + boundary_probe_failures.len();
if fatal > 0 {
eprintln!("\nFAIL: {fatal} fatal finding(s)");
std::process::exit(1);
}
println!("\nRESULT: OK");
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.
The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
field tree, what bytes come out. This file pins the layer above: given an
inbound Fire2 frame and a session, **which frames go back, in what order**.
That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
silently get wrong in ways a codec test cannot see a missing post-login
notification, a reply where the oracle stays silent, notifications in the wrong
order, session state not carried between RPCs.
Every transaction is produced by calling the real
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
connection exactly as it would be on a live socket, so ordering-dependent
behaviour (preAuth captures the locale; login sets the auth code that
getAuthToken later returns) is captured rather than assumed.
Determinism: the oracle's clock is pinned and its PRNG seeded, and the
deployment-dependent addresses are set before import (the responder reads them
at import time). See the sibling generator in openfut-protocol-blaze.
NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
synthetic offline identity. Session keys are minted from a seeded PRNG.
Usage: python3 fixtures/generate.py (write)
python3 fixtures/generate.py --check (verify committed files are current)
"""
from __future__ import annotations
import json
import os
import random
import sys
from collections import OrderedDict
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
if not os.path.isdir(TOOLS):
sys.exit("cannot find the Python oracle at %s" % TOOLS)
sys.path.insert(0, TOOLS)
CHECK_ONLY = "--check" in sys.argv[1:]
# Internal mode: re-exec of this script with sentinel addresses, used to derive
# the templated client-config table (see emit_config_table).
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
sys.argv = [sys.argv[0]]
# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
# a stray literal cannot be mistaken for a real address.
SENTINELS = [
("ADVERTISE-SENTINEL", "{advertise}"),
("BIND-SENTINEL", "{bind}"),
("POWCONTENT-SENTINEL", "{pow_content_host}"),
("POWHOST-SENTINEL", "{pow_host}"),
]
if CONFIG_TABLE_MODE:
os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
os.environ["POW_HOST"] = "POWHOST-SENTINEL"
import blaze_responder_v3b as _B # noqa: E402
out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
out["__default__"] = _B.client_config_for("__no_such_section__")
print(json.dumps(out))
raise SystemExit(0)
# Pin deployment config BEFORE import — the responder snapshots these at import
# time into module globals used by the response builders.
#
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
# address instead of reading its config, these make the failure loud rather than
# accidentally matching a loopback default.
ADVERTISE = "198.51.100.7"
BIND = "0.0.0.0"
POW_CONTENT_HOST = "198.51.100.7:8085"
POW_HOST = "198.51.100.7:8094"
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ["OPENFUT_BIND"] = BIND
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
os.environ["POW_HOST"] = POW_HOST
import heat2 # noqa: E402
import blaze_responder_v3b as B # noqa: E402
from fut_account import ACCOUNT # noqa: E402
FIXED_NOW = 1754870400
INT, STRING, STRUCT, LIST, MAP, BLOB = (
heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)
RECORDS = []
# ------------------------------------------------------------------ helpers
def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
user_index=0):
"""Build an inbound request frame the way the client would."""
msg_type = B.MESSAGE if msg_type is None else msg_type
payload = heat2.encode_tdf(fields) if fields else b""
return B.fire2(component, command, msg_num, msg_type, payload,
user_index=user_index)
def tx(session, name, frame, note=""):
"""Run one frame through the real dispatcher and record what came back."""
hdr = B.parse_fire2_header(frame)
body = frame[16 + hdr["metadata_len"]:]
fields = heat2.decode_tdf(body) if body else OrderedDict()
out = B.dispatch(hdr, fields, body, session["sess"])
RECORDS.append(OrderedDict((
("kind", "tx"),
("session", session["id"]),
("name", name),
("note", note),
("request_hex", frame.hex()),
("responses", [f.hex() for f in out]),
)))
return out
def new_session(sid):
s = {"id": sid, "sess": B.Session()}
RECORDS.append(OrderedDict((
("kind", "session"),
("id", sid),
# Minted per connection by the oracle; the Rust side must be able to
# inject it, because it appears in LoginResponse.SESS.KEY, the
# UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
# must be the same string.
("session_key", s["sess"].session_key),
("account_locale", s["sess"].account_locale),
("service_name", s["sess"].service_name),
)))
return s
# ------------------------------------------------------------------ script
def build():
RECORDS.append(OrderedDict((
("kind", "config"),
("advertise", ADVERTISE),
("bind", BIND),
("pow_content_host", POW_CONTENT_HOST),
("pow_host", POW_HOST),
("now", FIXED_NOW),
("identity", OrderedDict((
("persona_id", ACCOUNT.persona_id),
("persona_name", ACCOUNT.persona_name),
("user_id", ACCOUNT.user_id),
("ext_id", ACCOUNT.ext_id),
("email", ACCOUNT.email),
("namespace", ACCOUNT.NAMESPACE),
("client_platform", ACCOUNT.CLIENT_PLATFORM),
("persona_status", ACCOUNT.PERSONA_STATUS),
("user_session_type", ACCOUNT.USER_SESSION_TYPE),
("account_locale_int", ACCOUNT.account_locale_int),
("locale", ACCOUNT.locale),
("content_id", ACCOUNT.CONTENT_ID),
("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
("title_id", ACCOUNT.TITLE_ID),
("client_id", ACCOUNT.CLIENT_ID),
("platform", ACCOUNT.PLATFORM),
("server_version", B.SERVER_VERSION),
))),
)))
# ================= main connection: the real boot order =================
#
# Mirrors what FIFA 17 actually does, because ordering is load-bearing:
# preAuth captures the locale that later ALOC fields echo, and login sets
# the auth code that getAuthToken returns afterwards.
m = new_session("main")
tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
("CDAT", (STRUCT, OrderedDict([
("IITO", (INT, 0)),
("LANG", (INT, 0x656E5553)), # 'enUS'
("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST
("TYPE", (INT, 0)),
]))),
("CINF", (STRUCT, OrderedDict([
("BSDK", (STRING, "15.1.1.3.0")),
("CLNT", (STRING, "FIFA17")),
("ENV", (STRING, "prod")),
("LOC", (INT, 0x656E5553)),
]))),
("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")
tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
"Util::ping -> STIM only")
# Every section the responder knows, plus unknown ones. The known sections
# each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
# particular carries the roster URL, itself a documented loading gate — so
# covering only "BlazeSDK" would leave most of the table unverified.
for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
"OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
"OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
"OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
"TOTALLY_UNKNOWN"):
tx(m, "fetch_config_%s" % (cfid or "empty"),
req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
"unknown CFIDs still get the shared FUT/POW rows")
tx(m, "get_auth_token_before_login",
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
"no auth code yet -> synthesised OPENFUT-<key[:16]> token")
tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
"routine LoginStateLogout (state 500), NOT a failure; empty reply")
tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
("EXTB", (BLOB, b"")),
("PNAM", (STRING, "")),
]), msg_num=6),
"reply THEN three UserSessions pushes, in that order")
tx(m, "get_auth_token_after_login",
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
"now echoes the login's AUTH verbatim")
tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
"the RPC behind 'Unable to retrieve account information'")
tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))
for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
(0x20, "listEntitlements"),
(0x30, "listPersonaEntitlements2"),
(0x27, "grantEntitlement2")):
tx(m, "entitlements_%s" % label,
req_frame(B.COMP_AUTH, cmd, msg_num=11),
"all four aliases return the same two ONLINE_ACCESS records")
tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
"TELE/TICK/UROP; TELE.SESS must equal the login session key")
tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
tx(m, "user_settings_load",
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
tx(m, "user_settings_save",
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
"accepted and discarded; empty reply")
tx(m, "set_client_state",
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
tx(m, "set_client_metrics",
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))
tx(m, "update_network_info",
req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
"empty reply PLUS an unsolicited ExtendedDataUpdate push")
tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))
tx(m, "census_subscribe",
req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
"non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")
tx(m, "logout_after_login",
req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
"session teardown after a login; still an empty reply")
# ============================ fallback behaviour ========================
f = new_session("fallbacks")
tx(f, "transport_ping",
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
"msgType PING -> PING_REPLY with an empty body, whatever the command")
for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
(B.ERROR_REPLY, "error_reply"),
(B.PING_REPLY, "ping_reply")):
tx(f, "ignores_%s" % label,
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
"not a request -> NO frames at all")
tx(f, "unknown_command",
req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
"unimplemented RPC still gets an EMPTY reply so the client cannot hang")
tx(f, "unknown_component",
req_frame(0x1234, 0x0001, msg_num=33),
"same fallback for an entirely unknown component")
tx(f, "user_index_is_echoed",
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
"a reply echoes component/command/msgNum/userIndex verbatim")
# ================= locale echo on a non-default client ==================
loc = new_session("locale")
tx(loc, "preauth_de_locale",
req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
("CDAT", (STRUCT, OrderedDict([
("LANG", (INT, 0x64654445)), # 'deDE'
("SVCN", (STRING, "fifa-2017-pc-de")),
]))),
])),
"a non-enUS client: SVCN echo AND the captured locale must both change")
tx(loc, "login_with_de_locale",
req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
"UserAuthenticated.ALOC must carry the captured deDE locale")
# ------------------------------------------------------------------- output
def emit_config_table():
"""Derive the fetchClientConfig tables as address-TEMPLATED data.
These are 227-243 key/value rows per CFID, almost all of them the same URL.
Hand-transcribing them into Rust would be 400 lines of string literals that
nobody can review and one typo can break; deriving them mechanically from
the oracle removes that whole class of error and keeps them regenerable.
They are reverse-engineered *data*, not logic the same reason
`openfut-core` loads its content from `data/` rather than from source.
The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
so the adapter stays configurable; baking an address in here would recreate
exactly the hardcoding the client/server split removed.
Correctness is not assumed: the caller substitutes real addresses back in
and diffs against the oracle. See verify_config_table.
"""
import subprocess
raw = subprocess.run(
[sys.executable, os.path.abspath(__file__), "--_config_table"],
capture_output=True, text=True, check=True,
# Inherit nothing address-shaped; the child sets its own sentinels.
env={k: v for k, v in os.environ.items()
if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
).stdout
table = json.loads(raw)
def templatise(value):
for sentinel, token in SENTINELS:
value = value.replace(sentinel, token)
# Collapse whole URLs to URL-level tokens where one exists, so the Rust
# side builds them in exactly one place (AdapterConfig::utas_base and
# friends) instead of re-deriving the shape here. Without this the
# helpers become dead code and a hardcoded address in them goes
# undetected — verified by mutation testing. Longest first.
for whole, token in (
("http://{advertise}:8099/", "{utas_base}"),
("http://{bind}:42131", "{nucleus_base}"),
("http://{pow_content_host}", "{pow_content_url}"),
):
if value == whole:
return token
return value
return {cfid: [[k, templatise(v)] for k, v in rows]
for cfid, rows in table.items()}
def verify_config_table(table):
"""Substitute the real addresses back and require the oracle's exact rows.
This is what makes the templated table trustworthy rather than plausible.
"""
subst = {
"{utas_base}": "http://%s:8099/" % ADVERTISE,
"{nucleus_base}": "http://%s:42131" % BIND,
"{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
"{advertise}": ADVERTISE,
"{bind}": BIND,
"{pow_content_host}": POW_CONTENT_HOST,
"{pow_host}": POW_HOST,
}
def render(v):
for token, real in subst.items():
v = v.replace(token, real)
return v
for cfid, rows in table.items():
expected = B.client_config_for(
"__no_such_section__" if cfid == "__default__" else cfid)
got = [(k, render(v)) for k, v in rows]
if got != [(k, v) for k, v in expected]:
for (gk, gv), (ek, ev) in zip(got, expected):
if (gk, gv) != (ek, ev):
sys.exit("config template mismatch in %s: %r -> %r, oracle "
"has %r -> %r" % (cfid, gk, gv, ek, ev))
sys.exit("config template row-count mismatch in %s: %d vs %d"
% (cfid, len(got), len(expected)))
print("config table verified against the oracle for %d sections"
% len(table))
def frozen_clock():
import time as _time
original = _time.time
_time.time = lambda: float(FIXED_NOW)
return original, _time
def write(path, records):
body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
if CHECK_ONLY:
if not os.path.exists(path):
sys.exit("MISSING: %s has never been generated" % path)
with open(path, "r", encoding="utf-8") as fh:
if fh.read() != body:
sys.exit("STALE: %s does not match the oracle; re-run without "
"--check" % path)
print("current: %s (%d records)" % (os.path.basename(path), len(records)))
return
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
print("wrote %s (%d records)" % (os.path.basename(path), len(records)))
def write_json(path, obj):
body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
if CHECK_ONLY:
if not os.path.exists(path):
sys.exit("MISSING: %s has never been generated" % path)
with open(path, "r", encoding="utf-8") as fh:
if fh.read() != body:
sys.exit("STALE: %s does not match the oracle" % path)
print("current: %s" % os.path.basename(path))
return
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))
def main():
# The oracle logs every dispatch to stdout; useful live, pure noise here.
B.log = lambda *_a, **_k: None
table = emit_config_table()
verify_config_table(table)
write_json(os.path.join(HERE, "client_config.json"), table)
random.seed(0xB1A2E)
original_time, time_mod = frozen_clock()
try:
build()
finally:
time_mod.time = original_time
write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
txs = [r for r in RECORDS if r["kind"] == "tx"]
frames = sum(len(r["responses"]) for r in txs)
print("%d transactions, %d response frames, %d sessions"
% (len(txs), frames,
len([r for r in RECORDS if r["kind"] == "session"])))
if __name__ == "__main__":
main()
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Capture the roster oracle's responses byte-for-byte.
generate_roster.py [--check] [host:port]
Unlike `generate.py`, which imports the Blaze responder and calls its pure
functions, this captures over the wire. The roster response is shaped as much by
`http.server.BaseHTTPRequestHandler` as by the handler code -- HTTP/1.0 status
line, `Server:`/`Date:` injected ahead of the handler's own headers, POST
answered without a body -- and only the real socket shows all of that.
Two fields are volatile and are MASKED rather than recorded:
Date: changes every second
Server: carries the container's Python version
They are masked, not dropped, so their presence and position are still asserted.
The Server string is additionally recorded verbatim under `observed_server`, so
a drift between the container's Python and the adapter's `ORACLE_SERVER`
constant is visible rather than silent.
`--check` re-captures and compares. If the oracle is unreachable it FAILS rather
than passing: a check that cannot check must not report success.
"""
import json
import os
import re
import socket
import ssl
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "roster.json")
PATH = "/fifa17/fut/rosterupdate.xml"
DATE_RE = re.compile(rb"^Date: .+?\r\n", re.M)
SERVER_RE = re.compile(rb"^Server: (.+?)\r\n", re.M)
def fetch(host, port, method, body=None):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("ALL:@SECLEVEL=0")
s = ctx.wrap_socket(socket.create_connection((host, port), timeout=8),
server_hostname="fixture")
req = "%s %s HTTP/1.1\r\nHost: %s:%d\r\nAccept: */*\r\n" % (method, PATH, host, port)
if body is not None:
req += "Content-Length: %d\r\n" % len(body)
req += "\r\n"
s.sendall(req.encode() + (body or b""))
out = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
s.close()
return out
def capture(host, port):
result = {"path": PATH, "responses": {}}
servers = set()
for method, body in (("GET", None), ("HEAD", None), ("POST", b"probe=1")):
raw = fetch(host, port, method, body)
m = SERVER_RE.search(raw)
if m:
servers.add(m.group(1).decode())
masked = DATE_RE.sub(b"Date: <MASKED>\r\n", raw)
masked = SERVER_RE.sub(b"Server: <MASKED>\r\n", masked)
result["responses"][method] = masked.hex()
if len(servers) != 1:
raise SystemExit("oracle returned inconsistent Server headers: %r" % servers)
result["observed_server"] = servers.pop()
return result
def main():
check = "--check" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
host, port = (args[0].split(":") if args else ("127.0.0.1", "8081"))[0], \
int((args[0].split(":")[1] if args and ":" in args[0] else "8081"))
try:
fresh = capture(host, port)
except Exception as e:
# Explicitly a failure. A --check that silently passes when it could not
# reach the oracle is exactly the class of self-confirming tooling this
# project has been bitten by repeatedly.
raise SystemExit("cannot reach the roster oracle at %s:%d (%s). "
"Refusing to report success." % (host, port, e))
if check:
if not os.path.exists(OUT):
raise SystemExit("no fixture at %s -- run without --check first" % OUT)
with open(OUT) as f:
stored = json.load(f)
if stored.get("responses") != fresh["responses"]:
for m in sorted(set(stored.get("responses", {})) | set(fresh["responses"])):
a = stored.get("responses", {}).get(m)
b = fresh["responses"].get(m)
if a != b:
print("MISMATCH %s\n stored: %s\n live : %s" % (m, a, b))
raise SystemExit("roster fixtures differ from the live oracle")
if stored.get("observed_server") != fresh["observed_server"]:
raise SystemExit(
"the oracle's Server header changed: %r -> %r.\n"
"Update roster::ORACLE_SERVER and regenerate."
% (stored.get("observed_server"), fresh["observed_server"]))
print("roster fixtures match the live oracle (%d responses, server=%r)"
% (len(fresh["responses"]), fresh["observed_server"]))
return
with open(OUT, "w") as f:
json.dump(fresh, f, indent=2, sort_keys=True)
f.write("\n")
print("wrote %s (%d responses, server=%r)"
% (OUT, len(fresh["responses"]), fresh["observed_server"]))
if __name__ == "__main__":
main()

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