Commit Graph

110 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Five slots, every item pointer NULL. Five is the club's active club-item set --
home kit, away kit, badge, stadium, ball -- so the client knows it should hold
five and holds none. That is why FUN_1800d73d0 returns the 0x1802c2a28 sentinel
whose +0x10 is NULL, and why the tiles are untextured.
2026-08-24 16:08:02 +00:00
funman300 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 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 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 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 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 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 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 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 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 ab62440dbf Make FIFA17 roster hostname configurable 2026-08-21 02:35:27 +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 bd03aec82a Gate FIFA17 SBC dispatch acceptance 2026-08-18 20:20:38 +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 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 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 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 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 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 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 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 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 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).
2026-08-10 17:12:27 -07:00
funman300 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.
2026-08-10 17:08:06 -07:00
funman300 cc694774a3 wip: checkpoint FIFA 17 SBC research for Windows migration 2026-08-07 12:03:22 -07:00
funman300 3d3239bab9 feat: document and stage FIFA 17 SBC hook workflow 2026-08-07 11:44:05 -07:00
funman300 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
2026-08-06 18:28:52 -07:00
funman300 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>
2026-08-06 14:39:38 -07:00
funman300 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>
2026-08-06 14:33:10 -07:00
funman300 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>
2026-08-06 12:47:02 -07:00
funman300 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 e3092ca. fcc_discardcoins is resident
and complete, the client lookup runs and is correct, and it lands at item+0x3c. The
tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the
client was not failing a lookup; it was faithfully displaying the 0 we sent. Same
observable, completely different mechanism, and the version in e3092ca is wrong.
FUT_DISCARD_SEND remains exactly the right fix, now for the right reason.

WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the
table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own
+0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed.

CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9,
stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination.
All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in
the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have
answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent
a launch and returned nothing for three of five families.

THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it
lands on and whether the client keeps it. Built by diffing what we serve against the
parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer
rather than by assuming the +0x38 offset). Corrections that change what we serve:
+0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is
itemState, definitionId is not an atom at all.

A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at
+0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the
control was the wrong FORM. That is a new variant of the absence trap, which has now
cost six wrong verdicts, and it is recorded in the doc.

FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard
wrapper that items() had, so the pending pile, which is the one place a quick-sell
value is actually read, served unstamped cards. Found by verification, not testing.
All three read paths now stamp.

Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds
for the read paths, which copy, but NOT for cards minted while armed, because _item()
stamps at creation and those persist (9 items currently). Kept deliberately: the pack
reveal serves itemList straight from open_pack(), not through purchased(), so removing
creation-stamping would leave the screen that matters unstamped. Persisted values are
correct and self-heal, since every read recomputes and overwrites.

Nothing here has been on screen. Six patches are proposed in the doc as pasteable text,
env-flagged, defaulting off, none applied.

Live: 439 contract checks, 414 card-family checks, market suite, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:01 -07:00
funman300 e3092ca0f9 fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:55:05 -07:00
funman300 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>
2026-08-06 07:43:51 -07:00
funman300 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>
2026-08-05 20:03:41 -07:00
funman300 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>
2026-08-05 19:35:27 -07:00
funman300 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>
2026-08-05 19:25:01 -07:00
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00
funman300 d0dbfa99c0 fifa17-recon: the /settings gate bytes were never zero, and the plan built on that is dead
Yesterday's settings-gate plan asserted that IS_FRIENDLY_SEASON_ENABLED and
IS_DRAFT_MODE_ENABLED "have never been set to true by anything, on any run", and
proposed spending a launch on that premise. Measured against the running client,
both read 1, and so does packOpeningAnimationEnabled, while /settings has only ever
been answered {"configs": []}.

  disp 0x1fd3a (friendlySeasonsEnabled)      value = 1
  disp 0x1fd3d (enableDraftMode)             value = 1
  disp 0x1fd45 (packOpeningAnimationEnabled) value = 1

Reproduced on two separate launches and two different pids.

Where the reasoning went wrong: the finding that FUN_18011dc50 is the only writer of
those bytes, and that the FutDataManagerImpl constructor never touches them, was
correct. The inference was not. The applier runs whether or not the configs array has
content, and the struct it is handed defaults these fields to 1, so the bytes were
being written all along. "Nothing populates the array" was treated as "nothing writes
the byte". Only the first of those was ever established.

Seasons therefore does not refuse because its gate byte is false. Its gate byte is
true. That diagnosis restarts, and the live test in section 3 should not be run as
written. The doc keeps the wrong turn on the record rather than quietly deleting it.

tools/gate_byte_probe.py makes this repeatable instead of a one-off. It is read-only
(O_RDONLY + pread), resolves the pid by comm, re-derives the CardsDLL slide from
/proc/<pid>/maps rather than caching it across launches, proves the slide against the
FNV prologue at 0x180180d00 read from the on-disk PE before trusting any address, and
decodes each gate displacement out of its accessor stub (0f b6 81 <disp32>) rather
than reading it from a table. Needs the client at the FUT hub, since CardsDLL loads
only then.

Also carries the two /settings changes that were pending from before: the mode
defaults to `off` (the live-proven baseline, since nothing here has faced the game)
and the transfer-pile probe is 77 rather than 100, because 100 is a stock-looking
number that would prove nothing if it showed up in game.

Live: 439 contract checks pass. check_settings_flags.py passes in all four modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:02 -07:00
funman300 897259c8fb fifa17-recon: the /settings 42-flag gate, and why Seasons never asks
ENDPOINT_MAP said this class reads one key, `configs`, and that was true and
useless. What it missed is what happens after each element closes: the client
feeds the STRING VALUE of `type` back through the atom hasher and switches on
the result, 42 arms wide. A flag is a row, not a key, and the client hashes our
string itself.

Followed it to the end. FUN_18011dc50 is the only writer of the IS_* UI gate
bytes inside FutDataManagerImpl, every line is `byte = (field == 1)`, and the
constructor never touches those bytes. So a flag nobody sends is a gate nobody
opens. friendlySeasonsEnabled and enableDraftMode have never been sent by
anything, which is a mechanism for Seasons refusing while making zero requests
to any of the four servers.

The store is the control that makes this readable: IS_STORE_ENABLED is the same
kind of byte and its screen works, because storeEnabled and friends already
ship through the Blaze config store. That list has no seasons or draft flag.

Ship the gates behind FUT_SETTINGS (off/keep/gates, default gates), and
re-assert the working store flags in the same array on purpose: once a
populated array makes the applier run, it writes EVERY gate byte, so omitting
them could switch off a screen that works today.

maximumTradePileSize=100 rides along as a positive control, because a boolean
that changes nothing cannot distinguish "the flag did not help" from "the array
never reached the consumer".

check_settings_flags.py asserts each shipped name against the atom table AND
the recovered switch, since a misnamed flag is silently inert and looks exactly
like a failed fix. enableSquadBuildingSetsFeature is the reason both checks are
needed: a real atom with no arm here.

Live: 439 contract checks pass, market unit suite passes.
Not yet tested in game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:11:20 -07:00
funman300 ccf912c157 fifa17-recon: club items crashed the client -- unestablished fields, and too wide a blast radius
The game hung and then crashed on the first equippables fetch. My fault twice over.

CAUSE, primary. I copied teamid, leagueid and value straight out of the fcc row as
extras. `value` appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
and a scalar where an object is expected is the type-desync busy loop at 0x1801c7f1a
-- which presents exactly as "the game is taking its time" and then dies. Omission is
safe; an unestablished field is not. That is this project's own rule and I broke it
for three fields that were not needed to draw a card. All three are gone.

CAUSE, contributing. The last request before the crash was type=equippables&count=11
and we answered with 30 items spanning FIVE unverified cardsubtypeids at once: the
widest possible blast radius for a wrong shape, and it tells you nothing about which
subtype was wrong. equippables now answers [] until the subtypes are confirmed one
family at a time, and shelf() takes a families= filter so a test can serve exactly one.

Adds FUT_CLUBITEMS=probe:<family>, which serves one item per candidate subtype for a
single family, so the screen names the correct subtype instead of me guessing a third
time. Eight items, one family, one question.

The flag already defaulted off, so a plain restart cannot serve any of this.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:59:38 -07:00
funman300 f5002a0d4d fifa17-recon: serve club items -- the type names are SINGULAR and they were on the wire
Arming the counters made the client name the route within seconds, exactly as it did
for consumables:

    club?type=equippables&count=11
    club?type=stadium&count=200
    club?type=ball&count=200

So it IS club?type= for this family, with SINGULAR names -- stadium and ball, not
stadia and balls -- and equippables as the combined club-customisation view. Those
requests were being answered from STORE.items(), which holds no club items because
the shelf is synthetic, so they correctly returned empty and looked like nothing was
happening.

Now serves the shelf: stadium 4, ball 6, badge 8, kit 8, equippables 30 (all four
combined), with player untouched at 205.

The cardsubtypeid values remain UNVERIFIED. cardtype 9 has no arm in the merge, so a
wrong subtype cannot announce itself; whether these render is now a live question and
the next screenshot answers it.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:55:07 -07:00
funman300 3b58b29094 fifa17-recon: club-item counts, and packs that contain more than footballers
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.

CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).

Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.

What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.

PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.

Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.

Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:51:49 -07:00