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(""#;