diff --git a/docs/connection-gate-findings.md b/docs/connection-gate-findings.md
index 61cf9e1..71c4f66 100644
--- a/docs/connection-gate-findings.md
+++ b/docs/connection-gate-findings.md
@@ -1218,3 +1218,72 @@ Mirrors the existing `install_force_connect`. Enable by uncommenting the call in
bridge override (openfut-poke arm --host 127.0.0.1 --port 8443), relaunch FIFA. Compiles
clean for the Windows target. Expected: hook.log shows PUMP lines, conn flips to '+onl',
and a dial to 127.0.0.1:8443 (the Blaze handshake start).
+
+---
+
+## `LoginReasonCode` may be a string enum, not an int (2026-07-28)
+
+External cross-check of our LSX event encodings against **`ploxxxy/origin-sdk`**
+(github.com/ploxxxy/origin-sdk, MIT) — a third-party clean-room Rust implementation of
+the Origin SDK's LSX protocol, RE'd from observing SDK **9.12.1.7 / 10.6.1.8**. Not EA
+source; same provenance class as our own anadius64.dll disassembly, so clean-room holds.
+
+**What it confirms** (independent second derivation of things we RE'd alone):
+
+| Our finding | origin-sdk |
+|---|---|
+| LSX session crypto is AES-128-ECB + PKCS#7 | same |
+| `CRandom` = MSVC LCG (`214013` / `2531011`) | same constants |
+| `AES_KEY` `000102…0f` is real, not a placeholder | same — it is the `seed == 0` key |
+| Seed from first 2 chars of the ChallengeAccepted response | same derivation |
+| `GetAuthCode` → `` | same element + `@value` |
+| `Facility` service names in `GetConfig` | same set, incl. `ONLINE_STATUS_EVENT` |
+
+**What it contradicts.** We typed `LoginReasonCode` as an **int** (read off the `LoginT`
+deserializer's field layout) and pushed `LoginReasonCode="0"`. origin-sdk types it as a
+**string enum**:
+
+```
+UNDEFINED | USER_INITIATED | ALREADY_ONLINE | NETWORK_ERROR
+| INVALID_CREDENTIALS | ACCESSTOKEN_REFRESH_ERROR
+```
+
+**Why this is worth a run.** If FIFA string-compares this field the way it does `isOnline`
+(which had to be the literal `true`, never `1`) and `GetGameInfo`'s fixed `GameInfo`
+attribute, then `"0"` matches no arm, falls through to the default, and leaves the cached
+login state incomplete — while the deserializer still reports success. That is precisely
+the observed behaviour: `Login.deser` fired 44× and returned success, and FIFA never
+advanced. It is also the **third instance of the same bug class** in this codebase, both
+prior instances having the identical signature (delivered, parsed, no behavioural change).
+
+This bears directly on the gate as stated above: the EASFC controller polls a *combined*
+online-readiness condition that our flags do not satisfy. An incompletely-populated login
+state is a candidate missing term in that condition.
+
+**Status: UNCONFIRMED, both encodings still open.** The int layout came from FIFA 23's own
+deserializer; the enum from a different SDK version. Both can be true simultaneously if
+FIFA parses a string into an int slot. This is a cheap experiment, not a solved problem —
+and it does not supersede the NetConn-pump work above, which remains the
+highest-confidence next step.
+
+**How to run it.** Default is now `USER_INITIATED`; sweep candidates without a rebuild:
+
+```bash
+cargo run # USER_INITIATED (new default)
+OPENFUT_LOGIN_REASON_CODE=ALREADY_ONLINE cargo run
+OPENFUT_LOGIN_REASON_CODE=UNDEFINED cargo run
+OPENFUT_LOGIN_REASON_CODE=0 cargo run # control — previous behaviour
+```
+
+The bridge logs `LSX: Login event LoginReasonCode=` at startup, so each FIFA run's
+log records which encoding drove it.
+
+**Success signal** (unchanged): FIFA issues `GetAuthCode` over LSX, or resolves
+`spring18.gosredirector.ea.com`. Both are already logged. **Negative result is also worth
+recording** — it closes the encoding question and re-focuses everything on the EASFC poll.
+
+**Other reusable prior art found in the same sweep** (not yet adopted, listed for M3/M4):
+`jacobtread/blaze-ssl-async` (minimal SSLv3, RC4-SHA/MD5 — built for exactly ProtoSSL's
+constraints), `tdf` + `BlazePK-rs` (Blaze packet framing/TDF), and `futapi/fut` +
+`trydis/FIFA-Ultimate-Team-Toolkit` (working `fut.ea.com` REST clients — would replace the
+SPECULATIVE endpoint shapes in `mapper.rs`/`shaper.rs` with observed ones).
diff --git a/src/bin/xref.rs b/src/bin/xref.rs
index e01d3b0..e838ce8 100644
--- a/src/bin/xref.rs
+++ b/src/bin/xref.rs
@@ -10,13 +10,17 @@
//! absolute target address in a trailing `# ` comment. Matching those targets against the
//! virtual addresses (VAs) of harvested strings gives us the cross-references.
//!
-//! Pipeline:
+//! Pipeline (fenced as a block so the wrapped stage descriptions keep their column
+//! alignment — as a markdown list, rustdoc/clippy reflow the continuation lines):
+//!
+//! ```text
//! 1. Discovery — `objdump -p`/`-h`: ImageBase + section table; confirm `.rdata` and `.text`.
//! 2. Harvest — `objdump -s -j .rdata`: rebuild raw bytes, scan for NUL-terminated ASCII
//! strings matching any anchor substring, record each string's VA.
//! 3. Xref — STREAM `objdump -d`: for each instruction, read the `# ` target and
//! check it against harvested string VAs. On a hit, capture a context window.
//! 4. Report — group by anchor; print each site with its window; flag control-flow lines.
+//! ```
//!
//! Usage:
//! objdump must be on PATH.
@@ -675,7 +679,7 @@ fn xref(binary: &str, harvest: &Harvest, targets: &AddrTargets, context: usize)
for line in reader.lines() {
let line = line.context("error reading objdump -d stream")?;
line_count += 1;
- if line_count % 5_000_000 == 0 {
+ if line_count.is_multiple_of(5_000_000) {
eprintln!(" ... scanned {line_count} disassembly lines");
}
diff --git a/src/lsx.rs b/src/lsx.rs
index b972721..25fd0b7 100644
--- a/src/lsx.rs
+++ b/src/lsx.rs
@@ -41,13 +41,68 @@ const ONLINE_STATUS_EVENT: &str =
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
///
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
-/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
-/// FIFA switches on a specific success code, RE the consumer and adjust.
-const LOGIN_EVENT: &str =
- r#""#;
+///
+/// TODO/CONFIRM `LoginReasonCode` encoding — see `login_reason_code()`.
+fn login_event() -> String {
+ login_event_with(login_reason_code())
+}
+
+/// Split out from `login_event` so tests can exercise any reason code without going
+/// through the process-global `OnceLock` the env override caches into.
+fn login_event_with(reason_code: &str) -> String {
+ format!(
+ r#""#
+ )
+}
+
+/// `LoginReasonCode` was originally emitted as the integer `0`, read off the
+/// `LoginT` deserializer's field layout (int slot) — see the 2026-07-02 "Login event
+/// pushed" section in docs/connection-gate-findings.md. That push parsed fine and
+/// changed nothing.
+///
+/// A third-party clean-room RE of the Origin SDK (`ploxxxy/origin-sdk`, MIT — derived
+/// from observing SDK 9.12.1.7 / 10.6.1.8, NOT from EA source) types this field as a
+/// **string enum**, not an int:
+///
+/// `UNDEFINED` | `USER_INITIATED` | `ALREADY_ONLINE` | `NETWORK_ERROR`
+/// | `INVALID_CREDENTIALS` | `ACCESSTOKEN_REFRESH_ERROR`
+///
+/// If FIFA string-compares this the way it does `isOnline` (which had to be the literal
+/// `true`, not `1`) and `GetGameInfo`'s fixed `GameInfo` attribute, then `"0"` matches no
+/// arm and falls through to the default — leaving the cached login state incomplete while
+/// still deserializing "successfully". That is exactly the observed behaviour, and it is
+/// the same class of bug as those two.
+///
+/// `USER_INITIATED` is the default: FIFA's login is driven by the client at startup, and
+/// it is the only arm that denotes a real login rather than an error or a no-op.
+///
+/// Unconfirmed either way — the int layout was read from FIFA 23's own deserializer, the
+/// enum from a different SDK version, and the two can both be true if FIFA parses the
+/// string into an int slot. Override without rebuilding to sweep the candidates:
+///
+/// ```text
+/// OPENFUT_LOGIN_REASON_CODE=ALREADY_ONLINE cargo run
+/// OPENFUT_LOGIN_REASON_CODE=0 cargo run # restores the previous behaviour
+/// ```
+///
+/// Success signal is unchanged from the original experiment: FIFA issues `GetAuthCode`
+/// over LSX, or resolves `spring18.gosredirector.ea.com` (both logged).
+const LOGIN_REASON_CODE_DEFAULT: &str = "USER_INITIATED";
+
+fn login_reason_code() -> &'static str {
+ static VALUE: std::sync::OnceLock = std::sync::OnceLock::new();
+ VALUE
+ .get_or_init(|| {
+ std::env::var("OPENFUT_LOGIN_REASON_CODE")
+ .unwrap_or_else(|_| LOGIN_REASON_CODE_DEFAULT.to_string())
+ })
+ .as_str()
+}
/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
-const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
+fn online_push_events() -> [String; 2] {
+ [login_event(), ONLINE_STATUS_EVENT.to_string()]
+}
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
@@ -94,6 +149,9 @@ const GAME_INFO: &[(&str, &str, &str)] = &[
pub async fn start_server(addr: &str) -> anyhow::Result<()> {
let listener = TcpListener::bind(addr).await?;
info!("LSX server listening on {addr}");
+ // Surfaced at startup because this value is the active M2 experiment variable —
+ // the log must say which encoding a given FIFA run was driven with.
+ info!("LSX: Login event LoginReasonCode={}", login_reason_code());
loop {
let (stream, peer) = listener.accept().await?;
debug!("LSX: connection from {peer}");
@@ -138,6 +196,9 @@ async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
// OnlineStatusEvent push. A single task keeps owning the stream; the
// `select!` services reads and lets a timer fire the push, so no socket
// split / shared writer is needed.
+ // Built once per connection: the Login event's reason code is read from the
+ // environment, so rendering it per tick would re-allocate for no reason.
+ let push_events = online_push_events();
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
// interval fires immediately on the first tick — consume it so the first
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
@@ -168,7 +229,7 @@ async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
// like any session frame. Re-pushed each interval to cover the
// subscribe-timing race until FIFA advances (or the socket closes).
let mut push_err = false;
- for ev in ONLINE_PUSH_EVENTS {
+ for ev in &push_events {
let mut frame = lsx_encrypt(ev, seed).into_bytes();
frame.push(0);
debug!("LSX: pushing event: {ev}");
@@ -526,7 +587,7 @@ fn get_lsx_key(seed: u16) -> [u8; 16] {
fn hex_to_bytes(s: &str) -> Vec {
let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect();
- if s.len()%2!=0 { return Vec::new(); }
+ if !s.len().is_multiple_of(2) { return Vec::new(); }
(0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect()
}
fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() }
@@ -642,6 +703,55 @@ mod tests {
);
}
+ /// `isOnline` must be the string literal `true`, never `1`. An `isOnline="1"` push
+ /// WAS delivered and parsed, and FIFA read it as not-online and kept polling
+ /// (2026-07-02). This pins that finding so the encoding can't silently regress.
+ #[test]
+ fn online_status_event_uses_literal_true() {
+ assert!(
+ ONLINE_STATUS_EVENT.contains(r#"isOnline="true""#),
+ "isOnline must be the literal `true`, not `1`, got: {ONLINE_STATUS_EVENT}"
+ );
+ }
+
+ /// `LoginReasonCode` defaults to the `origin-sdk` string-enum arm, not the int `0`
+ /// that was pushed (and had no effect) on 2026-07-02. Both encodings remain
+ /// TODO/CONFIRM; this pins which one ships by default.
+ #[test]
+ fn login_event_defaults_to_string_reason_code() {
+ assert_eq!(
+ LOGIN_REASON_CODE_DEFAULT, "USER_INITIATED",
+ "default reason code is the origin-sdk arm denoting a real login"
+ );
+ let ev = login_event_with(LOGIN_REASON_CODE_DEFAULT);
+ assert!(
+ ev.contains(r#"LoginReasonCode="USER_INITIATED""#),
+ "got: {ev}"
+ );
+ assert!(ev.contains(r#"IsLoggedIn="true""#), "got: {ev}");
+ assert!(ev.contains(r#"UserIndex="0""#), "got: {ev}");
+ }
+
+ /// The int encoding must stay reachable — it is the control arm of the experiment,
+ /// and the value the earlier probe run was driven with.
+ #[test]
+ fn login_event_can_still_emit_the_int_reason_code() {
+ assert!(login_event_with("0").contains(r#"LoginReasonCode="0""#));
+ }
+
+ /// Order is load-bearing: log in, THEN go online. FIFA must see an authenticated
+ /// session before connectivity, per the 2026-07-02 push design.
+ #[test]
+ fn login_is_pushed_before_online_status() {
+ let events = online_push_events();
+ assert!(events[0].contains(""#;