Files
OpenFUT/fifa17-recon/tools/login_dump/UNDERAGE_PLAN.md
T
funman300 edab23f04a fifa17-recon: package the working offline FUT backend
Emulates FIFA 17's full online + Ultimate Team stack against an offline,
clean-room backend (no EA servers). Proven end-to-end 2026-08-01:
Origin login -> Blaze login -> device-trust -> the FUT hub.

Package:
- tools/openfut-fut.sh   one-command orchestrator (start/stop/status/restart)
- tools/root_arm.sh      idempotent host arm (sysctls, DNAT, /etc/hosts easw)
- tools/{lsx_responder_v2,blaze_responder_v3b,roster_server,utas_server,autopatch}.py
  the 5 servers (Origin LSX :4216, Blaze :42127/42130/42131, roster :8081,
  FUT/UTAS :8099) + heat2.py (Fire2/Heat2 TDF codec)
- FUT-RUNBOOK.md         runbook + gate-ladder troubleshooting
- docs/, tools/login_dump/*.md   the reverse-engineering write-ups

All findings are clean-room, from binaries we own; nothing from any leak.
The wire protocol maps 1:1 to FIFA 23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
2026-08-01 09:12:17 -07:00

418 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UNDERAGE_PLAN — FIFA17 `OSDK_UNDERAGE_ERROR` : source, fix, and the live experiment
Clean-room synthesis of four independent reverses, **re-verified end-to-end by me against live
`pid 39211` (`/proc/39211/mem`)** on 2026-07-31 ~16:10. Every VA/byte below is either read out of that
process this session or quoted from our own `/tmp/lsx.log` / `/tmp/blaze_responder.log`.
No leaked EA source or headers were consulted.
---
## 0. HEADLINE (one paragraph)
**`OSDK_UNDERAGE_ERROR` is not an age check, and Origin error `0xa2000012` was never produced.**
The label is a *mislabelled catch-all* on the OSDK login classifier `0x14717d5d0`, reachable from
**three** conditions, only one of which is the `0xa2000012` compare. We are on a different arm:
**the Origin auth-code string is NULL.** It is NULL because `lsx::AuthCodeT`'s deserializer
(`0x1471312a0`) reads exactly one attribute — lowercase **`value`** (`0x1436c7768`) — while
`lsx_responder_v2.py` was answering `<AuthCode Code="…" Return="…"/>`. The parse "succeeds" with a
zero-length string, `OriginRequestAuthCodeSync` returns **0 with `out_len == 0`**, the Ebisu manager
refuses to cache a zero-length code, and the classifier falls into the underage arm.
**Fix = one attribute name.** It is already applied (`lsx_responder_v2.py:385`); it needs a FIFA
relaunch to observe, because the OSDK state machine is latched.
---
## 1. THE SOURCE — end to end, tied to an input we control
### 1.1 The classifier and its three arms (byte-exact, read live)
`0x14717d5d0` is the OSDK login-state classifier. Live bytes at `0x14717d6e8` (read from
`/proc/39211/mem` this session):
```
488b06 4889f1 ff9080000000 3d120000a2 7434 4885ed 742f 807d0000 7429 488b0deb94a0fd ...
```
decoded:
```
14717d6e8 48 8b 06 mov rax,[rsi] ; rsi = the 'ebmg' sub-object
14717d6eb 48 89 f1 mov rcx,rsi
14717d6ee ff 90 80 00 00 00 call [rax+0x80] ; -> 0x147237430 = GetLastError()
14717d6f4 3d 12 00 00 a2 cmp eax,0xa2000012 ; ARM 1 -- the "age" compare
14717d6f9 74 34 je 0x14717d72f
14717d6fb 48 85 ed test rbp,rbp ; ARM 2 -- auth-code ptr == NULL <<< WE ARE HERE
14717d6fe 74 2f je 0x14717d72f
14717d700 80 7d 00 00 cmp BYTE PTR [rbp+0x0],0 ; ARM 3 -- auth code == ""
14717d704 74 29 je 0x14717d72f
--- SUCCESS PATH ---
14717d706 48 8b 0d eb 94 a0 fd mov rcx,[rip+…] ; # 0x144b86bf8 (the Blaze manager)
14717d70d 48 8b 01 mov rax,[rcx]
14717d710 ff 90 88 01 00 00 call [rax+0x188]
14717d716 31 d2 xor edx,edx
14717d718 48 89 c1 mov rcx,rax
14717d71b e8 e0 7a c3 ff call 0x146db5200
14717d720 48 89 ea mov rdx,rbp ; <-- HANDS THE AUTH CODE ON
14717d723 4c 8b 00 mov r8,[rax]
14717d726 48 89 c1 mov rcx,rax
14717d729 41 ff 50 30 call [r8+0x30] ; subsys->vt[0x30](authcode)
14717d72d eb 9b jmp …
--- THE LABEL ---
14717d72f 48 8d 05 7a f2 7d fc lea rax,[rip+…] ; # 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
mov [rdi+0x80],rax ; lea edx,[r8+0xa] ; state 10
```
All three `je` displacements land on `0x14717d72f` (verified: `0x14717d6fb+0x34`, `0x14717d700+0x2f`,
`0x14717d706+0x29`). `rbp` is loaded at `0x14717d661` from `call [rdx+0x68]`.
Live string read: `0x14395c9b0 = b'OSDK_UNDERAGE_ERROR'`.
### 1.2 The sub-object: `mgr->vt[0x60]` is a FourCC hashmap `find`, not a getter
`0x14719b1b0` is `mov rcx,[rcx+0x358]; jmp <open-hash find>`. The classifier at `0x14717d5f3` passes
`mov edx,0x90c3a20f; lea edx,[rdx-0x2b6134a8]` = **`0x65626d67` = `'ebmg'`** (the Ebisu/Blaze-auth
component; the sibling site `0x14717d7b0` uses `0x636e6e63` = `'cnnc'`, the connMgr).
Live walk I performed on pid 39211:
```
mgr = *[0x144b86bf8] = 0x43c46c70 (vptr 0x143959168)
map = [mgr+0x358] = 0x43d15f38
buckets= [map+0x50] = 0x43d15fd0 , nbuckets = [map+0x58] = 193
0x65626d67 % 193 -> node 0x43d16880 -> value
ebmg = 0x43d317a8 (vptr 0x143972550)
vt[0x60] = 0x147237c30
vt[0x68] = 0x147237350 <- GetAuthCode(blazeServerClientId)
vt[0x80] = 0x147237430 <- GetLastError
```
`0x147237430` live bytes = `8b 81 50 09 00 00 c3` = **`mov eax,[rcx+0x950]; ret`**.
So the classifier's error input is literally the field **`ebmg+0x950`**, and `rbp` is **`ebmg+0x948`**.
### 1.3 The writer of `ebmg+0x950` — full disassembly of `vt[0x68]` (live)
```
147237350 push/sub…; xor edi,edi ; rdi = 0
14723735f mov [rsp+0x48],rdi ; out_ptr = 0
147237364 mov [rsp+0x50],rdi ; out_len = 0
147237369 call 0x1471995b0 ; = mov rax,[0x144b86bf8]
147237374 call [rdx+0x188] ; = mov rax,[rcx+0x360] (the CONF holder)
14723737a mov rcx,[rax+0x750]
147237381 test rcx,rcx
147237384 je 0x1472373e0 ; EARLY-OUT -- does NOT touch +0x950
14723738e lea rdx,[rip+…] # 0x143972690 = "blazeServerClientId"
14723739a call [rax+0x48] ; cfg->GetString(key)
14723739d call 0x1470da6d0 ; OriginGetDefaultUser
1472373b9 call 0x1470db3c0 ; OriginRequestAuthCodeSync
1472373be mov DWORD PTR [rbx+0x950],eax ; *** THE ERROR WRITE ***
1472373c4 test eax,eax ; jne 0x1472373e0 ; err != 0 -> no cache
1472373c8 mov rax,[rsp+0x48] ; test rax,rax ; je ; out_ptr == 0 -> no cache
1472373d2 cmp QWORD PTR [rsp+0x50],rdi ; je ; out_len == 0 -> no cache <<< WE DIE HERE
1472373d9 mov QWORD PTR [rbx+0x948],rax ; cache the code
1472373e0 mov rax,[rbx+0x948] ; ret ; returns rbp for the classifier
```
`0x1470db3c0`'s logger string is `0x143936180 = "OriginRequestAuthCodeSync"`; its body is
`call 0x1470e2840` (SDK-ready gate) → `call 0x1470e67f0` (the impl).
The sibling `0x147237440` is identical but keys on `0x1439726a8 = "blazeSdkClientId"` and caches into
the `char[0x400]` at `ebmg+0x140`, writing the same error slot at `0x1472374c5`.
**Only three sites in the entire image write `+0x950`:** the ctor `0x147236d1b`
(`mov DWORD PTR [r14+0x950],ebp`, adjacent to `mov [r14+0x948],rbp` at `0x147236d14`; `rbp` is the
ctor's zero register — it is also written into the bool at `+0x110` via `mov [r14+0x110],bpl`), and
the two `mov [..+0x950],eax` immediately after `call 0x1470db3c0`.
**Therefore `ebmg+0x950` can hold nothing but `OriginRequestAuthCodeSync`'s return value.**
That function's reachable returns are `0`, `0xa2000004` (INVALID_ARGUMENT), `0xa2000003`
(INVALID_USER, the gate we already cleared), or the LSXRequest's own error field via
`call [rax+0x40]`. `0xa2000012` is not among them.
### 1.4 `0xa2000012` is a decoder-table constant — it is not, and cannot be, our value
Live reads:
```
codes[] @ 0x144340ef0 (uint32[81]) ; codes[21] @ 0x144340f44 = 0xa2000012
strings[] @ 0x144340750 (const char*[81][3], stride 0x18)
strings[21] @ 0x144340948 = { "ORIGIN_ERROR_AGE_RESTRICTED",
"ORIGIN_LEVEL_2",
"The item has age restrictions." }
```
Sanity anchors from the same arrays: `codes[6]=0xa2000003 -> ORIGIN_ERROR_INVALID_USER`,
`codes[7]=0xa2000004 -> ORIGIN_ERROR_INVALID_ARGUMENT` — matching the classifier and `0x1470e67f0`.
The **only** reader is `0x14712c850`, a linear search **by value** (`cmp rdx,0x51`) returning
`&strings[3*i]`, called from `0x1470dbe00` (`ErrorCodeToDescription`, returns `triple[2]`) — a
**logging decoder**. A RIP-relative xref scan over `0x145000000-0x14a000000` for the table region
returned exactly two operand refs, both inside `0x14712c850`. Nothing indexes the table to *produce*
a code, and BRIEF4's arithmetic scan already found 0 construction sites.
Full-address-space scan for the dword `12 00 00 a2`: 4 hits — the table entry `0x144340f44`, the
classifier's `cmp` immediate at `0x14717d6f5`, and two unaligned hits in compressed asset data.
**The genuine underage error is a different code entirely:** `codes[57] @ 0x144340fb8 = 0xa2060005 =
`ORIGIN_ERROR_COMMERCE_UNDERAGE_USER`. The classifier never references it.
### 1.5 LIVE PROOF of which arm fired
```
[0x43d317a8+0x140] = 40 bytes of 0x00 (blazeSdkClientId cache: empty)
[0x43d317a8+0x940] = 0x1
[0x43d317a8+0x948] = 0x0 ; <-- NULL auth code => ARM 2
[0x43d317a8+0x950] = 0xdeadbeef ; <-- NOT 0xa2000012
osdk state obj 0x43d189d8 (vptr 0x14395c180):
+0x80 -> 0x14395c9b0 = "OSDK_UNDERAGE_ERROR"
+0x7c = 10
+0x260 = 16 (0x10 = the latch)
```
**Adjudication of the reports' one disagreement.** Two reverses read `+0x950 == 0` (~15:52), two read
`0xdeadbeef` (~15:58, and I confirm `0xdeadbeef` now). One report concluded from `0xdeadbeef` that
`vt[0x68]` **never ran** (early-return on `[cfg+0x750] == NULL`) and proposed an *ordering* fix.
**That is refuted by our own bytes:**
* the ctor writes **`ebp` (= 0)**, not `0xdeadbeef`, to `+0x950` (`0x147236d1b`);
* the early-out at `0x147237384` jumps to `0x1472373e0` and **never touches `+0x950`**;
* so `0xdeadbeef` can only have arrived through `mov [rbx+0x950],eax` after `call 0x1470db3c0`
— i.e. `ebmg::GetAuthCode` **did** execute past the CONF gate;
* it is not allocator poison: I dumped the full 0x1000 bytes of the object and `ef be ad de` occurs
**exactly once**, at `+0x950`, with zeros on both sides.
Ordering is fine too: `[mgr+0x360] = 0x43c47330`, `[+0x750] = 0x43c47ff0` (non-NULL, live), and
`Util::preAuth` was answered at `[15:36:36]` (`/tmp/blaze_responder.log:36234`) while the five
`GetAuthCode` requests are `/tmp/lsx.log` lines 100120 of 127, with the log's last write at
`15:36:57`. So the auth-code fetches happened **after** the CONF map was installed.
The two `+0x950` readings tell one consistent story: **while the LSX socket was live, the fetch
returned `0` (success) with a zero-length code** (the empty-`value` signature — exactly ARM 2/3);
**after the socket went quiet, later fetches returned a garbage `0xdeadbeef`** from the failed
request object. Neither reading is `0xa2000012`.
### 1.6 THE CONDITION, tied to the input we control
Response handler for `LSXRequest<GetAuthCodeT, AuthCodeT, …>` is `0x1470e4ee0`. It sets
`[rbx+0x160] = 0` then calls the matcher `0x1470e2a70`, which checks root `"LSX"` (`0x143938024`),
attr `"id"` (`0x14355de00`), attr `"sender"` (`0x143938028`, byte-compared against the request's
recipient — the fix we already landed), then at `0x1470e2b63` matches child element
`0x143937ae0 = "AuthCode"` and tail-jumps `0x14712fac0 -> 0x1471312a0` = the `lsx::AuthCodeT`
deserializer.
Live disassembly of `0x1471312a0`'s attribute build/read (read this session):
```
14713131f … build ns prefix for "lsx" (0x14394def0) via 0x14713f940, optional ":" (0x143559ab0)
14713133d 4c 8d 05 24 64 59 fc lea r8,[rip+0xfc596424] # 0x1436c7768 <-- live cstr = b"value"
14713134c e8 df bd ff ff call 0x14712d130 ; concat -> attribute name
1471313e1 e8 6a ea 00 00 call 0x14713fe50 ; get-attribute-as-string (ONE call, only call)
14713141b b0 01 mov al,0x1 ; *** ALWAYS RETURNS SUCCESS ***
```
And the getter itself tolerates a missing attribute:
```
14713fe50 call [rax+0x50](name) ; attribute present?
14713fe68 je 0x14713febf ; -> `xor al,al; ret` -- dest left EMPTY, no error propagated
```
So a reply without a `value` attribute parses as **success with an empty `std::string`**.
That string is `LSXRequest+0xb8`, size at `+0xc8` — precisely what the impl reads back:
```
1470e6924 mov rbx,[rdi+0xc8] ; *out_len <- 0
1470e6965 mov [r15],rax ; *out_ptr <- non-NULL 1-byte alloc
1470e6968 mov [r12],rbx ; *out_len <- 0
returns 0 (SUCCESS)
```
`out_len == 0` → `0x1472373d7 je` → `+0x948` never written → `rbp == NULL` → **ARM 2** → state 10.
**What we actually sent this boot** (`/tmp/lsx.log`, ids 2529, all five):
```
<LSX><Response id="26" sender=""><AuthCode Code="OPENFUT-000000000000000000000000" Return="OPENFUT-000000000000000000000000"/></Response></LSX>
```
No `value` attribute. **This is the input we control, and it is the whole cause.**
*(Why `Code=`/`Return=` were chosen: the responder's old comment cited "Return" parsers `0x1471351e0`
/ `0x147136af0`. Their real xrefs are `0x14713522f` / `0x147136b3f`, both of which clear a
`std::vector` at `[r14+0x20]` — list-response parsers, not `AuthCodeT` (a single `std::string`).)*
**Cross-validation that this machinery is understood correctly:** `GetGameInfo` uses the identical
chain (`0x1470da720` → matcher `0x1470e3090` → `0x147135800`) with attribute
`0x14394e088 = "GameInfo"` — and our `<GetGameInfoResponse GameInfo="true"/>` demonstrably passes the
classifier's `strncmp(buf,"true",8)` at `0x14717d68d` (otherwise we would be showing
`0x1439633c0 = "TXT_ORIGIN_GAME_VERSION_OUT_OF_DATE"` from `0x14717d749`, not the underage label).
**Attribute names are per-response-type. For `AuthCode` it is `value`.**
### 1.7 What is definitively NOT involved
* **No age/DOB input anywhere.** `GetProfile`'s `IsUnderAge` is parsed correctly (`0x147136140` →
bool helper `0x14713ffa0`) and our `IsUnderAge="false"` lands as 0.
* **`QueryEntitlements` was never requested this boot** (`/tmp/lsx.log` verbs: `GetConfig`,
`GetSetting`×5, `IsProgressiveInstallationAvailable`, `GetInternetConnectedState`×2,
`GetGameInfo`×6, `GetProfile`×3, `GetAuthCode`×5, `SetPresence`×7, `SetDownloaderUtilization`).
* **Nucleus :42131 was never hit.**
* **`0xa2000012` (`ORIGIN_ERROR_AGE_RESTRICTED`) is a decoder-table string, never a produced value.**
---
## 2. THE FIX — ranked and minimal
### FIX 1 (the fix) — `lsx_responder_v2.py`, one attribute. **STATUS: APPLIED**
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/lsx_responder_v2.py`, `build_reply()`,
`GetAuthCode` branch (`:344`), reply line `:385`:
```python
return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
```
The load-bearing part is lowercase **`value`**; `Code=`/`Return=` are inert padding (the deserializer
makes exactly one attribute lookup and ignores everything else) and are kept only so the diff is
additive. The element name `AuthCode`, the `id` attribute and the **sender-echo**
(`sender` must byte-equal the request's `recipient` — hardcoded `strcmp` at `0x1470e2b49`) were
already correct and must not be touched.
Verified: `python3 lsx_responder_v2.py --selftest` emits
```
<LSX><Response id="42" sender=""><AuthCode value="OPENFUT-000000000000000000000000" Code="…" Return="…"/></Response></LSX>
[ok] selftest passed
```
The value only has to be **non-empty**: `0x1470e67f0` does no format validation — it `memcpy`s
`[req+0xb8]` of length `[req+0xc8]` into a fresh `len+1` allocation. Both fetchers
(`blazeServerClientId` → `+0x948`, `blazeSdkClientId` → `+0x140`) go through this same deserializer,
so one edit covers both.
### FIX 2 — `blaze_responder_v3b.py`: **NO CHANGE REQUIRED** (verified)
`Authentication::login` (1/0x0A) at `blaze_responder_v3b.py:1040`:
```python
if cmd == CMD_LOGIN:
sess.auth_code = get_str(fields or {}, "AUTH", "")
```
It **stores and never validates** the `AUTH` string, and `get_auth_token_response_fields()` (`:906`)
echoes `sess.auth_code` back. So the `OPENFUT-0000…` placeholder is accepted as-is. Keep the LSX code
and the Blaze `AUTH` string identical (the classifier's success path hands `rbp` straight to
`subsys->vt[0x30]` at `0x14717d729`, which is what ends up in `LoginRequest.AUTH`) — that is already
guaranteed because the responder writes the code to `AUTHCODE_FILE`.
### FIX 3 (defensive, optional) — do not stop the heartbeat too early
`lsx_responder_v2.py` sets `conn.stop_events = True` on the first `GetAuthCode`. That is why the LSX
log dies at 15:36:57 while the game keeps re-entering the fetch (each later fetch now returns
`0xdeadbeef`). Harmless for the gate itself, but it removes our only wire-level visibility of the
retry. Consider gating `stop_events` on "we have seen a Blaze `Authentication::login`" instead of on
the first `GetAuthCode`.
### Success signals, in order
1. `/tmp/lsx.log` shows `>> …<AuthCode value="OPENFUT-…"…>` (the emitted frame carries `value=`).
2. `ebmg+0x948` becomes a **non-NULL pointer to ASCII**, and `ebmg+0x950` reads **0**.
Re-find `ebmg` from scratch: `mgr = *[0x144b86bf8]` → `map = [mgr+0x358]` →
`buckets = [map+0x50]`, `n = [map+0x58]` → bucket `0x65626d67 % n` → walk `[node+0x10]` for
`dword[node] == 0x65626d67` → `obj = [node+8]`; assert `[obj] == 0x143972550`.
3. The OSDK state object (re-find by vptr `0x14395c180`) leaves the label:
`+0x80` no longer points at `0x14395c9b0`, and `+0x7c != 10`.
4. `/tmp/blaze_responder.log` shows **`RX … Authentication::login`** (component `0x0001`, cmd
`0x000A`) instead of the current `Authentication::logout` (1/0x46) give-up.
5. On screen: the "not eligible … age restriction" dialog is gone.
---
## 3. Memory-forge A/B — what was tried, and the server-side equivalent
**A memory forge of the field was attempted and it did NOT confirm the fix — for a reason we fully
understand and have proven.** Report it honestly:
| A/B | What was poked | Result |
|---|---|---|
| A/B 1 | `ebmg+0x948` ← pointer to a valid 32-char C string forged into the `char[0x400]` at `ebmg+0x140` | 24 s of polling: `osdk+0x80` stayed `0x14395c9b0`, `+0x7c` stayed 10, no Blaze traffic |
| A/B 2 (tick detector) | the 4-byte RIP displacement at `0x14717d732` patched `48 8d 05 7a f2 7d fc` → `48 8d 05 da f2 7d fc` so the underage arm would store `0x14395ca10` (`"OSDK_INVALID_USER"`) instead | 10 s: `osdk+0x80` never changed ⇒ **the classifier did not execute even once** |
| A/B 3 | `ebmg+0x950` ← `0xdeadbeef` written deliberately, re-read 2 s later | still `0xdeadbeef` ⇒ nothing overwrote it ⇒ same conclusion |
Both pokes were reverted and verified byte-identical; pid 39211 is still alive and unchanged (I
re-read all of the above from it after the fact).
**Why the forge could not work:** the OSDK login status is **latched**. `0x147190d50` opens with
`movsxd rax,[rcx+0x260]; cmp eax,0x10; je <ignore>` and sets `0x10` on the first accepted write.
Live: `[0x43d189d8+0x260] = 0x10`. Once latched, the classifier is never re-invoked, so no field you
forge can be re-read. (`0x14717d5d0` also has **zero** direct `e8/e9` callers and zero qword vtable
slots — it is reached through image-relative jump-table entries at `0x143fef788 / 0x143fef79c /
0x143fef7ac`, dword `0x0717d5d0` — so we cannot cheaply hand-call it either.)
**The field itself is nonetheless confirmed by pure observation, without a forge:** `ebmg+0x948` is
NULL *and* `ebmg+0x950` has never held `0xa2000012` (it is provably restricted to
`OriginRequestAuthCodeSync`'s return set), and there is exactly **one** RIP-reference to
`0x14395c9b0` in `0x145000000-0x14a000000` — `0x14717d732`. There is no other producer of the label.
**Server-side equivalent of the forge** (this is what FIX 1 does, at boot, with no memory writes):
where the forge wrote a `char*` into `ebmg+0x948`, the responder makes the game write it itself, by
supplying a non-empty `value` attribute so that `out_len != 0` and `0x1472373d9
(mov [rbx+0x948],rax)` executes naturally. Same destination, same pointer semantics, but taken
through the real code path — which additionally sets `+0x950 = 0` and, crucially, happens **before**
the `+0x260` latch closes.
---
## 4. What still needs a live experiment
**This fix is boot-time and cannot be validated on pid 39211. FIFA 17 must be relaunched.**
Both preconditions are unrecoverable in the current process: the state latch
(`0x43d189d8+0x260 == 0x10`) and the dead LSX socket (last write `15:36:57`).
### Run procedure
1. Leave `lsx_responder_v2.py` as-is (fix already at `:385`) and `blaze_responder_v3b.py` untouched.
2. Rotate the logs (`mv /tmp/lsx.log /tmp/lsx.log.prev`, same for `/tmp/blaze_responder.log`) so the
`value=` frames and the `+0x948` transition are unambiguous. **Also keep the rotation** — see
open question O1.
3. Restart both responders, launch FIFA 17, `pgrep -x FIFA17.exe` for the new pid.
4. Watch signals 15 from §2 in order. Poll `ebmg` and the OSDK state object with the re-find recipes
in §2 (never reuse `0x43d317a8` / `0x43d189d8` — those are this-boot heap addresses).
### Open questions the run should settle
* **O1 — was `0xa2000012` ever genuinely observed?** We could not reproduce it live and the code
cannot produce it in this path. It may have been inferred from the `cmp` immediate rather than
read. If a rotated `/tmp/lsx.log*` or an older `+0x950` reading really shows it, then a different
run took the `0x1470e4fbf` path (`cmovne edx,[rsp+0x40]` — an integer read straight out of the
response), which would mean a stray numeric field in one of our replies. Worth one `grep` of the
older log rotations before the run.
* **O2 — does the *second* fetcher also need to succeed?** `0x147237440` (`blazeSdkClientId`,
`FIFA17PC`) caches into `ebmg+0x140` and is gated by `cmp BYTE PTR [rcx+0x140],0` (cache-once,
live value 0/empty). After the fix, confirm **both** `+0x948` (from `FIFA17PC-SERVER`) and `+0x140`
(from `FIFA17PC`) get populated — Blaze may want the server-scoped code specifically. Note the two
fetchers **share** the single error slot `+0x950`, so a failing `blazeSdkClientId` fetch can clobber
a successful `blazeServerClientId` one; if that becomes a problem, the codes may need to differ
per `ClientId` (the responder already logs `ClientId`, so this is a one-line change).
* **O3 — does Blaze `Authentication::login` accept the placeholder?** Statically it does
(`:1040` stores `AUTH` without validation), but the wall may move: watch for
`AUTH_ERR_INVALID_PERSONA (26)` / `AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA` /
`AUTH_ERR_PERSONA_NOT_FOUND` in the reply we build. If FIFA rejects it, the next candidate is
making the code look Nucleus-shaped (base64/JWT-ish) rather than `OPENFUT-0000…`.
* **O4 — `[OriginSDK+0x3b0]+0x120` (the expected `sender`) is an EMPTY string.**
(`OriginSDK = *[0x144b7c7a0] = 0x25c98c50`, size 0 / cap 15.) Real Origin presumably fills a
session id there during the LSX handshake. Our empty echo satisfies the `strcmp` today, but confirm
no later verb needs it non-empty.
* **O5 — audit every other LSX verb the same mechanical way.** `GetAuthCode` was attribute-*guessed*;
so were `QueryEntitlements`, `SetPresence` and `QueryUserId`. The audit is cheap and mechanical:
find the element-name string's single xref → follow the tail-jump thunk → list the
`0x14713fe50` (string) / `0x14713ffa0` (bool) attribute-name arguments. `GetGameInfo`
(`"GameInfo"`) and `GetProfile` (`UserId`, `PersonaId`, `Persona`, `AvatarId`, `Country`,
`IsUnderAge`, `IsSubscriber`, `GeoCountry`, `CommerceCountry`, `CommerceCurrency`) are already
verified correct; the rest are not.
* **O6 — who dispatches `0x14717d5d0`?** Reached only through the jump-table slots at
`0x143fef788 / 0x143fef79c / 0x143fef7ac`. Not needed for the fix, but knowing the dispatcher would
let us re-trigger classification live in a future session instead of relaunching.