wip: checkpoint connection gate diagnostics
This commit is contained in:
@@ -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` → `<AuthCode value="…"/>` | 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=<value>` 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).
|
||||
|
||||
+6
-2
@@ -10,13 +10,17 @@
|
||||
//! absolute target address in a trailing `# <hex>` 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 `# <hex>` 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");
|
||||
}
|
||||
|
||||
|
||||
+117
-7
@@ -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#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
|
||||
///
|
||||
/// 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#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="{reason_code}"/></Event></LSX>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// `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<String> = 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<u8> {
|
||||
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("<Login "), "Login must be first, got: {}", events[0]);
|
||||
assert!(
|
||||
events[1].contains("<OnlineStatusEvent "),
|
||||
"OnlineStatusEvent must be second, got: {}",
|
||||
events[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_routes_get_game_info() {
|
||||
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
||||
|
||||
Reference in New Issue
Block a user