From 89cf5df71f3b6f756d1be09ba44450586754040a Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 23 Aug 2026 18:36:11 +0000 Subject: [PATCH] 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. --- openfut-hook/src/kit_trace.rs | 141 +++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 63 deletions(-) diff --git a/openfut-hook/src/kit_trace.rs b/openfut-hook/src/kit_trace.rs index a1deb70..3405639 100644 --- a/openfut-hook/src/kit_trace.rs +++ b/openfut-hook/src/kit_trace.rs @@ -156,31 +156,57 @@ unsafe extern "system" fn kit_db_clone_wrapper( original(rcx, rdx, r8, r9) } -// ── The ENGINE-PROVIDER path (2026-08-24) ──────────────────────────────────── +// ── ENGINE-PROVIDER path, RATE-LIMITED ─────────────────────────────────────── // -// The four traces above were written against the 2026-08-20 model, in which the -// selector was fed by DP command 0x7576 -> FutSquadServiceImpl::setAvailableKits. -// A live run refuted that: `KIT_SET` never fires, `KIT_GET` reports -// `KITS_AVAILABLE=0`, and `FUN_1801c3480` is entered 810 times without ever -// seeing a kit. The selector is actually fed by a provider CardsDLL registers -// INTO the FIFA engine — singleton FUN_1800338f0, vtable 0x1801f1d68, slots -// +0x08 enumerate and +0x10 describe — and nothing instrumented it. +// A first cut of these three froze the client (2026-08-24). `FUN_180033770` is +// POLLED — roughly 150 calls, alternating between the two real match team ids — +// and the wrapper did a synchronous `write_log` on every one. The game hung at +// the "are both teams ready" prompt. // -// These three close that gap. Still strictly passive: log, then tail-call. +// Everything below is therefore built so the hot path costs nothing: +// * the enumerate trace returns immediately unless the team is the FUT club; +// * every trace has its OWN small budget rather than sharing the global 256; +// * repeated identical observations are collapsed by a tiny dedupe set, so a +// poll logs once and then never again. +// No trace may log per-call on a polled function. That is the rule this file +// broke once already. -// FUN_180033770 (0x33770) — enumerate kits for a team. It answers only for the -// FUT custom club (130000) and otherwise forwards to the engine default, so the -// teamId it is ASKED about is the first thing worth knowing: if the pre-match -// screen asks about a real team id, our club items were never in scope. -static KIT_ENUM_TRAMP: AtomicUsize = AtomicUsize::new(0); -unsafe extern "system" fn kit_enum_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { - let logged = budget(); - if logged { - write_log(&format!( - "KIT_ENUM: provider.enumerate this={rcx:#x} teamId={rdx} (130000 = FUT club) \ - out={r8:#x} arg4={r9:#x}\n" - )); +/// The FUT custom club. `FUN_180033770` answers only for this team id. +const FUT_CLUB_TEAM_ID: usize = 130_000; + +/// Fixed-capacity "have I already logged this?" set. Lock-free and allocation +/// free so it is safe to consult from a polled game thread. +struct SeenSet { + slots: [AtomicUsize; 16], +} + +impl SeenSet { + const fn new() -> Self { + #[allow(clippy::declare_interior_mutable_const)] + const Z: AtomicUsize = AtomicUsize::new(usize::MAX); + SeenSet { slots: [Z; 16] } } + + /// True the FIRST time `key` is offered, false every time after. A full set + /// stops reporting rather than evicting: the point is a bounded log. + fn first_time(&self, key: usize) -> bool { + for slot in &self.slots { + match slot.compare_exchange(usize::MAX, key, Ordering::AcqRel, Ordering::Acquire) { + Ok(_) => return true, + Err(existing) if existing == key => return false, + Err(_) => continue, + } + } + false + } +} + +// FUN_180033770 (0x33770) — enumerate kits for a team. +static KIT_ENUM_TRAMP: AtomicUsize = AtomicUsize::new(0); +static ENUM_SEEN: SeenSet = SeenSet::new(); +unsafe extern "system" fn kit_enum_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { + // The polled case: real match team ids. Do NOT log, do NOT format, just go. + let interesting = rdx == FUT_CLUB_TEAM_ID && ENUM_SEEN.first_time(rdx); let t = KIT_ENUM_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; @@ -188,22 +214,27 @@ unsafe extern "system" fn kit_enum_wrapper(rcx: usize, rdx: usize, r8: usize, r9 let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); let r = original(rcx, rdx, r8, r9); - if logged { - write_log(&format!("KIT_ENUM: -> returned {r:#x}\n")); + if interesting { + // r8 is the out-list; report how many kits were actually offered. + let count = match (rd_usize(r8), rd_usize(r8 + 8)) { + (Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64, + _ => -1, + }; + write_log(&format!( + "KIT_ENUM: FUT club enumerate -> returned {r:#x}, offered_kits={count}\n" + )); } r } -// sub_180033430 (0x33430) — describe one kit. Ghidra never functionised this -// address. It decodes a packed id into (teamid, year, slot) and writes NAME/TYPE -// only when that triple equals the club's active home or away triple; a -// non-match leaves the descriptor completely untouched, which is what makes the -// engine fall back to its own catalogue kit. +// sub_180033430 (0x33430) — describe one kit. Dedupes on the packed id, so a +// carousel that re-describes the same kit logs it once. static KIT_DESC_TRAMP: AtomicUsize = AtomicUsize::new(0); +static DESC_SEEN: SeenSet = SeenSet::new(); unsafe extern "system" fn kit_desc_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { - if budget() { - // rdx carries the packed kit id: teamid<<14 | year_field<<5 | slot. - let packed = rdx as u32; + let packed = rdx as u32; + let fresh = DESC_SEEN.first_time(packed as usize); + if fresh { let teamid = packed >> 14; let year_field = (packed >> 5) & 0x1ff; let year = if year_field != 0 { @@ -211,10 +242,9 @@ unsafe extern "system" fn kit_desc_wrapper(rcx: usize, rdx: usize, r8: usize, r9 } else { 0 }; - let slot = packed & 0x1f; write_log(&format!( - "KIT_DESC: provider.describe this={rcx:#x} packed={packed:#x} \ - -> teamid={teamid} year={year} slot={slot}\n" + "KIT_DESC: packed={packed:#x} -> teamid={teamid} year={year} slot={}\n", + packed & 0x1f )); } let t = KIT_DESC_TRAMP.load(Ordering::Acquire); @@ -226,14 +256,14 @@ unsafe extern "system" fn kit_desc_wrapper(rcx: usize, rdx: usize, r8: usize, r9 original(rcx, rdx, r8, r9) } -// FUN_1800d73d0 (0xd73d0) — the club scan behind getActiveKit. Its prologue -// compares EDX against 2, so rdx is the home/away selector (2 home, 3 away), -// NOT the cardtype the earlier note assumed. This is the function that must find -// a `cardtype 7 + cardsubtypeid 9 + itemState 101/102` item; if it returns -// nothing, the active triple stays zero and every kit is unmatchable. +// FUN_1800d73d0 (0xd73d0) — the club scan behind getActiveKit. Live args are +// (mgr, cardtype, cardsubtypeid, selector): observed a2=7 with a3 in {9,10,11} +// and a4 in {0,2,3}. Only cardtype 7 matters here, and each (subtype, selector) +// pair is reported once. static KIT_SCAN_TRAMP: AtomicUsize = AtomicUsize::new(0); +static SCAN_SEEN: SeenSet = SeenSet::new(); unsafe extern "system" fn kit_scan_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { - let logged = budget(); + let fresh = rdx == 7 && SCAN_SEEN.first_time((r8 << 8) | (r9 & 0xff)); let t = KIT_SCAN_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; @@ -241,25 +271,10 @@ unsafe extern "system" fn kit_scan_wrapper(rcx: usize, rdx: usize, r8: usize, r9 let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); let r = original(rcx, rdx, r8, r9); - if logged { - // A hit returns the matching item; report the fields the caller reads so - // a wrong-shaped kit is distinguishable from an absent one. - let detail = if r != 0 { - format!( - " HIT item={r:#x} type[+0x4c]={:?} subtype[+0x50]={:?} itemState[+0x5c]={:?} \ - teamid[+0x94]={:?} category[+0xb8]={:?} year[+0xba]={:?}", - rd_i32(r + 0x4c), - rd_i32(r + 0x50), - rd_i32(r + 0x5c), - rd_i32(r + 0x94), - rd_i32(r + 0xb8), - rd_i32(r + 0xba), - ) - } else { - " MISS (no kit item matched -> active triple stays zero)".to_string() - }; + if fresh { write_log(&format!( - "KIT_SCAN: clubScan mgr={rcx:#x} sel={rdx} (2=home 3=away) a3={r8} a4={r9}{detail}\n" + "KIT_SCAN: cardtype=7 subtype={r8} selector={r9} -> ret={r:#x} \ + (subtype 9=kit 10=stadium 11=badge; selector 2=home 3=away)\n" )); } r @@ -334,10 +349,10 @@ unsafe fn worker() { &KIT_DB_CLONE_TRAMP, ); - // ── ENGINE-PROVIDER path. Prologues dumped from the analysed Ghidra project - // (cardsdll.dll, base 0x180000000) on 2026-08-24; every copy length below - // is instruction-aligned and none of these prologues is rip-relative, so - // the plain installer is correct for all three. + // ENGINE-PROVIDER path. Prologues dumped from the analysed Ghidra project + // (cardsdll.dll, base 0x180000000). All three copy lengths are + // instruction-aligned and none of these prologues is rip-relative, so the + // plain installer is correct — unlike FUN_1801c3480 above. // FUN_180033770: 48 8b c4 57 41 56 41 57 48 83 ec 60 48 c7 40 c0 fe ff ff ff (20). install_detour( @@ -352,7 +367,7 @@ unsafe fn worker() { kit_enum_wrapper as *const () as usize, &KIT_ENUM_TRAMP, ); - // sub_180033430 (Ghidra leaves this one undefined): + // sub_180033430 (Ghidra leaves it undefined): // 48 8b c4 55 41 54 41 55 41 56 41 57 48 8b ec (15). install_detour( base,