22cfae830fbda9613aa1bf6b534d1fa35bcb228d
132 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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. |
||
|
|
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? |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
ab62440dbf | Make FIFA17 roster hostname configurable | ||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
bd03aec82a | Gate FIFA17 SBC dispatch acceptance | ||
|
|
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
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
3153a93edf |
fifa17-recon: drop superseded docker-side tools/data copies
fifa17-recon/tools (authoritative) and fifa17-recon/data now feed the Docker build directly via the curated runtime-tools.list manifest. The duplicated fifa17-python/tools+data are removed so the repo has a single source of truth; the rebuilt openfut-fut-backend:dev image is byte-identical to the previous deployment (verified: manifest diff empty, 446/446 contract checks pass). |
||
|
|
f64106ed8b | fifa17-recon: fix compose dockerfile path for relocated build context | ||
|
|
9faaf12dd7 |
fifa17-recon: Docker build consumes authoritative tools via curated manifest
Build context moves from docker/fifa17-python/ up to fifa17-recon/ so the Dockerfile reads the single-source tools/ and data/ trees. Only the 77 runtime files listed in runtime-tools.list are installed into /app/tools (baseline image minus the two git-ignored certs, regenerated in-image). memdump and recon artifacts are excluded via fifa17-recon/.dockerignore. |
||
|
|
83539e33ec |
fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these files, which are OLDER than the deployed backend. The running container (C) is byte-identical to docker/fifa17-python/tools (B) and is a strict superset: it adds profile_path_for/select_account/ensure_security_question (fut_store), safe_header_for_log/safe_request_path/security_question_route (utas_server), account_sync_route/_match_call/match_ready_body, plus POW balance fields and match lifecycle support, with zero unique local functions lost. Reconciled tree is now a strict superset of B with every shared file byte-identical; verified via md5 map (0 missing, 0 differing). |
||
|
|
695421cfd4 | Merge remote-tracking branch 'origin/main' into fifa17-fut-squad-and-userinfo | ||
|
|
8cba70dc90 |
fifa17-recon: reconcile authoritative tools with running backend (B)
- Add 8 files present in docker/fifa17-python/tools but missing from the top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in the server's docker tree; byte-identical to the running image). - Preserve newer responder work already matching the running container: utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND), blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py, test_fut_contract.py, fifa17-hook-m1.sh. - Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime registries). Local tree is now a strict superset of B with all shared files byte-identical. |
||
|
|
28773e7cf1 |
fifa17-python: sync tools to running container state
The frozen baseline image predates two hot-patches made in the running container after build: * utas_server.py: FUT_MODES-gated offlineSeason block in GetHubData's club response (keeps the offline-season summary valid) * test_hub_offline_season_contract.py added to /app/tools Sync fifa17-python/tools to the running container (verified byte-identical, 237 files incl. the redir cert pair) and snapshot the live FS as openfut-fut-backend:python-running-2026-08-10 (docker commit). A fresh build from the committed sources now reproduces the running backend exactly (baked SHA256SUMS.txt diffed against the container manifest: identical). |
||
|
|
3ae5587a38 | docs: baseline manifest equivalence note (pycache + cert deltas expected) | ||
|
|
70a64e3709 |
fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit. |
||
|
|
cc694774a3 | wip: checkpoint FIFA 17 SBC research for Windows migration | ||
|
|
3d3239bab9 | feat: document and stage FIFA 17 SBC hook workflow | ||
|
|
a7e3e43ae9 |
fifa17-recon: the refusing modes have no server fix, and the hub-atom lead is cosmetic too
Completed the refusing-modes workflow (ground truth + 4 per-mode investigations + adversarial verify each + synthesis). All four mode families -- Seasons, Draft, SBC/Objectives, Tournaments -- are NOT_SERVER_REACHABLE, HIGH confidence, all four adversarial refutations failed. Live re-confirmed on pid 24653 (slide proven via FNV control): every named mode-gating byte reads ENABLED=1 (IS_FRIENDLY_SEASON_ENABLED +0x1fd3a, IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b, IS_DRAFT_MODE_ENABLED +0x1fd3d, plus the unnamed offline-draft-enable +0x1fd3e) yet the tiles stay greyed. The new lead this pass added -- do the six /hub mode sub-objects gate availability? -- is refuted: friendlySeason/offlineSeason/onlineSeason/draftSummary/tournament/ tournamentProgress carry only stats and display strings, no enabled/available/ unlocked atom. They are cosmetic, exactly like hub.tradePile. The one server-writable input that exists (friendlySeasonsEnabled -> +0x1fd3a via applier FUN_18011dc50) has its sole reader in the packed FIFA17.exe front-end via a vtable getter with no CardsDLL caller, and it is already 1. The refusal is decided in the Denuvo-packed Frostbite front-end, which has no server surface. docs/plan-2026-08-06-refusing-modes.md: full evidence chains, gate-byte table, the six sub-deser field maps, per-mode verdicts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np |
||
|
|
31fc590b99 |
fifa17-recon: the FUT-hub Transfer List tile counts, and the hub parser is NOT reflection
The Transfer List hub tile read "0 items / Selling 0" while a card was actively
listed. Enumerating the /hub parser FUN_180139610 straight from the on-disk
CardsDLL (objdump) refutes the old ENDPOINT_MAP claim that it uses C++ reflection
with "no atom ladder, nothing to enumerate": it has an ordinary running-sum atom
ladder reading 18 atoms. The tile is fed by hub.tradePile (0x333), a nested object
(sub-deser 0x18013ead0) reading count/selling/sold as scalar ints -- the same
scheme as GetAuctionCount, so serving it in the hub body is freeze-safe. The tile
never re-polls the standalone /tradePile/counts, which is why fixing that endpoint
alone did not move the tile.
Also: the hub tile polls LOWERCASE tradepile/counts while the Transfer List screen
uses camelCase tradePile; our case-sensitive routes matched only the screen, so the
tile's counts call fell through to /trade and got a shape the counts deser skips.
Made the tradePile routes case-insensitive.
And bake the proven transfer-market flags (FUT_TRADING/PILESIZES/TRADEABLE/
DISCARD_TABLE/DISCARD_SEND) into openfut-fut.sh so a plain `start` brings up the
working state instead of regressing trading to greyed-out.
- tools/utas_server.py: hub_data() serves tradePile:{count,selling,sold};
tradePile routes now re.I
- tools/openfut-fut.sh: utas launched with the working flag set
- docs/ENDPOINT_MAP.md: full 18-atom hub map + tile map, correction of the
reflection claim
- tools/ghidra_queries/objdump_atom_ladder.py: the objdump-based atom-ladder
decoder used to derive the above
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
|
||
|
|
245c22161b |
fifa17-recon: correct tradePile/counts shape, and narrow the marketdata array fix
Two follow-ups on the working transfer market.
1. GET /tradePile/counts now returns the FutGetAuctionCount shape
({count, maxAuctionsAllowed, offered, selling, sold}, all scalar ints, atoms
0xbc/0x1bf/0x1e5/0x2b8/0x2c9) via a dedicated route ordered before /tradePile.
Previously it fell through to tradepile_route and got the auction-LIST body, which
the counts deser skips, leaving every tally at its constructor default. Survivable
but wrong; the doc flags the loaded byte at +0x28 as gating a completion-handler
branch. selling reflects real STORE.listings().
2. Narrowed the marketdata bare-array fix to /pricelimits only. The client sends TWO
marketdata requests: /marketdata/pricelimits (GetSuggestedPricing, a bare array,
the thing that froze) and plain /marketdata?defId=N (price comparison, an OBJECT).
The prior commit returned the array for both, which the contract suite caught
(test_market_bodies: 'list' has no attribute get) -- plain /marketdata wants
{minPrice,maxPrice} and was never the freeze. Returning the array for it would be
the same desync in reverse. Now: pricelimits -> array, plain marketdata -> object.
The contract suite catching my over-broadened fix before it reached the game is the
suite doing its job. 439 contract checks pass, market unit suite passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
43557989f5 |
fifa17-recon: the transfer market works -- listed a card end to end, no freeze
The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.
1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
the massinfo END_OBJECT the client runs
cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
and unconditionally, which is why the gate read 0 all day regardless of /settings or
the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).
2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
(TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.
3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
(root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
requested defId. Live: price screen opened and Submit succeeded.
Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.
Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.
Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).
Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a3fd51692f |
fifa17-recon: the trading gate is still shut, and two of yesterday's conclusions were wrong
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.
REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.
REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.
AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
-> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.
The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.
Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.
WHAT IS ACTUALLY SHIPPED HERE, all default off:
* FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
in _item(), because the save holds 246 items minted before the flag existed and the
club route serves them straight from the save. That gap was caught by reading the
served JSON, not by unit-testing the factory.
* FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
only as a record of the refutation, with the reasoning inline so nobody retries it.
* fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
preferred could never have answered this for three of five families.
* Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
(which was never bisected; value is the established suspect and that response also
carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
never copied into the record.
Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e578443d73 |
fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I committed earlier today. THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on Transfer Market" have been disabled in the reveal screen and nobody knew why. TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the tradingEnabled gate byte. Read live and reproduced independently: slot +0x2b0 friendlySeasons disp 0x1fd3a VALUE=1 slot +0x2c8 draftMode disp 0x1fd3d VALUE=1 slot +0x2e0 packOpeningAnim disp 0x1fd45 VALUE=1 slot +0x270 tradingEnabled disp 0x1fd2e VALUE=0 tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates the settings work from this morning: that plan died because every gate it targeted already read 1, and the conclusion drawn was that the settings array does not matter. It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in _SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off. So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping only the boolean would look like the finding failed. THE DISCARD "MISS" NEVER EXISTED, which corrects |
||
|
|
e3092ca0f9 |
fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to
|
||
|
|
21a81ad63c |
fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.
THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:
value = round_half_up(rating * price / 100)
level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3,
derived from rating, NOT a wire field)
cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
across every subtype 0..599 with zero disagreements
A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.
This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.
Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.
ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.
THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.
The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.
extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.
A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.
Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.
Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.
Live: 439 contract checks pass, market suite passes, both flags off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3f3d5704a7 |
fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price
Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for
the whole life of the project.
QUICK SELL WAS NEVER SERVED. Captured on the wire:
DELETE /ut/game/fifa17/item/100000240 (single card, id in the URL, no body)
ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the
doc, and the regex therefore never matched a real quick sell. Every quick sell fell
through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had
never once been called.
The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client
takes its coin total from it, so with totalCredits absent it rendered an uninitialised
value. A real session showed 1,133,686,384 coins against a true balance of 9,889,600.
Display artifact only, corrected by the next GET /user/credits, and the save was never
touched, but it is the reason a stub is not acceptable here.
quick_sell_url_route() serves the real form and returns the corrected shape,
{"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits:
two quick sells fired through it in one session, each credited 150 and removed the
card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases
either side of them. An unknown id returns {"items": []} rather than claiming a sale we
cannot account for.
Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as
the new balance or ADDS it as a delta. We send the new balance. The discriminating
window is about five seconds wide, because the client refetches GET /user/credits
straight afterwards, so either reading self-corrects and the practical impact is a brief
wrong number. It mattered only while we answered {}, because that garbage persisted.
The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard
table, which remains unknown.
finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack
and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE
with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off
and is disarmed: it puts a price on a tile that the buy path does not charge.
TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here:
* The store grouping is broken and it is NOT cosmetic. All three packs collapse into
one display group, and the Bronze, Gold and Premium group tiles all drill into the
same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send
displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything
lands in group 0. The docs had this parked as a cosmetic "tiles read unknown"
issue; it is an availability bug.
* The FIFA Points price renders as the literal "or %1s", an unsubstituted printf
placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and
"mtx" is evidently not a currency token the client resolves. Cosmetic.
Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD /
11 RARES against exactly what we serve). An earlier screen showing zeros was the
display-GROUP level, which carries no content info. D3 was right.
Live: 439 contract checks pass, both probe flags off, store prices back to honest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
89da7b7609 |
fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.
THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.
The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.
Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.
HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.
Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.
TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.
ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
* duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
list at :1095. Bare ints where the element parser expects objects is a tokenizer
desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
* FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
top-level id.
No behaviour change. utas_server.py is comment-only. 439 contract checks pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1605e6effd |
fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.
TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):
9 START_OBJECT case 0x64, pushes state 2
10 END_OBJECT case 0x65, pops state 2
11 FIELD_NAME confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
atom-hashes the string at +0xf8
12 START_ARRAY case 0x66, pushes state 3
13 END_ARRAY case 0x67, pops state 3
1 error the caseD_78 sink
So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.
The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.
THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.
The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.
No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.
Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|