# 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.