diff --git a/openfut-hook/src/sbc_dispatch.rs b/openfut-hook/src/sbc_dispatch.rs index 42d5d32..f10638b 100644 --- a/openfut-hook/src/sbc_dispatch.rs +++ b/openfut-hook/src/sbc_dispatch.rs @@ -198,6 +198,14 @@ fn decide(input: DecisionInput) -> Result { 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 { crate::sbc_trace::readable_range(address, 4) .then(|| core::ptr::read_volatile(address as *const u32)) @@ -268,7 +276,6 @@ unsafe extern "system" fn completion_wrapper( ); COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed); - let response_vtable = crate::sbc_trace::guarded_usize(evidence.deserializer_this); let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire); let live_category_count = evidence .model @@ -295,8 +302,7 @@ unsafe extern "system" fn completion_wrapper( deserializer_reader: evidence.deserializer_reader, deserializer_result: evidence.deserializer_result, deserializer_thread: evidence.deserializer_thread, - response_class_matches: response_vtable - == evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA), + response_class_matches: response_class_matches(evidence.base, evidence.response_vtable), model: evidence.model, live_category_count, category_count: evidence.category_count, @@ -348,6 +354,39 @@ unsafe extern "system" fn completion_wrapper( 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 } @@ -760,6 +799,18 @@ mod tests { 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); diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs index 3e9f108..cf5482b 100644 --- a/openfut-hook/src/sbc_trace.rs +++ b/openfut-hook/src/sbc_trace.rs @@ -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); @@ -684,9 +685,11 @@ 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 } @@ -703,6 +706,7 @@ pub(crate) struct DispatchEvidence { 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, @@ -722,6 +726,7 @@ pub(crate) fn dispatch_evidence() -> DispatchEvidence { 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),