11 Commits

Author SHA1 Message Date
funman300 fe2e531b0c fix(seasons): derive the FUT web-file base from openfut.cfg, not a lab IP
`STAGING_FUT_BASE` hardcoded `http://10.10.0.120:8110/fut/` into the hook
binary, so the base-supply rewriter only worked on one machine and could not be
merged.

The prefix now comes from the same `openfut.cfg` / `openfut-common` source of
truth as every redirect target: `fut_content_base()` builds
`http://<host>:<fut_content_port>/fut/` from the configured host.
`OpenFutPorts` gains `fut_content` with a named default
(`default_ports::FUT_CONTENT = 8110`) and an optional `fut_content_port=` key,
matching how every other OpenFUT port is already handled. A cfg written before
the key existed still parses and takes the default — failing it would disarm the
network redirect too.

Arming fails SAFE: an absent or unusable config arms nothing and the rewriter
leaves every url exactly as the client built it, rather than pointing it at a
guessed host. This also adds the `openfut-common` dependency to the hook on this
branch; main already has it.

Proof: the cross-built artifact no longer contains the string 10.10.0.120
(previously compiled in), openfut-common is green at 16 tests including the new
config-derived-base and backward-compatibility cases, and the launcher builds.
2026-08-21 04:55:14 +00:00
funman300 c3d41153be wip(seasons): offline-Seasons base-supply URL rewriter + VEH crash logger
RETAINED DIAGNOSTIC WIP (pre-existing, brought forward, NOT production-ready).
Rewrites bare WEBFILE relpaths to a season content base and adds a vectored
crash logger used during offline-Seasons RE. Contains a HARDCODED staging
base (http://10.10.0.120:8110/fut/) \u2014 must be env-parameterized before any
promotion to main. Kept on this wip branch so main stays clean.
2026-08-20 16:07:41 +00:00
openfut 8d5bb6202a diag(fifa17): capture WEBFILE_DL url + guarded CACHE_PACKNAMES bypass
- Passive: log FUN_18017ff90 param_1 = the pack-names/cards-tournament-list
  WEBFILE_DL url (via relocating installer; rip-relative MOV R8,[DAT_1802e6580]).
- Guarded one-shot (staging client only): in the final completion FUN_1800ffe90,
  when the delivered result string is CACHE_PACKNAMES_FAILED, rewrite result byte0
  so it delivers SUCCESS -> LoadSeasons_Complete advances to LoadCurrentOfflineSeason.
  String-verified, once per process.
2026-08-20 00:18:51 +00:00
openfut 9c4db41289 diag(fifa17): probe LoadOfflineSeasons async completions (result string capture)
Adds passive field-logging detours on the FutCompetitionServiceImpl::LoadOfflineSeasons
async chain resolved by static RE:
  final completion FUN_1800ffe90 -> logs the exact status string delivered to the
    AS LoadSeasons_Complete callback ("SUCCESS" vs error string at result+8);
  stage-1 completion FUN_180106240 -> logs whether the first async stage's
    status (+0x1c) is ok or CACHE_PACKNAMES_FAILED.
Read-only; safe bounded C-string reader (rd_cstr).
2026-08-19 23:55:41 +00:00
openfut 164100fc40 diag(fifa17): passive season-native call tracer for offline-Seasons entry
Adds openfut-hook/src/season_trace.rs: read-only CardsDLL detours that log the
FIFA17 FUT offline-season entry native call sequence (no behavior change; each
wrapper logs then calls the original via a trampoline). Traces the FUT_Season
natives proven by the registration table FUN_18004e3f0:
  GetUsersOfflineDivision 0x4eb50 (NOT LoadOfflineSeasons),
  LoadOfflineSeasons 0x4ee10 + async impl 0x57560,
  LoadCurrentOfflineSeason 0x4eb70 + impl 0x57230 + completion 0x578e0,
  StartSeason 0x4f340, GetOfflineSeasonInfo 0x4e850.
Includes a near-trampoline installer (install_detour_reloc) that relocates a
single rip-relative disp32 so functions with rip-relative prologues can be
detoured (trampoline allocated within +/-1.5GiB of CardsDLL).
2026-08-19 23:37:26 +00:00
funman300 79e566883f hook(fifa17): pre-warm store purchase groups before screen-show; drop disproven rebind
The rebind approach was disproven live: the bind sensor measured mask=0x00 at
screen-show (container empty, all six slots hidden -> no tab bar), and a rebind
after the groups arrived (mask=0x0e = bronze|silver|gold) built NO tab bar. The
Scaleform movie only honours the framework's OWN bind at screen-show, not a later
re-publish/commit.

Root cause therefore stands confirmed: the store's GET store/purchasegroup/all
returns only after screen-show, so the first bind sees an empty container. Re-entry
works because the groups are cached by then.

Fix: load the purchase groups BEFORE the store screen is shown. FUN_180017870
(storefront) issues the store's own group request; firing it from the FUT hub event
pump (a real game thread, before the store screen exists) lets the response arrive
and populate the container so the first screen-show bind sees a full list and binds
the tabs natively -- the re-entry path, on first entry.

The bind detour is retained purely as the read-only SENSOR: the first-entry bind
mask is the definitive measurement of whether the pre-warm landed in time. mask!=0
=> pre-warm worked and the tabs bind natively; mask==0 with storefront_seen!=0 in
the pre-warm log => a hub-time request cannot land in time and the remaining route
is the extracted StoreFront.apt.

Removed: render detour, maybe_rebind/should_rebind, and all rebind state. Re-added
the hub-time maybe_prewarm_groups() call in sbc_dispatch::event_wrapper.

Promoted (build-armed). Deployed artifact 668e9324; profile-gated fifa17 build.
2026-08-19 18:48:54 +00:00
funman300 7724f168bc hook(fifa17): repair the store tab bar by rebinding the native binder
Replaces three disproven store-entry mechanisms (category clamp, late
*_CATEGORY_ID publish, purchase-group pre-warm) with the one repair the
reversing actually supports.

FUN_18007e5e0(ctx, panel) is the native tab binder the screen framework
invokes at screen-show. It is an unrolled six-slot loop; each slot gates on
one hard-coded category token and either publishes that group's id as
PANEL_ID for the slot or hides the slot:

  slot 0 mypacks, 1 bronze, 2 silver, 3 gold, 4 special,
  slot 5 points (extra gate: (*(store_vtbl+0x30))(store) must be false)

The gate FUN_180014df0(_, idx) resolves the token through FUN_180014380,
which linearly scans the loaded purchase groups (stride 0x108) comparing the
token at group+0x70. So a tab exists iff a purchase group carrying that
token is loaded AT BIND TIME. Our server emits mypacks/bronze/silver/gold
as displayGroup.value, so four tabs are expected.

On a cold session the store screen shows before its own
GET store/purchasegroup/all response arrives: every gate fails, all six
slots take the hide path, and the binder is never invoked again for that
screen. Re-entry works only because the groups are cached by then -- which
is exactly the reported symptom.

The repair re-invokes the binder once, with the framework's own (ctx, panel),
at the first render after the groups arrive, reproducing the re-entry
ordering on the first entry. Repeating the binder is safe: it only publishes
PANEL_ID or hides per slot, reads the group list from a process singleton,
and finishes by tail-calling panel->vtbl[0xd0](panel, true) -- the provider
commit that rebuilds the movie's bar.

Fail-closed: rebind only when the framework's bind observed an EMPTY mask and
at least one token now resolves (a store that already bound tabs is never
touched); one rebind per bind generation, claimed by compare-exchange; only
framework-supplied pointers are ever used; image plus all three function
signatures verified before any write and re-verified under thread suspension.
The gate probe passes a null this, which is sound because FUN_180014df0
forwards rcx to FUN_180014380, which discards it and uses a singleton.

Why the earlier attempts could not work: the clamp forced a single category
(regressing Browse Packs to bronze-only), the publish targeted FUN_18007df60
which does not bind panels, and the pre-warm ran from the FUT event
dispatcher -- after screen-show, so the slot decisions were already made.

Promoted (build-armed, no env var). Rollback is a version.dll file swap.
2026-08-19 18:02:10 +00:00
funman300 e4c56a225e hook(fifa17): pre-warm purchase groups so the store tab bar binds natively
Fixes the ordering instead of fighting the movie. The tab bar is bound by
FUN_18007e5e0 (six caption tests -> PANEL_ID, else hide panel), which is
slot 0 of a secondary vtable invoked by the screen framework at
screen-show. On a cold session the /store/purchasegroup groups have not
arrived by then, so all six panels hide and no tab bar is drawn. Late
publishing does not fix it: deploying the 0x278a publish at render time
fired with its gate accepting (log: tabpublish=1 state=0x418) and the bar
still did not appear, i.e. the movie ignores late tab updates.

So load the groups BEFORE the store is ever opened. The store screen
issues its own pack-list request at 0x18007f25e as
FUN_180017870(*(base+0x2de0d0)) -- a single-argument call on the
storefront global. Issue exactly that call once per process from the FUT
event dispatcher, which already runs on a game thread long before the
store screen exists. When the user then opens the store, the native
screen-show bind sees a populated group list and binds the tabs itself --
the same reason a second entry has always worked.

Fail-closed: base + CardsDLL image validated, storefront read guarded,
FUN_180017870 fingerprinted before the first call, one request per
process claimed before issuing (no re-entrant double request), and
skipped entirely once groups exist. Logs prewarm= for evidence.
fmt/clippy -D warnings clean, 32 hook tests pass.
2026-08-19 16:11:58 +00:00
funman300 8ca89bcc75 hook(fifa17): bind the store tab bar on first entry
The category clamp fixed WHICH content the first store render draws, but
the tab bar was still missing on first entry (operator-observed: first
open = bronze packs with no tab bar; re-entry = same packs WITH
Bronze/Silver/Gold tabs).

Root cause: the store screen dispatcher 0x18007d880 publishes the tab bar
on a DIFFERENT event than it renders. Event 0x278a -> FUN_18007df60
resolves the six hardcoded tab tokens against the loaded purchase groups
and publishes *_CATEGORY_ID; event 0x753f -> FUN_18007dab0 renders. On
first entry the publish runs before /store/purchasegroup has landed, so
all six tokens resolve -1, every panel hides, and no tab bar is drawn;
re-entry only works because the groups are cached by then.

Re-run the native publish once per screen from the render detour, where
the groups are provably present (the ordinal-1 lookup already proves it),
reproducing the working re-entry order (publish, then render). Safe: it
is the same call the dispatcher makes with the same single argument, it
self-gates on screen+0x2cc == 0x418 (a mismatch is a native no-op, not a
fault), it is fingerprinted before the first call, and it runs at most
once per screen instance. Also logs the gate state so a no-op publish is
diagnosable. fmt/clippy -D warnings clean, 29 hook tests pass.
2026-08-19 15:57:23 +00:00
funman300 9aecc658ad hook(fifa17): guarded store-entry category clamp (promoted)
The FUT store flashes a Browse-Packs overview on first open: the store
screen ctor leaves screen+0x290 (CATEGORY_ID) at 0, and the resolver
FUN_1800147f0 treats 0 as list-all, so the first render draws the group
overview before the movie posts a tab ordinal.

Detour the store render FUN_18007dab0 (RVA 0x7dab0): when the incoming
category is 0, substitute the first present group ordinal (1) so the
first frame lands on a real tab. Provably crash-safe: it writes 1 only
after FUN_180014420(_, 1) (the resolver's own ordinal->group lookup,
whose first arg is dead) returns non-NULL, which is exactly the
resolver's non-crash precondition; the positive-invalid NULL deref at
0x14882 is thus unreachable. No group yet -> category left 0 -> Browse,
still safe.

Promoted like the SBC dispatch: build-armed (CLAMP_PROMOTED), no env.
Signature-gated on both the detoured render and the called lookup,
image-validated, installed under thread suspension, fail-closed. Only
the overview flash is addressed; the empty-My-Packs entry dialog is
movie-side (packed .apt) and out of CardsDLL reach (see Vault
Store Resolver Guard 2026-08-19). fmt/clippy -D warnings clean both
feature sets, 26 hook tests pass, x86_64-pc-windows-gnu release builds.
2026-08-19 15:20:47 +00:00
funman300 af7a5948a7 launcher: plain-language launch status for players
The dashboard named OpenFUT internals at a player: "Client integration", "Local
services", "Hook DLL", and a "Deployed -> 10.10.0.120" value that conflates a DLL
with a server address. None of it tells someone who just wants to play whether they
can press Play.

Rows are now Game files / Background helpers / Game patch, and the patch row states
Installed rather than echoing the host it will point FIFA at.

The important fix is the helper state. Two of the three cases returned labels that
sound like faults for what is the NORMAL idle condition - the helpers only run
alongside a session, and Launch starts whatever is missing - with "Partly running"
being the worst: it reads broken and offers nothing to act on. Both collapse to
"Start with the game", which says what will happen. Observed live: the dashboard
showed "Partly running" while genuinely healthy, and pressing Launch brought
autopatch up on its own.

No behaviour change: readiness values are untouched, so the overall verdict pill and
the launch gating are identical. fmt, clippy -D warnings, 75 tests clean.
2026-08-19 04:26:29 +00:00
11 changed files with 1368 additions and 16 deletions
Generated
+1
View File
@@ -2293,6 +2293,7 @@ dependencies = [
"eframe",
"egui",
"openfut-common",
"parking_lot",
"serde",
"serde_json",
"tokio",
+56 -2
View File
@@ -53,6 +53,12 @@ pub mod default_ports {
pub const BLAZE_REDIRECTOR: u16 = 42127;
/// OpenFUT FIFA 17 Blaze main listener.
pub const BLAZE_MAIN: u16 = 42130;
/// OpenFUT FUT web-file (CDN) content server. Unlike the others this is not
/// an EA redirect target: the client never dials it directly, because its
/// `RS4::ServerSettings` CDN base arrives EMPTY in the emulator. The hook
/// supplies the missing `<base>/fut/` prefix, and the base is built from the
/// configured server host plus this port.
pub const FUT_CONTENT: u16 = 8110;
}
/// OpenFUT destination ports. Each field is where an intercepted EA source port
@@ -66,6 +72,9 @@ pub struct OpenFutPorts {
pub blaze_redirector: u16,
/// Destination for EA :42127 traffic (Blaze main).
pub blaze_main: u16,
/// FUT web-file content server. Not a redirect destination — see
/// [`default_ports::FUT_CONTENT`].
pub fut_content: u16,
}
impl Default for OpenFutPorts {
@@ -74,6 +83,7 @@ impl Default for OpenFutPorts {
https: default_ports::HTTPS,
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
blaze_main: default_ports::BLAZE_MAIN,
fut_content: default_ports::FUT_CONTENT,
}
}
}
@@ -209,6 +219,7 @@ impl ServerConfig {
"https_port" => ports.https = parse_port(value)?,
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
"fut_content_port" => ports.fut_content = parse_port(value)?,
other => {
return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'",
@@ -225,8 +236,27 @@ impl ServerConfig {
/// Serialize to the structured `openfut.cfg` format.
pub fn to_cfg_string(&self) -> String {
format!(
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n",
self.host,
self.ports.https,
self.ports.blaze_redirector,
self.ports.blaze_main,
self.ports.fut_content
)
}
/// Base URL the FUT web-file (CDN) prefix is built from, e.g.
/// `http://10.10.0.120:8110/fut/`.
///
/// The client's `RS4::ServerSettings` CDN base arrives EMPTY in the emulator,
/// so FUT web-file urls reach the download entry point as bare relative paths
/// and fail. The hook supplies this prefix. Built from the SAME configured
/// host as every other redirect, so a lab address is never compiled in.
pub fn fut_content_base(&self) -> String {
format!(
"http://{}:{}/fut/",
self.host.trim(),
self.ports.fut_content
)
}
@@ -414,12 +444,36 @@ mod tests {
https: 8443,
blaze_redirector: 10041,
blaze_main: 42127,
fut_content: 8110,
},
};
let s = c.to_cfg_string();
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
}
/// The FUT web-file prefix follows the CONFIGURED server, so no lab address
/// is ever compiled into the hook.
#[test]
fn fut_content_base_follows_the_configured_host() {
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
assert_eq!(c.fut_content_base(), "http://192.168.1.50:8110/fut/");
let c = ServerConfig::parse("host=fut.mylan.home\nfut_content_port=9110\n").unwrap();
assert_eq!(c.fut_content_base(), "http://fut.mylan.home:9110/fut/");
}
/// A cfg written before `fut_content_port` existed must still parse, taking
/// the default rather than failing the whole config (which would disarm the
/// network redirect too).
#[test]
fn cfg_without_content_port_takes_the_default() {
let c = ServerConfig::parse(
"host=10.0.0.5\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
)
.unwrap();
assert_eq!(c.ports.fut_content, default_ports::FUT_CONTENT);
}
#[test]
fn configured_ipv4_becomes_correct_sockaddr() {
// Resolve an IPv4 literal and confirm the sin_addr value.
+5
View File
@@ -2,10 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"openfut-common",
"windows-sys",
]
+4
View File
@@ -39,6 +39,10 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
# Single source of truth for the OpenFUT redirect config (openfut.cfg schema,
# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the
# launcher so the hook and openfut.cfg agree by construction.
openfut-common = { path = "../openfut-common" }
[profile.release]
opt-level = "s"
+2
View File
@@ -86,6 +86,8 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
crate::sbc_trace::install();
crate::sbc_dispatch::install();
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
0
}
+4
View File
@@ -28,7 +28,11 @@ mod sbc_hook;
mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_trace;
mod ssl_patch;
#[cfg(feature = "fifa17")]
mod store_entry;
mod tls_bypass;
mod transport_watch;
mod version_proxy;
+4
View File
@@ -443,6 +443,10 @@ unsafe extern "system" fn event_wrapper(
}
_ => {}
}
// Piggyback the store pre-warm on this game-thread hub event: it loads the
// purchase groups once, before the store screen is shown, so the store's native
// screen-show tab bind sees a populated group list (see `store_entry`).
crate::store_entry::maybe_prewarm_groups();
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
let result = original(controller, event, payload);
EVENT_EXITS.fetch_add(1, Ordering::Release);
+793
View File
@@ -0,0 +1,793 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's offline-season
//! entry flow.
//!
//! RE (2026-08-19, live memory) placed the "problem communicating with the FIFA
//! Ultimate Team servers" modal in the `futOfflineSeasonEntry` ActionScript's
//! season-load path. A first trace on the load completion `FUN_1800578e0`
//! (`0x578e0`) armed but NEVER fired on an entry attempt — so the modal is raised
//! before that callback runs. These traces log the actual CardsDLL season-native
//! call sequence (which functions the entry screen reaches, and in what order) so
//! we can see exactly where the flow stops/fails. Every trace is read-only: it
//! logs, then calls the original through a trampoline; it never alters control
//! flow. Targets are chosen so their copied prologues are position-independent
//! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::OnceLock;
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::Diagnostics::Debug::{
AddVectoredExceptionHandler, EXCEPTION_POINTERS,
};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use crate::sbc_trace::{
absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build,
};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
unsafe fn rd_u8(addr: usize) -> Option<u8> {
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
}
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
if addr == 0 || !readable_range(addr, 1) {
return String::from("<unreadable>");
}
let mut out = Vec::new();
let mut i = 0;
while i < max && readable_range(addr + i, 1) {
let b = core::ptr::read_volatile((addr + i) as *const u8);
if b == 0 {
break;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
/// MUST be whole, position-independent instructions) with an absolute jump to
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
unsafe fn install_detour(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let Some(trampoline) = allocate_trampoline(target, copy_len) else {
write_log(&format!("SEASON_TRACE: {name}: trampoline alloc failed\n"));
return false;
};
trampoline_slot.store(trampoline, Ordering::Release);
let mut patch = [0x90u8; 24];
let jump = absolute_jump(wrapper);
patch[..jump.len()].copy_from_slice(&jump);
let mut old = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, old, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed at {target:#x} (tramp {trampoline:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
fn log_call(name: &str, rcx: usize, rdx: usize, r8: usize) {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!(
"SEASON_CALL: {name} rcx={rcx:#x} rdx={rdx:#x} r8={r8:#x}\n"
));
}
}
/// Declare a passive 4-register-arg call trace. The wrapper is entered via the
/// abs-jump patched over the target prologue (original args in rcx/rdx/r8/r9,
/// caller's return address on the stack), logs, then tail-calls the original via
/// the trampoline. A 4-arg/usize-return signature safely covers these season
/// natives (<=4 integer args, void/int returns).
macro_rules! season_call_trace {
($wrap:ident, $tramp:ident, $name:literal) => {
static $tramp: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn $wrap(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
log_call($name, rcx, rdx, r8);
let t = $tramp.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
};
}
season_call_trace!(
load_current_native_wrapper,
LOAD_CURRENT_NATIVE_TRAMP,
"LoadCurrentOfflineSeason_native"
);
season_call_trace!(
start_season_native_wrapper,
START_SEASON_NATIVE_TRAMP,
"StartSeason_native"
);
season_call_trace!(
get_info_native_wrapper,
GET_INFO_NATIVE_TRAMP,
"GetOfflineSeasonInfo_native"
);
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
// actually calls; hands the callback name to the manager's async slot 0x80.
season_call_trace!(
load_offline_real_wrapper,
LOAD_OFFLINE_REAL_TRAMP,
"LoadOfflineSeasons_native(0x4ee10)"
);
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
// count and invokes the LoadSeasons_Complete AS callback.
season_call_trace!(
load_offline_async_wrapper,
LOAD_OFFLINE_ASYNC_TRAMP,
"LoadOfflineSeasons_asyncimpl(0x57560)"
);
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
// string ptr. Logs those, then calls the original.
static LOAD_CURRENT_IMPL_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn load_current_impl_wrapper(
param_1: usize,
param_2: usize,
param_3: usize,
param_4: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
// param_3 -> C string season id (best-effort read of first bytes).
let sid = if param_3 != 0 && readable_range(param_3, 8) {
let p = *(param_3 as *const usize);
if p != 0 && readable_range(p, 8) {
*(p as *const u64)
} else {
0
}
} else {
0
};
write_log(&format!(
"SEASON_CALL: LoadCurrentOfflineSeason_impl mgr={param_1:#x} stateByte={param_2:#x} sidPtr={param_3:#x} sidHead={sid:#x}\n"
));
}
let t = LOAD_CURRENT_IMPL_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param_1, param_2, param_3, param_4)
}
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
// whether it ever fires; logs the result fields it branches on.
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let status = rd_i32(result + 0x1c);
let state = rd_u8(result + 0x68);
let season_id = rd_i32(result + 0x5c);
write_log(&format!(
"SEASON_LOAD_COMPLETE: ctx={ctx:#x} result={result:#x} status(+0x1c)={} state(+0x68)={} seasonId(+0x5c)={}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
state.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
season_id.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
let t = COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// GetUsersOfflineDivision native FUN_18004eb50 (registration FUN_18004e3f0 proved
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
// so it needs the relocating installer below.
season_call_trace!(
get_users_division_wrapper,
GET_USERS_DIVISION_TRAMP,
"GetUsersOfflineDivision_native(0x4eb50)"
);
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
/// CardsDLL data still fits after we relocate a copied prologue into it.
unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
const GRAN: usize = 0x10000;
let mut step = GRAN;
while step < 0x6000_0000 {
for signed in [step as isize, -(step as isize)] {
let cand = base.wrapping_add(signed as usize) & !(GRAN - 1);
if cand == 0 {
continue;
}
let p = VirtualAlloc(cand as _, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if !p.is_null() {
return Some(p as usize);
}
}
step += GRAN;
}
None
}
/// Passive detour for a target whose copied prologue contains a single
/// rip-relative operand (disp32 at `disp_off`, instruction ending at `insn_end`,
/// both within the copied bytes). The trampoline is allocated near `base` and the
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
#[allow(clippy::too_many_arguments)]
unsafe fn install_detour_reloc(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
disp_off: usize,
insn_end: usize,
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let jump = absolute_jump(wrapper);
let tramp_len = copy_len + jump.len();
let Some(tramp) = alloc_near(base, tramp_len) else {
write_log(&format!(
"SEASON_TRACE: {name}: near trampoline alloc failed\n"
));
return false;
};
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
// Relocate the rip-relative disp32 to keep the same absolute target.
let orig_disp = core::ptr::read_unaligned((target + disp_off) as *const i32) as i64;
let abs_target = target as i64 + insn_end as i64 + orig_disp;
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
write_log(&format!(
"SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"
));
return false;
}
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
let back = absolute_jump(target + copy_len);
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
let mut old = 0u32;
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
write_log(&format!(
"SEASON_TRACE: {name}: trampoline protect failed\n"
));
return false;
}
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
trampoline_slot.store(tramp, Ordering::Release);
let mut patch = [0x90u8; 24];
patch[..jump.len()].copy_from_slice(&jump);
let mut prot = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut prot) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, prot, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed(reloc) at {target:#x} (tramp {tramp:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
// FutCompetitionServiceImpl::LoadOfflineSeasons FINAL completion (FUN_1800ffe90):
// delivers the result to the AS callback LoadSeasons_Complete via
// FUN_18019fb30->slot0x20(vm,"_global",cbref, "SUCCESS" | errString). param_1 = the
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn final_completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
// Read the delivered status: byte0==0 => failure with an error string at +8.
let flag = rd_u8(result);
let errstr = if flag == Some(0) {
let p = if readable_range(result + 8, 8) {
core::ptr::read_volatile((result + 8) as *const usize)
} else {
0
};
rd_cstr(p, 96)
} else {
String::new()
};
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let cbref = if readable_range(ctx + 0x18, 8) {
core::ptr::read_volatile((ctx + 0x18) as *const usize)
} else {
0
};
let kind = match flag {
Some(0) => "ERROR",
Some(_) => "SUCCESS",
None => "??",
};
let shown = if flag == Some(0) {
errstr.as_str()
} else {
"SUCCESS"
};
write_log(&format!(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
));
}
// Base-supply experiment: the CACHE_PACKNAMES failure is expected to be fixed
// by the WEBFILE base-supply (the real file now downloads), so the guarded
// success-forcing bypass is DISABLED — a recurring CACHE_PACKNAMES here means
// the base-supply did not take effect and MUST NOT be masked.
if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES")
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
{
write_log("SEASONS_BYPASS: DISABLED (base-supply active); CACHE_PACKNAMES not masked\n");
}
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// LoadOfflineSeasons STAGE-1 async completion (FUN_180106240): fails with
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
// the next async stage. Logs whether the first async stage succeeded. Passive.
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn stage1_completion_wrapper(
param1: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
if result == 0 {
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
} else {
let status = rd_i32(result + 0x1c);
let verdict = if status == Some(0) {
"ok(chain next)"
} else {
"CACHE_PACKNAMES_FAILED"
};
write_log(&format!(
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
}
let t = STAGE1_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param1, result, r8, r9)
}
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
// URL of the pack-names / cards-tournament-list web file. Its prologue has a
// rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating installer
// (disp32 at copied offset 7, instruction end 11).
//
// BASE-SUPPLY: the client's RS4::ServerSettings CDN base (DAT_1802e6408+0x30) is
// EMPTY in the emulator — FUN_180124270 only sets it when the OSDK getter
// slot0x3f8 is non-empty, and it has no default (unlike the API base). So every
// FUT WEBFILE url arrives here as a BARE relative path and 999s (client
// sentinel). We supply the missing intended `<CDN>/fut/` prefix so the REAL file
// downloads and parses. This is a data-supply, NOT a success-forcing bypass;
// absolute urls (containing "://", e.g. the "http://sbc/..." tile route) pass
// through untouched.
//
// The prefix comes from `openfut.cfg` via `openfut-common`, the same single
// source of truth as every redirect target, so no lab address is compiled in.
// Unset (config missing/unusable) means NO rewrite: a url is left exactly as the
// client built it rather than pointed at a guessed host.
static FUT_CONTENT_BASE: OnceLock<String> = OnceLock::new();
/// Arm the FUT web-file prefix from the resolved configuration. Idempotent: the
/// first call wins.
pub(crate) fn set_fut_content_base(base: String) {
let _ = FUT_CONTENT_BASE.set(base);
}
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
let orig = rd_cstr(rcx, 256);
let mut arg_rcx = rcx;
// Owned buffer that stays alive across the original() call below. The caller
// frees its own url buffer immediately after FUN_18017ff90 returns, so the
// client copies the url synchronously during the call — a local buffer is
// sufficient and nothing is leaked.
let mut full: Vec<u8> = Vec::new();
if let Some(base) = FUT_CONTENT_BASE.get() {
if !orig.is_empty() && !orig.contains("://") {
full.extend_from_slice(base.as_bytes());
full.extend_from_slice(orig.trim_start_matches('/').as_bytes());
full.push(0); // NUL terminator for the C-string
arg_rcx = full.as_ptr() as usize;
}
}
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
if arg_rcx != rcx {
write_log(&format!(
"SEASONS_WEBFILE_URL: orig={orig:?} rewritten={:?}\n",
rd_cstr(arg_rcx, 256)
));
} else {
write_log(&format!("SEASONS_WEBFILE_URL: url={orig:?} (unchanged)\n"));
}
}
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
let ret = original(arg_rcx, rdx, r8, r9);
drop(full); // ensure the url buffer outlives the download-start call
ret
}
// ───────────────────────── crash locator (VEH) ──────────────────────────────
// A vectored exception handler that logs the faulting code/address/module for
// fatal exceptions, then lets the crash proceed (EXCEPTION_CONTINUE_SEARCH). It
// pinpoints the StartSeason crash: whether it is a CardsDLL season-data
// null-deref (fixable by supplying matches/opponents) or an engine/other fault.
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static CARDS_SIZE: AtomicUsize = AtomicUsize::new(0);
static CRASH_LOGS: AtomicUsize = AtomicUsize::new(0);
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
/// OptionalHeader.SizeOfImage from the module's PE headers (fallback 64 MiB).
unsafe fn cards_image_size(base: usize) -> usize {
if !readable_range(base + 0x3c, 4) {
return 0x0400_0000;
}
let e_lfanew = core::ptr::read_volatile((base + 0x3c) as *const u32) as usize;
let so_off = base + e_lfanew + 0x50; // NT header + OptionalHeader.SizeOfImage
if !readable_range(so_off, 4) {
return 0x0400_0000;
}
core::ptr::read_volatile(so_off as *const u32) as usize
}
unsafe extern "system" fn crash_logger(info: *mut EXCEPTION_POINTERS) -> i32 {
if info.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let rec = (*info).ExceptionRecord;
if rec.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let code = (*rec).ExceptionCode as u32;
// Only fatal codes; skip the many benign first-chance SEH exceptions.
let interesting = matches!(
code,
0xC000_0005 // access violation
| 0xC000_001D // illegal instruction
| 0xC000_0094 // integer divide by zero
| 0xC000_00FD // stack overflow
| 0xC000_0025 // noncontinuable exception
);
if !interesting || CRASH_LOGS.fetch_add(1, Ordering::Relaxed) >= 8 {
return EXCEPTION_CONTINUE_SEARCH;
}
let addr = (*rec).ExceptionAddress as usize;
let base = CARDS_BASE.load(Ordering::Acquire);
let size = CARDS_SIZE.load(Ordering::Acquire);
let module = if base != 0 && addr >= base && addr < base + size {
format!("CardsDLL+{:#x}", addr - base)
} else {
"other".to_string()
};
let (kind, fault) = if code == 0xC000_0005 && (*rec).NumberParameters >= 2 {
let op = (*rec).ExceptionInformation[0];
let fa = (*rec).ExceptionInformation[1];
let k = match op {
0 => "read",
1 => "write",
8 => "exec",
_ => "?",
};
(k, fa)
} else {
("", 0usize)
};
write_log(&format!(
"SEASON_CRASH: code={code:#010x} at={addr:#x} module={module} access={kind} fault_addr={fault:#x}\n"
));
EXCEPTION_CONTINUE_SEARCH
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
return;
}
CARDS_BASE.store(base, Ordering::Release);
CARDS_SIZE.store(cards_image_size(base), Ordering::Release);
AddVectoredExceptionHandler(1, Some(crash_logger));
write_log("SEASON_TRACE: crash logger (VEH) armed\n");
// (rva, name, copy_len, signature, wrapper, trampoline slot)
install_detour(
base,
0x4eb70,
"LoadCurrentOfflineSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_current_native_wrapper as *const () as usize,
&LOAD_CURRENT_NATIVE_TRAMP,
);
install_detour(
base,
0x4f340,
"StartSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
start_season_native_wrapper as *const () as usize,
&START_SEASON_NATIVE_TRAMP,
);
install_detour(
base,
0x4e850,
"GetOfflineSeasonInfo_native",
15,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24,
0x18,
],
get_info_native_wrapper as *const () as usize,
&GET_INFO_NATIVE_TRAMP,
);
install_detour(
base,
0x57230,
"LoadCurrentOfflineSeason_impl",
19,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40,
0x98, 0xfe, 0xff, 0xff, 0xff,
],
load_current_impl_wrapper as *const () as usize,
&LOAD_CURRENT_IMPL_TRAMP,
);
install_detour(
base,
0x578e0,
"LoadCurrentOfflineSeason_completion",
16,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff,
0xff, 0xff,
],
completion_wrapper as *const () as usize,
&COMPLETION_TRAMP,
);
install_detour_reloc(
base,
0x4eb50,
"GetUsersOfflineDivision_native",
14,
&[
0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01,
],
7,
11,
get_users_division_wrapper as *const () as usize,
&GET_USERS_DIVISION_TRAMP,
);
install_detour(
base,
0x4ee10,
"LoadOfflineSeasons_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_offline_real_wrapper as *const () as usize,
&LOAD_OFFLINE_REAL_TRAMP,
);
install_detour(
base,
0x57560,
"LoadOfflineSeasons_asyncimpl",
17,
&[
0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
],
load_offline_async_wrapper as *const () as usize,
&LOAD_OFFLINE_ASYNC_TRAMP,
);
install_detour(
base,
0xffe90,
"LoadOfflineSeasons_final_completion",
16,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48,
0x8b, 0xda,
],
final_completion_wrapper as *const () as usize,
&FINAL_COMPLETION_TRAMP,
);
install_detour(
base,
0x106240,
"LoadOfflineSeasons_stage1_completion",
15,
&[
0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00,
0x00,
],
stage1_completion_wrapper as *const () as usize,
&STAGE1_COMPLETION_TRAMP,
);
install_detour_reloc(
base,
0x17ff90,
"start_webfile_dl_url",
14,
&[
0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1,
],
7,
11,
url_capture_wrapper as *const () as usize,
&URL_CAPTURE_TRAMP,
);
write_log("SEASON_TRACE: all season-native traces armed\n");
}
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
arm_fut_content_base();
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
/// Resolve the FUT web-file prefix from `openfut.cfg` next to the game exe, via
/// the shared `openfut-common` parser — the same single source of truth the
/// network redirect uses, so the lab address is never compiled in.
///
/// Fails SAFE: an absent or unusable config arms nothing, and the url rewriter
/// then leaves every url exactly as the client built it.
fn arm_fut_content_base() {
let path = match std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("openfut.cfg")))
{
Some(p) => p,
None => {
write_log("SEASONS_WEBFILE_BASE: cannot locate openfut.cfg — no url rewrite\n");
return;
}
};
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
write_log(&format!(
"SEASONS_WEBFILE_BASE: {} unreadable ({e}) — no url rewrite\n",
path.display()
));
return;
}
};
match openfut_common::ServerConfig::parse(&contents) {
Ok(cfg) => {
let base = cfg.fut_content_base();
write_log(&format!("SEASONS_WEBFILE_BASE: armed {base}\n"));
set_fut_content_base(base);
}
Err(e) => write_log(&format!(
"SEASONS_WEBFILE_BASE: openfut.cfg unusable ({e}) — no url rewrite\n"
)),
}
}
+485
View File
@@ -0,0 +1,485 @@
//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show.
//!
//! # Confirmed root cause (live, 2026-08-19)
//!
//! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework
//! invokes at store screen-show. It is an unrolled six-slot loop; each slot gates
//! on one hard-coded category token and either publishes that group's id as
//! `PANEL_ID` for the slot, or hides the slot:
//!
//! ```text
//! if (FUN_180014df0(_, idx)) // token present?
//! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx));
//! else
//! (*(panel_vtbl+0xa0))(panel, slot); // hide slot
//! ```
//!
//! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`.
//! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans
//! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`.
//! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME.
//!
//! The bind detour below measured the ground truth on the retail client:
//!
//! ```text
//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show
//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after
//! ```
//!
//! `mask=0x00` at screen-show confirms the container is empty when the framework
//! binds, so all six slots hide and no tab bar is built. The store's own
//! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works
//! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0
//! `mypacks` is clear because an empty My Packs serves no `mypacks` group.)
//!
//! # What did NOT work, and why this module changed
//!
//! A previous version re-invoked the binder at the next render, once the groups had
//! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that
//! late bind: the Scaleform movie only honours the framework's OWN bind at
//! screen-show, not a later re-publish/commit. That approach is abandoned.
//!
//! # This module: make the container non-empty BEFORE the first bind
//!
//! The only publish the movie honours is the framework's bind at screen-show, and
//! re-entry proves that bind builds the bar correctly when the container is already
//! full. So the fix is to load the purchase groups BEFORE the store screen is shown.
//!
//! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`.
//! Firing it from the FUT hub event pump (a real game thread, well before the store
//! screen exists) gives the response time to arrive and populate the container, so
//! the first screen-show bind sees a full list and binds the tabs natively — exactly
//! the re-entry path, on first entry.
//!
//! The bind detour is retained purely as the SENSOR: the first-entry bind mask is
//! the safe, definitive measurement of whether the pre-warm populated the container
//! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively;
//! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request
//! cannot land in time and the remaining route is the extracted `StoreFront.apt`.
//!
//! # Fail-closed
//!
//! * Pre-warm fires at most once per process, claimed atomically, and only once the
//! storefront singleton is non-null; the storefront pointer is read through a
//! guarded load and the request function's signature is validated before the call.
//! * The bind detour only reads (captures pointers, probes the game's own gate with
//! a provably-dead `this`) and never mutates store state.
//! * Image plus every function signature are verified before any write and again
//! under thread suspension; one wrong byte aborts with no write and no call.
//!
//! # Promotion
//!
//! PROMOTED: armed by the build, never by an environment variable (see
//! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
/// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework
/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw.
const BIND_RVA: usize = 0x7e5e0;
/// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the
/// six hard-coded tokens and reports whether a loaded purchase group carries it.
const HAS_CATEGORY_RVA: usize = 0x14df0;
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
/// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm.
const REQUEST_GROUPS_RVA: usize = 0x17870;
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
/// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET).
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
/// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`.
/// Taken from the binder's unrolled call sequence, not from the index order of
/// `FUN_180014580`'s jump table (which is deliberately different).
const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1];
/// Whole-instruction prologue length relocated into the trampoline; also the number
/// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the
/// 14-byte absolute jump.
const COPY_LEN: usize = 15;
const ABS_JUMP_LEN: usize = 14;
/// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp;
/// mov [rsp+0x18],rsi` = 5 + 5 + 5.
const BIND_SIGNATURE: [u8; COPY_LEN] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18,
];
/// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate
/// probe only runs on the exact build it was reversed against.
const HAS_CATEGORY_SIGNATURE: [u8; 15] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35,
];
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the
/// pre-warm only fires the genuine request on the exact build it was reversed against.
const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [
0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff,
0xff, 0xff,
];
type BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void;
type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8;
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
/// The tab-bar repair is PROMOTED: armed by the build, never by an environment
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
/// identically. Promotion does not weaken any check — the signature gate, the image
/// validation and the thread quiesce all remain in the runtime evidence path.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays build-armed. Regressing it to an env gate
/// would silently restore the missing first-entry tab bar on a normal launch, so it
/// must be a deliberate, visible change here rather than a missing variable.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0);
/// Gate mask the framework's most recent bind observed (bit N = slot N would bind).
static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0);
static LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
/// Set once the pre-warm request has been fired (or is provably unnecessary).
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
/// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged
/// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired
/// but the response did not land before screen-show".
static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0);
/// Pure pre-warm decision, isolated for host tests.
///
/// Fire exactly once, and only once the storefront singleton is non-null; before
/// that, keep waiting (a null storefront early at the hub is expected).
fn should_prewarm(already_done: bool, storefront: usize) -> bool {
!already_done && storefront != 0
}
/// Probe all six category tokens with the game's own gate and return a slot mask.
///
/// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and
/// fetches the group container from a singleton, so a null `this` is exactly what
/// the native code effectively passes. Called only from the bind detour, where the
/// store subsystem is provably live.
unsafe fn gate_mask() -> u8 {
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 {
return 0;
}
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
return 0;
};
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
let mut mask = 0u8;
for (slot, index) in GATE_INDICES.iter().enumerate() {
if gate_fn(core::ptr::null_mut(), *index) != 0 {
mask |= 1 << slot;
}
}
mask
}
/// Ask the game to load the purchase groups now, on the caller's (game) thread.
///
/// Called from the FUT event dispatcher so it runs on a real game thread well before
/// the store screen is ever shown — the same thread the store screen itself would use
/// for this call at entry. Fail-closed: base/signature/storefront all validated, at
/// most one request per process.
pub(crate) unsafe fn maybe_prewarm_groups() {
if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) {
return;
}
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 || !crate::sbc_trace::valid_cards_image(base) {
return;
}
let Some(storefront) = base
.checked_add(STOREFRONT_GLOBAL_RVA)
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
else {
return;
};
if storefront != 0 {
PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release);
}
if !should_prewarm(false, storefront) {
// Storefront not up yet at the hub: keep waiting, do not consume the attempt.
return;
}
let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else {
return;
};
if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len())
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return;
}
// Claim the single attempt before issuing it, so a re-entrant event can never
// fire a second request.
PREWARM_DONE.store(true, Ordering::Release);
PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
let request_fn: RequestGroupsFn = core::mem::transmute(request);
request_fn(storefront as *mut c_void);
crate::write_log(&format!(
"STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n"
));
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
/// Detour target for the native tab binder. Read-only sensor: records the gate mask
/// the framework's bind is about to act on, then runs the original unchanged. This is
/// the definitive measurement of whether the pre-warm populated the container in time.
unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void {
let mask = gate_mask();
LAST_BIND_MASK.store(mask as u32, Ordering::Release);
LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
BIND_ENTRIES.fetch_add(1, Ordering::AcqRel);
let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire));
original(ctx, panel)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_hook(base: usize) -> InstallOutcome {
let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else {
return InstallOutcome::CleanFailure;
};
// Fingerprint the image and ALL THREE functions: the one we detour and the two we
// call (gate probe, group request). A single mismatched byte aborts cleanly with
// no write and no call.
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(base, bind, BIND_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(
base,
request,
REQUEST_GROUPS_SIGNATURE.len(),
)
|| core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE
|| core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len())
!= HAS_CATEGORY_SIGNATURE
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return InstallOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
bind as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else {
return InstallOutcome::CleanFailure;
};
BIND_TRAMPOLINE.store(trampoline, Ordering::Release);
STORE_BASE.store(base, Ordering::Release);
let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(bind, bind_wrapper as *const () as usize, &BIND_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_hook(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => {
crate::write_log("STORE_TABS: bind sensor + pre-warm installed (promoted)\n")
}
InstallOutcome::CleanFailure => {
crate::write_log("STORE_TABS: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut binds_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let binds = BIND_ENTRIES.load(Ordering::Acquire);
if binds != binds_seen {
crate::write_log(&format!(
"STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n",
binds,
LAST_BIND_MASK.load(Ordering::Acquire),
PREWARM_ATTEMPTS.load(Ordering::Acquire),
PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire),
LAST_THREAD.load(Ordering::Relaxed),
));
binds_seen = binds;
reports += 1;
}
}
crate::write_log("STORE_TABS: report cap reached; hook remains installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log(
"STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n",
);
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_indices_match_the_native_slot_order() {
// mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0
// tests them in, which is NOT the index order of FUN_180014580's jump table.
assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]);
}
#[test]
fn prewarms_once_the_storefront_is_up() {
assert!(should_prewarm(false, 0x1000));
}
#[test]
fn waits_while_the_storefront_is_still_null() {
assert!(!should_prewarm(false, 0));
}
#[test]
fn never_prewarms_twice() {
assert!(!should_prewarm(true, 0x1000));
}
#[test]
fn detour_signature_is_long_enough_for_the_absolute_jump() {
assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN);
assert_eq!(COPY_LEN, BIND_SIGNATURE.len());
}
#[test]
fn request_signature_covers_the_validated_prologue() {
// 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`.
assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18);
}
}
+13 -14
View File
@@ -541,7 +541,7 @@ impl LauncherApp {
status_text(ui, readiness_status(server), &server_label);
ui.end_row();
ui.label(RichText::new("Client integration").color(theme::TEXT_WEAK));
ui.label(RichText::new("Game files").color(theme::TEXT_WEAK));
status_text(
ui,
readiness_status(integration),
@@ -555,11 +555,11 @@ impl LauncherApp {
);
ui.end_row();
ui.label(RichText::new("Local services").color(theme::TEXT_WEAK));
ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK));
status_text(ui, readiness_status(services), &services_label);
ui.end_row();
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK));
status_text(ui, readiness_status(hook), &hook_label);
ui.end_row();
});
@@ -725,13 +725,12 @@ impl LauncherApp {
match (ready, blocked) {
(_, true) => (launch::Readiness::Attention, "Blocked".into()),
(2, _) => (launch::Readiness::Ready, "Ready".into()),
(0, _) => (
// Not a problem: Launch starts them. Stating "Stopped" is honest
// and does not demand an action.
launch::Readiness::Unknown,
"Stopped — Launch starts them".into(),
),
(_, _) => (launch::Readiness::Unknown, "Partly running".into()),
// Neither stopped nor mid-start is a fault: the helpers only run
// alongside a session and Launch brings up whatever is missing. This
// used to read "Partly running", which sounds broken for what is the
// normal idle state and gave the player nothing to act on. Say what
// will happen instead.
(_, _) => (launch::Readiness::Unknown, "Start with the game".into()),
}
}
@@ -745,14 +744,14 @@ impl LauncherApp {
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
{
Some(d) if d == self.config.server_config() => {
(launch::Readiness::Ready, format!("Deployed → {}", d.host))
(launch::Readiness::Ready, "Installed".into())
}
// Launch rewrites it, so this is not something to demand action for.
Some(d) => (
Some(_) => (
launch::Readiness::Unknown,
format!("Deployed → {} · Launch updates it", d.host),
"Installed · Launch will update it".into(),
),
None => (launch::Readiness::Attention, "No openfut.cfg".into()),
None => (launch::Readiness::Attention, "Not set up".into()),
}
}
+1
View File
@@ -314,6 +314,7 @@ impl LauncherConfig {
https: self.openfut_https_port,
blaze_redirector: self.openfut_blaze_redirector_port,
blaze_main: self.openfut_blaze_main_port,
fut_content: openfut_common::default_ports::FUT_CONTENT,
},
}
}