From 01f5559df5529814bd0ebab06a74d33be62641bc Mon Sep 17 00:00:00 2001 From: wiseLlama0 <114631736+wiseLlama0@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:30:04 -0400 Subject: [PATCH 1/4] Combat resolvers: match class names exactly and pre-check pointers Problem ------- The damage and kill resolvers in resolvers/gameplay.rs pick the memory layout to read an object with from object_inherits_from, which walks the object's class chain and compared each class name by substring. "Character" therefore also matched unrelated classes such as CharacterDeathcam_C, and those objects were then read as an ATBLCharacter: values at ATBLCharacter field offsets were treated as pointers and followed, although the object has a different layout. Pointers were checked only for null before being dereferenced, and the class-chain walk had no bound. Fix --- - object_inherits_from compares class names exactly. - looks_like_pointer, a cheap pre-filter (non-null, 8-byte aligned, inside the user-mode address range), is applied to the object, to each super_struct, and to the player-state and controller pointers before they are dereferenced. - The class-chain walk stops after 32 levels. Why this fixes it ----------------- With exact matching, only classes that really derive from the named class take that code path, so the ATBLCharacter layout is only applied to objects that have it. The pre-filter rejects null and obviously invalid values before any read, and the bound keeps a corrupted class chain from looping. The pre-filter does not prove that an object lives at an address: a value such as 0x100000000 passes it. The next commit adds that check. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/resolvers/gameplay.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/resolvers/gameplay.rs b/src/resolvers/gameplay.rs index fa58c2d..430d208 100644 --- a/src/resolvers/gameplay.rs +++ b/src/resolvers/gameplay.rs @@ -23,16 +23,28 @@ fn object_display_name(ptr: *mut c_void) -> Option { .filter(|name| !name.trim().is_empty()) } +/// Cheap pre-filter: non-null, 8-byte aligned and inside the canonical user-mode range. +fn looks_like_pointer(p: *const c_void) -> bool { + let v = p as usize; + v >= 0x10000 && v < 0x0000_7FFF_FFFF_0000 && v % 8 == 0 +} + fn object_inherits_from(ptr: *mut c_void, needle: &str) -> bool { + if !looks_like_pointer(ptr) { return false; } let mut curr = unsafe { (ptr as *const UObject).as_ref() } .and_then(|o| unsafe { o.uobject_base_utility.uobject_base.class_private.as_ref() }) .map(|c| &c.ustruct as *const UStruct); + let mut depth = 0; while let Some(s) = unsafe { curr.and_then(|p| p.as_ref()) } { - if s.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string().contains(needle) { + // Exact class-name match: a substring match would let "Character" accept classes such as + // CharacterDeathcam_C, which do not have the ATBLCharacter layout. + if s.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() == needle { return true; } - curr = (!s.super_struct.is_null()).then_some(s.super_struct); + depth += 1; + if depth > 32 { return false; } + curr = looks_like_pointer(s.super_struct.cast()).then_some(s.super_struct); } false } @@ -58,6 +70,7 @@ fn fallback_combat_actor(ptr: *mut c_void) -> Option { } fn combat_actor_from_player_state(player_state: *mut APlayerState) -> Option { + if !looks_like_pointer(player_state.cast()) { return None; } let ps = unsafe { player_state.as_ref() }?; let name = ps.player_name_private.copy_to_string().ok()?; (!name.trim().is_empty()).then(|| { @@ -74,6 +87,7 @@ fn combat_actor_from_actor(ptr: *mut c_void) -> Option { return combat_actor_from_player_state(pawn.player_state as *mut APlayerState) .or_else(|| { + if !looks_like_pointer(pawn.controller.cast()) { return None; } let controller = unsafe { pawn.controller.cast::().as_ref() }?; combat_actor_from_player_state(controller.player_state) }) @@ -81,7 +95,7 @@ fn combat_actor_from_actor(ptr: *mut c_void) -> Option { .or_else(|| fallback_combat_actor(ptr)); } - if object_inherits_from(ptr, "Controller") { + if object_inherits_from(ptr, "Controller") || object_inherits_from(ptr, "PlayerController") || object_inherits_from(ptr, "AIController") || object_inherits_from(ptr, "TBLPlayerController") { let controller = unsafe { (ptr as *mut AController).as_ref() }?; return combat_actor_from_player_state(controller.player_state) .or_else(|| fallback_combat_actor(ptr)); From bd9f2cd0dd450023737c17da66bbe02b00a70568 Mon Sep 17 00:00:00 2001 From: wiseLlama0 <114631736+wiseLlama0@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:30:04 -0400 Subject: [PATCH 2/4] Combat resolvers: read only live objects; read FString headers safely Problem ------- Servers crashed with "ACCESS VIOLATION ... READ addr=0x100000440" at the first instruction of TArray::copy_to_string, called from the damage and kill resolvers while they read a player's name (APlayerState::PlayerNamePrivate). The value behind it is 0x0000000100000100, read from ATBLCharacter::LastPlayerState, which the plugin declares as a raw pointer. Read as two 32-bit values it is 0x100 and 1, which looks like a TWeakObjectPtr (object index, serial number) rather than an address. It is aligned and in the user-mode range, so looks_like_pointer accepts it. PlayerNamePrivate is at offset 0x338, and copy_to_string read the string's length (offset 8) directly, so it faulted at 0x100000100 + 0x338 + 8 = 0x100000440. Any dangling pointer to a destroyed object could fail the same way. The resolvers also read LastPlayerState from any object matching "Character", although the field exists only on ATBLCharacter and lies past the end of a plain ACharacter. Fix --- - ue::try_read copies a value through ReadProcessMemory and returns None instead of faulting. - FString::copy_to_string reads its header (data pointer, length, capacity) with try_read, and rejects a negative length, a length above capacity, or one above 2^20 code units before allocating. - FUObjectArray::is_live reports whether a pointer is a live UObject: the object's InternalIndex, read with try_read, must name a GUObjectArray slot that holds this exact pointer and is neither pending kill nor unreachable. This is the test UE's IsValid makes. - The resolvers require is_live for every actor, controller, player state and damage-source pointer before reading through it. - LastPlayerState is read only when the object is a TBLCharacter. - A rejected player state is logged as a warning naming the field it came from, e.g. "TBLCharacter.LastPlayerState". Why this fixes it ----------------- A pointer is followed only if GUObjectArray holds that exact object at the index the object claims, so weak-pointer bit patterns, garbage and pointers to destroyed objects are all rejected before any field is read. If a bad pointer still reaches copy_to_string, try_read makes the header read fail with an error instead of an access violation. With this change, a server under load logged "ignoring PlayerState 0x100000100 from TBLCharacter.LastPlayerState: not a live object" twice and kept running; before, that value crashed it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/resolvers/gameplay.rs | 49 ++++++++++++++++++++++++++++++--------- src/ue/memory.rs | 25 ++++++++++++++++++++ src/ue/mod.rs | 2 ++ src/ue/object_array.rs | 22 ++++++++++++++++++ src/ue/string.rs | 23 +++++++++++++++--- 5 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 src/ue/memory.rs diff --git a/src/resolvers/gameplay.rs b/src/resolvers/gameplay.rs index 430d208..6916988 100644 --- a/src/resolvers/gameplay.rs +++ b/src/resolvers/gameplay.rs @@ -3,15 +3,18 @@ use std::os::raw::c_void; use crate::events::models::{CombatActor, Damage, DamageSource, GameEvent, Kill}; use crate::features::events::EVENT_SYSTEM; use crate::game::chivalry2::{AController, APlayerState, ATBLCharacter, FDeathDamageTakenEvent, FDamageTakenEvent, PlayerFlags}; +use crate::tools::hook_globals::globals; use crate::ue::{UObject, UStruct}; fn object_class_name(ptr: *mut c_void) -> Option { + if !is_live_object(ptr) { return None; } let obj = unsafe { (ptr as *const UObject).as_ref() }?; let class = unsafe { obj.uobject_base_utility.uobject_base.class_private.as_ref() }?; Some(class.ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string()) } fn object_name(ptr: *mut c_void) -> Option { + if !is_live_object(ptr) { return None; } let obj = unsafe { (ptr as *const UObject).as_ref() }?; Some(obj.uobject_base_utility.uobject_base.name_private.to_string()) } @@ -23,14 +26,22 @@ fn object_display_name(ptr: *mut c_void) -> Option { .filter(|name| !name.trim().is_empty()) } -/// Cheap pre-filter: non-null, 8-byte aligned and inside the canonical user-mode range. +/// Cheap pre-filter: non-null, 8-byte aligned and inside the canonical user-mode range. Not proof +/// that an object lives there (0x100000000 passes); use is_live_object before reading through a +/// pointer. fn looks_like_pointer(p: *const c_void) -> bool { let v = p as usize; v >= 0x10000 && v < 0x0000_7FFF_FFFF_0000 && v % 8 == 0 } +/// True when `p` is a live UObject according to GUObjectArray (FUObjectArray::is_live): catches +/// unmapped garbage and dangling pointers to destroyed objects alike. +fn is_live_object(p: *const c_void) -> bool { + looks_like_pointer(p) && unsafe { globals().guobject_array_unchecked() }.is_live(p.cast()) +} + fn object_inherits_from(ptr: *mut c_void, needle: &str) -> bool { - if !looks_like_pointer(ptr) { return false; } + if !is_live_object(ptr) { return false; } let mut curr = unsafe { (ptr as *const UObject).as_ref() } .and_then(|o| unsafe { o.uobject_base_utility.uobject_base.class_private.as_ref() }) .map(|c| &c.ustruct as *const UStruct); @@ -69,8 +80,13 @@ fn fallback_combat_actor(ptr: *mut c_void) -> Option { Some(CombatActor::new(normalize_display_name(&obj), is_bot)) } -fn combat_actor_from_player_state(player_state: *mut APlayerState) -> Option { - if !looks_like_pointer(player_state.cast()) { return None; } +/// `via` names the field the pointer came from, for the warning when it is not a live object. +fn combat_actor_from_player_state(player_state: *mut APlayerState, via: &str) -> Option { + if player_state.is_null() { return None; } + if !is_live_object(player_state.cast()) { + crate::swarn!(f; "ignoring PlayerState {:p} from {}: not a live object", player_state, via); + return None; + } let ps = unsafe { player_state.as_ref() }?; let name = ps.player_name_private.copy_to_string().ok()?; (!name.trim().is_empty()).then(|| { @@ -80,29 +96,40 @@ fn combat_actor_from_player_state(player_state: *mut APlayerState) -> Option Option { if ptr.is_null() { return None; } + if !is_live_object(ptr) { + crate::swarn!(f; "ignoring combat actor {:p}: not a live object", ptr); + return None; + } - if object_inherits_from(ptr, "TBLCharacter") || object_inherits_from(ptr, "Character") { + let is_tbl_character = object_inherits_from(ptr, "TBLCharacter"); + if is_tbl_character || object_inherits_from(ptr, "Character") { + // The APawn fields are valid on any Character; the cast to ATBLCharacter is only read + // further for LastPlayerState, which exists on ATBLCharacter alone and lies past the end + // of a plain ACharacter. let char = unsafe { (ptr as *mut ATBLCharacter).as_ref() }?; let pawn = &char.base_tbl_character_base.base_character.base_pawn; - return combat_actor_from_player_state(pawn.player_state as *mut APlayerState) + return combat_actor_from_player_state(pawn.player_state as *mut APlayerState, "Pawn.PlayerState") .or_else(|| { - if !looks_like_pointer(pawn.controller.cast()) { return None; } + if !is_live_object(pawn.controller) { return None; } let controller = unsafe { pawn.controller.cast::().as_ref() }?; - combat_actor_from_player_state(controller.player_state) + combat_actor_from_player_state(controller.player_state, "Pawn.Controller.PlayerState") + }) + .or_else(|| { + if !is_tbl_character { return None; } + combat_actor_from_player_state(char.last_player_state as *mut APlayerState, "TBLCharacter.LastPlayerState") }) - .or_else(|| combat_actor_from_player_state(char.last_player_state as *mut APlayerState)) .or_else(|| fallback_combat_actor(ptr)); } if object_inherits_from(ptr, "Controller") || object_inherits_from(ptr, "PlayerController") || object_inherits_from(ptr, "AIController") || object_inherits_from(ptr, "TBLPlayerController") { let controller = unsafe { (ptr as *mut AController).as_ref() }?; - return combat_actor_from_player_state(controller.player_state) + return combat_actor_from_player_state(controller.player_state, "Controller.PlayerState") .or_else(|| fallback_combat_actor(ptr)); } if object_inherits_from(ptr, "PlayerState") { - return combat_actor_from_player_state(ptr.cast()) + return combat_actor_from_player_state(ptr.cast(), "PlayerState") .or_else(|| fallback_combat_actor(ptr)); } diff --git a/src/ue/memory.rs b/src/ue/memory.rs new file mode 100644 index 0000000..8dfdeb9 --- /dev/null +++ b/src/ue/memory.rs @@ -0,0 +1,25 @@ +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; +use windows::Win32::System::Threading::GetCurrentProcess; + +/// Copies a `T` from `ptr` without dereferencing it: ReadProcessMemory on our own process fails +/// cleanly on unmapped or protected memory where a plain read raises an access violation. For +/// values read through pointers that may be dangling or garbage (fields of game objects that +/// were never validated). +pub fn try_read(ptr: *const T) -> Option { + if ptr.is_null() { + return None; + } + let size = std::mem::size_of::(); + let mut out = std::mem::MaybeUninit::::uninit(); + let mut bytes_read = 0usize; + let result = unsafe { + ReadProcessMemory( + GetCurrentProcess(), + ptr.cast(), + out.as_mut_ptr().cast(), + size, + Some(&mut bytes_read), + ) + }; + (result.is_ok() && bytes_read == size).then(|| unsafe { out.assume_init() }) +} diff --git a/src/ue/mod.rs b/src/ue/mod.rs index a2c3d51..1c583dc 100644 --- a/src/ue/mod.rs +++ b/src/ue/mod.rs @@ -3,6 +3,7 @@ mod array; pub mod kismet; mod malloc; mod map; +mod memory; mod name; mod object; pub mod object_array; @@ -12,6 +13,7 @@ pub mod sync; pub use array::*; pub use malloc::*; pub use map::*; +pub use memory::*; pub use name::*; pub use object::*; pub use string::*; diff --git a/src/ue/object_array.rs b/src/ue/object_array.rs index 507dabb..be44fcf 100644 --- a/src/ue/object_array.rs +++ b/src/ue/object_array.rs @@ -35,6 +35,28 @@ impl FUObjectArray { CriticalSectionGuard::lock(&self.obj_objects_critical, &self.obj_objects) } + /// True when `obj` is a live object: the InternalIndex stored in it, read without faulting, + /// names a slot that still holds this very pointer, and that slot is neither pending kill nor + /// unreachable. A dangling or garbage pointer fails because its memory is unreadable, or its + /// slot is out of range, empty or holds another object. Reads the chunk table without the + /// lock, as allocate_serial_number below does; meant for the game thread. + pub fn is_live(&self, obj: *const UObjectBase) -> bool { + const UNREACHABLE: i32 = 1 << 28; + const PENDING_KILL: i32 = 1 << 29; + if obj.is_null() { + return false; + } + let index_ptr = (obj as *const u8) + .wrapping_add(std::mem::offset_of!(UObjectBase, internal_index)) as *const i32; + let Some(index) = super::try_read(index_ptr) else { return false }; + let objects = unsafe { &*self.obj_objects.get() }; + if index < 0 || index >= objects.num_elements || objects.max_chunks <= 0 { + return false; + } + let item = objects.item(index); + std::ptr::eq(item.object, obj) && item.flags & (UNREACHABLE | PENDING_KILL) == 0 + } + pub fn allocate_serial_number(&self, index: ObjectIndex) -> i32 { let objects = unsafe { &*self.obj_objects.get() }; let item = objects.item(index); diff --git a/src/ue/string.rs b/src/ue/string.rs index a130049..9105f71 100644 --- a/src/ue/string.rs +++ b/src/ue/string.rs @@ -19,6 +19,7 @@ pub enum FStringCopyError { MemoryReadFailed, PartialRead, Utf16DecodeFailed, + ImplausibleHeader, } impl FStringCopyError { @@ -29,6 +30,7 @@ impl FStringCopyError { FStringCopyError::MemoryReadFailed => "FString backing memory copy failed", FStringCopyError::PartialRead => "FString backing memory copy was partial", FStringCopyError::Utf16DecodeFailed => "FString UTF-16 decode failed", + FStringCopyError::ImplausibleHeader => "FString header had an impossible length", } } } @@ -38,12 +40,27 @@ impl TArray { /// in the FString's backing buffer, which is not safe to do in a multithreaded environment. /// This function copies the buffer to a new string, which is safe to do in a multithreaded /// environment. + /// + /// The header (data pointer, length, capacity) is read the same way as the buffer, so an + /// FString inside a dangling or garbage object returns an error instead of raising an access + /// violation. A header whose length is negative, exceeds its capacity or is implausibly large + /// is rejected before anything is allocated. pub fn copy_to_string(&self) -> Result { - let utf16_len = self.len(); - let utf16_ptr = self.as_ptr(); - if utf16_len == 0 || utf16_ptr.is_null() { + #[derive(Clone, Copy)] + #[repr(C)] + struct Header { data: *const u16, num: i32, max: i32 } + const MAX_CODE_UNITS: i32 = 1 << 20; + + let header = super::try_read(self as *const Self as *const Header) + .ok_or(FStringCopyError::MemoryReadFailed)?; + if header.num == 0 || header.data.is_null() { return Err(FStringCopyError::NullOrEmptyBuffer); } + if header.num < 0 || header.num > header.max || header.num > MAX_CODE_UNITS { + return Err(FStringCopyError::ImplausibleHeader); + } + let utf16_len = header.num as usize; + let utf16_ptr = header.data; let byte_len = utf16_len .checked_mul(std::mem::size_of::()) From 27078f429aadd28dc4f46c4b90ba6247600b765f Mon Sep 17 00:00:00 2001 From: wiseLlama0 <114631736+wiseLlama0@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:30:04 -0400 Subject: [PATCH 3/4] ProcessEvent: block ClientSetCameraMode under --desync-patch Problem ------- With --desync-patch on, loading a Team Objective map crashes the server with EXCEPTION_STACK_OVERFLOW: about five seconds after the map loads on an empty server, within a minute with players on. It reproduces on an empty server with "servertravel To_DarkForest"; with the patch off, the same travel works. The crash stack repeats a cycle of six frames that passes through UObject::ProcessEvent. The plugin already contains a fix for this kind of loop: a ProcessEvent hook in resolvers/desync.rs that drops ClientSetCameraMode, commented "event spam (deadlock)". But the hook's enable condition is { || false }, so it is never installed and the event is never blocked. Fix --- - The ProcessEvent hook is installed whenever --desync-patch is given. - The hook now sees every event in the game, so the "is this ClientSetCameraMode?" check uses the function's FName comparison index. The name is converted to a string once per distinct function and the result is cached per thread, instead of allocating a string for every event. - The per-event "Class::Function" debug log is removed for the same reason. Why this fixes it ----------------- The desync patch makes UNetDriver::GetNetMode report a dedicated server while the process runs as a listen server with a local player controller. In that state ClientSetCameraMode feeds an event loop, which the existing block was written to stop; it only needed to run. With the hook installed, the Team Objective map loads, the plugin logs "ClientSetCameraMode blocked" four times, the match reaches InProgress, and two players then played a match on it. Travel back to a free-for-all map also works. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/resolvers/desync.rs | 46 +++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/resolvers/desync.rs b/src/resolvers/desync.rs index b9cb25f..f38209d 100644 --- a/src/resolvers/desync.rs +++ b/src/resolvers/desync.rs @@ -1,6 +1,6 @@ use std::os::raw::c_void; -use crate::{game::engine::ENetMode, sdebug, sinfo, tools::hook_globals::{cli_args, globals}, ue::{FName, UFunction, UObject, UStruct}}; +use crate::{game::engine::ENetMode, sdebug, sinfo, tools::hook_globals::{cli_args, globals}, ue::{FName, UFunction, UObject}}; use crate::resolvers::unchained_integration::*; // Desync patch @@ -36,28 +36,38 @@ define_pattern_resolver!(DedicatedServerInit, [ CREATE_HOOK!(DedicatedServerInit, ACTIVE, NONE, (), (this_ptr: *mut c_void), {}); // Block the ClientSetCameraMode event spam (deadlock) +// +// Under --desync-patch, ClientSetCameraMode feeds an unbounded event loop: loading a Team +// Objective map ends in EXCEPTION_STACK_OVERFLOW a few seconds later. The hook is therefore +// installed whenever --desync-patch is given. +thread_local! { + // FName comparison index -> "is ClientSetCameraMode". Bounded by the number of distinct UFunctions. + static CAMERA_MODE_INDEX_CACHE: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); +} + define_pattern_resolver!(ProcessEvent,["40 55 56 57 41 54 41 55 41 56 41 57 48 81 EC F0 00 00 00 48 8D 6C 24 30 48 89 9D 18 01"]); -CREATE_HOOK!(ProcessEvent, { || false }, NONE, (), ( - object: *mut UObject, - function: *mut UFunction, +CREATE_HOOK!(ProcessEvent, { || cli_args().apply_desync_patch }, NONE, (), ( + object: *mut UObject, + function: *mut UFunction, params: *mut c_void ),{ - let func_name = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; - - if func_name == "ClientSetCameraMode" { - sdebug!(f; "SetCameramode blocked"); - return; + if function.is_null() { + return CALL_ORIGINAL!(ProcessEvent(object, function, params)); } - else { - let current_class = unsafe { (*object).uobject_base_utility.uobject_base.class_private as *const UStruct }; - - let mut name: String = "".to_string(); - if !current_class.is_null() { - name = unsafe { (*current_class).ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; - } - crate::sdebug![f; "\x1b[32m[{}::{}]\x1b[0m ", name, func_name]; - return CALL_ORIGINAL!(ProcessEvent(object, function, params)); + // Every event passes here, so decide "is this ClientSetCameraMode?" by FName index, with the + // string conversion done once per distinct function and remembered. + let index = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.comparison_index.value }; + let blocked = CAMERA_MODE_INDEX_CACHE.with(|cache| { + *cache.borrow_mut().entry(index).or_insert_with(|| { + let func_name = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; + func_name == "ClientSetCameraMode" + }) + }); + if blocked { + sdebug!(f; "ClientSetCameraMode blocked"); + return; } + CALL_ORIGINAL!(ProcessEvent(object, function, params)) }); // Desync for listen server From 9afe97a901a5a31b5d8bd97b1060938de9300a40 Mon Sep 17 00:00:00 2001 From: wiseLlama0 <114631736+wiseLlama0@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:30:04 -0400 Subject: [PATCH 4/4] ProcessEvent: add a per-thread recursion guard under --desync-patch Problem ------- The Team Objective crash fixed by the previous commit was a stack overflow from an event loop running through UObject::ProcessEvent. Blocking ClientSetCameraMode ends that loop, but the net-mode mismatch the desync patch creates could start other loops of the same kind. Each would take the server down with nothing in the log naming the event involved. Fix --- The ProcessEvent hook counts its nesting depth per thread. At 300 nested calls it drops the event instead of calling the original and logs "recursion depth N reached; dropping Class::Function" at error level. The counter is decremented by a guard object, so it stays correct on every return path. Why this fixes it ----------------- Every iteration of such a loop passes through ProcessEvent, so the counter rises with the loop. Legitimate nesting stays far below 300 (UE's own script recursion limit is 120), so only a runaway loop reaches it. Dropping the event there unwinds the recursion before the stack runs out, and the log line names the event that needs a permanent fix. The guard has not tripped since ClientSetCameraMode is blocked; it is a safety net. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/resolvers/desync.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/resolvers/desync.rs b/src/resolvers/desync.rs index f38209d..f3170b3 100644 --- a/src/resolvers/desync.rs +++ b/src/resolvers/desync.rs @@ -1,6 +1,6 @@ use std::os::raw::c_void; -use crate::{game::engine::ENetMode, sdebug, sinfo, tools::hook_globals::{cli_args, globals}, ue::{FName, UFunction, UObject}}; +use crate::{game::engine::ENetMode, sdebug, sinfo, tools::hook_globals::{cli_args, globals}, ue::{FName, UFunction, UObject, UStruct}}; use crate::resolvers::unchained_integration::*; // Desync patch @@ -40,10 +40,30 @@ CREATE_HOOK!(DedicatedServerInit, ACTIVE, NONE, (), (this_ptr: *mut c_void), {}) // Under --desync-patch, ClientSetCameraMode feeds an unbounded event loop: loading a Team // Objective map ends in EXCEPTION_STACK_OVERFLOW a few seconds later. The hook is therefore // installed whenever --desync-patch is given. +// +// The same hook carries a per-thread depth counter far above legitimate Blueprint nesting, so a +// further event loop of this kind becomes a dropped event plus an error log naming it instead of +// a stack overflow. thread_local! { + static PROCESS_EVENT_DEPTH: std::cell::Cell = std::cell::Cell::new(0); // FName comparison index -> "is ClientSetCameraMode". Bounded by the number of distinct UFunctions. static CAMERA_MODE_INDEX_CACHE: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); } +// UE's own script recursion limit is 120; native+script nesting in normal play stays well below +// this. A runaway loop reaches it within milliseconds. +const PROCESS_EVENT_MAX_DEPTH: u32 = 300; + +fn process_event_names(object: *mut UObject, function: *mut UFunction) -> (String, String) { + let func_name = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; + let mut class_name = String::new(); + if !object.is_null() { + let current_class = unsafe { (*object).uobject_base_utility.uobject_base.class_private as *const UStruct }; + if !current_class.is_null() { + class_name = unsafe { (*current_class).ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; + } + } + (class_name, func_name) +} define_pattern_resolver!(ProcessEvent,["40 55 56 57 41 54 41 55 41 56 41 57 48 81 EC F0 00 00 00 48 8D 6C 24 30 48 89 9D 18 01"]); CREATE_HOOK!(ProcessEvent, { || cli_args().apply_desync_patch }, NONE, (), ( @@ -54,12 +74,18 @@ CREATE_HOOK!(ProcessEvent, { || cli_args().apply_desync_patch }, NONE, (), ( if function.is_null() { return CALL_ORIGINAL!(ProcessEvent(object, function, params)); } + let depth = PROCESS_EVENT_DEPTH.with(|d| d.get()); + if depth >= PROCESS_EVENT_MAX_DEPTH { + let (class_name, func_name) = process_event_names(object, function); + log::error!(target: "ProcessEvent", "recursion depth {depth} reached; dropping {class_name}::{func_name} to break the loop"); + return; + } // Every event passes here, so decide "is this ClientSetCameraMode?" by FName index, with the // string conversion done once per distinct function and remembered. let index = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.comparison_index.value }; let blocked = CAMERA_MODE_INDEX_CACHE.with(|cache| { *cache.borrow_mut().entry(index).or_insert_with(|| { - let func_name = unsafe { (*function).ustruct.ufield.uobject.uobject_base_utility.uobject_base.name_private.to_string() }; + let (_, func_name) = process_event_names(std::ptr::null_mut(), function); func_name == "ClientSetCameraMode" }) }); @@ -67,6 +93,10 @@ CREATE_HOOK!(ProcessEvent, { || cli_args().apply_desync_patch }, NONE, (), ( sdebug!(f; "ClientSetCameraMode blocked"); return; } + PROCESS_EVENT_DEPTH.with(|d| d.set(depth + 1)); + struct DepthGuard; + impl Drop for DepthGuard { fn drop(&mut self) { PROCESS_EVENT_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); } } + let _guard = DepthGuard; CALL_ORIGINAL!(ProcessEvent(object, function, params)) });