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
23 KiB
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), not0xdeadbeef, to+0x950(0x147236d1b); - the early-out at
0x147237384jumps to0x1472373e0and never touches+0x950; - so
0xdeadbeefcan only have arrived throughmov [rbx+0x950],eaxaftercall 0x1470db3c0— i.e.ebmg::GetAuthCodedid execute past the CONF gate; - it is not allocator poison: I dumped the full 0x1000 bytes of the object and
ef be ad deoccurs 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'sIsUnderAgeis parsed correctly (0x147136140→ bool helper0x14713ffa0) and ourIsUnderAge="false"lands as 0. QueryEntitlementswas never requested this boot (/tmp/lsx.logverbs: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:
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 memcpys
[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:
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
/tmp/lsx.logshows>> …<AuthCode value="OPENFUT-…"…>(the emitted frame carriesvalue=).ebmg+0x948becomes a non-NULL pointer to ASCII, andebmg+0x950reads 0. Re-findebmgfrom scratch:mgr = *[0x144b86bf8]→map = [mgr+0x358]→buckets = [map+0x50],n = [map+0x58]→ bucket0x65626d67 % n→ walk[node+0x10]fordword[node] == 0x65626d67→obj = [node+8]; assert[obj] == 0x143972550.- The OSDK state object (re-find by vptr
0x14395c180) leaves the label:+0x80no longer points at0x14395c9b0, and+0x7c != 10. /tmp/blaze_responder.logshowsRX … Authentication::login(component0x0001, cmd0x000A) instead of the currentAuthentication::logout(1/0x46) give-up.- 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
- Leave
lsx_responder_v2.pyas-is (fix already at:385) andblaze_responder_v3b.pyuntouched. - Rotate the logs (
mv /tmp/lsx.log /tmp/lsx.log.prev, same for/tmp/blaze_responder.log) so thevalue=frames and the+0x948transition are unambiguous. Also keep the rotation — see open question O1. - Restart both responders, launch FIFA 17,
pgrep -x FIFA17.exefor the new pid. - Watch signals 1–5 from §2 in order. Poll
ebmgand the OSDK state object with the re-find recipes in §2 (never reuse0x43d317a8/0x43d189d8— those are this-boot heap addresses).
Open questions the run should settle
- O1 — was
0xa2000012ever genuinely observed? We could not reproduce it live and the code cannot produce it in this path. It may have been inferred from thecmpimmediate rather than read. If a rotated/tmp/lsx.log*or an older+0x950reading really shows it, then a different run took the0x1470e4fbfpath (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 onegrepof the older log rotations before the run. - O2 — does the second fetcher also need to succeed?
0x147237440(blazeSdkClientId,FIFA17PC) caches intoebmg+0x140and is gated bycmp BYTE PTR [rcx+0x140],0(cache-once, live value 0/empty). After the fix, confirm both+0x948(fromFIFA17PC-SERVER) and+0x140(fromFIFA17PC) get populated — Blaze may want the server-scoped code specifically. Note the two fetchers share the single error slot+0x950, so a failingblazeSdkClientIdfetch can clobber a successfulblazeServerClientIdone; if that becomes a problem, the codes may need to differ perClientId(the responder already logsClientId, so this is a one-line change). - O3 — does Blaze
Authentication::loginaccept the placeholder? Statically it does (:1040storesAUTHwithout validation), but the wall may move: watch forAUTH_ERR_INVALID_PERSONA (26)/AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA/AUTH_ERR_PERSONA_NOT_FOUNDin the reply we build. If FIFA rejects it, the next candidate is making the code look Nucleus-shaped (base64/JWT-ish) rather thanOPENFUT-0000…. - O4 —
[OriginSDK+0x3b0]+0x120(the expectedsender) 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 thestrcmptoday, but confirm no later verb needs it non-empty. - O5 — audit every other LSX verb the same mechanical way.
GetAuthCodewas attribute-guessed; so wereQueryEntitlements,SetPresenceandQueryUserId. The audit is cheap and mechanical: find the element-name string's single xref → follow the tail-jump thunk → list the0x14713fe50(string) /0x14713ffa0(bool) attribute-name arguments.GetGameInfo("GameInfo") andGetProfile(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 at0x143fef788 / 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.