Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------
Expand Down
132 changes: 132 additions & 0 deletions game_patch/hud/multi_spectate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <common/config/BuildConfig.h>
#include <xlog/xlog.h>
#include <patch_common/CallHook.h>
#include <patch_common/CodeInjection.h>
#include <patch_common/FunHook.h>
#include <patch_common/AsmWriter.h>
#include <shlwapi.h>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void(rf::Entity*, uint8_t*, bool)> 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)
Expand All @@ -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);
}
Expand All @@ -449,12 +545,35 @@ CallHook<float(rf::Player*)> 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<void()> 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()
Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions game_patch/misc/player_fpgun.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ static FunHook<void(rf::Player*)> player_fpgun_render_ir_hook{
},
};

static FunHook<void(rf::Player*)> 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) {
Expand Down Expand Up @@ -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<rf::Player*>(regs.ebx);
gr_set_render_target(target->ir_data.ir_bitmap_handle);
},
};

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions game_patch/rf/weapon.h
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ namespace rf
static auto& weapon_is_on_off_weapon = addr_as_ref<bool(int weapon_type, bool alt_fire)>(0x004C8350);
static auto& weapon_is_semi_automatic = addr_as_ref<bool(int weapon_type)>(0x004C92C0);
static auto& weapon_is_melee = addr_as_ref<bool(int weapon_type)>(0x004C91B0);
static auto& weapon_has_scanner = addr_as_ref<bool(int weapon_type)>(0x004C8AE0);
static auto& weapon_uses_clip = addr_as_ref<bool(int weapon_type)>(0x004C86E0);
static auto& weapon_get_fire_wait_ms = addr_as_ref<int __cdecl(int weapon_type, bool alt_fire)>(0x004C8710);
static auto& weapon_restore_mesh = addr_as_ref<void(Weapon *wp, const char *mesh_filename)>(0x004C8140);
Expand Down
Loading