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
|
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',
|
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).
|
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
|
//! absolute target address in a trailing `# <hex>` comment. Matching those targets against the
|
||||||
//! virtual addresses (VAs) of harvested strings gives us the cross-references.
|
//! 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`.
|
//! 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
|
//! 2. Harvest — `objdump -s -j .rdata`: rebuild raw bytes, scan for NUL-terminated ASCII
|
||||||
//! strings matching any anchor substring, record each string's VA.
|
//! strings matching any anchor substring, record each string's VA.
|
||||||
//! 3. Xref — STREAM `objdump -d`: for each instruction, read the `# <hex>` target and
|
//! 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.
|
//! 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.
|
//! 4. Report — group by anchor; print each site with its window; flag control-flow lines.
|
||||||
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! objdump must be on PATH.
|
//! objdump must be on PATH.
|
||||||
@@ -675,7 +679,7 @@ fn xref(binary: &str, harvest: &Harvest, targets: &AddrTargets, context: usize)
|
|||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
let line = line.context("error reading objdump -d stream")?;
|
let line = line.context("error reading objdump -d stream")?;
|
||||||
line_count += 1;
|
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");
|
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).
|
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
|
||||||
///
|
///
|
||||||
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
|
/// 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.
|
/// TODO/CONFIRM `LoginReasonCode` encoding — see `login_reason_code()`.
|
||||||
const LOGIN_EVENT: &str =
|
fn login_event() -> String {
|
||||||
r#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
|
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.
|
/// 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).
|
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
|
||||||
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
|
/// 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<()> {
|
pub async fn start_server(addr: &str) -> anyhow::Result<()> {
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
info!("LSX server listening on {addr}");
|
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 {
|
loop {
|
||||||
let (stream, peer) = listener.accept().await?;
|
let (stream, peer) = listener.accept().await?;
|
||||||
debug!("LSX: connection from {peer}");
|
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
|
// OnlineStatusEvent push. A single task keeps owning the stream; the
|
||||||
// `select!` services reads and lets a timer fire the push, so no socket
|
// `select!` services reads and lets a timer fire the push, so no socket
|
||||||
// split / shared writer is needed.
|
// 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);
|
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
|
||||||
// interval fires immediately on the first tick — consume it so the first
|
// interval fires immediately on the first tick — consume it so the first
|
||||||
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
|
// 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
|
// like any session frame. Re-pushed each interval to cover the
|
||||||
// subscribe-timing race until FIFA advances (or the socket closes).
|
// subscribe-timing race until FIFA advances (or the socket closes).
|
||||||
let mut push_err = false;
|
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();
|
let mut frame = lsx_encrypt(ev, seed).into_bytes();
|
||||||
frame.push(0);
|
frame.push(0);
|
||||||
debug!("LSX: pushing event: {ev}");
|
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> {
|
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
||||||
let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect();
|
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()
|
(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() }
|
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]
|
#[test]
|
||||||
fn dispatch_routes_get_game_info() {
|
fn dispatch_routes_get_game_info() {
|
||||||
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
||||||
|
|||||||
Reference in New Issue
Block a user