# 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 100–120 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 25–29, 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 1–5 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.
