5 Commits

Author SHA1 Message Date
funman300 bee97055db Add gated FIFA17 Offline Seasons PMA repair 2026-08-27 22:39:55 +00:00
funman300 021a044859 fix(fifa17): preserve offline-season fixture through game setup 2026-08-25 20:14:16 +00:00
funman300 d7641175be Revert: remove the engine-provider kit detours entirely
Two client breakages in a row from detouring FUN_180033770 / sub_180033430 /
FUN_1800d73d0: the first cut froze FIFA at the "are both teams ready" prompt,
and the rate-limited rewrite CRASHED it at the same point. Rate limiting fixed
the I/O problem and the crash still happened, so the fault is the detours
themselves, not the logging.

Most likely cause: sub_180033430 is an address Ghidra never functionised, and at
least one of these is reached in a way a 14-byte inline patch cannot survive -
an interior branch target, or a callee taking stack arguments that the 4-register
wrapper silently drops when it tail-calls the original.

What the aborted runs did establish, and it is worth keeping:
  - KIT_SCAN fires for cardtype 7 with subtype 9 selector 2 AND selector 3, so
    BOTH the home and away club scans do run.
  - The FUT club enumerate (teamId 130000) did NOT occur in the crashed run
    before the kit screen, and KIT_DESC never fired at all.
  - KITS_AVAILABLE remains 0.
  - The "ret" value logged by kit_scan is the same constant for every call and
    is not a usable item pointer, so that reading was wrong.

Next attempt must NOT patch this code path. Read the state from outside the
process instead - /proc/PID/mem plus objdump against the live client, which
cannot crash the game because it never writes to it.
2026-08-23 18:39:44 +00:00
funman300 89cf5df71f kit_trace: rate-limited engine-provider traces (the first cut froze the client)
The previous version of these three detours hung FIFA at the "are both teams
ready" prompt. FUN_180033770 is POLLED - about 150 calls alternating between the
two real match team ids - and the wrapper did a synchronous write_log on every
one. That is the whole cause; the detours themselves were sound.

Rebuilt so the hot path costs nothing:

  - kit_enum returns immediately unless the team is the FUT club (130000), so
    the polled case does no formatting and no I/O at all. When it is the FUT
    club it reports the out-list length, i.e. how many kits were actually
    offered - the number that decides whether the carousel has anything.
  - kit_desc dedupes on the packed kit id, so a carousel that re-describes the
    same kit logs it once. It decodes teamid/year/slot for comparison against
    the active triple.
  - kit_scan only reports cardtype 7 and dedupes on (subtype, selector).

Dedupe is a fixed 16-slot lock-free SeenSet - no allocation, no locks, safe to
consult from a polled game thread. A full set stops reporting rather than
evicting, because the point is a bounded log.

Rule this file broke once and must not break again: no trace may log per-call on
a polled function.

Motivation changed too. The kit selector is not cosmetic: it is what blocks
entering a match, which also explains why every match in the capture corpus is a
DNF with an unpopulated params object - entered and backed out of. A screenshot
shows the carousel with two tiles, one named HOME and one labelled "undefined",
both with untextured white shirts, which is exactly sub_180033430 writing NAME
on a match and nothing at all on a miss.
2026-08-23 18:36:11 +00:00
funman300 0f2d66e8ca kit_trace: instrument the ENGINE-PROVIDER path, which nothing was watching
A live run refuted the model the existing traces were built on. KIT_SET never
fires, KIT_GET reports KITS_AVAILABLE=0, and FUN_1801c3480 is entered 810 times
without ever seeing a kit (772 players +0x60=1, 24 zeroed +0x60=4, 12 players
+0x60=6, 2 type-2 +0x60=4). So the DP command 0x7576 ->
FutSquadServiceImpl::setAvailableKits path is simply not the one in use.

The selector is fed by a provider CardsDLL registers into the FIFA engine -
singleton FUN_1800338f0, vtable 0x1801f1d68, slot +0x08 enumerate and +0x10
describe - and no trace covered it. Three passive detours added:

  KIT_ENUM  FUN_180033770  logs the teamId asked for; it answers only for the
                           FUT club 130000 and otherwise forwards to the engine
                           default, so a real team id here means our club items
                           were never in scope.
  KIT_DESC  sub_180033430  decodes the packed id into (teamid, year, slot). A
                           descriptor whose triple does not equal the active
                           home/away triple is left untouched, which is what
                           makes the engine substitute its own catalogue kit.
  KIT_SCAN  FUN_1800d73d0  the club scan behind getActiveKit: reports HIT with
                           the item fields, or MISS meaning the active triple
                           stays zero.

Prologues were dumped from the analysed Ghidra project (cardsdll.dll, base
0x180000000). Every copy length is instruction-aligned and none of the three
prologues is rip-relative, so the plain installer is correct for all of them -
unlike FUN_1801c3480, which needs the relocating installer for its
MOV RAX,[rip+...]. Note FUN_1800d73d0's prologue compares EDX against 2, so rdx
is the home/away selector (2/3), not the cardtype an earlier note assumed.

All three log then tail-call: behaviour is unchanged.
2026-08-23 17:55:04 +00:00
4 changed files with 850 additions and 0 deletions
+2
View File
@@ -159,6 +159,8 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
crate::season_team_compat::install();
crate::offline_seasons_pma::install();
crate::kit_trace::install();
0
}
+4
View File
@@ -20,6 +20,8 @@ mod fifa17_tls;
mod iat;
#[cfg(feature = "fifa17")]
mod kit_trace;
#[cfg(feature = "fifa17")]
mod offline_seasons_pma;
mod patch_mem;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
@@ -30,6 +32,8 @@ mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_team_compat;
#[cfg(feature = "fifa17")]
mod season_trace;
#[cfg(feature = "fifa17")]
mod store_entry;
+399
View File
@@ -0,0 +1,399 @@
//! FIFA 17 Offline Seasons PMA completion compatibility repair.
//!
//! Retail-compatible main-menu Kick Off completes the PMA instructions state by
//! broadcasting event `1` through the mode-zero child's callback dispatcher. FUT
//! Offline Seasons reaches the same PMA UI state but its completed drill scenario
//! broadcasts event `5`, which returns the UI to state `0` and leaves the drill
//! active. This default-off repair intercepts that shared callback dispatcher and
//! rewrites only the fully identified Offline Seasons `5` to `1`, then calls the
//! original dispatcher so every native subscriber observes the working completion.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{guarded_u8, guarded_usize, readable_range, validate_cards_build};
use crate::season_trace::install_detour;
use crate::write_log;
const ENABLE_ENV: &str = "OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX";
const CALLBACK_DISPATCHER_RVA: usize = 0x07ac_87b0;
const CALLBACK_DISPATCHER_COPY_LEN: usize = 15;
const CALLBACK_DISPATCHER_SIGNATURE: [u8; CALLBACK_DISPATCHER_COPY_LEN] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20,
];
const GAMEPLAY_GLOBAL_SLOT_RVA: usize = 0x04bf_b910;
const CALLBACK_DISPATCHER_VTABLE_RVA: usize = 0x03ae_9ba0;
const PMA_INSTRUCTIONS_VTABLE_RVA: usize = 0x03af_2750;
const PMA_INSTRUCTIONS_HANDLER_RVA: usize = 0x07ac_91e0;
const FREE_ROAM_VTABLE_RVA: usize = 0x03ae_df58;
const FREE_ROAM_DTOR_RVA: usize = 0x07a5_db70;
const FUT_SECONDARY_LISTENER_VTABLE_RVA: usize = 0x20e9b8;
const FUT_SELECTED_LISTENER_VTABLE_RVA: usize = 0x20fea8;
const EVENT_COMPLETE_ADVANCE: u32 = 1;
const EVENT_DRILL_COMPLETE: u32 = 5;
const PMA_UI_INSTRUCTIONS_STATE: usize = 4;
const FREE_ROAM_ACTIVE_PMA_STATE: i32 = 9;
const OFFLINE_SEASONS_MODE_ID: i32 = 21;
static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false);
static DISPATCHER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static CANDIDATE_REPORTS: AtomicUsize = AtomicUsize::new(0);
static MAIN_BASE: AtomicUsize = AtomicUsize::new(0);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Decision {
Rewrite,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Rejection {
None,
RepairDisabled,
DispatcherClass,
InstructionsState,
ListenerTopology,
FreeRoamClass,
FreeRoamState,
FreeRoamNotReady,
SecondaryListenerClass,
SelectedListenerClass,
OfflineSeasonsMode,
}
#[derive(Clone, Copy, Debug)]
struct DecisionInput {
repair_enabled: bool,
dispatcher_class: bool,
instructions_state: bool,
selected_index: Option<i32>,
free_roam_class: bool,
free_roam_state: Option<i32>,
free_roam_ready: Option<i32>,
secondary_listener_class: bool,
selected_listener_class: bool,
selected_mode: Option<i32>,
}
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
if !input.repair_enabled {
return Err(Rejection::RepairDisabled);
}
if !input.dispatcher_class {
return Err(Rejection::DispatcherClass);
}
if !input.instructions_state {
return Err(Rejection::InstructionsState);
}
if input.selected_index != Some(2) {
return Err(Rejection::ListenerTopology);
}
if !input.free_roam_class {
return Err(Rejection::FreeRoamClass);
}
if input.free_roam_state != Some(FREE_ROAM_ACTIVE_PMA_STATE) {
return Err(Rejection::FreeRoamState);
}
if input.free_roam_ready != Some(1) {
return Err(Rejection::FreeRoamNotReady);
}
if !input.secondary_listener_class {
return Err(Rejection::SecondaryListenerClass);
}
if !input.selected_listener_class {
return Err(Rejection::SelectedListenerClass);
}
if input.selected_mode != Some(OFFLINE_SEASONS_MODE_ID) {
return Err(Rejection::OfflineSeasonsMode);
}
Ok(Decision::Rewrite)
}
fn enabled(value: Option<&str>) -> bool {
value == Some("1")
}
unsafe fn read_i32(address: usize) -> Option<i32> {
readable_range(address, 4).then(|| core::ptr::read_volatile(address as *const i32))
}
unsafe fn expected_pointer(address: usize, expected: usize) -> bool {
guarded_usize(address) == Some(expected)
}
unsafe fn main_image_matches(base: usize) -> bool {
expected_pointer(
base + CALLBACK_DISPATCHER_VTABLE_RVA,
base + CALLBACK_DISPATCHER_RVA,
) && expected_pointer(
base + PMA_INSTRUCTIONS_VTABLE_RVA,
base + PMA_INSTRUCTIONS_HANDLER_RVA,
) && expected_pointer(base + FREE_ROAM_VTABLE_RVA, base + FREE_ROAM_DTOR_RVA)
}
unsafe fn instructions_state_active(dispatcher: usize, main_base: usize) -> bool {
let sentinel = match dispatcher.checked_add(8) {
Some(value) => value,
None => return false,
};
let mut node = match guarded_usize(sentinel) {
Some(value) => value,
None => return false,
};
for _ in 0..8 {
if node == sentinel {
return false;
}
let listener = match node
.checked_add(0x10)
.and_then(|address| guarded_usize(address))
{
Some(value) if value != 0 => value,
_ => return false,
};
if guarded_usize(listener) == Some(main_base + PMA_INSTRUCTIONS_VTABLE_RVA) {
let parent = listener
.checked_add(8)
.and_then(|address| guarded_usize(address));
let machine = parent
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let states = machine
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let current = machine
.and_then(|value| value.checked_add(0x10))
.and_then(|address| guarded_usize(address));
let state_four = states
.and_then(|value| value.checked_add(PMA_UI_INSTRUCTIONS_STATE * 8))
.and_then(|address| guarded_usize(address));
return current == Some(listener)
&& state_four == Some(listener)
&& guarded_u8(listener + 0x18) == Some(0);
}
node = match guarded_usize(node) {
Some(value) => value,
None => return false,
};
}
false
}
unsafe fn snapshot(dispatcher: usize) -> DecisionInput {
let main_base = MAIN_BASE.load(Ordering::Acquire);
let cards_base = CARDS_BASE.load(Ordering::Acquire);
let dispatcher_class =
guarded_usize(dispatcher) == Some(main_base + CALLBACK_DISPATCHER_VTABLE_RVA);
let gameplay_global = guarded_usize(main_base + GAMEPLAY_GLOBAL_SLOT_RVA);
let listener_manager = gameplay_global
.and_then(|value| value.checked_add(0x58))
.and_then(|address| guarded_usize(address));
let table = listener_manager.and_then(|value| guarded_usize(value));
let selected_index = table
.and_then(|value| value.checked_add(0x20))
.and_then(|address| read_i32(address));
let free_roam = table.and_then(|value| guarded_usize(value));
let secondary = table
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let selected = match (table, selected_index) {
(Some(value), Some(index @ 0..=2)) => value
.checked_add(index as usize * 8)
.and_then(|address| guarded_usize(address)),
_ => None,
};
DecisionInput {
repair_enabled: REPAIR_ACTIVE.load(Ordering::Acquire),
dispatcher_class,
instructions_state: instructions_state_active(dispatcher, main_base),
selected_index,
free_roam_class: free_roam.and_then(|value| guarded_usize(value))
== Some(main_base + FREE_ROAM_VTABLE_RVA),
free_roam_state: free_roam
.and_then(|value| value.checked_add(0x30))
.and_then(|address| read_i32(address)),
free_roam_ready: free_roam
.and_then(|value| value.checked_add(0x124))
.and_then(|address| read_i32(address)),
secondary_listener_class: secondary.and_then(|value| guarded_usize(value))
== Some(cards_base + FUT_SECONDARY_LISTENER_VTABLE_RVA),
selected_listener_class: selected.and_then(|value| guarded_usize(value))
== Some(cards_base + FUT_SELECTED_LISTENER_VTABLE_RVA),
selected_mode: selected
.and_then(|value| value.checked_add(0x18))
.and_then(|address| read_i32(address)),
}
}
type DispatcherFn = unsafe extern "system" fn(usize, u32, usize, usize) -> usize;
unsafe extern "system" fn dispatcher_wrapper(
dispatcher: usize,
event: u32,
r8: usize,
r9: usize,
) -> usize {
let trampoline = DISPATCHER_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: DispatcherFn = core::mem::transmute(trampoline);
if event != EVENT_DRILL_COMPLETE {
return original(dispatcher, event, r8, r9);
}
let input = snapshot(dispatcher);
let decision = decide(input);
let rewritten = matches!(decision, Ok(Decision::Rewrite));
let forwarded_event = if rewritten {
EVENT_COMPLETE_ADVANCE
} else {
event
};
let report = CANDIDATE_REPORTS.fetch_add(1, Ordering::Relaxed);
if report < 16 {
write_log(&format!(
"[OpenFUT][OfflineSeasons] PMA completion observed event={event} dispatcher={dispatcher:#x} pma_state4={} selected_index={} selected_mode={} free_roam_state={} ready={} action={} rejection={:?}\n",
input.instructions_state,
input.selected_index.unwrap_or(-1),
input.selected_mode.unwrap_or(-1),
input.free_roam_state.unwrap_or(-1),
input.free_roam_ready.unwrap_or(-1),
if rewritten { "rewrite-5-to-1" } else { "native" },
decision.err().unwrap_or(Rejection::None),
));
}
original(dispatcher, forwarded_event, r8, r9)
}
unsafe fn worker() {
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
if main_base == 0 || !main_image_matches(main_base) {
write_log("[OpenFUT][OfflineSeasons] main FIFA image mismatch; inactive\n");
return;
}
let mut cards_base = 0usize;
for _ in 0..600u32 {
cards_base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if cards_base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if cards_base == 0 || !validate_cards_build(cards_base) {
write_log("[OpenFUT][OfflineSeasons] CardsDLL unavailable/invalid; inactive\n");
return;
}
MAIN_BASE.store(main_base, Ordering::Release);
CARDS_BASE.store(cards_base, Ordering::Release);
if !install_detour(
main_base,
CALLBACK_DISPATCHER_RVA,
"OfflineSeasons_PMA_callback_dispatcher",
CALLBACK_DISPATCHER_COPY_LEN,
&CALLBACK_DISPATCHER_SIGNATURE,
dispatcher_wrapper as *const () as usize,
&DISPATCHER_TRAMPOLINE,
) {
write_log("[OpenFUT][OfflineSeasons] callback dispatcher hook failed; inactive\n");
return;
}
REPAIR_ACTIVE.store(true, Ordering::Release);
write_log("[OpenFUT][OfflineSeasons] PMA completion repair ARMED\n");
}
pub(crate) fn install() {
if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) {
write_log("[OpenFUT][OfflineSeasons] PMA completion repair disabled\n");
return;
}
write_log("[OpenFUT][OfflineSeasons] PMA completion repair requested\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input() -> DecisionInput {
DecisionInput {
repair_enabled: true,
dispatcher_class: true,
instructions_state: true,
selected_index: Some(2),
free_roam_class: true,
free_roam_state: Some(9),
free_roam_ready: Some(1),
secondary_listener_class: true,
selected_listener_class: true,
selected_mode: Some(21),
}
}
#[test]
fn feature_is_default_off() {
assert!(!enabled(None));
assert!(!enabled(Some("0")));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn exact_offline_seasons_evidence_rewrites() {
assert_eq!(decide(valid_input()), Ok(Decision::Rewrite));
}
#[test]
fn every_runtime_gate_fails_closed() {
let cases: &[(Rejection, fn(&mut DecisionInput))] = &[
(Rejection::RepairDisabled, |input: &mut DecisionInput| {
input.repair_enabled = false
}),
(Rejection::DispatcherClass, |input: &mut DecisionInput| {
input.dispatcher_class = false
}),
(Rejection::InstructionsState, |input: &mut DecisionInput| {
input.instructions_state = false
}),
(Rejection::ListenerTopology, |input: &mut DecisionInput| {
input.selected_index = Some(1)
}),
(Rejection::FreeRoamClass, |input: &mut DecisionInput| {
input.free_roam_class = false
}),
(Rejection::FreeRoamState, |input: &mut DecisionInput| {
input.free_roam_state = Some(10)
}),
(Rejection::FreeRoamNotReady, |input: &mut DecisionInput| {
input.free_roam_ready = Some(0)
}),
(
Rejection::SecondaryListenerClass,
|input: &mut DecisionInput| input.secondary_listener_class = false,
),
(
Rejection::SelectedListenerClass,
|input: &mut DecisionInput| input.selected_listener_class = false,
),
(
Rejection::OfflineSeasonsMode,
|input: &mut DecisionInput| input.selected_mode = Some(1),
),
];
for &(expected, mutate) in cases {
let mut input = valid_input();
mutate(&mut input);
assert_eq!(decide(input), Err(expected));
}
}
}
+445
View File
@@ -0,0 +1,445 @@
//! FIFA 17 Offline Seasons game-setup team compatibility candidate.
//!
//! `FUT::SeasonsManagerOfflineHelper` first projects the authentic dynamic pair
//! `[fixture_team, user_team]`. Later, `futSelectTeam::SetupTeamsInfo()` asks
//! `CardsGameSetupAdapter.GetTeam(side)` while rebuilding its panel state. The
//! first native reads expose the correct fixture and user teams on distinct GetTeam
//! sides. A later repeated read of the user-returning side is fed into SetTeam's
//! inverse side mapping and duplicates the user's XI over the opponent.
//!
//! This default-off candidate corrects that source read, not SetTeam or the final
//! writer. It records the projector pair, requires one native observation of each
//! team on distinct sides, and permits one correction on the next repeated
//! user-team read. Missing or conflicting evidence always preserves native behavior.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Mutex;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{readable_range, target_va, validate_cards_build};
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32};
use crate::write_log;
const ENABLE_ENV: &str = "OPENFUT_FIFA17_SEASON_TEAM_COMPAT";
const FIXTURE_PROJECTOR_RVA: usize = 0x0fc500;
const GET_TEAM_RVA: usize = 0x0054a0;
const FIXTURE_PROJECTOR_SIGNATURE: [u8; 19] = [
0x40, 0x57, 0x41, 0x54, 0x41, 0x56, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
];
const GET_TEAM_SIGNATURE: [u8; 17] = [
0x48, 0x8b, 0x05, 0xb9, 0x8a, 0x2d, 0x00, 0x4c, 0x8b, 0x80, 0x50, 0x03, 0x00, 0x00, 0x49, 0xff,
0xe0,
];
const UNKNOWN_SIDE: i32 = -1;
static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false);
static FIXTURE_PROJECTOR_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static GET_TEAM_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static TEAM_STATE: Mutex<TeamState> = Mutex::new(TeamState::empty());
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TeamState {
fixture_team: i32,
user_team: i32,
fixture_get_side: i32,
user_get_side: i32,
correction_used: bool,
}
impl TeamState {
const fn empty() -> Self {
Self {
fixture_team: 0,
user_team: 0,
fixture_get_side: UNKNOWN_SIDE,
user_get_side: UNKNOWN_SIDE,
correction_used: false,
}
}
fn capture(&mut self, fixture_team: i32, user_team: i32) -> bool {
if !valid_pair(fixture_team, user_team) {
*self = Self::empty();
return false;
}
*self = Self {
fixture_team,
user_team,
fixture_get_side: UNKNOWN_SIDE,
user_get_side: UNKNOWN_SIDE,
correction_used: false,
};
true
}
fn observe_get_team(&mut self, side: i32, native_team: i32) -> GetTeamDecision {
if !valid_side(side) || !valid_pair(self.fixture_team, self.user_team) {
return GetTeamDecision::native(native_team);
}
if native_team == self.fixture_team {
if (self.fixture_get_side != UNKNOWN_SIDE && self.fixture_get_side != side)
|| self.user_get_side == side
{
return self.clear_on_conflict(native_team);
}
let first_observation = self.fixture_get_side == UNKNOWN_SIDE;
self.fixture_get_side = side;
return GetTeamDecision {
team: native_team,
event: if self.user_get_side != UNKNOWN_SIDE {
DecisionEvent::NativePairConfirmed
} else if first_observation {
DecisionEvent::FixtureObserved
} else {
DecisionEvent::None
},
};
}
if native_team == self.user_team {
if self.user_get_side == UNKNOWN_SIDE {
if self.fixture_get_side == side {
return self.clear_on_conflict(native_team);
}
self.user_get_side = side;
return GetTeamDecision {
team: native_team,
event: if self.fixture_get_side != UNKNOWN_SIDE {
DecisionEvent::NativePairConfirmed
} else {
DecisionEvent::UserObserved
},
};
}
if self.user_get_side != side {
return self.clear_on_conflict(native_team);
}
if !self.correction_used && self.fixture_get_side != UNKNOWN_SIDE {
self.correction_used = true;
return GetTeamDecision {
team: self.fixture_team,
event: DecisionEvent::Corrected,
};
}
}
GetTeamDecision::native(native_team)
}
fn clear_on_conflict(&mut self, native_team: i32) -> GetTeamDecision {
*self = Self::empty();
GetTeamDecision {
team: native_team,
event: DecisionEvent::ConflictingNativePair,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DecisionEvent {
None,
FixtureObserved,
UserObserved,
NativePairConfirmed,
ConflictingNativePair,
Corrected,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct GetTeamDecision {
team: i32,
event: DecisionEvent,
}
impl GetTeamDecision {
const fn native(team: i32) -> Self {
Self {
team,
event: DecisionEvent::None,
}
}
}
const fn valid_side(side: i32) -> bool {
side == 0 || side == 1
}
const fn valid_pair(fixture_team: i32, user_team: i32) -> bool {
fixture_team > 0 && user_team > 0 && fixture_team != user_team
}
fn enabled(value: Option<&str>) -> bool {
value == Some("1")
}
fn exact_signature(current: &[u8], expected: &[u8]) -> bool {
current == expected
}
unsafe fn target_matches(base: usize, rva: usize, signature: &[u8]) -> bool {
let Some(target) = target_va(base, rva) else {
return false;
};
readable_range(target, signature.len())
&& exact_signature(
core::slice::from_raw_parts(target as *const u8, signature.len()),
signature,
)
}
type FixtureProjectorFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize;
type GetTeamFn = unsafe extern "system" fn(usize, i32) -> i32;
unsafe extern "system" fn fixture_projector_wrapper(
context: usize,
output_pair: usize,
r8: usize,
r9: usize,
) -> usize {
let trampoline = FIXTURE_PROJECTOR_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: FixtureProjectorFn = core::mem::transmute(trampoline);
let result = original(context, output_pair, r8, r9);
let pair = rd_i32(output_pair).zip(rd_i32(output_pair.saturating_add(4)));
let captured = pair.is_some_and(|(fixture_team, user_team)| {
TEAM_STATE
.lock()
.map(|mut state| state.capture(fixture_team, user_team))
.unwrap_or(false)
});
match pair {
Some((fixture_team, user_team)) if captured => write_log(&format!(
"SEASON_TEAM_COMPAT: fixture captured fixture={fixture_team} user={user_team}\n"
)),
Some((fixture_team, user_team)) => write_log(&format!(
"SEASON_TEAM_COMPAT: invalid fixture pair [{fixture_team},{user_team}]; inactive\n"
)),
None => {
if let Ok(mut state) = TEAM_STATE.lock() {
*state = TeamState::empty();
}
write_log("SEASON_TEAM_COMPAT: unreadable fixture pair; inactive\n");
}
}
result
}
unsafe extern "system" fn get_team_wrapper(adapter: usize, side: i32) -> i32 {
let trampoline = GET_TEAM_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: GetTeamFn = core::mem::transmute(trampoline);
let native_team = original(adapter, side);
if !REPAIR_ACTIVE.load(Ordering::Acquire) {
return native_team;
}
let decision = TEAM_STATE
.lock()
.map(|mut state| state.observe_get_team(side, native_team))
.unwrap_or_else(|_| GetTeamDecision::native(native_team));
match decision.event {
DecisionEvent::FixtureObserved => write_log(&format!(
"SEASON_TEAM_COMPAT: fixture observed side={side} team={native_team}\n"
)),
DecisionEvent::UserObserved => write_log(&format!(
"SEASON_TEAM_COMPAT: user observed side={side} team={native_team}\n"
)),
DecisionEvent::NativePairConfirmed => write_log(&format!(
"SEASON_TEAM_COMPAT: native pair confirmed side={side} team={native_team}\n"
)),
DecisionEvent::ConflictingNativePair => write_log(&format!(
"SEASON_TEAM_COMPAT: conflicting native pair at side={side}; state cleared\n"
)),
DecisionEvent::Corrected => write_log(&format!(
"SEASON_TEAM_COMPAT: corrected GetTeam side={side} native={native_team} fixture={}\n",
decision.team
)),
DecisionEvent::None => {}
}
decision.team
}
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_TEAM_COMPAT: CardsDLL unavailable/invalid; inactive\n");
return;
}
if !target_matches(base, FIXTURE_PROJECTOR_RVA, &FIXTURE_PROJECTOR_SIGNATURE)
|| !target_matches(base, GET_TEAM_RVA, &GET_TEAM_SIGNATURE)
{
write_log("SEASON_TEAM_COMPAT: target signature mismatch; inactive\n");
return;
}
if !install_detour(
base,
FIXTURE_PROJECTOR_RVA,
"OfflineSeason_fixture_projector",
FIXTURE_PROJECTOR_SIGNATURE.len(),
&FIXTURE_PROJECTOR_SIGNATURE,
fixture_projector_wrapper as *const () as usize,
&FIXTURE_PROJECTOR_TRAMPOLINE,
) {
write_log("SEASON_TEAM_COMPAT: fixture projector hook failed; inactive\n");
return;
}
if !install_detour_reloc(
base,
GET_TEAM_RVA,
"CardsGameSetupAdapter_GetTeam",
GET_TEAM_SIGNATURE.len(),
&GET_TEAM_SIGNATURE,
3,
7,
get_team_wrapper as *const () as usize,
&GET_TEAM_TRAMPOLINE,
) {
write_log("SEASON_TEAM_COMPAT: GetTeam hook failed; inactive\n");
return;
}
REPAIR_ACTIVE.store(true, Ordering::Release);
write_log("SEASON_TEAM_COMPAT: candidate ARMED; exact fixture evidence gate enabled\n");
}
pub(crate) fn install() {
if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) {
write_log("SEASON_TEAM_COMPAT: disabled\n");
return;
}
write_log("SEASON_TEAM_COMPAT: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_is_default_off() {
assert!(!enabled(None));
assert!(!enabled(Some("0")));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn signature_validation_is_exact() {
assert!(exact_signature(&GET_TEAM_SIGNATURE, &GET_TEAM_SIGNATURE));
let mut changed = GET_TEAM_SIGNATURE;
changed[0] ^= 1;
assert!(!exact_signature(&changed, &GET_TEAM_SIGNATURE));
}
#[test]
fn invalid_fixture_never_arms_state() {
let mut state = TeamState::empty();
assert!(!state.capture(0, 130000));
assert!(!state.capture(73, 73));
assert_eq!(
state.observe_get_team(0, 130000),
GetTeamDecision::native(130000)
);
}
#[test]
fn fixture_must_be_observed_natively_before_correction() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 130000,
event: DecisionEvent::UserObserved
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision::native(130000)
);
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
assert!(!state.correction_used);
}
#[test]
fn correction_requires_native_pair_then_is_one_shot() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(0, 73),
GetTeamDecision {
team: 73,
event: DecisionEvent::FixtureObserved
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 130000,
event: DecisionEvent::NativePairConfirmed
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 73,
event: DecisionEvent::Corrected
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision::native(130000)
);
}
#[test]
fn second_fixture_replaces_all_prior_state() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(state.observe_get_team(0, 73).team, 73);
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
assert_eq!(state.observe_get_team(1, 130000).team, 73);
assert!(state.capture(240, 130000));
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
assert_eq!(state.user_get_side, UNKNOWN_SIDE);
assert!(!state.correction_used);
assert_eq!(state.observe_get_team(0, 240).team, 240);
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
assert_eq!(state.observe_get_team(1, 130000).team, 240);
}
#[test]
fn conflicting_fixture_sides_fail_closed() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(0, 73).event,
DecisionEvent::FixtureObserved
);
assert_eq!(
state.observe_get_team(1, 73).event,
DecisionEvent::ConflictingNativePair
);
assert_eq!(state, TeamState::empty());
}
}