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

23 KiB
Raw Blame History

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 == 00x1472373d7 je+0x948 never written → rbp == NULLARM 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 0x1470e30900x147135800) 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:

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

  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] == 0x65626d67obj = [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 fc48 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+0x9500xdeadbeef 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-0x14a0000000x14717d732. 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.