From 69dfed73027c171083fc37605492b1ec73af0a01 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 22 Feb 2026 21:47:51 -0500 Subject: [PATCH 1/2] sync rail scanner --- game_patch/hud/multi_spectate.cpp | 132 ++++++++++++++++++++++++++++++ game_patch/misc/player_fpgun.cpp | 34 +++++++- game_patch/rf/weapon.h | 1 + 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/game_patch/hud/multi_spectate.cpp b/game_patch/hud/multi_spectate.cpp index 090527a2..284f2f7a 100644 --- a/game_patch/hud/multi_spectate.cpp +++ b/game_patch/hud/multi_spectate.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -150,6 +151,10 @@ void multi_spectate_set_target_player(rf::Player* player) g_local_queued_delayed_spawn = false; stop_draw_respawn_timer_notification(); } + else { + // Clear scanner state when leaving spectate + rf::local_player->fpgun_data.scanning_for_target = false; + } g_spectate_mode_enabled = entering_player_spectate; if (g_spectate_mode_target != player) { @@ -413,6 +418,93 @@ static ConsoleCommand2 spectate_mode_follow_killer_cmd{ "When a player you're spectating dies, automatically spectate their killer", }; +// gameplay_render_frame checks scanning_for_target at 0x00431CCC for FOV and scanner overlay +// rendering, BEFORE player_render_new (0x0043285D) runs. The game loop clears scanning_for_target +// on local_player each frame (FUN_004ad410) because local_player isn't holding the rail driver. +// This early injection derives scanner state from the spectate target before the first read. +// The rail driver scanner toggle (FUN_004ad560) sets scanning_for_target but NOT zooming_in. +// Entity state flags (FUN_00475930) only pack RF_ES_ZOOMING from zooming_in. This injection +// also sets RF_ES_ZOOMING (bit 0x08) when scanning_for_target is true, so the scanner state +// is synced to other clients via stock entity_update packets. +// Wraps entity state flags sync (FUN_00475930) to handle scanner state on both sides: +// SENDING: includes scanning_for_target as RF_ES_ZOOMING in the flags +// RECEIVING: converts RF_ES_ZOOMING back to scanning_for_target for scanner weapons +static FunHook entity_state_flags_sync_hook{ + 0x00475930, + [](rf::Entity* entity, uint8_t* flags, bool is_sending) { + // PRE-CALL (sending side): set scanning_for_target in entity state so it gets packed + // as RF_ES_ZOOMING by the original function + rf::Player* player = entity ? rf::player_from_entity_handle(entity->handle) : nullptr; + bool was_scanning = false; + if (is_sending && player && player->fpgun_data.scanning_for_target) { + // Temporarily set zooming_in so the stock code packs RF_ES_ZOOMING + was_scanning = true; + player->fpgun_data.zooming_in = true; + } + + entity_state_flags_sync_hook.call_target(entity, flags, is_sending); + + // POST-CALL (sending side): restore zooming_in + if (was_scanning && player) { + player->fpgun_data.zooming_in = false; + } + + // POST-CALL (receiving side): convert zooming_in to scanning_for_target for scanners + if (!is_sending && player) { + if (player->fpgun_data.zooming_in && + rf::weapon_has_scanner(entity->ai.current_primary_weapon)) { + player->fpgun_data.scanning_for_target = true; + player->fpgun_data.zooming_in = false; + } else if (!player->fpgun_data.zooming_in) { + player->fpgun_data.scanning_for_target = false; + } + } + }, +}; + +// gameplay_render_frame checks scanning_for_target at 0x00431CCC for FOV and scanner overlay +// rendering, BEFORE player_render_new (0x0043285D) runs. The game loop clears scanning_for_target +// on local_player each frame (FUN_004ad410) because local_player isn't holding the rail driver. +// This early injection derives scanner state from the spectate target before the first read. +static CodeInjection gameplay_render_frame_early_scanner_sync{ + 0x00431CCC, + []() { + if (g_spectate_mode_enabled && rf::local_player && g_spectate_mode_target) { + // The receiving-side injection sets scanning_for_target directly on the target. + // Copy it to local_player so gameplay_render_frame's scanner overlay code sees it. + bool scanning = g_spectate_mode_target->fpgun_data.scanning_for_target; + rf::local_player->fpgun_data.scanning_for_target = scanning; + } + }, +}; + +// The scope overlay block at 0x00431D1C-0x00431E4C renders based on EDI (camera scope object), +// regardless of scanning state. When the rail scanner is active, we must suppress the scope +// overlay so it doesn't render on top of (or instead of) the scanner. Force EDI=0 to skip it. +static CodeInjection gameplay_render_frame_skip_scope_when_scanning{ + 0x00431D1C, + [](auto& regs) { + if (g_spectate_mode_enabled && rf::local_player && + rf::local_player->fpgun_data.scanning_for_target) { + regs.edi = 0; + } + }, +}; + +// gameplay_render_frame skips the HUD render (FUN_00437ba0) when scanning_for_target is true. +// Since multi_spectate_render is called from inside that function, the spectate HUD never draws +// when the rail scanner overlay is active. This injection runs right after the skip point and +// draws the spectate HUD on top of the scanner overlay. +static CodeInjection gameplay_render_frame_spectate_hud_over_scanner{ + 0x00432A20, + []() { + if (g_spectate_mode_enabled && rf::local_player && + rf::local_player->fpgun_data.scanning_for_target) { + multi_spectate_render(); + } + }, +}; + #if SPECTATE_MODE_SHOW_WEAPON static void player_render_new(rf::Player* player) @@ -438,6 +530,10 @@ static void player_render_new(rf::Player* player) rf::local_player->fpgun_data.zooming_in = g_spectate_mode_target->fpgun_data.zooming_in; rf::local_player->fpgun_data.zoom_factor = g_spectate_mode_target->fpgun_data.zoom_factor; + // Copy scanner state from target (set by receiving-side entity state flags injection) + rf::local_player->fpgun_data.scanning_for_target = + g_spectate_mode_target->fpgun_data.scanning_for_target; + rf::player_fpgun_process(g_spectate_mode_target); rf::player_render(g_spectate_mode_target); } @@ -449,12 +545,35 @@ CallHook gameplay_render_frame_player_fpgun_get_zoom_hook{ 0x00431B6D, [](rf::Player* pp) { if (g_spectate_mode_enabled) { + // Rail driver scanner has its own FOV (set via the scanning_for_target path). + // Return 0 so the sniper scope overlay doesn't render. + if (g_spectate_mode_target->fpgun_data.scanning_for_target) { + return 0.0f; + } return gameplay_render_frame_player_fpgun_get_zoom_hook.call_target(g_spectate_mode_target); } return gameplay_render_frame_player_fpgun_get_zoom_hook.call_target(pp); }, }; +// render_to_dynamic_textures (0x00431820) iterates all players and calls player_fpgun_render_for_rail_gun +// for each player with scanning_for_target=true. It runs BEFORE gameplay_render_frame, so our early +// injection there is too late. This hook sets scanning_for_target on local_player before the iteration, +// so the scanner texture gets rendered. Our existing hook on player_fpgun_render_for_rail_gun then +// redirects the render to use the spectate target's viewpoint. +static FunHook render_to_dynamic_textures_hook{ + 0x00431820, + []() { + if (g_spectate_mode_enabled && rf::local_player && g_spectate_mode_target) { + // The receiving-side injection sets scanning_for_target directly on the target. + // Copy it to local_player so render_to_dynamic_textures renders the scanner texture. + bool scanning = g_spectate_mode_target->fpgun_data.scanning_for_target; + rf::local_player->fpgun_data.scanning_for_target = scanning; + } + render_to_dynamic_textures_hook.call_target(); + }, +}; + #endif // SPECTATE_MODE_SHOW_WEAPON void multi_spectate_appy_patch() @@ -468,10 +587,23 @@ void multi_spectate_appy_patch() spectate_mode_minimal_ui_cmd.register_cmd(); spectate_mode_follow_killer_cmd.register_cmd(); + // Handle scanner state in entity state flags (both sending and receiving) + entity_state_flags_sync_hook.install(); + + // Sync scanner state early in gameplay_render_frame before the first scanning_for_target check + gameplay_render_frame_early_scanner_sync.install(); + + // Suppress scope overlay when rail scanner is active in spectate + gameplay_render_frame_skip_scope_when_scanning.install(); + + // Draw spectate HUD over rail scanner overlay (scanner skips the normal HUD render path) + gameplay_render_frame_spectate_hud_over_scanner.install(); + #if SPECTATE_MODE_SHOW_WEAPON AsmWriter(0x0043285D).call(player_render_new); gameplay_render_frame_player_fpgun_get_zoom_hook.install(); + render_to_dynamic_textures_hook.install(); write_mem_ptr(0x0048857E + 2, &g_spectate_mode_target); // obj_mark_all_for_room write_mem_ptr(0x00488598 + 1, &g_spectate_mode_target); // obj_mark_all_for_room diff --git a/game_patch/misc/player_fpgun.cpp b/game_patch/misc/player_fpgun.cpp index 7ec6e2b1..ab2f31fb 100644 --- a/game_patch/misc/player_fpgun.cpp +++ b/game_patch/misc/player_fpgun.cpp @@ -52,6 +52,27 @@ static FunHook player_fpgun_render_ir_hook{ }, }; +static FunHook player_fpgun_render_for_rail_gun_hook{ + 0x004ADC60, + [](rf::Player* player) { + if (player->cam) { + rf::Player* target = player->cam->player; + // render_to_dynamic_textures sets drawing_entity_bmp on local_player, + // but the function stores param_1 in a global and entity render functions + // read drawing_entity_bmp from there. When spectating, we redirect to the + // spectate target, so we must propagate the flag. + bool propagate = (target != player); + if (propagate) { + target->fpgun_data.drawing_entity_bmp = player->fpgun_data.drawing_entity_bmp; + } + player_fpgun_render_for_rail_gun_hook.call_target(target); + if (propagate) { + target->fpgun_data.drawing_entity_bmp = false; + } + } + }, +}; + static CodeInjection player_fpgun_play_anim_injection{ 0x004A947B, [](auto& regs) { @@ -208,8 +229,13 @@ void player_fpgun_on_player_death(rf::Player* pp) CodeInjection railgun_scanner_start_render_to_texture{ 0x004ADD0A, [](auto& regs) { - rf::Player* player = regs.ebx; - gr_set_render_target(player->ir_data.ir_bitmap_handle); + // Always render into local_player's bitmap. The HUD overlay in gameplay_render_frame + // reads from local_player->ir_data.ir_bitmap_handle, so that's where the content must go. + // In normal play EBX == local_player so this is equivalent. In spectate, EBX is the + // spectate target (redirected by player_fpgun_render_for_rail_gun_hook) but the HUD + // still reads from local_player. + rf::Player* target = rf::local_player ? rf::local_player : static_cast(regs.ebx); + gr_set_render_target(target->ir_data.ir_bitmap_handle); }, }; @@ -327,6 +353,10 @@ void player_fpgun_do_patch() // Render IR for player that is currently being shown by camera - needed for spectate mode player_fpgun_render_ir_hook.install(); + + // Render rail gun scanner for spectated player + player_fpgun_render_for_rail_gun_hook.install(); + AsmWriter(0x004ADCB5).nop(6); // player_fpgun_render_for_rail_gun - remove local_player check #endif // SPECTATE_MODE_SHOW_WEAPON // Update fpgun 3D sounds positions diff --git a/game_patch/rf/weapon.h b/game_patch/rf/weapon.h index a531b0c3..2924ee5b 100644 --- a/game_patch/rf/weapon.h +++ b/game_patch/rf/weapon.h @@ -291,6 +291,7 @@ namespace rf static auto& weapon_is_on_off_weapon = addr_as_ref(0x004C8350); static auto& weapon_is_semi_automatic = addr_as_ref(0x004C92C0); static auto& weapon_is_melee = addr_as_ref(0x004C91B0); + static auto& weapon_has_scanner = addr_as_ref(0x004C8AE0); static auto& weapon_uses_clip = addr_as_ref(0x004C86E0); static auto& weapon_get_fire_wait_ms = addr_as_ref(0x004C8710); static auto& weapon_restore_mesh = addr_as_ref(0x004C8140); From 48ce2c0623c2482946ec3e17dac38be50400f72d Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 22 Feb 2026 22:01:13 -0500 Subject: [PATCH 2/2] changelog --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 94f7fb9a..31529b49 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,7 @@ Version 1.3.0 (Bakeapple): Not yet released [@nickalreadyinuse](https://github.com/nickalreadyinuse) - Optimize network performance for `af_obj_update` packets and bot decommission logic - Sync animation state for crouched players in first person spectate view +- Sync rail scanner in first person spectate Version 1.2.2 (Willow): Released Jan-04-2026 --------------------------------