The squad parser writes actives element i to club-item slot r15d+i, and r15d
is shared scratch that other atom handlers clobber. Which slots are filled
therefore reveals the index the parse actually started from, which is the
open question behind the regression in vault section 18.
probe_resident_fields.py now prints the slot index of every populated entry
plus the empty ones, and verify_kits.sh runs it next to the map census so one
command reports both residency and whether the squad survived.
Two tools, both gated on positive controls because the previous pass
produced a confidently wrong negative result.
atom_mapper_emu.py interprets the atom -> field-id dispatch functions
instead of pattern-scanning them. Its selftest encodes the two decoder
traps that caused earlier mistakes - ModRM rm=5 with mod!=0 is [rbp+disp]
rather than RIP-relative, and a constant may reach its use through a
register - plus the live-verified controls that the item mapper maps atom
568 'players' to field id 1 and atom 11 'actives' to 0. The mandatory
manager atom 424 control still fails as a coverage limit: only 2 of the 52
resolver callers are pure dispatch chains, so the tool refuses to support
any absence claim about the squad mapper.
The live probes enumerate the resident item map at owner+0x160c8, whose
layout came from the lower_bound at 0x180119640: key = wire instance id at
node+0x20, record at node+0x28, count at owner+0x160e8. probe_map2 reaches
22 nodes against a count field of 22, so the enumeration validates itself,
and probe_hunt searches all writable memory with its own in-run positive
control. Result: all four club staff are resident, both kit ids are absent
everywhere, and a lookup miss returns the static sentinel 0x1802c2a28
whose +0x10 is NULL - which is exactly the KIT_SCAN symptom.
Records are 0x180 bytes starting at owner+0x162d8, below the embedded
manager subobject at owner+0x1f9d8. Walking that pool answers whether the
client ever builds a record for an item it was served, independently of
whether the record is filed into a collection.
Result on the live client with the kit selector open: 21 records exist -
18 squad players with category 1, plus one cardtype 10 and two cardtype 4
staff with category 0 - and no cardtype 7 record anywhere, despite two kit
items having been served three times in the same boot.
The kit clone driver FUN_1801c3480 gates on record+0x60 == 4, and no
instruction stores that immediate, so the value had to be read off a
genuinely resident record. This dumps cardtype, cardsubtypeid, itemState,
category, teamid and teamkittypetechid for both the player vector and the
club-item vector, resolving the store through the same chain as the census.
Result: all 18 resident players carry category 1 and the five club-item
slots are null, which identifies +0x60 as a per-collection tag rather than
item data.
A client-local nat rule redirecting dport 8081 to the plain-HTTP staging
UTAS host captures the roster/squad-update TLS connection, which is
https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml.
The staging host completes the TCP handshake then closes on the
ClientHello, so the client retries and shows "An error occurred
downloading the FUT squad update."
Proven by capturing on the server while probing from the client: a
connection addressed to :8081 arrives as dport 8299, while an :8443
control in the same capture yields 75 packets and a clean handshake.
The script deletes only nat rules whose destination port is 8299, then
verifies the endpoint presents CN = winter15.gosredirector.ea.com.
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.
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.
Recovered from the live retail client (pid 44405, parked on the pre-match kit
selector) by read-only /proc/PID/mem. No writes, no debugger, no detours, no
client modification. Denuvo decrypts FIFA17.exe in memory, so the immediate that
is absent on disk is present at runtime.
At live 0x147de80e8 FIFA17.exe runs a name-registration loop through the same
string-setter vtable slot (+0x20) CardsDLL uses, so each id can be given its
name:
0x7579 useSavedMatchData -> movb $1, ctx+0x151
0x757a useSavedMatchKits -> movb $1, ctx+0x152 == KITS_AVAILABLE
0x7587 setFUTServerEnvironment
271 pairs recovered and committed. This is a DIFFERENT namespace from CardsDLL's
DataProvider id table -- the same numeric id has a different name in each --
matching the APT's own split between game.uif.UIFDataProviderList and the
action/command list.
KITS_AVAILABLE therefore does not mean "the server sent kits". It means "use the
previously SAVED match kits", and the APT writes those via ACTION_SAVE_MATCH_KIT
in SaveKitsForMatch. Live, both flags read 0 on a fresh match at the exact
moment the tiles are blank. So 0 is CORRECT on first entry and the client is
designed to take the fallback path. The gate was never the defect, which is what
the 2026-08-23 measured negative was already indicating when better item data
changed nothing.
The fallback is GetKitArrayForFUT -> ION_Uniform.GetIDs(teamId), which natively
gates on team id 130000 (0x1800d8ab0) or 130001 (0x1800d8ad0) -- the synthetic
FUT home/away pair -- and otherwise defers to the generic engine catalogue. For
a matching team it packs the two active kit records and admits them through a
check that resolves at runtime to "cmp edx,0x189a1003 / setne al", i.e. merely
"not the invalid sentinel".
Still no OpenFUT change: whether the selector requests 130000/130001, and
whether the active kit records resolve, are both unanswered and both live
questions. Full write-up in the Vault under Kit Selector APT Decode section 8.
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.
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.
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.
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.
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.
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.
They were failing closed as "squad fitness". Four facts say otherwise: each
family holds exactly 21 rows = 7 types x 3 levels matching the published
"6 single attributes + 1 ALL" list; those six rows are the ONLY ones with
weightrare=2 while all 36 single-attribute rows are 0, and the published list
marks ALL rare; their amounts are exactly 3/6/10 against the documented ALL
card's +3/+6/+10; and bc=6 is one past the six real slots, an "all" sentinel,
with c0=0 reading as "not single-ATTRIBUTE" rather than "not single-target".
The misreading came from consumables.json's FUT_FITNESS_UC/MC label, which is
tool-authored -- build_consumables.py names the 7th element of its attribute
array -- and has no documented provenance. The bc/c0 values beside it ARE
reversed; only the name was not. The genuine squad-fitness card is subtype
220 in fcc_healingcards (10/20/30) and player fitness is 219; that family
stays unsupported.
The two families carry different authored ceilings, 15 single-attribute and
10 all-six, so ceiling_for() reports the right one per effect -- sending the
single-attribute ceiling for an all-six card would let a +15 all-six boost
through, granting 90 attribute points from a card worth 60.
Also corrects ENDPOINT_MAP row 7: ApplyCardByRes carries urlIndex 0x0e, which
resolves to ut/%s/item/resource, and the live verb is POST. The row claimed
PUT ut/%s/item for both apply RPCs, and that conflation is what kept the
"apply must ride PUT ut/%s/item" hypothesis alive until the POST capture
settled it -- every observed PUT ut/%s/item is a pile move.
Two corrections found while trying to close the effect boundary statically.
1. `contract: 7` IS OUR OWN PLACEHOLDER. fut_store.py:232's generic _item()
factory -- which builds every item the oracle serves -- hardcodes
playStyle 250 / contract 7 / fitness 99 on players and consumables alike. The
staging GK reads back exactly those three constants. So the production
catalog's contract:7 for resource 5001004 is an oracle placeholder
round-tripped through an observed profile, not an EA value. Its status is not
INFERRED, it is KNOWN-BOGUS as a source. Had the effect been implemented on
it, it would have been a fabricated game rule wearing observed-data clothing.
2. fcc_contractcards is NOT amount-less. An earlier note here claimed it "has no
amount column, so this value comes from observed data". It has 13 rows with
gold/silver/bronze/rating, 6 player + 6 manager paired by rating plus a
99/99/99 special. The sibling fcc_healingcards shares every column except
that it carries a single `amount`, which argues the differing columns ARE the
effect payload (per target tier). Against that: the values are non-monotonic
across tiers, which suits weights better than amounts; and no column of
5001004 is 7, so neither reading explains the placeholder.
The reader that would settle amount-vs-weight is in FIFA17.exe, not CardsDLL
(the table and column literals are absent from the DLL), so this stays
EFFECT_UNKNOWN rather than being guessed.
Also records, in content_taxonomy.rs, the competing reading of `development`:
fut_consumables.py's TYPE_CATEGORIES groups it as card-categories {6,7,8,9,10}
(modifiers only), explicitly flagged there as inferred from UI-bucket names and
never observed on the wire. Different enum space from the CONSUMABLE_TYPE switch
that actually emits the segment, and the switch gives formation/position/
playStyle/managerLeagueModifier their own segments rather than folding them into
development -- so the unfiltered reading is better supported, but it is still a
reading and the doc now says so instead of sounding settled.
248 adapter tests, fmt clean. No behaviour change.
Milestone 2: the consumable-apply protocol is now LIVE_PROVEN.
Adds opt-in passthrough BODY logging (OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1,
default off, capped at 512 bytes) because a body is what names an unknown
mutation's operands, while also being the one place a request could carry
something that should not reach a log. Staging probe only.
With it, one operator apply captured the whole thing:
POST /ut/game/fifa17/item/resource/5001004
{"apply":[{"id":100000003}]}
source consumable : resource 5001004 (player contract, subtype 201) -- in the PATH
target item(s) : wire 100000003 (squad slot 0 GK, resourceId 200389) -- body apply[]
verb : POST
There is NO /apply endpoint, exactly as the static route work concluded. The
apply re-uses `ut/%s/item/resource`, which we already serve for GET (definition
lookup); the POST verb on that path is the mutation and nothing claimed it. This
is the wire form of the ApplyCardByRes task (id 0x0e), which is why the source is
a definition id rather than an instance id. `apply` is an array, so one resource
can name several targets.
Fail-closed verified: with the upstream dead the request 502s and Core is left
exactly unchanged -- coins 29,843,976, owned 1993, consumables 17, source card
still owned. No partial mutation.
NOT implemented: the response shape is unobserved and the EFFECT is unreversed.
Our catalog carries contract:7 for 5001004, documented as the matches granted,
but that is observed profile data (INFERRED), so no effect is written on it.
Bonus, caught by the same logging: the client really does request
`club/consumables/development`, which has no arm in
consumable_families_for_category and is served empty. Recorded, not guessed.
Host 120 lib tests, fmt clean.
Milestone 2 groundwork.
The passthrough arm forwarded to Python without ever recording WHAT was asked
for, so on staging -- where the upstream is deliberately dead -- an unhandled
request produced an anonymous 502. It now logs method, path and body length
before forwarding, which is how the next unclaimed route gets identified:
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=0
Also records the FUT TASK vocabulary read out of the live client. The client
drives UTAS through named tasks held in a CardsDLL .rdata table of 0x20-byte
MixedCase/UPPERCASE slots, with a .data descriptor table giving each a task id:
ApplyCard 0x0d, ApplyCardByRes 0x0e, ConsumeCard, ActivateCard, AssingCard(sic),
MoveCard, MoveCardByRes, SwapCard, DiscardCard, DiscardCardByRes, ViewCards, ...
So consumable application IS a first-class client action even though the route
table contains no /apply endpoint -- it must ride an existing route. The
descriptor's function pointer is a `mov [rip+flag], cl; ret` setter, not a
request builder, so the request is assembled elsewhere keyed by task id; that is
cheaper to answer with one live capture than with more static tracing.
Search tooling carries mandatory positive controls (tradePile, ut/%s/item, squad
-- all FOUND), so the "no /apply route" result is a valid negative rather than a
failed scan.
Host 120 lib tests, fmt and clippy clean.
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).
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.
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.
Quick-sell paid an invented five-tier rating ladder (its own comment said
"PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a
94-rated TOTW special and a 94-rated gold common both sold for 1500, and every
non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder
existed in three places (adapter wire, host payout, an integration test's private
copy), which is a drift waiting to happen.
Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins
table and its formula, round_half_up(rating * price / 100), keyed
(cardtype, level, rare). All of it is already reversed in
plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live
club items, 22 of 22 exact. DISCARD_COINS is generated from
fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and
asserts row-for-row agreement, so the transcription cannot drift.
Collapse the three ladders into one method. ItemIdentityResolver::discard_value
both stamps the wire discardValue and prices the sale, because a non-zero
discardValue suppresses the client's local computation -- whatever is sent is
what the player is promised. The host's quick_sell_value is deleted and the
integration test's copy now calls the single implementation. A test with a
resolver double returning an impossible price proves the credit follows the wire;
reverting the payout to a ladder fails it.
Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real
1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for
specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an
operator's decision.
Staff decline to the ladder rather than pay 0: the client re-rates cardtypes
2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not
guessed -- see the falsifier in the doc.
Verified on staging with the real club, both modes: flag off 1500 wire / 1500
paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100).
Consumables price from their catalog rating and agree with the client's own
computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy
clean.
Decoded the vocabulary from the binary rather than trusting a case count:
FUN_18012ec50 is `cmp ecx,0x1d` plus a 30-entry jump table at 0x18012ed9c, each
case `mov ecx,<atom>; jmp <atom->string>`. Resolving those atoms against
fut_atoms.tsv yields the exact token list, and it matches club_type_filter
one-for-one — 30 implemented, none missing, none invented.
That is worth a test rather than a note. A MISSING arm answers a real tab with
unsupported_type and an empty screen; an INVENTED arm is worse, because it is
dead code that looks like coverage. Mutation-checked: renaming the leaguelogos
arm fails the test.
Two facts fall out that were previously guesswork. There is no
`playergoalkeeper` token — the client has only DEF/MID/FWD tabs — so a
goalkeeper appearing under playerdefender is CORRECT and not a filter bug, which
I had flagged as suspicious while sweeping. And `healing`/`contract`/`training`
exist as ?type= arms even though consumables have their own route.
Also completes the last unapplied item of plan section 7: the full vocabulary is
now written into ENDPOINT_MAP.md with how it was derived.
Rows 12, 13 and 16 carried guessed or placeholder URLs (`ut/%s/item?type=…`,
`ut/%s/…`). The real binding is a table, not an inference: the 125-row action
table at 0x1802caa20 indexes the 48-entry URL-base table at 0x18021df80 through
column 1, and base index 3 = ut/%s/club is carried by exactly four rows. So the
client can emit exactly four families on that base:
ClubSearch, ClubStats, StaffStats, ConsumablesSearch — which also corrects those
three rows to /club?<query>, /club/stats/staff and /club/consumables/<cat>, and
updates their status now that the Rust host serves them.
Added the complete club query grammar (ordered, with its suppression rules and
sub-vocabularies, including that the request spells it onSale where the response
says forSale), the seven /club/stats forms, the fact that /club/stats/team does
NOT exist, and the two base-table holes that are composed outside CardsDLL so
nobody re-derives them as findings.
Section 7 of the plan is now marked APPLIED and kept as the audit trail.
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.
Chased the league-logo lead to a useful boundary and stopped there.
FUN_180119bd0 — the cardtype-7 caption resolver the whole club-item story rests
on — has ZERO references anywhere in CardsDLL: no call, no jmp, address never
taken in .text/.rdata/.data. It is nonetheless a real function. An unreferenced
real function in a DLL is almost certainly an export, which puts its caller in
FIFA17.exe. So the owned cardtype-9 caption path is not in CardsDLL and looking
for it there is wasted effort; the launch probe remains far cheaper than parsing
the export table and 79 MB of EXE.
Also verified definitionId a fourth way, by a different method than the existing
three: every real atom name appears exactly once in CardsDLL's .rdata
(resourceId, cardsubtypeid, itemState, assetId, cardassetid, rareflag, owners,
contract, discardValue, and localizedName), while definitionId is absent
entirely. Recorded but NOT applied — the path carrying it is live-proven and the
saving is payload only.
Method note added: CardsDLL is .text 0x180001000, .rdata 0x1801e5000, .data
0x18028a000. Confusing a live mapping offset with an image offset reads the wrong
section and returns false negatives — it made every atom lookup, controls
included, come back ABSENT until corrected. Validate scans against a known key.
FUN_180098f20, previously described as the league-logo function with a hedged
"localizedName, probably", read in full: it queries fcc_leaguelogos WHERE
leagueid == %d, reads carddbid/value/cardassetid, and captions with
'LeagueName_Abbr_15_%d' in the 'FUT String' domain. A database-backed league
name therefore exists, in exactly the shape kits use for teamid — so "cardtype 9
has no DB name resolver" is too strong for league logos.
Deliberately NOT concluded: its only caller passes [rbx+0x20] as the league id,
and rbx there is a loop cursor over small list elements (int/double/int), not the
0x158-byte card record. Reading that as the record's assetId and shipping "send
leagueid as assetId" would be the exact inference this document exists to
prevent. Recorded as a lead with the next question named: does the OWNED render
path reach this resolver, and which field feeds it?
FUN_1801aa190 (the plan's "two minutes of work" item) is eleven instructions and
resolves TWO parallel arrays, not the one the earlier claim described:
f(self, idx, which) reads item+0x104+idx*4 when the flag is clear and
item+0x124+idx*4 when it is set — eight ints each, 0x20 apart. Live, BOTH read
all zeros on every resident record including a rating-94 player, so neither can
be the reason any action is greyed today.
The FUT roster database question is partly answered. Scanning FIFA17.exe in the
live process recovers the full API name set — StartFUTRosterDownload,
DL_FUT_LIVEDB, APPLY_FUT_LIVEDB, LoadFUTDatabase, UnLoadFUTDatabase,
SetFUTDatabaseUnloaded, UpdateFUTDBVersion, GetFUTDBCRC, RosterXMLDownloadedFail,
.dbFUTVer/.dbMajor/.dbMinor/CRCs — none of which exists in CardsDLL. That is a
downloaded, versioned, CRC-checked live database with its own lifecycle, which is
categorically not the shipped card tables. Whether it is loaded RIGHT NOW is
still open: the load flag was not located, and the absence of an open DB file
proves nothing since the process only holds Frostbite bundles.
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.
Ball (30), league logo (31) and misc (231/232/233/236) were tracked as three
separate holes. They are one: cardtype 9 has no database name resolver, so the
displayed name can only come from `localizedName` on the wire, and that single
unproven step gates all three. The cardtype-7 families caption themselves from
the client's own tables, which is why kit, badge and stadium now project.
Ownership, content_kind, club/stats counting and restart durability are already
in place for all three, so the outstanding launch probe is the only remaining
work.
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.
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.
`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.
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).
Reversed from the pinned CardsDLL (4706a881): eligibilityKey and
eligibilityOperation are localization ordinals (LOC_SBC_ELG_KEY_%d),
not the atom hex ids. The client's only consumer is the requirement-
display string builder at ~0x1800ef900 (formats via indexed locale
keys, no comparison/gate). The ordinal->string map lives only in the
packed locale (absent from all assets we hold), so any emitted value
would render the WRONG requirement text. Submission stays fully
validated server-side by Core; the empty elgReq is display-only.
Correct ENDPOINT_MAP.md's implied atom-id==ordinal assumption and
pin the exact remaining blocker at the emit site.
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.
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.
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.
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.
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).
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.
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).
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.