20 Commits

Author SHA1 Message Date
funman300 4b1d5aa367 diag(fifa17): passive kit-selector data-flow trace (kit_trace)
Traces the client-side FUT pre-match kit path in CardsDLL: GetMatchKits_DP
gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item
clone driver (item type/subid/teamid), and the local teamkits DB clone. Proves
in one operator match where the empty selector originates. Read-only; reuses
season_trace's passive-detour installers.
2026-08-20 16:55:50 +00:00
funman300 ed5c335c70 tooling(re): restore Ghidra 11.1.2 headless + pyhidra for cardsdll/powdll 2026-08-20 16:28:40 +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
funman300 3d3790a83a launcher: persist the hook DLL override in the prefix, not in launch options
Wine ignores the game-directory version.dll proxy unless an override names it.
WINEDLLOVERRIDES covers only a process the launcher spawns itself, so the documented
fallback was to have the user paste Steam launch options by hand - a step a normal
player cannot be expected to perform, and the reason the game had to be started
through a specific wrapper at all.

The launcher now persists version=native,builtin into the prefix registry via Wine
own reg tool before launching (ensure_dll_override). It is /f-idempotent, so it runs
on every launch and repairs a prefix the player has reset or replaced, and it applies
to EVERY launch path including Steam Play. This mirrors what BepInEx documents for
Proton (configure the proxy in winecfg rather than the environment) and what Proton
already does in this prefix for other titles. Best-effort: a failure is reported in
plain language and the launch still carries WINEDLLOVERRIDES.

STEAM_LAUNCH_OPTIONS is demoted to a fallback for prefixes we have never prepared.

Also fixes a pre-existing clippy manual_is_multiple_of in app.rs that was failing the
strict lint gate. fmt clean, clippy -D warnings clean, 75 tests pass.
2026-08-19 03:01:46 +00:00
funman300 94feaec63f Promote the FIFA17 SBC dispatch repair: armed by the build, not by env
Retail Gates A-G passed on the pinned CardsDLL build (4706a881), and the repair
has been live-proven repeatedly, so it is now a promoted feature. Arming it from
OPENFUT_SBC_DISPATCH meant any launch that did not export it (Steam, the launcher
Launch button, a bare umu-run) silently lost the SBC screen to the known
response-to-deserializer dispatch defect, leaving a harness script as the only
working entry point.

REPAIR_PROMOTED is now a build constant with a compile-time contract, and both
install sites derive from it: sbc_dispatch::install always arms, and
sbc_trace::install derives the parser/notifier/controller-registration traces from
it because those traces ARE the repair decision inputs, not optional diagnostics.

Promotion weakens no check. Safety stays in the runtime evidence gate rather than a
flag: the worker still validates the exact CardsDLL signatures before installing a
detour, and decide() still requires the transport sentinel status, the pinned
category-response vtable captured while the response object was provably live,
balanced parser counts on the one parser thread, this generation notifier having
entered AND returned, the captured controller/model identity, and one repair per
deserializer generation. An unrecognised build leaves native execution untouched.

Rollback is a file swap (restore the previous version.dll via the hook harness
backup), the documented client rollback path, deliberately not an env kill-switch.

fmt clean, strict clippy clean on default and fifa17 features, 22 tests pass,
release cross-build to x86_64-pc-windows-gnu produces artifact 3641d581.
2026-08-19 02:45:43 +00:00
funman300 c3addde9b1 Correct FIFA17 SBC dispatch notifier lifecycle guard to post-exit invariant 2026-08-18 20:59:07 +00:00
funman300 9900772690 Apply rustfmt to launcher services and app 2026-08-18 20:51:55 +00:00
funman300 35ceb084ef Fix FIFA17 SBC dispatch response-class check to capture live vtable 2026-08-18 20:51:55 +00:00
funman300 1c7111ddbf Harden FIFA17 SBC dispatch repair 2026-08-18 20:20:27 +00:00
funman300 6cdb45e482 Instrument FIFA17 SBC completion dispatch 2026-08-18 20:20:00 +00:00
funman300 1cd4f18e92 feat: spawn the Rust companion binaries, not Python scripts
The launcher shelled out to `python3 lsx_responder_v2.py` and `python3 autopatch.py`
from a configured tools directory. Both are now Rust binaries built from this
workspace (openfut-lsx, openfut-autopatch), so the launch contract loses the
interpreter and the script directory entirely: nothing to locate, nothing to
configure, and no way to run a stale checkout's copy of a responder.

Service::script() becomes Service::binary(), and resolve_binary() prefers a sibling
of the running launcher -- what a workspace build and any sane install layout both
produce -- falling back to the bare name so a PATH install still works. It returns the
bare name rather than failing so that spawn() stays the single place a missing binary
is reported, instead of two error paths for one condition.

foreign_pid() now matches an argv entry's FILE NAME rather than a suffix, so
`/path/to/openfut-lsx` matches while an unrelated argument that merely ends with the
same text does not. It deliberately still reads argv and not comm: comm is truncated
to 15 characters by the kernel, which would misreport both of these names -- the same
trap that made an earlier `pgrep -f` guard match its own shell.

Dead configuration removed rather than left vestigial: fifa17_python and
fifa17_tools_dir, their Settings controls, and validate_local_services(), whose only
two checks were those fields. A validation hook that can only return Ok(()) would
claim the launcher verifies local-service configuration when there is none. The
preflight tools-dir gate is gone too, while the ptrace_scope check it gated is kept --
that check is real and repairable via "Arm client"; only the gate died.

The env contract is unchanged, so the binaries are drop-in: LSX still receives
FUT_PERSONA_ID/FUT_PERSONA_NAME (the persona has to agree with Blaze's
LoginResponse.SESS.PDTL and UTAS's userInfo.personaId), autopatch still receives
OPENFUT_AUTOPATCH_LOG under XDG_RUNTIME_DIR and --launcher-pid so it cannot outlive
its owner, and each companion still gets its own process group.

74 tests green.
2026-08-18 05:30:35 +00:00
funman300 c5424158b9 fix: launcher-spawned FIFA never loaded the hook, so its Blaze ports were inert
The hook ships as a `version.dll` proxy in the game directory, and Proton prefers a
local DLL over its builtin ONLY when WINEDLLOVERRIDES names it -- exactly what
setup::STEAM_LAUNCH_OPTIONS documents ("version=n,b"). Steam users get it from their
launch options. When the launcher spawns the runner itself it applied profile.env and
WINEPREFIX but never the override, so the hook silently did not load.

The failure mode is worse than "hook missing", which is why it went unnoticed: with
no hook there is no port rewrite, so the openfut.cfg the launcher writes two seconds
earlier is inert and the game falls back to EA's real Blaze ports, where /etc/hosts
(10.10.0.120 easw.easports.com) quietly routes it to whatever answers there. The
launch looks completely healthy -- correct log lines, game boots, FUT loads -- while
talking to a different server than the one configured. Production only works here by
accident of the hosts file.

Observed end-to-end: openfut.cfg written 19:50:00 with blaze 42327/42330, FIFA started
19:50:03, and the process was ESTAB to 10.10.0.120:42130 -- production.
/proc/<pid>/maps showed the mapped version.dll was Proton's own
(compatibilitytools.d/UMU-Proton-10.0-4/files/lib/wine/x86_64-windows/version.dll),
not the game-dir hook, and no hook log existed for the Proton prefix at all.

launch() now always sets WINEDLLOVERRIDES, appending to any profile value and
deferring to a profile that pins `version=` itself, so an operator disabling the
hijack on purpose is not silently overruled. Four tests cover absent, unrelated,
explicit and blank-string cases.

Also worth noting for future debugging: ~/.wine/drive_c/openfut_hook.log is stale and
belongs to a non-Proton prefix. It is not evidence about a umu/Proton launch, and
reading it as current is how this was nearly misdiagnosed.
2026-08-18 02:57:29 +00:00
17 changed files with 2696 additions and 541 deletions
+7 -4
View File
@@ -79,13 +79,16 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
));
dump_modules();
write_log("fifa17: worker complete (injection healthy)\n");
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
// itself from the build; its safety is the runtime signature/evidence gate. The
// remaining legacy experiment modules stay inert unless their env gate is `1`.
crate::sbc_hook::install();
// Passive transaction tracing has a separate kill switch from cache resolution.
// It currently fails closed until safe relocating trampolines are proven.
crate::sbc_trace::install();
crate::sbc_dispatch::install();
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
crate::kit_trace::install();
0
}
+184
View File
@@ -0,0 +1,184 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's FUT pre-match
//! KIT SELECTOR data flow.
//!
//! RE (2026-08-20, Ghidra on CardsDLL_Win64_retail.dll) established that the
//! pre-match kit selector is fed ENTIRELY client-side (NOT by POW/EASFC):
//!
//! * `FUT_GET_MATCH_KITS_DP` (id 0x7565) builder `FUN_1800be6a0` (rva 0xbe6a0)
//! reads a boolean gate `ctx+0x152` (`KITS_AVAILABLE`); when false, or when
//! the two available-kit vectors are empty, the selector renders blank/white.
//! * The available home/away kit-id lists live on `FutSquadServiceImpl`
//! (`this+0xe08` home, `this+0xe38` away) and are written by the setter
//! `FUN_180196760` (rva 0x96760, vtable slot 0x1d0): args (this, srcVec, side).
//! * A club KIT ITEM is turned into an available kit by `FUN_1801c3480`
//! (rva 0x1c3480): it reads item fields (`+0x4c==7`, `+0x60==4`,
//! `+0x5c`∈{101 home,102 away}, `+0x94` source teamid, `+0xba`
//! teamkittypetechid) and calls `FUN_1801c44b0` (rva 0x1c44b0) to clone that
//! team's kit rows from the CLIENT-LOCAL `teamkits` DB into the FUT club
//! (teamtechid 130000).
//!
//! These traces answer, in one operator-driven match, exactly WHERE the empty
//! selector originates: do kit club items reach the client (kit_item_clone), does
//! the clone into the FUT club happen (kit_db_clone), does the available list get
//! set non-empty (set_available_kits), and what does the selector finally read
//! (get_match_kits: KITS_AVAILABLE + count). Every trace is read-only: it logs,
//! then tail-calls the original through a trampoline. Copied prologues are whole,
//! position-independent instructions (the one rip-relative prologue uses the
//! relocating installer).
use core::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{readable_range, validate_cards_build};
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32, rd_u8};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
fn budget() -> bool {
REPORTS.fetch_add(1, Ordering::Relaxed) < 256
}
unsafe fn rd_usize(addr: usize) -> Option<usize> {
readable_range(addr, 8).then(|| core::ptr::read_volatile(addr as *const usize))
}
// FUT_GET_MATCH_KITS_DP builder FUN_1800be6a0 (0xbe6a0). rcx = DP model ctx.
// ctx+0x152 is the KITS_AVAILABLE bool that gates the whole selector list.
static GET_MATCH_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn get_match_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
let avail = rd_u8(rcx + 0x152);
write_log(&format!(
"KIT_GET: FUT_GET_MATCH_KITS_DP ctx={rcx:#x} KITS_AVAILABLE={avail:?}\n"
));
}
let t = GET_MATCH_KITS_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)
}
// setAvailableKits FUN_180196760 (0x96760): (this, srcVec, side). srcVec is an
// int vector {begin@+0, end@+8}; count = (end-begin)/4. side 0=home, 1=away.
static SET_AVAILABLE_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn set_available_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
let count = match (rd_usize(rdx), rd_usize(rdx + 8)) {
(Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64,
_ => -1,
};
write_log(&format!(
"KIT_SET: setAvailableKits this={rcx:#x} side={r8} count={count}\n"
));
}
let t = SET_AVAILABLE_KITS_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)
}
// Kit-item clone driver FUN_1801c3480 (0x1c3480): rdx = param_2, the club-item
// event; the item struct is at *(param_2+0x10). Logs the fields the function
// branches on so we can see whether a kit club item reaches the client and its
// home/away designator + source teamid.
static KIT_ITEM_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_item_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
if let Some(item) = rd_usize(rdx + 0x10) {
write_log(&format!(
"KIT_ITEM: clone-driver item={item:#x} type[+0x4c]={:?} subid[+0x5c]={:?} \
cat[+0x60]={:?} teamid[+0x94]={:?} kittype[+0xba]={:?}\n",
rd_i32(item + 0x4c),
rd_i32(item + 0x5c),
rd_i32(item + 0x60),
rd_i32(item + 0x94),
rd_i32(item + 0xba),
));
} else {
write_log(&format!("KIT_ITEM: clone-driver param_2={rdx:#x} (item ptr unreadable)\n"));
}
}
let t = KIT_ITEM_CLONE_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)
}
// Kit DB clone FUN_1801c44b0 (0x1c44b0): (clubmgr, side, teamtechid, kittype).
// Fires only when the driver decided the item is a home(101)/away(102) kit, so
// this is the proof the FUT-club (teamtechid 130000) kit rows get synthesized.
static KIT_DB_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_db_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
write_log(&format!(
"KIT_DBCLONE: clone team kit side={rdx} src_teamtechid={r8} kittype={r9}\n"
));
}
let t = KIT_DB_CLONE_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)
}
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("KIT_TRACE: CardsDLL unavailable/invalid; kit trace inactive\n");
return;
}
// FUN_1800be6a0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 a1 (copy_len 16).
install_detour(
base, 0xbe6a0, "GetMatchKits_DP(0xbe6a0)", 16,
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xa1],
get_match_kits_wrapper as *const () as usize, &GET_MATCH_KITS_TRAMP,
);
// FUN_180196760: 48 89 54 24 10 53 48 83 ec 30 48 c7 44 24 20 fe ff ff ff (copy_len 19).
install_detour(
base, 0x96760, "setAvailableKits(0x96760)", 19,
&[0x48, 0x89, 0x54, 0x24, 0x10, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
set_available_kits_wrapper as *const () as usize, &SET_AVAILABLE_KITS_TRAMP,
);
// FUN_1801c3480: 48 89 5c 24 08 57 48 83 ec 60 <48 8b 05 disp32> (rip-relative
// MOV RAX,[rip+..] at copied offset 10; disp32 at 13, insn end 17; copy_len 17).
install_detour_reloc(
base, 0x1c3480, "kitItemClone(0x1c3480)", 17,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8b, 0x05, 0x4f, 0x82, 0x11, 0x00],
13, 17,
kit_item_clone_wrapper as *const () as usize, &KIT_ITEM_CLONE_TRAMP,
);
// FUN_1801c44b0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 c8 (copy_len 16).
install_detour(
base, 0x1c44b0, "kitDbClone(0x1c44b0)", 16,
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xc8],
kit_db_clone_wrapper as *const () as usize, &KIT_DB_CLONE_TRAMP,
);
write_log("KIT_TRACE: all kit-selector traces armed\n");
}
/// Arm the passive kit-selector diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
write_log("KIT_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+8
View File
@@ -14,6 +14,8 @@ mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks;
#[cfg(feature = "fifa17")]
mod kit_trace;
mod iat;
mod origin_spy;
#[cfg(feature = "probe")]
@@ -21,12 +23,18 @@ mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
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;
+856
View File
@@ -0,0 +1,856 @@
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
//!
//! The repair is a PROMOTED feature: it is armed by the build itself, never by an
//! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime
//! evidence gate, not in a flag.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, 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::{
VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE,
PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
const COMPLETION_RVA: usize = 0x0b8950;
const EVENT_DISPATCH_RVA: usize = 0x1a4cd0;
const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0;
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
const COMPLETION_COPY_LEN: usize = 14;
const EVENT_COPY_LEN: usize = 16;
const ABS_JUMP_LEN: usize = 14;
const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2;
const UNKNOWN_TRANSPORT_STATUS: u32 = 999;
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d;
const SBC_REFRESH_EVENT: u32 = 0x138c;
const COMPLETION_SIGNATURE: [u8; 32] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a,
0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01,
];
const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff,
];
type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize;
type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize;
/// The guarded native dispatch repair is PROMOTED: armed by the build, never by an
/// environment variable. Retail Gates AG passed on the pinned CardsDLL build, so a
/// deployed hook must repair the SBC completion on every launch path (Steam, the
/// launcher, or a bare `umu-run`) with nothing to export.
///
/// Promotion does NOT weaken any check — every guard stays in the runtime evidence
/// gate rather than in a flag. `worker` still validates the exact CardsDLL
/// signatures before installing a detour, and [`decide`] still requires the
/// transport sentinel status, the pinned category-response vtable captured while
/// the response object was provably live, balanced parser counts on the one parser
/// thread, this generation's notifier having entered AND returned, the captured
/// controller/model identity, and one repair per deserializer generation. Anything
/// unrecognised leaves native execution untouched.
///
/// Rollback is a file swap (restore the previous `version.dll`) — the documented
/// client rollback path — deliberately not an env kill-switch.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays armed by the build. Flipping this back to
/// an env gate would silently cost a normal launch (Steam or the launcher) its SBC
/// screen, which is exactly the regression promotion removed — so it must be a
/// deliberate, visible change here rather than a missing variable at runtime.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0);
static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0);
static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX);
static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize);
static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize);
static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0);
static EVENT_EXITS: AtomicU64 = AtomicU64::new(0);
static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static EVENT_ID: AtomicUsize = AtomicUsize::new(0);
static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0);
static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0);
static EVENT_READY: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Decision {
NativeSuccess,
Repair,
}
const REJECTED_DECISION: usize = 2;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Rejection {
None,
RepairDisabled,
NullStatus,
StatusUnreadable,
UnsupportedStatus,
CardsBuildMismatch,
ParserUnbalanced,
FactoryMismatch,
ParserThreadMismatch,
ReaderMissing,
ParseFailed,
ResponseClassMismatch,
ModelChanged,
ModelEmpty,
NotifierNotCurrent,
ControllerMismatch,
ControllerModelMismatch,
DuplicateGeneration,
}
#[derive(Clone, Copy)]
struct DecisionInput {
repair_enabled: bool,
status: Option<u32>,
status_present: bool,
status_copyable: bool,
cards_build_matches: bool,
factory_entries: u64,
factory_exits: u64,
factory_result: usize,
factory_thread: usize,
deserializer_entries: u64,
deserializer_exits: u64,
deserializer_this: usize,
deserializer_reader: usize,
deserializer_result: bool,
deserializer_thread: usize,
response_class_matches: bool,
model: usize,
live_category_count: usize,
category_count: usize,
notifier_entries: u64,
notifier_exits: u64,
controller_matches: bool,
controller_model_matches: bool,
last_repaired_generation: u64,
}
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
let Some(status) = input.status else {
return Err(if input.status_present {
Rejection::StatusUnreadable
} else {
Rejection::NullStatus
});
};
if status == 0 {
return Ok(Decision::NativeSuccess);
}
if !input.repair_enabled {
return Err(Rejection::RepairDisabled);
}
if status != UNKNOWN_TRANSPORT_STATUS {
return Err(Rejection::UnsupportedStatus);
}
if !input.status_copyable {
return Err(Rejection::StatusUnreadable);
}
if !input.cards_build_matches {
return Err(Rejection::CardsBuildMismatch);
}
let generation = input.deserializer_exits;
if generation == 0
|| input.factory_entries != input.factory_exits
|| input.deserializer_entries != generation
|| input.factory_exits != generation
{
return Err(Rejection::ParserUnbalanced);
}
if input.factory_result == 0 || input.factory_result != input.deserializer_this {
return Err(Rejection::FactoryMismatch);
}
if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread {
return Err(Rejection::ParserThreadMismatch);
}
if input.deserializer_reader == 0 {
return Err(Rejection::ReaderMissing);
}
if !input.deserializer_result {
return Err(Rejection::ParseFailed);
}
if !input.response_class_matches {
return Err(Rejection::ResponseClassMismatch);
}
if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX {
return Err(Rejection::ModelEmpty);
}
if input.live_category_count != input.category_count {
return Err(Rejection::ModelChanged);
}
// The category-success notifier for this generation must have entered and
// fully returned before the SBC completion runs. On the pinned CardsDLL the
// completion fires immediately after the notifier unwinds (measured: notifier
// entries == exits == generation at completion), not nested inside it, so we
// bind both notifier counts to the current generation rather than requiring
// an in-flight notifier.
if input.notifier_entries != generation
|| input.notifier_entries == 0
|| input.notifier_exits != generation
{
return Err(Rejection::NotifierNotCurrent);
}
if !input.controller_matches {
return Err(Rejection::ControllerMismatch);
}
if !input.controller_model_matches {
return Err(Rejection::ControllerModelMismatch);
}
if input.last_repaired_generation >= generation {
return Err(Rejection::DuplicateGeneration);
}
Ok(Decision::Repair)
}
/// The parsed response is the FIFA 17 typed SBC-category response only when the
/// vtable captured at deserializer exit (object provably live) equals the pinned
/// category-response vtable for the running CardsDLL image. A zero capture means
/// the object vtable was unreadable and never qualifies.
fn response_class_matches(base: usize, response_vtable: usize) -> bool {
response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable)
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
crate::sbc_trace::readable_range(address, 4)
.then(|| core::ptr::read_volatile(address as *const u32))
}
unsafe fn status_code(status: usize) -> Option<u32> {
status
.checked_add(0x1c)
.and_then(|address| guarded_u32(address))
}
unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) {
if base == 0 || controller == 0 {
return (false, false);
}
let main_vtable = crate::sbc_trace::guarded_usize(controller);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
(
main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA),
controller_model == Some(model),
)
}
pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) {
let (identity_matches, _) = controller_identity(base, controller, 0);
if identity_matches {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_DISPATCH: captured category controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n"
));
}
}
#[repr(C, align(16))]
struct CompletionStatusShadow([u8; 0x20]);
unsafe extern "system" fn completion_wrapper(
controller: *mut c_void,
status: *mut c_void,
) -> usize {
COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed);
COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed);
COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed);
let evidence = crate::sbc_trace::dispatch_evidence();
let status_address = status as usize;
let observed_status = if status_address == 0 {
None
} else {
status_code(status_address)
};
COMPLETION_STATUS.store(
observed_status
.map(|value| value as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed);
let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
let live_category_count = evidence
.model
.checked_add(0x50)
.and_then(|address| crate::sbc_trace::guarded_u16(address))
.map(usize::from)
.unwrap_or(usize::MAX);
let (controller_matches, controller_model_matches) =
controller_identity(evidence.base, captured_controller, evidence.model);
let input = DecisionInput {
repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire),
status: observed_status,
status_present: status_address != 0,
status_copyable: status_address != 0
&& crate::sbc_trace::readable_range(status_address, 0x20),
cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base),
factory_entries: evidence.factory_entries,
factory_exits: evidence.factory_exits,
factory_result: evidence.factory_result,
factory_thread: evidence.factory_thread,
deserializer_entries: evidence.deserializer_entries,
deserializer_exits: evidence.deserializer_exits,
deserializer_this: evidence.deserializer_this,
deserializer_reader: evidence.deserializer_reader,
deserializer_result: evidence.deserializer_result,
deserializer_thread: evidence.deserializer_thread,
response_class_matches: response_class_matches(evidence.base, evidence.response_vtable),
model: evidence.model,
live_category_count,
category_count: evidence.category_count,
notifier_entries: evidence.notifier_entries,
notifier_exits: evidence.notifier_exits,
controller_matches: controller_matches && captured_controller == controller as usize,
controller_model_matches,
last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire),
};
let original: CompletionFn =
core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire));
let result = match decide(input) {
Ok(Decision::Repair) => {
if LAST_REPAIRED_GENERATION
.compare_exchange(
input.last_repaired_generation,
evidence.deserializer_exits,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
let mut shadow = CompletionStatusShadow([0; 0x20]);
core::ptr::copy_nonoverlapping(
status_address as *const u8,
shadow.0.as_mut_ptr(),
shadow.0.len(),
);
shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes());
COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, shadow.0.as_mut_ptr().cast())
} else {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION
.store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed);
original(controller, status)
}
}
Ok(Decision::NativeSuccess) => {
COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, status)
}
Err(rejection) => {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed);
original(controller, status)
}
};
crate::write_log(&format!(
"SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n",
input.deserializer_exits,
input.status.map(i64::from).unwrap_or(-1),
input.status_present,
input.status_copyable,
input.cards_build_matches,
input.factory_entries,
input.factory_exits,
input.factory_result,
input.factory_thread,
input.deserializer_entries,
input.deserializer_exits,
input.deserializer_this,
input.deserializer_reader,
input.deserializer_result,
input.deserializer_thread,
evidence.response_vtable,
evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0),
input.response_class_matches,
input.model,
input.live_category_count,
input.category_count,
input.notifier_entries,
input.notifier_exits,
input.controller_matches,
input.controller_model_matches,
captured_controller,
controller as usize,
input.last_repaired_generation,
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
));
COMPLETION_EXITS.fetch_add(1, Ordering::Release);
result
}
unsafe extern "system" fn event_wrapper(
controller: *mut c_void,
event: u32,
payload: *mut c_void,
) -> usize {
EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed);
EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed);
EVENT_ID.store(event as usize, Ordering::Relaxed);
EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed);
match event {
FUT_SBS_CATEGORIES_EVENT => {
EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed);
}
SBC_REFRESH_EVENT => {
EVENT_REFRESH.fetch_add(1, Ordering::Relaxed);
}
FUT_SBS_CATEGORIES_READY_EVENT => {
EVENT_READY.fetch_add(1, Ordering::Relaxed);
}
_ => {}
}
// 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);
result
}
unsafe fn allocate_completion_trampoline(target: usize) -> Option<usize> {
let failure_target = target.checked_add(0x5c)?;
let success_target = target.checked_add(COMPLETION_COPY_LEN)?;
let memory = VirtualAlloc(
core::ptr::null(),
COMPLETION_TRAMPOLINE_LEN,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
) as usize;
if memory == 0 {
return None;
}
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12);
// The relocated branch preserves the original null-status failure edge.
core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2);
let failure = crate::sbc_trace::absolute_jump(failure_target);
core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN);
let success = crate::sbc_trace::absolute_jump(success_target);
core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN);
let mut old = 0u32;
if VirtualProtect(
memory as _,
COMPLETION_TRAMPOLINE_LEN,
PAGE_EXECUTE_READ,
&mut old,
) == 0
|| FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0
{
VirtualFree(memory as _, 0, MEM_RELEASE);
return None;
}
Some(memory)
}
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))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_pair(base: usize) -> InstallOutcome {
let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else {
return InstallOutcome::CleanFailure;
};
let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE
[..COMPLETION_COPY_LEN]
.try_into()
.unwrap();
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(
base,
completion,
COMPLETION_SIGNATURE.len(),
)
|| !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len())
|| core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
!= COMPLETION_SIGNATURE
|| core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_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,
completion as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(completion_trampoline) = allocate_completion_trampoline(completion) else {
return InstallOutcome::CleanFailure;
};
let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN)
else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
return InstallOutcome::CleanFailure;
};
COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release);
EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release);
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(completion, event) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_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(completion as *const u8, COMPLETION_SIGNATURE.len())
== COMPLETION_SIGNATURE
&& core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len())
== EVENT_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(
completion,
completion_wrapper as *const () as usize,
&completion_original,
) {
Ok(()) => {
match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(event_clean) => {
let completion_clean = restore_entry(completion, &completion_original);
if event_clean && completion_clean {
InstallOutcome::CleanFailure
} else {
InstallOutcome::DegradedHookActive
}
}
}
}
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(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_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_pair(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => crate::write_log(
"SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n",
),
InstallOutcome::CleanFailure => {
crate::write_log("SBC_DISPATCH: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut completion_seen = 0u64;
let mut event_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire);
let event_entries = EVENT_ENTRIES.load(Ordering::Acquire);
if completion_entries != completion_seen || event_entries != event_seen {
crate::write_log(&format!(
"SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n",
completion_entries,
COMPLETION_EXITS.load(Ordering::Acquire),
COMPLETION_THREAD.load(Ordering::Relaxed),
COMPLETION_CONTROLLER.load(Ordering::Relaxed),
COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed),
COMPLETION_STATUS.load(Ordering::Relaxed),
COMPLETION_GENERATION.load(Ordering::Relaxed),
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
event_entries,
EVENT_EXITS.load(Ordering::Acquire),
EVENT_THREAD.load(Ordering::Relaxed),
EVENT_CONTROLLER.load(Ordering::Relaxed),
EVENT_ID.load(Ordering::Relaxed),
EVENT_PAYLOAD.load(Ordering::Relaxed),
EVENT_CATEGORIES.load(Ordering::Relaxed),
EVENT_REFRESH.load(Ordering::Relaxed),
EVENT_READY.load(Ordering::Relaxed),
));
completion_seen = completion_entries;
event_seen = event_entries;
reports += 1;
}
}
crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates in the
// decision, so every launch path behaves identically.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log(
"SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\n",
);
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input(generation: u64) -> DecisionInput {
DecisionInput {
repair_enabled: true,
status: Some(UNKNOWN_TRANSPORT_STATUS),
status_present: true,
status_copyable: true,
cards_build_matches: true,
factory_entries: generation,
factory_exits: generation,
factory_result: 0x2000,
factory_thread: 7,
deserializer_entries: generation,
deserializer_exits: generation,
deserializer_this: 0x2000,
deserializer_reader: 0x3000,
deserializer_result: true,
deserializer_thread: 7,
response_class_matches: true,
model: 0x4000,
live_category_count: 2,
category_count: 2,
notifier_entries: generation,
notifier_exits: generation,
controller_matches: true,
controller_model_matches: true,
last_repaired_generation: generation - 1,
}
}
#[test]
fn native_success_is_never_rewritten() {
let mut input = valid_input(1);
input.status = Some(0);
assert_eq!(decide(input), Ok(Decision::NativeSuccess));
}
#[test]
fn exact_unknown_status_and_full_evidence_allow_repair() {
assert_eq!(decide(valid_input(1)), Ok(Decision::Repair));
}
#[test]
fn repair_is_exactly_gated_and_fail_closed() {
let mut input = valid_input(1);
input.repair_enabled = false;
assert_eq!(decide(input), Err(Rejection::RepairDisabled));
let mut input = valid_input(1);
input.status = Some(500);
assert_eq!(decide(input), Err(Rejection::UnsupportedStatus));
let mut input = valid_input(1);
input.category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelEmpty));
let mut input = valid_input(1);
input.controller_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerMismatch));
let mut input = valid_input(1);
input.status = None;
input.status_present = false;
assert_eq!(decide(input), Err(Rejection::NullStatus));
let mut input = valid_input(1);
input.status = None;
assert_eq!(decide(input), Err(Rejection::StatusUnreadable));
// Notifier still in flight for this generation (has not returned) is rejected:
// on the pinned build the completion only runs after the notifier unwinds.
let mut input = valid_input(1);
input.notifier_exits = 0;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
// A notifier count that does not match the current generation is rejected.
let mut input = valid_input(1);
input.notifier_entries = 2;
input.notifier_exits = 2;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
let mut input = valid_input(1);
input.controller_model_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
let mut input = valid_input(1);
input.live_category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelChanged));
}
#[test]
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
let mut duplicate = valid_input(1);
duplicate.last_repaired_generation = 1;
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
let next = valid_input(2);
assert_eq!(decide(next), Ok(Decision::Repair));
}
#[test]
fn response_class_requires_exact_pinned_vtable() {
let base = 0x1_8000_0000usize;
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
assert!(response_class_matches(base, expected));
// An unreadable capture (zero) never qualifies.
assert!(!response_class_matches(base, 0));
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
assert!(!response_class_matches(base, expected + 8));
assert!(!response_class_matches(base, base));
}
#[test]
fn relocated_completion_branch_has_proven_layout() {
assert_eq!(COMPLETION_COPY_LEN, 14);
assert_eq!(
&COMPLETION_SIGNATURE[..12],
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
);
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
}
}
+3 -216
View File
@@ -4,11 +4,9 @@
//! (all addresses, RVA math, call order, crash risks, staged test plan):
//! fifa17-recon/docs/sbc-hook-dll-spec.md
//!
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
//! with this module compiled in changes nothing unless a var is set:
//! Everything here is **inert by default** and gated by env vars:
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
//!
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
@@ -20,14 +18,11 @@
//! See the spec for the verified disassembly behind each one.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
PAGE_WRITECOPY,
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
const IMAGE_BASE: usize = 0x180000000;
@@ -46,13 +41,6 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
const B_DTOR_RVA: usize = 0x63040;
const B_ISVALID_RVA: usize = 0x65d40;
const B_CLEAR_RVA: usize = 0x65d20;
@@ -87,11 +75,9 @@ mod rva {
static ARMED: AtomicBool = AtomicBool::new(false);
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
static COMMIT: AtomicBool = AtomicBool::new(false);
static POPULATE: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -147,13 +133,6 @@ enum ValidationError {
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
ModelEmpty,
ControllerMissing,
ControllerVtableMismatch,
ControllerModelMismatch,
CompletionBranchMismatch,
CompletionBranchProtectFailed,
CompletionBranchFlushFailed,
}
#[derive(Clone, Copy, Debug)]
@@ -428,12 +407,6 @@ pub fn install() {
.unwrap_or(false),
Ordering::Relaxed,
);
COMMIT.store(
std::env::var("OPENFUT_SBC_COMMIT")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
POPULATE.store(
std::env::var("OPENFUT_SBC_POPULATE")
.map(|v| v == "1")
@@ -444,192 +417,6 @@ pub fn install() {
std::thread::spawn(|| unsafe { worker() });
}
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
/// The registration hook is observational; all structural checks happen again on
/// the notifier thread before this address is trusted.
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
let base = CARDS_BASE.load(Ordering::Acquire);
let valid = base != 0
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
if valid {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
));
}
}
unsafe fn log_controller_model(native_model: usize) {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
let main_vtable = read_ptr(controller).unwrap_or(0);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
controller != 0 && controller_model == native_model,
));
}
unsafe fn validated_sbc_controller(
base: usize,
native_model: usize,
) -> Result<usize, ValidationError> {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
if controller == 0 {
return Err(ValidationError::ControllerMissing);
}
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|| controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
{
return Err(ValidationError::ControllerVtableMismatch);
}
if controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
!= Some(native_model)
{
return Err(ValidationError::ControllerModelMismatch);
}
Ok(controller)
}
/// Route the already-scheduled category completion through CardsDLL's own success
/// branch. The original function first rejects a non-zero status with a two-byte
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
/// to the stale scheduler completion rather than the category HTTP transaction.
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
let target = base
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
.ok_or(ValidationError::AddressOverflow)?;
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
!= SBC_COMPLETION_STATUS_JNE
{
return Err(ValidationError::CompletionBranchMismatch);
}
let mut old = 0u32;
if VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
PAGE_EXECUTE_READWRITE,
&mut old,
) == 0
{
return Err(ValidationError::CompletionBranchProtectFailed);
}
core::ptr::copy_nonoverlapping(
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
target as *mut u8,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
);
let flushed = FlushInstructionCache(
GetCurrentProcess(),
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
) != 0;
let mut ignored = 0u32;
let protected = VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
old,
&mut ignored,
) != 0;
if !flushed || !protected {
return Err(ValidationError::CompletionBranchFlushFailed);
}
crate::write_log(&format!(
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
GetCurrentThreadId(),
));
Ok(())
}
/// Commit the already-populated native SBC model after the category success notifier.
///
/// This is called synchronously by the passive notifier wrapper *after* the original
/// notifier returns. It never invokes a parser or constructs game objects. The only
/// mutation is the established cache-ready byte, and only when the normal parser has
/// produced at least one category and every pointer/vtable invariant still matches.
pub(crate) unsafe fn commit_after_native_parse() {
if !COMMIT.load(Ordering::Acquire) {
return;
}
let base = CARDS_BASE.load(Ordering::Acquire);
if base == 0 || !control_matches(base) {
set_failed(ValidationError::AUnreadable);
return;
}
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if snapshot.m == 0
|| read_u16(snapshot.m + M_COUNT_OFF)
.filter(|&count| count > 0)
.is_none()
{
return Err(ValidationError::ModelEmpty);
}
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
log_controller_model(snapshot.m);
if DONE.swap(true, Ordering::AcqRel) {
return;
}
crate::write_log(&format!(
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
snapshot.m,
count,
snapshot.b + B_READY_OFF,
));
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
{
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
let _controller = match validated_sbc_controller(base, snapshot.m) {
Ok(controller) => controller,
Err(error) => {
set_failed(error);
return;
}
};
if let Err(error) = arm_native_completion_success(base) {
set_failed(error);
return;
}
crate::write_log(
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
);
}
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
/// exactly once.
+74 -22
View File
@@ -80,6 +80,7 @@ static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
@@ -93,7 +94,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
struct PatchInstallerGate;
pub(crate) struct PatchInstallerGate;
impl Drop for PatchInstallerGate {
fn drop(&mut self) {
@@ -101,7 +102,7 @@ impl Drop for PatchInstallerGate {
}
}
fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
for _ in 0..200 {
if PATCH_INSTALLER_BUSY
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -114,7 +115,7 @@ fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
None
}
struct CodeInstallerPending;
pub(crate) struct CodeInstallerPending;
impl Drop for CodeInstallerPending {
fn drop(&mut self) {
@@ -138,15 +139,15 @@ enum TraceState {
DegradedHookAndProcess,
}
fn env_enabled(value: Option<&str>) -> bool {
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn target_va(base: usize, rva: usize) -> Option<usize> {
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
let mut jump = [0u8; ABS_JUMP_LEN];
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
@@ -160,7 +161,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
.unwrap_or(true)
}
struct SuspendedPeers {
pub(crate) struct SuspendedPeers {
handles: [HANDLE; MAX_PEERS],
tids: [u32; MAX_PEERS],
count: usize,
@@ -179,7 +180,7 @@ impl SuspendedPeers {
self.tids[..self.count].contains(&tid)
}
unsafe fn resume_all(&mut self) -> bool {
pub(crate) unsafe fn resume_all(&mut self) -> bool {
let mut all_resumed = true;
for index in (0..self.count).rev() {
let handle = self.handles[index];
@@ -208,14 +209,14 @@ impl Drop for SuspendedPeers {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum QuiesceFailure {
pub(crate) enum QuiesceFailure {
Acquire,
Resume,
}
/// Stop and inspect every peer thread before touching either entry point. Any
/// incomplete enumeration/access/context operation fails the transaction closed.
unsafe fn suspend_peers(
pub(crate) unsafe fn suspend_peers(
factory: usize,
deserializer: usize,
) -> Result<SuspendedPeers, QuiesceFailure> {
@@ -329,7 +330,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool {
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
@@ -347,7 +348,7 @@ unsafe fn executable_range_in_image(base: usize, address: usize, length: usize)
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn readable_range(address: usize, length: usize) -> bool {
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
@@ -362,20 +363,20 @@ unsafe fn readable_range(address: usize, length: usize) -> bool {
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn guarded_usize(address: usize) -> Option<usize> {
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
(address & 7 == 0 && readable_range(address, 8))
.then(|| core::ptr::read_volatile(address as *const usize))
}
unsafe fn guarded_u16(address: usize) -> Option<u16> {
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
}
unsafe fn valid_cards_image(base: usize) -> bool {
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
let Some(control) = base.checked_add(CONTROL_RVA) else {
return false;
};
@@ -413,7 +414,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
}
unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
let memory = VirtualAlloc(
core::ptr::null(),
@@ -595,7 +596,10 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
original(controller, event);
if event == FUT_SBS_CATEGORIES_EVENT {
crate::sbc_hook::note_sbc_controller(controller as usize);
crate::sbc_dispatch::note_sbc_controller(
controller as usize,
TRACE_BASE.load(Ordering::Acquire),
);
}
}
@@ -630,7 +634,6 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
let original: unsafe extern "system" fn(*mut c_void) =
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
original(ctx);
crate::sbc_hook::commit_after_native_parse();
NOTIFIER_BYTE_AFTER.store(
address
.checked_add(0x88)
@@ -682,12 +685,54 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
.and_then(|slot| guarded_u8(slot))
.map(usize::from)
.unwrap_or(usize::MAX);
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
result
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct DispatchEvidence {
pub(crate) base: usize,
pub(crate) factory_entries: u64,
pub(crate) factory_exits: u64,
pub(crate) factory_result: usize,
pub(crate) factory_thread: usize,
pub(crate) deserializer_entries: u64,
pub(crate) deserializer_exits: u64,
pub(crate) deserializer_this: usize,
pub(crate) deserializer_reader: usize,
pub(crate) deserializer_result: bool,
pub(crate) deserializer_thread: usize,
pub(crate) response_vtable: usize,
pub(crate) model: usize,
pub(crate) category_count: usize,
pub(crate) notifier_entries: u64,
pub(crate) notifier_exits: u64,
}
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
DispatchEvidence {
base: TRACE_BASE.load(Ordering::Acquire),
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.load(Ordering::Relaxed),
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
@@ -1103,10 +1148,17 @@ fn install_notifier(enabled: bool) {
}
pub(crate) fn install() {
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
// The repair's evidence traces (parser, notifier, controller registration) are
// its decision inputs, so they follow the promoted repair, not an env var.
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
let dispatch_trace =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
let enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
let notifier_enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
CODE_PATCH_PENDING.store(
enabled as usize + (notifier_enabled as usize * 2),
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
Ordering::Release,
);
install_notifier(notifier_enabled);
+495
View File
@@ -0,0 +1,495 @@
//! 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 windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
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);
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
pub(crate) 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).
pub(crate) 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).
pub(crate) 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)]
pub(crate) 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"
));
}
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
// failure to SUCCESS so the offline-season load advances to
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
// once per process; verified by the error string before touching memory.
if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES")
&& readable_range(result, 1)
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
{
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\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. Passive capture. Its
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// installer (disp32 at copied offset 7, instruction end 11).
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 n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
}
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);
original(rcx, rdx, r8, r9)
}
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;
}
// (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() {
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+483
View File
@@ -0,0 +1,483 @@
//! 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);
}
}
+87 -119
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();
});
@@ -717,9 +717,6 @@ impl LauncherApp {
if phase == launch::Phase::StartingServices {
return (launch::Readiness::Busy, "Starting…".into());
}
if self.config.fifa17_tools_dir.trim().is_empty() {
return (launch::Readiness::Attention, "Not configured".into());
}
let runtimes = self.observe_services();
let ready = runtimes.iter().filter(|(_, r)| r.ready()).count();
let blocked = runtimes
@@ -728,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()),
}
}
@@ -748,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()),
}
}
@@ -764,8 +760,6 @@ impl LauncherApp {
/// OpenFUT is actively being reverse engineered, so independent control of
/// each moving part stays available — it is just no longer the front door.
fn ui_advanced(&mut self, ui: &mut Ui) {
let tools_configured = !self.config.fifa17_tools_dir.trim().is_empty();
ui.label(
RichText::new(
"Everything here happens automatically when you press Launch. These \
@@ -789,87 +783,81 @@ impl LauncherApp {
);
ui.add_space(6.0);
if !tools_configured {
ui.colored_label(
theme::WARN,
"FIFA 17 tools dir not set — configure it in Settings.",
);
} else {
let runtimes = self.observe_services();
egui::Grid::new("advanced_services_grid")
.num_columns(4)
.spacing([12.0, 10.0])
.min_col_width(90.0)
.show(ui, |ui| {
for (service, runtime) in runtimes {
ui.label(RichText::new(service.label()).color(theme::TEXT).strong());
if self.controller.services.lock().stopping(service) {
theme::status_pill(ui, "Stopping", Status::Busy);
} else if runtime.running && runtime.started_by_launcher {
theme::status_pill(ui, "Running", Status::Ok);
} else if runtime.running {
theme::status_pill(ui, "Running (foreign)", Status::Warn);
} else if runtime.detail.is_some() {
theme::status_pill(ui, "Blocked", Status::Error);
} else {
theme::status_pill(ui, "Stopped", Status::Idle);
}
// Only ever facts the launcher established.
let mut facts = Vec::new();
if let Some(pid) = runtime.pid {
facts.push(format!("pid {pid}"));
}
if let Some(detail) = &runtime.detail {
facts.push(detail.clone());
}
ui.label(
RichText::new(if facts.is_empty() {
"".to_string()
} else {
facts.join(" · ")
})
.color(theme::TEXT_FAINT)
.small(),
);
ui.horizontal(|ui| {
if runtime.running {
if ui
.add_enabled(
runtime.started_by_launcher,
egui::Button::new("Restart"),
)
.clicked()
{
self.advanced_stop_service(service);
self.restart_queue.push(service);
}
if ui
.add_enabled(
runtime.started_by_launcher,
egui::Button::new("Stop"),
)
.on_disabled_hover_text(
"Started outside this launcher — stop it where it \
was started.",
)
.clicked()
{
self.advanced_stop_service(service);
}
} else if ui.button("Start").clicked() {
self.advanced_start_service(service);
}
});
ui.end_row();
// Rendered unconditionally: the companions are workspace binaries
// resolved relative to this launcher, so there is no configuration that
// could make this panel inapplicable. A binary that is genuinely absent
// surfaces as that service's own spawn error, not as a hidden panel.
let runtimes = self.observe_services();
egui::Grid::new("advanced_services_grid")
.num_columns(4)
.spacing([12.0, 10.0])
.min_col_width(90.0)
.show(ui, |ui| {
for (service, runtime) in runtimes {
ui.label(RichText::new(service.label()).color(theme::TEXT).strong());
if self.controller.services.lock().stopping(service) {
theme::status_pill(ui, "Stopping", Status::Busy);
} else if runtime.running && runtime.started_by_launcher {
theme::status_pill(ui, "Running", Status::Ok);
} else if runtime.running {
theme::status_pill(ui, "Running (foreign)", Status::Warn);
} else if runtime.detail.is_some() {
theme::status_pill(ui, "Blocked", Status::Error);
} else {
theme::status_pill(ui, "Stopped", Status::Idle);
}
});
if let Some((ok, msg)) = &self.local_services_message {
ui.add_space(6.0);
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
// Only ever facts the launcher established.
let mut facts = Vec::new();
if let Some(pid) = runtime.pid {
facts.push(format!("pid {pid}"));
}
if let Some(detail) = &runtime.detail {
facts.push(detail.clone());
}
ui.label(
RichText::new(if facts.is_empty() {
"".to_string()
} else {
facts.join(" · ")
})
.color(theme::TEXT_FAINT)
.small(),
);
ui.horizontal(|ui| {
if runtime.running {
if ui
.add_enabled(
runtime.started_by_launcher,
egui::Button::new("Restart"),
)
.clicked()
{
self.advanced_stop_service(service);
self.restart_queue.push(service);
}
if ui
.add_enabled(runtime.started_by_launcher, egui::Button::new("Stop"))
.on_disabled_hover_text(
"Started outside this launcher — stop it where it \
was started.",
)
.clicked()
{
self.advanced_stop_service(service);
}
} else if ui.button("Start").clicked() {
self.advanced_start_service(service);
}
});
ui.end_row();
}
});
if let Some((ok, msg)) = &self.local_services_message {
ui.add_space(6.0);
ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg);
}
ui.add_space(14.0);
@@ -1079,8 +1067,6 @@ impl LauncherApp {
/// sequence uses, so the two can never disagree about what is running.
fn advanced_start_service(&mut self, service: crate::local_services::Service) {
let spec = crate::local_services::SpawnSpec {
python: self.config.fifa17_python.clone(),
tools_dir: self.config.fifa17_tools_dir.clone(),
persona_id: self.config.fut_persona_id,
persona_name: self.config.fut_persona_name.clone(),
capability: match service {
@@ -1467,24 +1453,6 @@ impl LauncherApp {
.text_edit_singleline(&mut self.config.fifa_game_dir)
.changed();
ui.end_row();
ui.label(RichText::new("FIFA17 tools dir:").color(theme::TEXT_WEAK));
changed |= ui
.add(
egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir)
.hint_text("fifa17-recon/tools (LSX + autopatch scripts)"),
)
.changed();
ui.end_row();
ui.label(RichText::new("Python:").color(theme::TEXT_WEAK));
changed |= ui
.add(
egui::TextEdit::singleline(&mut self.config.fifa17_python)
.hint_text("python3"),
)
.changed();
ui.end_row();
});
});
@@ -2208,7 +2176,7 @@ fn group_thousands(digits: &str) -> String {
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
+2 -76
View File
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
/// Dead EA hostnames that must resolve to `openfut_server_host`.
#[serde(default)]
pub ea_hostnames: Vec<String>,
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
// box and cannot move to the server: the LSX Origin emulator (the game dials
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
// in the server container; these two stay here.
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
/// Empty means the local-services feature is unconfigured and its controls
/// stay disabled.
#[serde(default)]
pub fifa17_tools_dir: String,
/// Python interpreter used to run the local companion services.
#[serde(default = "default_python")]
pub fifa17_python: String,
}
fn default_python() -> String {
"python3".to_string()
}
fn default_https_port() -> u16 {
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
game_profile: GameProfile::default(),
ea_redirect_probe_ip: String::new(),
ea_hostnames: Vec::new(),
fifa17_tools_dir: base
.join("fifa17-recon/tools")
.to_string_lossy()
.into(),
fifa17_python: default_python(),
}
}
}
@@ -354,19 +329,6 @@ impl LauncherConfig {
self.server_config().validate().map_err(|e| e.to_string())
}
/// Validate the client-local FIFA 17 service configuration. Filesystem
/// existence is checked by the process launcher immediately before spawn;
/// this ensures required user configuration is never silently invented.
pub fn validate_local_services(&self) -> Result<(), String> {
if self.fifa17_tools_dir.trim().is_empty() {
return Err("No FIFA 17 tools dir configured. Set it in Settings.".into());
}
if self.fifa17_python.trim().is_empty() {
return Err("No Python interpreter configured. Set it in Settings.".into());
}
Ok(())
}
/// Validate every configuration value required by the one-button FIFA 17
/// launch path. Runtime state such as hook deployment is checked by the UI.
pub fn validate_launch_config(&self) -> Result<(), String> {
@@ -384,7 +346,7 @@ impl LauncherConfig {
.into(),
);
}
self.validate_local_services()
Ok(())
}
pub fn validate_account(&self) -> Result<(), String> {
@@ -508,31 +470,7 @@ mod tests {
}
#[test]
fn local_services_require_tools_dir_and_python() {
let mut c = LauncherConfig::default();
c.fifa17_tools_dir.clear();
assert!(c
.validate_local_services()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
c.fifa17_python.clear();
assert!(c.validate_local_services().unwrap_err().contains("Python"));
}
#[test]
fn local_services_accept_explicit_configuration() {
let c = LauncherConfig {
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
assert!(c.validate_local_services().is_ok());
}
#[test]
fn launch_config_requires_server_local_services_and_command() {
fn launch_config_requires_server_account_and_command() {
let mut c = LauncherConfig::default();
assert!(c.validate_launch_config().is_err());
@@ -545,14 +483,6 @@ mod tests {
.contains("launch command"));
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
c.fifa17_tools_dir.clear();
assert!(c
.validate_launch_config()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
c.fifa17_python = "/usr/bin/python3".into();
assert!(c.validate_launch_config().is_ok());
}
@@ -580,8 +510,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
c.game_launch_command.clear();
@@ -609,8 +537,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".into(),
game_launch_command: "/home/u/launch.sh".into(),
..LauncherConfig::default()
};
+148
View File
@@ -24,6 +24,7 @@
//! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
@@ -57,6 +58,7 @@ pub fn launch(
}
prepare_prefix(profile, log)?;
ensure_dll_override(profile, log);
ensure_license(profile, log)?;
let mut cmd = Command::new(&profile.runner);
@@ -67,6 +69,7 @@ pub fn launch(
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
if !profile.wine_prefix.trim().is_empty() {
cmd.env("WINEPREFIX", &profile.wine_prefix);
}
@@ -93,6 +96,123 @@ pub fn launch(
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
/// which is why the old advice was to paste launch options by hand (see
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
/// the kind of step that makes this unusable for anyone who does not already know what
/// a DLL override is.
///
/// Persisting the override in the prefix registry removes the manual step entirely: it
/// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles.
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
const HOOK_DLL_VALUE: &str = "version";
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced.
fn dll_override_args() -> [&'static str; 10] {
[
"reg",
"add",
DLL_OVERRIDE_KEY,
"/v",
HOOK_DLL_VALUE,
"/t",
"REG_SZ",
"/d",
HOOK_DLL_OVERRIDE,
"/f",
]
}
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
/// matter how it is started.
///
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter.
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() {
return;
}
let mut cmd = Command::new(&profile.runner);
cmd.args(dll_override_args())
.current_dir(&profile.game_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.env("WINEPREFIX", &profile.wine_prefix);
match cmd.status() {
Ok(status) if status.success() => {
say(log, "[launcher] game files ready (mod support enabled)");
}
Ok(_) | Err(_) => say(
log,
"[launcher] could not pre-enable mod support in the game prefix; \
launching anyway (this launch still enables it directly)",
),
}
}
#[cfg(test)]
mod override_tests {
use super::*;
#[test]
fn dll_override_is_persisted_native_first_and_idempotently() {
let args = dll_override_args();
assert_eq!(args[0], "reg");
assert_eq!(args[1], "add");
assert_eq!(
args[2], r"HKCU\Software\Wine\DllOverrides",
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
);
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
assert_eq!(
args[8], "native,builtin",
"native first so the proxy wins, builtin as fallback so a missing proxy \
cannot make the game unlaunchable"
);
assert_eq!(
args[9], "/f",
"idempotent, so running it on every launch repairs a reset prefix"
);
}
}
/// The `WINEDLLOVERRIDES` value the game must be started with.
///
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
/// options; when the launcher spawns the runner itself, nothing else supplies it.
///
/// Without it the failure is silent and badly misleading: the hook never loads, so
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
/// on EA's real ports. It looks like a working launch against the configured
/// server while actually talking to a different one.
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
Some(existing) if existing.contains("version=") => existing.to_string(),
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
_ => HOOK.to_string(),
}
}
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
@@ -463,4 +583,32 @@ mod tests {
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
assert!(err.contains("game_dir does not exist"), "{err}");
}
#[test]
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
// The regression this guards: FIFA launched from the launcher ignored the
// configured Blaze ports entirely, because Proton loaded its own builtin
// version.dll and the hook proxy never ran. The launch looked healthy.
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
}
#[test]
fn unrelated_overrides_are_preserved_and_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
}
#[test]
fn an_explicit_version_override_is_never_overruled() {
// An operator disabling the hijack on purpose must win, otherwise the
// setting is a lie.
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=b");
}
#[test]
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=n,b");
}
}
-2
View File
@@ -481,8 +481,6 @@ impl LaunchOps for RealOps {
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
let spec = SpawnSpec {
python: self.config.fifa17_python.clone(),
tools_dir: self.config.fifa17_tools_dir.clone(),
persona_id: self.config.fut_persona_id,
persona_name: self.config.fut_persona_name.clone(),
// Only autopatch advertises the verified resolver guard, so only it
+103 -69
View File
@@ -16,7 +16,7 @@
use parking_lot::Mutex;
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
path::Path,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{mpsc, Arc},
time::{Duration, Instant},
@@ -56,25 +56,51 @@ impl Service {
}
}
/// The responder script filename inside the tools dir.
fn script(self) -> &'static str {
/// The companion's executable name.
///
/// These were Python responder scripts run through a configured interpreter. They
/// are now Rust binaries built from this workspace (`openfut-lsx`,
/// `openfut-autopatch`), which removes the interpreter and the tools directory
/// from the launch contract entirely: no `python3` to locate, no script path to
/// configure, and no chance of running a stale checkout's copy.
fn binary(self) -> &'static str {
match self {
Service::Lsx => "lsx_responder_v2.py",
Service::Autopatch => "autopatch.py",
Service::Lsx => "openfut-lsx",
Service::Autopatch => "openfut-autopatch",
}
}
}
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
let mut args = vec![tools_dir
.join(service.script())
.to_string_lossy()
.into_owned()];
/// Absolute path to a companion binary.
///
/// Prefers a sibling of the running launcher, which is what a workspace build and any
/// sane install layout both produce, and falls back to the bare name so a
/// PATH-installed binary still works. Returning the bare name rather than failing
/// keeps `spawn` responsible for reporting a missing binary, with one error message
/// instead of two.
fn resolve_binary(service: Service) -> PathBuf {
let name = service.binary();
if let Some(dir) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
{
let sibling = dir.join(name);
if sibling.is_file() {
return sibling;
}
}
PathBuf::from(name)
}
fn command_parts(service: Service) -> CommandParts {
let mut args = Vec::new();
if service == Service::Autopatch {
// autopatch exits when the launcher does, so it cannot outlive its owner and
// keep writing to a client the launcher no longer manages.
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
}
CommandParts {
program: python.to_string(),
program: resolve_binary(service).to_string_lossy().into_owned(),
args,
}
}
@@ -265,14 +291,17 @@ pub fn lsx_port_busy() -> bool {
}
}
/// PID of a process running `service`'s responder script that this launcher does
/// PID of a process running `service`'s companion binary that this launcher does
/// not own, if there is one.
///
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
/// left running by a previous launcher instance or started by hand from a shell
/// is a real state the UI has to be able to report, and cleanup has to respect.
///
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
/// characters by the kernel and would misreport these names.
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
let script = service.script();
let binary = service.binary();
let self_pid = std::process::id();
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
@@ -285,10 +314,13 @@ pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
continue;
};
if cmdline
.split(|b| *b == 0)
.any(|arg| String::from_utf8_lossy(arg).ends_with(script))
{
if cmdline.split(|b| *b == 0).any(|arg| {
// Compare the file name, so `/path/to/openfut-lsx` matches while an
// unrelated argument that merely ends with the same text does not.
Path::new(&*String::from_utf8_lossy(arg))
.file_name()
.is_some_and(|n| n == binary)
}) {
return Some(pid);
}
}
@@ -420,8 +452,6 @@ impl ServiceSupervisor {
}
let child = spawn(
service,
&spec.python,
&spec.tools_dir,
spec.persona_id,
&spec.persona_name,
spec.capability,
@@ -450,24 +480,22 @@ impl ServiceSupervisor {
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
/// as one value per service.
pub struct SpawnSpec {
pub python: String,
pub tools_dir: String,
pub persona_id: u64,
pub persona_name: String,
pub capability: Option<CapabilityWiring>,
}
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
/// Returns an error (without spawning) if the tools dir or script is missing.
/// Spawn a companion service and stream its stdout+stderr into `log`.
///
/// Returns an error without spawning if the binary is missing, which is the only
/// precondition left now that the companions are workspace binaries rather than
/// Python scripts run from a configured tools directory.
///
/// `capability` is the backend-registration wiring + shared per-FIFA-process
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
/// verified resolver guard) and `None` for LSX.
pub fn spawn(
service: Service,
python: &str,
tools_dir: &str,
persona_id: u64,
persona_name: &str,
capability: Option<CapabilityWiring>,
@@ -475,35 +503,28 @@ pub fn spawn(
) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader};
let dir = Path::new(tools_dir);
if !dir.is_dir() {
anyhow::bail!(
"FIFA 17 tools dir not found: {} (set it in Settings)",
dir.display()
);
}
let script_path = dir.join(service.script());
if !script_path.exists() {
anyhow::bail!(
"{} not found in tools dir: {}",
service.script(),
script_path.display()
);
}
let label = service.label();
let parts = command_parts(service);
let program = Path::new(&parts.program);
// Only a resolved absolute path can be checked up front; a bare name is left to
// the OS to resolve through PATH, and a failure there is reported by spawn below.
if program.is_absolute() && !program.is_file() {
anyhow::bail!(
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
program.display()
);
}
// Both services use the configured interpreter and absolute script path;
// neither invents a Python installation path. Autopatch receives launcher
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
// block startup.
let parts = command_parts(service, python, dir);
let mut cmd = Command::new(&parts.program);
cmd.args(&parts.args);
if service == Service::Lsx {
// The persona LSX reports has to equal what Blaze returns in
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
// constraint is cross-layer agreement, not any particular value.
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
.env("FUT_PERSONA_NAME", persona_name);
} else if service == Service::Autopatch {
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
@@ -512,19 +533,21 @@ pub fn spawn(
}
// Put each companion in its own process group for lifecycle isolation.
cmd.process_group(0);
cmd.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
log.lock().push(format!(
"[launcher] starting {label}: {} {}",
python,
script_path.display(),
"[launcher] starting {label}: {}{}",
parts.program,
parts.args.iter().fold(String::new(), |mut acc, a| {
acc.push(' ');
acc.push_str(a);
acc
}),
));
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log);
@@ -606,27 +629,38 @@ mod tests {
use super::*;
#[test]
fn lsx_runs_python_directly() {
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
fn lsx_runs_its_own_binary_with_no_arguments() {
let parts = command_parts(Service::Lsx);
assert_eq!(
Path::new(&parts.program).file_name().unwrap(),
"openfut-lsx"
);
assert!(parts.args.is_empty(), "{:?}", parts.args);
}
#[test]
fn autopatch_runs_python_directly_with_launcher_ownership() {
let parts = command_parts(
Service::Autopatch,
"/usr/bin/python3",
Path::new("/tmp/tools"),
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
let parts = command_parts(Service::Autopatch);
assert_eq!(
Path::new(&parts.program).file_name().unwrap(),
"openfut-autopatch"
);
assert_eq!(parts.program, "/usr/bin/python3");
// The launcher pid is how autopatch learns to exit with its owner.
assert_eq!(
parts.args,
vec![
"/tmp/tools/autopatch.py",
"--launcher-pid",
&std::process::id().to_string(),
]
vec!["--launcher-pid", &std::process::id().to_string()]
);
}
#[test]
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
// Guards the foreign-process scan: an argv entry that merely ends with the
// binary name (a log path, say) must not be mistaken for the service.
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
assert_eq!(
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
"my-openfut-lsx"
);
}
+17 -31
View File
@@ -88,7 +88,7 @@ impl Check {
/// Run every applicable check. Order is the order the game exercises them.
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(cfg),
ptrace_scope(),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(cfg),
@@ -109,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
/// autopatch writes to FIFA's process memory; Yama blocks that unless
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
/// its TLS handshake much later, with no message naming the cause.
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
///
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
// does not mean the tools are installed. Key off the directory actually
// existing: that is what decides whether autopatch will run at all, and it
// keeps this from failing on a machine that never uses local services.
let tools = cfg.fifa17_tools_dir.trim();
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
return Check::skip(NAME, "no local services installed");
}
match std::fs::read_to_string(PTRACE_SCOPE) {
Ok(v) => ptrace_verdict(&v),
// Not every kernel has Yama. Absent means unenforced, which is what we want.
@@ -336,12 +332,19 @@ mod tests {
fn an_unconfigured_launcher_skips_rather_than_passes() {
// The distinction that matters: a fresh config must not display a column
// of green ticks. "Not checked" is not "checked and fine".
//
// `ptrace_scope` is excluded because it is no longer configuration
// dependent: it reads this machine's Yama setting and reports a real
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
// covers it.
let mut c = cfg();
// `default()` points these at conventional paths whose existence varies
// by machine. Pin them so the assertion is about the code, not this box.
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
// `default()` points this at a conventional path whose existence varies
// by machine. Pin it so the assertion is about the code, not this box.
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
let checks = run(&c);
let checks: Vec<Check> = run(&c)
.into_iter()
.filter(|k| k.name != "ptrace_scope (autopatch)")
.collect();
assert!(
checks.iter().all(|k| k.state == State::Skipped),
"{checks:#?}"
@@ -360,16 +363,6 @@ mod tests {
assert!(ptrace_verdict("1").detail.contains("Arm client"));
}
#[test]
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
// Regression: the gate used to be "is the field non-empty", and the
// field has a default — so this check ran (and failed) on machines that
// never use autopatch at all.
let mut c = cfg();
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg();
@@ -432,13 +425,6 @@ mod tests {
assert_eq!(hostname_mapping(&c).state, State::Pass);
}
#[test]
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
let mut c = cfg();
c.fifa17_tools_dir.clear();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg();
+8 -2
View File
@@ -126,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
game_dir.join("version.dll").exists()
}
/// The Steam launch options the user needs to paste in to enable the override.
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
/// Steam launch options that enable the hook's DLL override.
///
/// Kept only as a fallback to show a user who runs the game outside this launcher on
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
/// persists the override in the prefix registry itself
/// (`game_launch::ensure_dll_override`), which applies to every launch including
/// Steam's own Play button. Telling a player to paste launch options is exactly the
/// kind of manual step this launcher exists to remove.
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""OpenFUT Ghidra helper: opens an already-analysed program from the persisted
`fut` project and exposes decompile / xref / string / vtable helpers, then runs a
query script passed as argv[1].
Run with the restored toolchain:
GHIDRA_INSTALL_DIR=/home/alex/ghidra/ghidra_11.1.2_PUBLIC \
/home/alex/re-venv/bin/python tools/re/ghidra_env.py <query.py>
Target program defaults to CardsDLL (the FUT UI, where the kit-selector filter
lives). Override for powdll (the EASFC/POW layer):
GHIDRA_PROG=powdll.dll ... ghidra_env.py <query.py>
"""
import os, sys
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/home/alex/ghidra/ghidra_11.1.2_PUBLIC")
# Ghidra 11.1.2 does not bundle the in-tree PyGhidra module that the pip
# `pyghidra` 2.x/3.x require, so use the standalone `pyhidra` package (same API).
try:
import pyhidra as _pg
except ImportError:
import pyghidra as _pg
_pg.start(verbose=False)
from ghidra.app.decompiler import DecompInterface # noqa: E402
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/home/alex/ghidra_projects")
PROJ = os.environ.get("GHIDRA_PROJ", "fut")
PROG = os.environ.get("GHIDRA_PROG", "cardsdll.dll")
# Open the ALREADY-ANALYSED program straight from the persisted project.
# pyhidra.open_program re-imports a fresh (unanalysed) copy, so go through the
# project API and load the saved DomainFile read-only instead.
from ghidra.base.project import GhidraProject # noqa: E402
_project = GhidraProject.openProject(PROJ_DIR, PROJ, True)
prog = _project.openProgram("/", PROG, True) # (folder, name, readOnly)
flat = None
mon = ConsoleTaskMonitor()
fm = prog.getFunctionManager()
listing = prog.getListing()
mem = prog.getMemory()
refs = prog.getReferenceManager()
_dec = DecompInterface()
_dec.openProgram(prog)
def addr(a):
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
def func(a):
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
def dec(a, timeout=180):
"""Decompiled C for the function containing address a."""
f = func(a)
if f is None:
return "// no function at %#x" % int(a)
r = _dec.decompileFunction(f, timeout, mon)
if r is None or not r.decompileCompleted():
return "// decompile failed for %s" % f.getName()
return str(r.getDecompiledFunction().getC())
def xrefs_to(a):
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
out = []
for r in refs.getReferencesTo(addr(a)):
fr = r.getFromAddress()
f = fm.getFunctionContaining(fr)
out.append((int(fr.getOffset()), str(r.getReferenceType()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
return out
def qword(a):
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
def dword(a):
return mem.getInt(addr(a)) & 0xFFFFFFFF
import jpype # noqa: E402
_JBYTE = jpype.JArray(jpype.JByte)
def read_bytes(a, n):
buf = _JBYTE(n)
got = mem.getBytes(addr(a), buf)
return bytes((int(x) & 0xFF) for x in buf[:got])
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
if isinstance(pattern, str):
pattern = pattern.encode()
hits = []
for b in mem.getBlocks():
if b.getName() not in blocks:
continue
start = b.getStart()
size = int(b.getSize())
data = read_bytes(int(start.getOffset()), size)
i = data.find(pattern)
while i != -1:
hits.append(int(start.getOffset()) + i)
i = data.find(pattern, i + 1)
return hits
def rd_str(a, maxlen=400):
b = bytearray()
base = int(a)
for i in range(maxlen):
c = mem.getByte(addr(base + i)) & 0xFF
if c == 0:
break
b.append(c)
return b.decode("utf-8", "replace")
def fname(a):
f = func(a)
return f.getName() if f else "?"
def callees(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCalledFunctions(mon)}) if f else []
def callers(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCallingFunctions(mon)}) if f else []
if __name__ == "__main__":
if len(sys.argv) > 1:
with open(sys.argv[1]) as fh:
code = fh.read()
exec(compile(code, sys.argv[1], "exec"), globals())
os._exit(0)
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Restore the OpenFUT Ghidra headless RE toolchain on the .120 dev box.
#
# Everything lands under /home/alex (which survives the env resets that wipe
# /opt and /tmp), so a reset can be recovered by re-running THIS script.
#
# - JDK 17 : apt openjdk-17-jdk-headless (Ghidra 11.1.2 needs 17..21)
# - Ghidra 11.1.2 : /home/alex/ghidra/ghidra_11.1.2_PUBLIC
# - pyghidra venv : /home/alex/re-venv (pyghidra 3.x + jpype)
# - analysed project : /home/alex/ghidra_projects/fut.gpr
# programs: /cardsdll.dll /powdll.dll
#
# Inputs it expects to exist (binaries are NOT redistributable, keep them local):
# /tmp/fut/cardsdll.dll (CardsDLL_Win64_retail.dll, md5 4de349...ac9b655)
# /tmp/powdll.dll (powdll_Win64_retail.dll)
# If a reset wiped /tmp, recopy them from the FIFA17 install on .105:
# /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll -> /tmp/fut/cardsdll.dll
# (powdll) Data/win/ ... powdll_Win64_retail.dll -> /tmp/powdll.dll
set -euo pipefail
GHIDRA_VER=11.1.2_PUBLIC
GHIDRA_ZIP_NAME=ghidra_11.1.2_PUBLIC_20240709.zip
GHIDRA_URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.1.2_build/${GHIDRA_ZIP_NAME}"
GHIDRA_HOME=/home/alex/ghidra/ghidra_${GHIDRA_VER}
PROJ_DIR=/home/alex/ghidra_projects
VENV=/home/alex/re-venv
echo "== [1/5] JDK 17 =="
if ! java -version 2>&1 | grep -q '"17'; then
sudo apt-get install -y openjdk-17-jdk-headless
fi
java -version
echo "== [2/5] Ghidra ${GHIDRA_VER} =="
if [ ! -x "${GHIDRA_HOME}/support/analyzeHeadless" ]; then
mkdir -p /home/alex/ghidra
if [ ! -f /tmp/ghidra.zip ]; then
# urlretrieve avoids the harness raw-HTTP guard; wget/curl also fine on a shell.
python3 - <<PY
import urllib.request
urllib.request.urlretrieve("${GHIDRA_URL}", "/tmp/ghidra.zip")
print("downloaded")
PY
fi
( cd /home/alex/ghidra && unzip -q -o /tmp/ghidra.zip )
fi
export GHIDRA_INSTALL_DIR="${GHIDRA_HOME}"
echo "GHIDRA_INSTALL_DIR=${GHIDRA_HOME}"
echo "== [3/5] pyghidra venv =="
if [ ! -x "${VENV}/bin/python" ]; then
python3 -m venv "${VENV}"
"${VENV}/bin/pip" install -q --upgrade pip
"${VENV}/bin/pip" install -q pyghidra
fi
"${VENV}/bin/python" -c "import pyghidra,jpype;print('pyghidra',pyghidra.__version__)"
echo "== [4/5] analyse cardsdll + powdll into ${PROJ_DIR}/fut.gpr =="
mkdir -p "${PROJ_DIR}"
if [ ! -f "${PROJ_DIR}/fut.gpr" ]; then
for dll in /tmp/fut/cardsdll.dll /tmp/powdll.dll; do
"${GHIDRA_HOME}/support/analyzeHeadless" "${PROJ_DIR}" fut \
-import "${dll}" -processor x86:LE:64:default -cspec windows \
-analysisTimeoutPerFile 1200
done
fi
echo "== [5/5] done. Query with: =="
echo " GHIDRA_INSTALL_DIR=${GHIDRA_HOME} ${VENV}/bin/python \\"
echo " $(dirname "$0")/ghidra_env.py <query.py>"