# 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 ``. 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 `. 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` 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):
```
```
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 `` 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
```
[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 `>> …` (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 ` 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.