From 63843d6aba617544584fc3e1f048709b9b914a48 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 11 Mar 2026 01:58:19 -0230 Subject: [PATCH 1/6] init --- editor_patch/level.cpp | 11 ++ editor_patch/level.h | 16 +- editor_patch/resources.h | 2 + editor_patch/resources.rc | 6 +- game_patch/CMakeLists.txt | 2 + game_patch/graphics/gr.cpp | 1 + game_patch/hud/hud_weapons.cpp | 8 + game_patch/misc/camera.cpp | 7 + game_patch/misc/level.cpp | 2 + game_patch/misc/level.h | 10 + game_patch/misc/misc.cpp | 2 + game_patch/misc/side_scroller.cpp | 318 ++++++++++++++++++++++++++++++ game_patch/misc/side_scroller.h | 8 + 13 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 game_patch/misc/side_scroller.cpp create mode 100644 game_patch/misc/side_scroller.h diff --git a/editor_patch/level.cpp b/editor_patch/level.cpp index ca3f0f0bc..456b97095 100644 --- a/editor_patch/level.cpp +++ b/editor_patch/level.cpp @@ -517,6 +517,12 @@ CodeInjection CLevelDialog_OnInitDialog_patch{ std::snprintf(buffer, sizeof(buffer), "%.3f", alpine_level_props.static_mesh_ambient_light_modifier); SetDlgItemTextA(hdlg, IDC_MESH_AMBIENT_LIGHT_MODIFIER, buffer); CheckDlgButton(hdlg, IDC_RF2_STYLE_GEOMOD, alpine_level_props.rf2_style_geomod ? BST_CHECKED : BST_UNCHECKED); + + // Populate game style dropdown + HWND combo = GetDlgItem(hdlg, IDC_GAME_STYLE_COMBO); + SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("Default")); + SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("Side-scroller")); + SendMessageA(combo, CB_SETCURSEL, alpine_level_props.game_style, 0); }, }; @@ -541,6 +547,11 @@ CodeInjection CLevelDialog_OnOK_patch{ alpine_level_props.static_mesh_ambient_light_modifier = modifier; } alpine_level_props.rf2_style_geomod = IsDlgButtonChecked(hdlg, IDC_RF2_STYLE_GEOMOD) == BST_CHECKED; + + int sel = static_cast(SendDlgItemMessageA(hdlg, IDC_GAME_STYLE_COMBO, CB_GETCURSEL, 0, 0)); + if (sel != CB_ERR) { + alpine_level_props.game_style = static_cast(sel); + } }, }; diff --git a/editor_patch/level.h b/editor_patch/level.h index 5268dc26b..3f8b49b41 100644 --- a/editor_patch/level.h +++ b/editor_patch/level.h @@ -331,13 +331,16 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; + // v5 + uint8_t game_style = 0; // 0 = Default, 1 = Side-scroller + // v4 (continued - vectors kept after scalars for clean versioning) std::vector geoable_brush_uids; std::vector geoable_room_uids; // computed at save time, parallel to geoable_brush_uids std::vector breakable_brush_uids; std::vector breakable_room_uids; // computed at save time, parallel to breakable_brush_uids std::vector breakable_materials; // material type per entry - static constexpr std::uint32_t current_alpine_chunk_version = 4u; + static constexpr std::uint32_t current_alpine_chunk_version = 5u; // defaults for existing levels, overwritten for maps with these fields in their alpine level props chunk // relevant for maps without alpine level props and maps with older alpine level props versions @@ -350,6 +353,7 @@ struct AlpineLevelProperties override_static_mesh_ambient_light_modifier = false; static_mesh_ambient_light_modifier = 2.0f; rf2_style_geomod = false; + game_style = 0; geoable_brush_uids.clear(); geoable_room_uids.clear(); breakable_brush_uids.clear(); @@ -389,6 +393,8 @@ struct AlpineLevelProperties uint8_t mat = (i < breakable_materials.size()) ? breakable_materials[i] : 0; file.write(mat); } + // v5 + file.write(game_style); } void Deserialize(rf::File& file, std::size_t chunk_len) @@ -515,6 +521,14 @@ struct AlpineLevelProperties breakable_materials[i] = mat; } } + + if (version >= 5) { + std::uint8_t u8 = 0; + if (!read_bytes(&u8, sizeof(u8))) + return; + game_style = u8; + xlog::debug("[AlpineLevelProps] game_style {}", game_style); + } } }; diff --git a/editor_patch/resources.h b/editor_patch/resources.h index 785fdbe21..f62e906b2 100644 --- a/editor_patch/resources.h +++ b/editor_patch/resources.h @@ -25,6 +25,8 @@ #define IDC_MATERIAL_COMBO 2008 #define IDC_MATERIAL_LABEL 2009 #define IDC_NO_DEBRIS 2010 +#define IDC_GAME_STYLE_LABEL 2011 +#define IDC_GAME_STYLE_COMBO 2012 #define IDD_BRUSH_MODE_PANEL 205 #define IDD_BRUSH_PROPERTIES 270 diff --git a/editor_patch/resources.rc b/editor_patch/resources.rc index d21e2b568..4ba99821c 100644 --- a/editor_patch/resources.rc +++ b/editor_patch/resources.rc @@ -100,7 +100,7 @@ FONT 8, "MS Sans Serif", 0, 0, 1 } LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT -IDD_LEVEL_PROPERTIES DIALOGEX 0, 0, 298, 362 +IDD_LEVEL_PROPERTIES DIALOGEX 0, 0, 298, 382 STYLE DS_MODALFRAME | DS_SETFONT | WS_CAPTION | WS_VISIBLE | WS_POPUP | WS_SYSMENU CAPTION "Level Properties" FONT 8, "MS Sans Serif" @@ -144,7 +144,7 @@ FONT 8, "MS Sans Serif" DEFPUSHBUTTON "OK", IDOK, 241, 7, 50, 14 PUSHBUTTON "Cancel", IDCANCEL, 241, 24, 50, 14 - GROUPBOX "Advanced Options", -1, 7, 268, 284, 90 + GROUPBOX "Advanced Options", -1, 7, 268, 284, 110 AUTOCHECKBOX "Legacy Cyclic_Timer events", IDC_LEGACY_CYCLIC_TIMERS, 13, 280, 175, 10 AUTOCHECKBOX "Legacy movers", IDC_LEGACY_MOVERS, 13, 292, 175, 10 AUTOCHECKBOX "Player starts with headlamp", IDC_STARTS_WITH_HEADLAMP, 13, 304, 175, 10 @@ -152,6 +152,8 @@ FONT 8, "MS Sans Serif" LTEXT "New scale value:", -1, 30, 331, 120, 8 EDITTEXT IDC_MESH_AMBIENT_LIGHT_MODIFIER, 90, 328, 30, 14, ES_AUTOHSCROLL AUTOCHECKBOX "Brush-based geomod (RF2-style)", IDC_RF2_STYLE_GEOMOD, 13, 344, 200, 10 + LTEXT "Game Style:", IDC_GAME_STYLE_LABEL, 13, 360, 45, 8 + COMBOBOX IDC_GAME_STYLE_COMBO, 60, 358, 100, 60, WS_TABSTOP | CBS_DROPDOWNLIST } LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 4a3ceb778..f6ac36abe 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -189,6 +189,8 @@ set(SRCS misc/destruction.cpp misc/destruction.h misc/camera.cpp + misc/side_scroller.cpp + misc/side_scroller.h misc/ui.cpp misc/game.cpp misc/level.cpp diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index e5514bffb..452857925 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -273,6 +273,7 @@ FunHook gameplay_render_frame_hook{ } multi_spectate_sync_crouch_anim(); + gameplay_render_frame_hook.call_target(pp, flags); }, }; diff --git a/game_patch/hud/hud_weapons.cpp b/game_patch/hud/hud_weapons.cpp index ed3a83ab3..68859b5e6 100644 --- a/game_patch/hud/hud_weapons.cpp +++ b/game_patch/hud/hud_weapons.cpp @@ -14,6 +14,7 @@ #include "../misc/alpine_settings.h" #include "../misc/misc.h" #include "hud_internal.h" +#include "../misc/side_scroller.h" float g_hud_ammo_scale = 1.0f; bool g_displaying_custom_reticle = false; @@ -52,6 +53,13 @@ CallHook render_reticle_gr_bitmap_hook{ x = static_cast((x - clip_w / 2.0F) * scale + clip_w / 2.0F); y = static_cast((y - clip_h / 2.0F) * scale + clip_h / 2.0F); + // In side-scroller mode, offset reticle to the cursor position + int reticle_off_x = 0, reticle_off_y = 0; + if (side_scroller_get_reticle_offset(reticle_off_x, reticle_off_y)) { + x += reticle_off_x; + y += reticle_off_y; + } + hud_scaled_bitmap(bm_handle, x, y, scale, mode); }, }; diff --git a/game_patch/misc/camera.cpp b/game_patch/misc/camera.cpp index 72fdcf736..8ba5f6311 100644 --- a/game_patch/misc/camera.cpp +++ b/game_patch/misc/camera.cpp @@ -16,6 +16,7 @@ #include "../rf/os/frametime.h" #include "player.h" #include "../hud/multi_spectate.h" +#include "side_scroller.h" constexpr auto screen_shake_fps = 150.0f; static float g_camera_shake_factor = 0.6f; @@ -85,6 +86,12 @@ CallHook camera_enter_first_person_level_post{ 0x004A43AB }, [](rf::Camera* camera) { + // Side-scroller mode always uses third-person camera + if (is_side_scroller_mode()) { + rf::camera_enter_third_person(camera); + return; + } + const bool default_third_person = g_alpine_options_config.is_option_loaded(AlpineOptionID::DefaultThirdPerson) && std::get(g_alpine_options_config.options.at(AlpineOptionID::DefaultThirdPerson)); diff --git a/game_patch/misc/level.cpp b/game_patch/misc/level.cpp index fe79243ec..37e2a99a9 100644 --- a/game_patch/misc/level.cpp +++ b/game_patch/misc/level.cpp @@ -12,6 +12,7 @@ #include "level.h" #include "misc.h" #include "player.h" +#include "side_scroller.h" #include "../multi/server.h" CodeInjection level_read_data_check_restore_status_patch{ @@ -102,6 +103,7 @@ CodeInjection level_load_init_patch{ AlpineLevelProperties::instance() = {}; DashLevelProps::instance() = {}; set_headlamp_toggle_enabled(AlpineLevelProperties::instance().starts_with_headlamp); + side_scroller_on_level_load(); }, }; diff --git a/game_patch/misc/level.h b/game_patch/misc/level.h index 8f1ceee5b..857dfc0b8 100644 --- a/game_patch/misc/level.h +++ b/game_patch/misc/level.h @@ -24,6 +24,8 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; + // v5 + uint8_t game_style = 0; // 0 = Default, 1 = Side-scroller // std::vector geoable_brush_uids; // unnecessary in game std::vector geoable_room_uids; // std::vector breakable_brush_uids; // unnecessary in game @@ -163,6 +165,14 @@ struct AlpineLevelProperties } xlog::trace("[AlpineLevelProps] GAME: total breakable entries loaded={}", bcount); } + + if (version >= 5) { + std::uint8_t u8 = 0; + if (!read_bytes(&u8, sizeof(u8))) + return; + game_style = u8; + xlog::debug("[AlpineLevelProps] game_style {}", game_style); + } } }; diff --git a/game_patch/misc/misc.cpp b/game_patch/misc/misc.cpp index 863072ab3..a357d1e86 100644 --- a/game_patch/misc/misc.cpp +++ b/game_patch/misc/misc.cpp @@ -44,6 +44,7 @@ void player_fpgun_do_patch(); void g_solid_do_patch(); void destruction_do_patch(); void camera_do_patch(); +void side_scroller_do_patch(); void ui_apply_patch(); void game_apply_patch(); void character_apply_patch(); @@ -666,6 +667,7 @@ void misc_init() destruction_do_patch(); register_sound_commands(); camera_do_patch(); + side_scroller_do_patch(); ui_apply_patch(); game_apply_patch(); character_apply_patch(); diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp new file mode 100644 index 000000000..a931f4eeb --- /dev/null +++ b/game_patch/misc/side_scroller.cpp @@ -0,0 +1,318 @@ +#include +#include +#include +#include +#include +#include +#include "side_scroller.h" +#include "level.h" +#include "../rf/player/player.h" +#include "../rf/player/camera.h" +#include "../rf/entity.h" +#include "../rf/input.h" +#include "../rf/gr/gr.h" +#include "../rf/os/frametime.h" + +// Side-scroller camera orientation (fixed): +// Camera is at player.x + 30, looking in -X direction +// rvec = (0, 0, 1) -> screen right is world +Z +// uvec = (0, 1, 0) -> screen up is world +Y +// fvec = (-1, 0, 0) -> camera looks in world -X +static constexpr float camera_offset_x = 12.0f; + +static const rf::Matrix3 g_side_scroller_orient = { + {0.0f, 0.0f, 1.0f}, // rvec: right is +Z + {0.0f, 1.0f, 0.0f}, // uvec: up is +Y + {-1.0f, 0.0f, 0.0f}, // fvec: forward is -X +}; + +// Cached side-scroller camera position (updated each frame, read by getter hooks) +static rf::Vector3 g_ss_camera_pos = {0.0f, 0.0f, 0.0f}; +static bool g_ss_camera_active = false; + +// Reticle position in screen pixels (from top-left) +static float g_reticle_x = 0.0f; +static float g_reticle_y = 0.0f; +static bool g_reticle_initialized = false; + +// Mouse deltas captured before stock processing +static int g_stolen_mouse_dx = 0; +static int g_stolen_mouse_dy = 0; + +// Raw mouse delta globals (mouse_get_delta only copies, doesn't clear) +static auto& g_mouse_dx = addr_as_ref(0x01885464); +static auto& g_mouse_dy = addr_as_ref(0x01885468); + +// True when reticle is behind the player (left of screen center), flips body to face -Z +static bool g_ss_aiming_backward = false; + +bool is_side_scroller_mode() +{ + return AlpineLevelProperties::instance().game_style == 1; +} + +void side_scroller_on_level_load() +{ + g_reticle_initialized = false; + g_reticle_x = 0.0f; + g_reticle_y = 0.0f; + g_stolen_mouse_dx = 0; + g_stolen_mouse_dy = 0; + g_ss_camera_active = false; + g_ss_aiming_backward = false; +} + +bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y) +{ + if (!is_side_scroller_mode()) { + return false; + } + int half_w = rf::gr::clip_width() / 2; + int half_h = rf::gr::clip_height() / 2; + offset_x = static_cast(g_reticle_x) - half_w; + offset_y = static_cast(g_reticle_y) - half_h; + return true; +} + +static void steal_mouse_deltas() +{ + // Read mouse deltas, then zero the globals so the stock code can't use them + // for camera rotation. mouse_get_delta only copies, it doesn't clear. + int dz = 0; + rf::mouse_get_delta(g_stolen_mouse_dx, g_stolen_mouse_dy, dz); + g_mouse_dx = 0; + g_mouse_dy = 0; +} + +static void update_reticle_from_stolen_mouse() +{ + int screen_w = rf::gr::clip_width(); + int screen_h = rf::gr::clip_height(); + + if (!g_reticle_initialized) { + g_reticle_x = static_cast(screen_w) / 2.0f; + g_reticle_y = static_cast(screen_h) / 2.0f; + g_reticle_initialized = true; + } + + // Apply mouse sensitivity from player controls + float sensitivity = 1.0f; + if (rf::local_player) { + sensitivity = rf::local_player->settings.controls.mouse_sensitivity; + } + + g_reticle_x += static_cast(g_stolen_mouse_dx) * sensitivity; + g_reticle_y += static_cast(g_stolen_mouse_dy) * sensitivity; + + // Clamp to screen bounds + g_reticle_x = std::clamp(g_reticle_x, 0.0f, static_cast(screen_w - 1)); + g_reticle_y = std::clamp(g_reticle_y, 0.0f, static_cast(screen_h - 1)); +} + +static void update_side_scroller_camera_pos(rf::Player* player) +{ + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + g_ss_camera_active = false; + return; + } + + // Compute desired camera position: 30m to the right (+X) of the player, at eye height. + // Using eye height ensures screen center aligns with the weapon fire origin. + float eye_y = 0.0f; + if (entity->info) { + eye_y = entity->info->local_eye_offset.y; + } + g_ss_camera_pos.x = entity->pos.x + camera_offset_x; + g_ss_camera_pos.y = entity->pos.y + eye_y; + g_ss_camera_pos.z = entity->pos.z; + g_ss_camera_active = true; +} + +static void apply_side_scroller_aim(rf::Player* player) +{ + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + return; + } + + int screen_w = rf::gr::clip_width(); + int screen_h = rf::gr::clip_height(); + + // fov_h is in degrees — convert to radians for trig functions + constexpr float deg2rad = 3.14159265f / 180.0f; + float fov_h = player->viewport.fov_h * deg2rad; + float aspect = static_cast(screen_w) / static_cast(screen_h); + float fov_v = 2.0f * std::atan(std::tan(fov_h * 0.5f) / aspect); + + // Normalized screen coordinates: -1 to +1 from center + float half_w = static_cast(screen_w) * 0.5f; + float half_h = static_cast(screen_h) * 0.5f; + float norm_x = (g_reticle_x - half_w) / half_w; + float norm_y = (g_reticle_y - half_h) / half_h; + + // Unproject reticle to world-space aim point at the player's X plane. + // Camera is at (player.x + 30, player.y, player.z) looking in -X. + // At the player's X plane (30m along view), the aim point offset from camera center is: + float dz = std::tan(norm_x * fov_h * 0.5f) * camera_offset_x; // screen right = +Z + float dy = -std::tan(norm_y * fov_v * 0.5f) * camera_offset_x; // screen up = +Y + + // Camera is now at eye height, so dy is already relative to the eye/weapon origin. + // Direction from eye to aim target is simply (0, dy, dz). + rf::Vector3 dir; + dir.x = 0.0f; // force zero — aim is always in the YZ plane + dir.y = dy; + dir.z = dz; + + float dist = std::sqrt(dir.y * dir.y + dir.z * dir.z); + if (dist < 0.001f) { + return; + } + dir.y /= dist; + dir.z /= dist; + + // Build eye_orient matrix from aim direction. + // fvec = aim direction in YZ plane + // rvec = +X or -X (mirror when aiming left to keep character upright) + // uvec = fvec × rvec + rf::Matrix3 aim_orient; + aim_orient.fvec = dir; + + if (dir.z >= 0.0f) { + aim_orient.rvec = {1.0f, 0.0f, 0.0f}; + } + else { + aim_orient.rvec = {-1.0f, 0.0f, 0.0f}; + } + + // uvec = fvec × rvec + aim_orient.uvec.x = aim_orient.fvec.y * aim_orient.rvec.z - aim_orient.fvec.z * aim_orient.rvec.y; + aim_orient.uvec.y = aim_orient.fvec.z * aim_orient.rvec.x - aim_orient.fvec.x * aim_orient.rvec.z; + aim_orient.uvec.z = aim_orient.fvec.x * aim_orient.rvec.y - aim_orient.fvec.y * aim_orient.rvec.x; + + // Set eye_orient directly (controls weapon aim and visual direction). + // Do NOT touch entity->orient (body orientation stays facing +Z). + entity->eye_orient = aim_orient; + + // Zero all phb so the body stays facing +Z for movement. + entity->control_data.phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.eye_phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.delta_phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.delta_eye_phb = {0.0f, 0.0f, 0.0f}; +} + +// Hook the player control input reading function (FUN_004a6060). This runs in the game loop +// right after keyboard/mouse input is read into ci, BEFORE any game tick processing. +// This is the correct place to remap controls since ci.move is consumed later during +// player_do_frame → FUN_004a77a0 (physics processing). +FunHook side_scroller_control_read_hook{ + 0x004A6060, + [](rf::Player* player) { + side_scroller_control_read_hook.call_target(player); + + if (!is_side_scroller_mode() || player != rf::local_player) return; + + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) return; + + auto& ci = entity->ai.ci; + + // A/D (strafe = ci.move.x) controls left/right movement on screen. + // Remap to ci.move.z (forward/back along body's facing direction). + // Body orient already handles direction — no negation needed. + float original_strafe = ci.move.x; // A/D + ci.move.z = original_strafe; + ci.move.x = 0.0f; // no toward/away-from-camera movement + // ci.move.y preserved (jump/crouch) + + // Suppress mouse look rotation (camera is fixed) + ci.mouse_dh = 0.0f; + ci.mouse_dp = 0.0f; + ci.rot = {0.0f, 0.0f, 0.0f}; + }, +}; + +// Hook player_do_frame to steal mouse input and remap controls each frame +FunHook side_scroller_player_do_frame_hook{ + 0x004A2700, + [](rf::Player* player) { + bool is_local_ss = is_side_scroller_mode() && player == rf::local_player; + + if (is_local_ss) { + // BEFORE stock processing: steal mouse deltas and zero the globals + // so the stock code can't use them for camera rotation. + steal_mouse_deltas(); + update_reticle_from_stolen_mouse(); + + // Determine if aiming backward (reticle left of screen center = -Z) + float half_w = static_cast(rf::gr::clip_width()) * 0.5f; + g_ss_aiming_backward = (g_reticle_x < half_w); + + // Set body orient BEFORE stock processing so movement uses correct direction. + // Facing +Z: fvec=(0,0,1), rvec=(1,0,0), uvec=(0,1,0) + // Facing -Z: fvec=(0,0,-1), rvec=(-1,0,0), uvec=(0,1,0) + rf::Entity* pre_entity = rf::entity_from_handle(player->entity_handle); + if (pre_entity) { + if (g_ss_aiming_backward) { + pre_entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + pre_entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + pre_entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + else { + pre_entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + pre_entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + pre_entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + } + } + + side_scroller_player_do_frame_hook.call_target(player); + + if (!is_local_ss) { + return; + } + + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + return; + } + + // AFTER stock processing: set orient directly for aim direction, + // zero all phb/deltas so movement stays correct + update_side_scroller_camera_pos(player); + apply_side_scroller_aim(player); + + // Re-set body orient in case stock code modified it via phb + if (g_ss_aiming_backward) { + entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + else { + entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + }, +}; + +// Hook gr_setup_3d call inside gameplay_render_frame to override viewer position/orientation. +// This intercepts at the final call that sets up the D3D view matrix, bypassing all +// intermediate third-person camera adjustments that were overwriting our values. +CallHook side_scroller_gr_setup_3d_hook{ + 0x00431D14, + [](rf::Matrix3& viewer_orient, rf::Vector3& viewer_pos, float horizontal_fov, bool zbuffer_flag, bool z_scale) { + if (g_ss_camera_active && is_side_scroller_mode()) { + viewer_pos = g_ss_camera_pos; + viewer_orient = g_side_scroller_orient; + } + side_scroller_gr_setup_3d_hook.call_target(viewer_orient, viewer_pos, horizontal_fov, zbuffer_flag, z_scale); + }, +}; + +void side_scroller_do_patch() +{ + side_scroller_player_do_frame_hook.install(); + side_scroller_control_read_hook.install(); + side_scroller_gr_setup_3d_hook.install(); +} diff --git a/game_patch/misc/side_scroller.h b/game_patch/misc/side_scroller.h new file mode 100644 index 000000000..5a0553039 --- /dev/null +++ b/game_patch/misc/side_scroller.h @@ -0,0 +1,8 @@ +#pragma once + +void side_scroller_do_patch(); +void side_scroller_on_level_load(); +bool is_side_scroller_mode(); + +// Called by hud_weapons to get reticle offset for side-scroller mode +bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y); From 224fc48c700289292fe1edb5bc60bfd654105fbb Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 11 Mar 2026 14:07:30 -0230 Subject: [PATCH 2/6] fix aim angle --- game_patch/misc/side_scroller.cpp | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp index a931f4eeb..49a387bc2 100644 --- a/game_patch/misc/side_scroller.cpp +++ b/game_patch/misc/side_scroller.cpp @@ -46,6 +46,11 @@ static auto& g_mouse_dy = addr_as_ref(0x01885468); // True when reticle is behind the player (left of screen center), flips body to face -Z static bool g_ss_aiming_backward = false; +// Debug: aim line from eye position along aim direction +static rf::Vector3 g_dbg_aim_start = {0, 0, 0}; +static rf::Vector3 g_dbg_aim_end = {0, 0, 0}; +static bool g_dbg_aim_valid = false; + bool is_side_scroller_mode() { return AlpineLevelProperties::instance().game_style == 1; @@ -152,10 +157,10 @@ static void apply_side_scroller_aim(rf::Player* player) float norm_y = (g_reticle_y - half_h) / half_h; // Unproject reticle to world-space aim point at the player's X plane. - // Camera is at (player.x + 30, player.y, player.z) looking in -X. - // At the player's X plane (30m along view), the aim point offset from camera center is: - float dz = std::tan(norm_x * fov_h * 0.5f) * camera_offset_x; // screen right = +Z - float dy = -std::tan(norm_y * fov_v * 0.5f) * camera_offset_x; // screen up = +Y + // Camera is at (player.x + offset, player.y, player.z) looking in -X. + // Standard perspective unproject: scale normalized coords by tan(half-fov) * distance. + float dz = norm_x * std::tan(fov_h * 0.5f) * camera_offset_x; // screen right = +Z + float dy = -norm_y * std::tan(fov_v * 0.5f) * camera_offset_x; // screen up = +Y // Camera is now at eye height, so dy is already relative to the eye/weapon origin. // Direction from eye to aim target is simply (0, dy, dz). @@ -194,6 +199,19 @@ static void apply_side_scroller_aim(rf::Player* player) // Do NOT touch entity->orient (body orientation stays facing +Z). entity->eye_orient = aim_orient; + // Store debug aim line: from eye position, 50m along aim direction + float eye_offset_y = 0.0f; + if (entity->info) { + eye_offset_y = entity->info->local_eye_offset.y; + } + g_dbg_aim_start.x = entity->pos.x; + g_dbg_aim_start.y = entity->pos.y + eye_offset_y; + g_dbg_aim_start.z = entity->pos.z; + g_dbg_aim_end.x = g_dbg_aim_start.x + dir.x * 50.0f; + g_dbg_aim_end.y = g_dbg_aim_start.y + dir.y * 50.0f; + g_dbg_aim_end.z = g_dbg_aim_start.z + dir.z * 50.0f; + g_dbg_aim_valid = true; + // Zero all phb so the body stays facing +Z for movement. entity->control_data.phb = {0.0f, 0.0f, 0.0f}; entity->control_data.eye_phb = {0.0f, 0.0f, 0.0f}; @@ -307,6 +325,14 @@ CallHook side_scroller_gr_s viewer_orient = g_side_scroller_orient; } side_scroller_gr_setup_3d_hook.call_target(viewer_orient, viewer_pos, horizontal_fov, zbuffer_flag, z_scale); + + // Draw debug aim line (red) + if (g_dbg_aim_valid && is_side_scroller_mode()) { + rf::gr::line_arrow( + g_dbg_aim_start.x, g_dbg_aim_start.y, g_dbg_aim_start.z, + g_dbg_aim_end.x, g_dbg_aim_end.y, g_dbg_aim_end.z, + 255, 0, 0); + } }, }; From 4e8cc0bbefd9b57aaac3d4bbc13169835c0abfa1 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 11 Mar 2026 15:14:45 -0230 Subject: [PATCH 3/6] prevent X axis movement by player --- game_patch/misc/side_scroller.cpp | 53 ++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp index 49a387bc2..bebbf17bc 100644 --- a/game_patch/misc/side_scroller.cpp +++ b/game_patch/misc/side_scroller.cpp @@ -14,7 +14,7 @@ #include "../rf/os/frametime.h" // Side-scroller camera orientation (fixed): -// Camera is at player.x + 30, looking in -X direction +// Camera is at player.x + offset, looking in -X direction // rvec = (0, 0, 1) -> screen right is world +Z // uvec = (0, 1, 0) -> screen up is world +Y // fvec = (-1, 0, 0) -> camera looks in world -X @@ -46,6 +46,9 @@ static auto& g_mouse_dy = addr_as_ref(0x01885468); // True when reticle is behind the player (left of screen center), flips body to face -Z static bool g_ss_aiming_backward = false; +// X-axis lock: player is always locked to X=0 in side-scroller maps +static constexpr float g_ss_locked_x = 0.0f; + // Debug: aim line from eye position along aim direction static rf::Vector3 g_dbg_aim_start = {0, 0, 0}; static rf::Vector3 g_dbg_aim_end = {0, 0, 0}; @@ -114,6 +117,42 @@ static void update_reticle_from_stolen_mouse() g_reticle_y = std::clamp(g_reticle_y, 0.0f, static_cast(screen_h - 1)); } +// Pre-physics: zero X dynamics so input doesn't drive X movement, +// but leave positions alone so collision detection can work naturally. +static void pre_lock_entity_x(rf::Entity* entity) +{ + entity->p_data.vel.x = 0.0f; + entity->p_data.force.x = 0.0f; +} + +// Post-physics: measure X displacement from collision response, +// convert it to a Z impulse (pushing player back out of angled surfaces), +// then snap everything to X=0. +static void post_lock_entity_x(rf::Entity* entity) +{ + // Capture how much collision pushed us in X + float dx = entity->pos.x - g_ss_locked_x; + + // Apply as Z correction: collision wanted to push us out in X, + // convert that to pushing back in Z (opposite movement direction) + float abs_dx = std::abs(dx); + if (abs_dx > 0.0001f) { + float sign = (entity->p_data.vel.z >= 0.0f) ? -1.0f : 1.0f; + entity->pos.z += sign * abs_dx; + entity->p_data.pos.z += sign * abs_dx; + entity->p_data.next_pos.z += sign * abs_dx; + } + + // Snap all X state to locked value + entity->pos.x = g_ss_locked_x; + entity->last_pos.x = g_ss_locked_x; + entity->correct_pos.x = g_ss_locked_x; + entity->p_data.pos.x = g_ss_locked_x; + entity->p_data.next_pos.x = g_ss_locked_x; + entity->p_data.vel.x = 0.0f; + entity->p_data.force.x = 0.0f; +} + static void update_side_scroller_camera_pos(rf::Player* player) { rf::Entity* entity = rf::entity_from_handle(player->entity_handle); @@ -122,7 +161,7 @@ static void update_side_scroller_camera_pos(rf::Player* player) return; } - // Compute desired camera position: 30m to the right (+X) of the player, at eye height. + // Compute desired camera position: offset to the right (+X) of the player, at eye height. // Using eye height ensures screen center aligns with the weapon fire origin. float eye_y = 0.0f; if (entity->info) { @@ -271,6 +310,10 @@ FunHook side_scroller_player_do_frame_hook{ // Facing -Z: fvec=(0,0,-1), rvec=(-1,0,0), uvec=(0,1,0) rf::Entity* pre_entity = rf::entity_from_handle(player->entity_handle); if (pre_entity) { + // Zero X dynamics so input doesn't drive X movement, + // but let collision response happen naturally + pre_lock_entity_x(pre_entity); + if (g_ss_aiming_backward) { pre_entity->orient.fvec = {0.0f, 0.0f, -1.0f}; pre_entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; @@ -295,8 +338,10 @@ FunHook side_scroller_player_do_frame_hook{ return; } - // AFTER stock processing: set orient directly for aim direction, - // zero all phb/deltas so movement stays correct + // AFTER stock processing: convert X collision displacement to Z, + // then lock X to 0 + post_lock_entity_x(entity); + update_side_scroller_camera_pos(player); apply_side_scroller_aim(player); From 72d320ec22051d1e601fe50f0594e9264a659ace Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 12 Mar 2026 11:53:57 -0230 Subject: [PATCH 4/6] add alpha windows for detail and mover geometry --- .../graphics/d3d11/gr_d3d11_context.cpp | 24 ++ game_patch/graphics/d3d11/gr_d3d11_context.h | 18 +- game_patch/graphics/d3d11/gr_d3d11_solid.cpp | 80 +++-- game_patch/graphics/d3d11/gr_d3d11_solid.h | 6 +- game_patch/misc/side_scroller.cpp | 276 +++++++++++++++--- game_patch/misc/side_scroller.h | 13 + game_patch/rf/vmesh.h | 2 + resources/shaders/standard_ps.hlsl | 32 ++ 8 files changed, 395 insertions(+), 56 deletions(-) diff --git a/game_patch/graphics/d3d11/gr_d3d11_context.cpp b/game_patch/graphics/d3d11/gr_d3d11_context.cpp index b42388b15..a69502ee2 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_context.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_context.cpp @@ -2,6 +2,7 @@ #include #include "../../rf/gr/gr_light.h" #include "../../rf/os/frametime.h" +#include "../../misc/side_scroller.h" #include "gr_d3d11.h" #include "gr_d3d11_context.h" #include "gr_d3d11_texture.h" @@ -249,6 +250,13 @@ namespace df::gr::d3d11 float disable_textures; std::array fog_color; float pad0; + // Side-scroller occlusion (dithered transparency) + std::array ss_player_pos; + float ss_fade_strength; + float ss_camera_x; + float ss_radius; + float ss_is_detail; + float ss_pad2; }; static_assert(sizeof(RenderModeBufferData) % 16 == 0); @@ -289,6 +297,22 @@ namespace df::gr::d3d11 data.disable_textures = current_lightmap_only_ ? 1.0f : 0.0f; data.pad0 = 0.0f; + const auto& ss = get_ss_occlusion_params(); + if (ss.active) { + data.ss_player_pos = {ss.player_x, ss.player_y, ss.player_z}; + data.ss_fade_strength = ss.fade_strength; + data.ss_camera_x = ss.camera_x; + data.ss_radius = ss.radius; + } + else { + data.ss_player_pos = {0.0f, 0.0f, 0.0f}; + data.ss_fade_strength = 0.0f; + data.ss_camera_x = 0.0f; + data.ss_radius = 0.0f; + } + data.ss_is_detail = current_ss_is_detail_ ? 1.0f : 0.0f; + data.ss_pad2 = 0.0f; + D3D11_MAPPED_SUBRESOURCE mapped_subres; DF_GR_D3D11_CHECK_HR( device_context->Map(buffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped_subres) diff --git a/game_patch/graphics/d3d11/gr_d3d11_context.h b/game_patch/graphics/d3d11/gr_d3d11_context.h index 916f6bc70..228a9e4b5 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_context.h +++ b/game_patch/graphics/d3d11/gr_d3d11_context.h @@ -9,6 +9,7 @@ #include "gr_d3d11_texture.h" #include "gr_d3d11_state.h" #include "../../misc/alpine_settings.h" +#include "../../misc/side_scroller.h" #include "../../rf/gr/gr.h" namespace df::gr::d3d11 @@ -86,7 +87,8 @@ namespace df::gr::d3d11 bool alpha_test = mode.get_zbuffer_type() == gr::ZBUFFER_TYPE_FULL_ALPHA_TEST; bool fog_allowed = mode.get_fog_type() != gr::FOG_NOT_ALLOWED; int colorblind_mode = g_alpine_game_config.colorblind_mode; - if (force_update_ || current_alpha_test_ != alpha_test || current_fog_allowed_ != fog_allowed || current_color_ != color || current_colorblind_mode_ != colorblind_mode || current_lightmap_only_ != lightmap_only) { + bool ss_active = get_ss_occlusion_params().active; + if (force_update_ || ss_active || current_alpha_test_ != alpha_test || current_fog_allowed_ != fog_allowed || current_color_ != color || current_colorblind_mode_ != colorblind_mode || current_lightmap_only_ != lightmap_only) { current_alpha_test_ = alpha_test; current_fog_allowed_ = fog_allowed; current_color_ = color; @@ -109,6 +111,14 @@ namespace df::gr::d3d11 } } + void set_ss_is_detail(bool is_detail) + { + if (current_ss_is_detail_ != is_detail) { + current_ss_is_detail_ = is_detail; + force_update_ = true; + } + } + private: void update_buffer(ID3D11DeviceContext* device_context); @@ -119,6 +129,7 @@ namespace df::gr::d3d11 rf::Color current_color_{255, 255, 255}; int current_colorblind_mode_ = 0; bool current_lightmap_only_ = false; + bool current_ss_is_detail_ = false; }; class PerFrameBuffer @@ -256,6 +267,11 @@ namespace df::gr::d3d11 per_frame_buffer_.update(device_context_); } + void set_ss_is_detail(bool is_detail) + { + render_mode_cbuffer_.set_ss_is_detail(is_detail); + } + void fog_set() { render_mode_cbuffer_.handle_fog_change(); diff --git a/game_patch/graphics/d3d11/gr_d3d11_solid.cpp b/game_patch/graphics/d3d11/gr_d3d11_solid.cpp index f7f7cf9fd..349226773 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_solid.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_solid.cpp @@ -2,6 +2,7 @@ #undef NDEBUG #include +#include #include #include #include @@ -251,6 +252,8 @@ namespace df::gr::d3d11 void add_face(GFace* face, GSolid* solid); GRenderCache build(ID3D11Device* device); + void set_is_sky(bool is_sky) { is_sky_ = is_sky; } + int get_num_verts() const { return num_verts_; @@ -302,18 +305,10 @@ namespace df::gr::d3d11 for (GFace& face : room->face_list) { add_face(&face, solid); } - // Only iterate detail_rooms for non-detail rooms. Detail rooms should never - // have sub-detail rooms in Red Faction. After RF2-style geomod, the boolean - // engine may corrupt the detail_rooms VArray on detail rooms, causing infinite - // recursion if we iterate it unconditionally. - if (!room->is_detail) { - for (GRoom* detail_room : room->detail_rooms) { - if (detail_room->face_list.empty()) { - continue; // skip destroyed breakable detail rooms - } - add_room(detail_room, solid); - } - } + // Detail rooms are rendered separately via render_detail() which sets the + // ss_is_detail flag for side-scroller dithered transparency. Including them + // here would cause double-rendering and break that effect. Sky rooms also + // render their detail rooms explicitly in render_sky_room(). } static inline FaceRenderType determine_face_render_type(GFace* face) @@ -743,12 +738,14 @@ namespace df::gr::d3d11 return cache; } - void SolidRenderer::render_detail(rf::GSolid* solid, GRoom* room, bool alpha) + void SolidRenderer::render_detail(rf::GSolid* solid, GRoom* room, bool alpha, bool is_sky) { - GRenderCache* cache = get_or_create_detail_room_cache(solid, room); + GRenderCache* cache = get_or_create_detail_room_cache(solid, room, is_sky); if (!cache) return; FaceRenderType render_type = alpha ? FaceRenderType::alpha : FaceRenderType::opaque; + render_context_.set_ss_is_detail(true); cache->render(render_type, render_context_); + render_context_.set_ss_is_detail(false); } // Sentinel value stored in room->geo_cache to mark detail rooms that have been checked @@ -756,16 +753,41 @@ namespace df::gr::d3d11 // clear_cache() resets all geo_cache to nullptr, which properly clears this sentinel. static const auto k_empty_detail_sentinel = reinterpret_cast(uintptr_t(1)); - GRenderCache* SolidRenderer::get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room) + GRenderCache* SolidRenderer::get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room, bool is_sky) { + auto cache = reinterpret_cast(room->geo_cache); + + // Check if existing cache needs invalidation due to sky mode mismatch + // (e.g. pre-cached as normal but now needed for a dynamic sky room set via Set_Skybox) + bool cached_as_sky = sky_detail_rooms_.count(room) > 0; + if (is_sky != cached_as_sky && (cache || room->geo_cache == k_empty_detail_sentinel)) { + if (cache) { + // Remove the old cache object from detail_render_cache_ + auto it = std::find_if(detail_render_cache_.begin(), detail_render_cache_.end(), + [cache](const auto& ptr) { return ptr.get() == cache; }); + if (it != detail_render_cache_.end()) { + detail_render_cache_.erase(it); + } + } + room->geo_cache = nullptr; + cache = nullptr; + } + if (room->geo_cache == k_empty_detail_sentinel) { return nullptr; } - auto cache = reinterpret_cast(room->geo_cache); + if (!cache) { - xlog::debug("Creating render cache for detail room {} (faces: {})", - room->room_index, room->face_list.size()); + xlog::debug("Creating render cache for detail room {} (faces: {} sky: {})", + room->room_index, room->face_list.size(), is_sky); GRenderCacheBuilder builder; + if (is_sky) { + builder.set_is_sky(true); + sky_detail_rooms_.insert(room); + } + else { + sky_detail_rooms_.erase(room); + } builder.add_room(room, solid); xlog::debug("Detail room {} builder: verts={} inds={} batches={}", room->room_index, builder.get_num_verts(), builder.get_num_inds(), builder.get_num_batches()); @@ -797,6 +819,7 @@ namespace df::gr::d3d11 detail_render_cache_.clear(); mover_render_cache_.clear(); geo_cache_rooms_.clear(); + sky_detail_rooms_.clear(); geo_cache_num_rooms = 0; xlog::debug("Room render cache clear complete"); } @@ -827,7 +850,24 @@ namespace df::gr::d3d11 before_render(sky_room_offset, rf::identity_matrix); } render_room_faces(rf::level.geometry, room, FaceRenderType::opaque); + + // Render detail rooms without frustum culling — skybox geometry is rendered + // with an offset/rotation so camera-based bounding box checks don't apply. + // All opaque detail faces must be drawn before any alpha detail faces to + // ensure correct blending when detail rooms overlap. + for (GRoom* detail_room : room->detail_rooms) { + if (!detail_room->face_list.empty()) { + render_detail(rf::level.geometry, detail_room, false, true); + } + } + render_room_faces(rf::level.geometry, room, FaceRenderType::alpha); + + for (GRoom* detail_room : room->detail_rooms) { + if (!detail_room->face_list.empty()) { + render_detail(rf::level.geometry, detail_room, true, true); + } + } render_context_.update_lights(); } @@ -836,8 +876,10 @@ namespace df::gr::d3d11 xlog::trace("Rendering movable solid {}", solid); GRenderCache* cache = get_or_create_movable_solid_cache(solid); before_render(pos, orient); + render_context_.set_ss_is_detail(true); cache->render(FaceRenderType::opaque, render_context_); cache->render(FaceRenderType::alpha, render_context_); + render_context_.set_ss_is_detail(false); if (decals_enabled) { render_movable_solid_dynamic_decals(solid, pos, orient); } @@ -928,6 +970,8 @@ namespace df::gr::d3d11 } } for (rf::GRoom* room: solid->cached_detail_room_list) { + // Pre-cache as non-sky; if later rendered in a sky room context, + // get_or_create_detail_room_cache will detect the mismatch and rebuild. get_or_create_detail_room_cache(solid, room); } } diff --git a/game_patch/graphics/d3d11/gr_d3d11_solid.h b/game_patch/graphics/d3d11/gr_d3d11_solid.h index 2cab13a22..36516a2e8 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_solid.h +++ b/game_patch/graphics/d3d11/gr_d3d11_solid.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include "gr_d3d11_shader.h" @@ -48,14 +49,14 @@ namespace df::gr::d3d11 void before_render(const rf::Vector3& pos, const rf::Matrix3& orient); void after_render(); void render_room_faces(rf::GSolid* solid, rf::GRoom* room, FaceRenderType render_type); - void render_detail(rf::GSolid* solid, rf::GRoom* room, bool alpha); + void render_detail(rf::GSolid* solid, rf::GRoom* room, bool alpha, bool is_sky = false); void render_dynamic_decals(rf::GRoom** rooms, int num_rooms); void render_alpha_detail_dynamic_decals(rf::GRoom* detail_room); void render_movable_solid_dynamic_decals(rf::GSolid* solid, const rf::Vector3& pos, const rf::Matrix3& orient); void before_render_decals(); void after_render_decals(); RoomRenderCache* get_or_create_normal_room_cache(rf::GSolid* solid, rf::GRoom* room); - GRenderCache* get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room); + GRenderCache* get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room, bool is_sky = false); GRenderCache* get_or_create_movable_solid_cache(rf::GSolid* solid); ComPtr device_; @@ -68,5 +69,6 @@ namespace df::gr::d3d11 std::vector> detail_render_cache_; std::unordered_map> mover_render_cache_; std::vector geo_cache_rooms_; + std::unordered_set sky_detail_rooms_; }; } diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp index bebbf17bc..a33cf07e7 100644 --- a/game_patch/misc/side_scroller.cpp +++ b/game_patch/misc/side_scroller.cpp @@ -12,6 +12,8 @@ #include "../rf/input.h" #include "../rf/gr/gr.h" #include "../rf/os/frametime.h" +#include "../rf/vmesh.h" +#include "../rf/weapon.h" // Side-scroller camera orientation (fixed): // Camera is at player.x + offset, looking in -X direction @@ -49,16 +51,45 @@ static bool g_ss_aiming_backward = false; // X-axis lock: player is always locked to X=0 in side-scroller maps static constexpr float g_ss_locked_x = 0.0f; -// Debug: aim line from eye position along aim direction +// Debug: aim line from fire position along aim direction (red = apply_side_scroller_aim) static rf::Vector3 g_dbg_aim_start = {0, 0, 0}; static rf::Vector3 g_dbg_aim_end = {0, 0, 0}; static bool g_dbg_aim_valid = false; +// Debug: fire line from actual fire pos hook output (green = entity_calc_fire_pos_hook) +static rf::Vector3 g_dbg_fire_start = {0, 0, 0}; +static rf::Vector3 g_dbg_fire_end = {0, 0, 0}; +static bool g_dbg_fire_valid = false; + +// Cached hand position for debug line (updated in fire pos hook, read in aim function) +static rf::Vector3 g_ss_hand_pos = {0, 0, 0}; +static bool g_ss_hand_pos_valid = false; + +// Cached aim orient: computed from reticle each frame, used by fire pos hook. +// Stored separately because stock player_do_frame may overwrite entity->eye_orient +// between our pre-hook (where we set it) and weapon firing (where the hook reads it). +static rf::Matrix3 g_ss_aim_orient = {}; +static bool g_ss_aim_orient_valid = false; + +// Actual rendering FOV in degrees, captured from gr_setup_3d each frame. +// The rendering pipeline applies Hor+ scaling and user FOV settings, so this may differ +// from player->viewport.fov_h. We must use the same FOV for reticle unprojection. +static float g_ss_render_fov_h_deg = 90.0f; + +// Side-scroller occlusion: dithered transparency for geometry between camera and player +static SsOcclusionParams g_ss_occlusion = {}; +static float g_ss_occlusion_fade_current = 0.0f; // smoothly ramps toward target + bool is_side_scroller_mode() { return AlpineLevelProperties::instance().game_style == 1; } +const SsOcclusionParams& get_ss_occlusion_params() +{ + return g_ss_occlusion; +} + void side_scroller_on_level_load() { g_reticle_initialized = false; @@ -68,6 +99,10 @@ void side_scroller_on_level_load() g_stolen_mouse_dy = 0; g_ss_camera_active = false; g_ss_aiming_backward = false; + g_ss_hand_pos_valid = false; + g_ss_aim_orient_valid = false; + g_ss_occlusion = {}; + g_ss_occlusion_fade_current = 0.0f; } bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y) @@ -162,7 +197,7 @@ static void update_side_scroller_camera_pos(rf::Player* player) } // Compute desired camera position: offset to the right (+X) of the player, at eye height. - // Using eye height ensures screen center aligns with the weapon fire origin. + // Camera Y at eye height is used as the reference for reticle unprojection. float eye_y = 0.0f; if (entity->info) { eye_y = entity->info->local_eye_offset.y; @@ -173,6 +208,95 @@ static void update_side_scroller_camera_pos(rf::Player* player) g_ss_camera_active = true; } +// Get the world position of the weapon muzzle in third-person. +// Two-step tag lookup: character hand tag → weapon muzzle tag (same as stock NPC code). +// Falls back to hand position if the weapon lacks a muzzle tag. +static bool get_muzzle_world_pos(rf::Entity* entity, rf::Vector3& out_pos) +{ + if (!entity->info || !entity->vmesh) return false; + int hand_tag = entity->info->hand_tags[1]; // right hand + if (hand_tag == -1) return false; + + // Step 1: character model hand tag → hand world pos/orient + rf::Matrix3 hand_orient; + rf::Vector3 hand_pos; + rf::vmesh_get_tag_world_pos(entity->vmesh, hand_tag, entity->orient, entity->pos, + hand_orient, hand_pos); + + // Step 2: weapon third-person model muzzle tag → muzzle world pos + int weapon_type = entity->ai.current_primary_weapon; + if (weapon_type >= 0 && weapon_type < rf::num_weapon_types) { + rf::WeaponInfo& wi = rf::weapon_types[weapon_type]; + if (wi.third_person_vmesh_handle && wi.third_person_muzzle_tag != -1) { + rf::Matrix3 muzzle_orient; + rf::vmesh_get_tag_world_pos(wi.third_person_vmesh_handle, wi.third_person_muzzle_tag, + hand_orient, hand_pos, muzzle_orient, out_pos); + return true; + } + } + + // Fallback: use hand position if weapon has no muzzle tag + out_pos = hand_pos; + return true; +} + +// Hook entity_calc_fire_pos (0x0041b040) to override fire origin with hand tag position. +// Stock code falls back to eye_pos for player characters since they lack primary_fire_points. +// In side-scroller mode we want projectiles to spawn from the visible weapon position. +FunHook entity_calc_fire_pos_hook{ + 0x0041b040, + [](rf::Entity* entity, int weapon_type, rf::Vector3* out_pos, rf::Matrix3* out_orient, + int fire_slot, int is_primary) { + // In side-scroller mode, stock code may have reset entity orient to +Z. + // Temporarily restore the correct body orient so that both stock fire pos + // calculation and get_muzzle_world_pos compute the muzzle on the correct side. + bool is_local_ss = false; + rf::Matrix3 saved_orient; + if (is_side_scroller_mode() && rf::local_player) { + rf::Entity* local_entity = rf::entity_from_handle(rf::local_player->entity_handle); + if (entity == local_entity) { + is_local_ss = true; + saved_orient = entity->orient; + if (g_ss_aiming_backward) { + entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } else { + entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + } + } + + entity_calc_fire_pos_hook.call_target(entity, weapon_type, out_pos, out_orient, + fire_slot, is_primary); + + if (!is_local_ss) return; + + rf::Vector3 hand_pos; + if (get_muzzle_world_pos(entity, hand_pos)) { + *out_pos = hand_pos; + g_ss_hand_pos = hand_pos; + g_ss_hand_pos_valid = true; + } + + // Restore original orient (stock code may need it intact) + entity->orient = saved_orient; + + if (g_ss_aim_orient_valid) { + *out_orient = g_ss_aim_orient; + } + + // Debug: record actual fire pos hook output for green debug line + g_dbg_fire_start = *out_pos; + g_dbg_fire_end.x = out_pos->x + out_orient->fvec.x * 50.0f; + g_dbg_fire_end.y = out_pos->y + out_orient->fvec.y * 50.0f; + g_dbg_fire_end.z = out_pos->z + out_orient->fvec.z * 50.0f; + g_dbg_fire_valid = true; + }, +}; + static void apply_side_scroller_aim(rf::Player* player) { rf::Entity* entity = rf::entity_from_handle(player->entity_handle); @@ -183,9 +307,10 @@ static void apply_side_scroller_aim(rf::Player* player) int screen_w = rf::gr::clip_width(); int screen_h = rf::gr::clip_height(); - // fov_h is in degrees — convert to radians for trig functions + // Use the actual rendering FOV (captured from gr_setup_3d), not player->viewport.fov_h, + // because the rendering pipeline applies Hor+ scaling and user FOV settings. constexpr float deg2rad = 3.14159265f / 180.0f; - float fov_h = player->viewport.fov_h * deg2rad; + float fov_h = g_ss_render_fov_h_deg * deg2rad; float aspect = static_cast(screen_w) / static_cast(screen_h); float fov_v = 2.0f * std::atan(std::tan(fov_h * 0.5f) / aspect); @@ -195,25 +320,61 @@ static void apply_side_scroller_aim(rf::Player* player) float norm_x = (g_reticle_x - half_w) / half_w; float norm_y = (g_reticle_y - half_h) / half_h; - // Unproject reticle to world-space aim point at the player's X plane. - // Camera is at (player.x + offset, player.y, player.z) looking in -X. - // Standard perspective unproject: scale normalized coords by tan(half-fov) * distance. + // Unproject reticle to world-space point on the X=0 plane. + // Camera is at (player.x + offset, camera_y, player.z) looking in -X. float dz = norm_x * std::tan(fov_h * 0.5f) * camera_offset_x; // screen right = +Z float dy = -norm_y * std::tan(fov_v * 0.5f) * camera_offset_x; // screen up = +Y - // Camera is now at eye height, so dy is already relative to the eye/weapon origin. - // Direction from eye to aim target is simply (0, dy, dz). + float reticle_world_y = g_ss_camera_pos.y + dy; + float reticle_world_z = g_ss_camera_pos.z + dz; + + // Get muzzle position (where projectiles actually spawn from) + rf::Vector3 muzzle_pos; + if (!get_muzzle_world_pos(entity, muzzle_pos)) { + muzzle_pos = entity->eye_pos; + } + + // Aim direction from muzzle to reticle world point. + // This ensures the debug line, bullets, and projectiles all pass through the reticle. + float muzzle_dir_y = reticle_world_y - muzzle_pos.y; + float muzzle_dir_z = reticle_world_z - muzzle_pos.z; + float muzzle_dist = std::sqrt(muzzle_dir_y * muzzle_dir_y + muzzle_dir_z * muzzle_dir_z); + + // Camera-center direction (always points outward from the player, never flips) + float cam_dir_y = dy; + float cam_dir_z = dz; + float cam_dist = std::sqrt(cam_dir_y * cam_dir_y + cam_dir_z * cam_dir_z); + + if (cam_dist < 0.001f) { + return; + } + + // Normalize both directions + float mn_y = 0.0f, mn_z = 0.0f; + if (muzzle_dist > 0.001f) { + mn_y = muzzle_dir_y / muzzle_dist; + mn_z = muzzle_dir_z / muzzle_dist; + } + float cn_y = cam_dir_y / cam_dist; + float cn_z = cam_dir_z / cam_dist; + + // Blend: when the reticle is close to the muzzle, smoothly transition from + // muzzle→reticle direction to camera-center direction to avoid abrupt jumps. + constexpr float blend_min = 1.0f; // full camera-center direction + constexpr float blend_max = 3.0f; // full muzzle→reticle direction + float t = std::clamp((muzzle_dist - blend_min) / (blend_max - blend_min), 0.0f, 1.0f); + rf::Vector3 dir; - dir.x = 0.0f; // force zero — aim is always in the YZ plane - dir.y = dy; - dir.z = dz; + dir.x = 0.0f; + dir.y = mn_y * t + cn_y * (1.0f - t); + dir.z = mn_z * t + cn_z * (1.0f - t); - float dist = std::sqrt(dir.y * dir.y + dir.z * dir.z); - if (dist < 0.001f) { + float dir_len = std::sqrt(dir.y * dir.y + dir.z * dir.z); + if (dir_len < 0.001f) { return; } - dir.y /= dist; - dir.z /= dist; + dir.y /= dir_len; + dir.z /= dir_len; // Build eye_orient matrix from aim direction. // fvec = aim direction in YZ plane @@ -234,21 +395,18 @@ static void apply_side_scroller_aim(rf::Player* player) aim_orient.uvec.y = aim_orient.fvec.z * aim_orient.rvec.x - aim_orient.fvec.x * aim_orient.rvec.z; aim_orient.uvec.z = aim_orient.fvec.x * aim_orient.rvec.y - aim_orient.fvec.y * aim_orient.rvec.x; - // Set eye_orient directly (controls weapon aim and visual direction). - // Do NOT touch entity->orient (body orientation stays facing +Z). + // Set eye_orient directly (controls weapon aim direction). + // Also cache in g_ss_aim_orient so the fire pos hook has a reliable copy + // even if stock code overwrites eye_orient mid-frame. entity->eye_orient = aim_orient; - - // Store debug aim line: from eye position, 50m along aim direction - float eye_offset_y = 0.0f; - if (entity->info) { - eye_offset_y = entity->info->local_eye_offset.y; - } - g_dbg_aim_start.x = entity->pos.x; - g_dbg_aim_start.y = entity->pos.y + eye_offset_y; - g_dbg_aim_start.z = entity->pos.z; - g_dbg_aim_end.x = g_dbg_aim_start.x + dir.x * 50.0f; - g_dbg_aim_end.y = g_dbg_aim_start.y + dir.y * 50.0f; - g_dbg_aim_end.z = g_dbg_aim_start.z + dir.z * 50.0f; + g_ss_aim_orient = aim_orient; + g_ss_aim_orient_valid = true; + + // Debug aim line: from muzzle along aim direction (through reticle world point) + g_dbg_aim_start = muzzle_pos; + g_dbg_aim_end.x = muzzle_pos.x + dir.x * 50.0f; + g_dbg_aim_end.y = muzzle_pos.y + dir.y * 50.0f; + g_dbg_aim_end.z = muzzle_pos.z + dir.z * 50.0f; g_dbg_aim_valid = true; // Zero all phb so the body stays facing +Z for movement. @@ -324,6 +482,11 @@ FunHook side_scroller_player_do_frame_hook{ pre_entity->orient.rvec = {1.0f, 0.0f, 0.0f}; pre_entity->orient.uvec = {0.0f, 1.0f, 0.0f}; } + + // Compute fresh aim orient from current reticle BEFORE stock processing, + // so weapons firing mid-frame use the correct direction. + update_side_scroller_camera_pos(player); + apply_side_scroller_aim(player); } } @@ -342,10 +505,8 @@ FunHook side_scroller_player_do_frame_hook{ // then lock X to 0 post_lock_entity_x(entity); - update_side_scroller_camera_pos(player); - apply_side_scroller_aim(player); - - // Re-set body orient in case stock code modified it via phb + // Re-set body orient BEFORE apply_side_scroller_aim so that + // get_muzzle_world_pos() computes the muzzle on the correct side if (g_ss_aiming_backward) { entity->orient.fvec = {0.0f, 0.0f, -1.0f}; entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; @@ -356,6 +517,9 @@ FunHook side_scroller_player_do_frame_hook{ entity->orient.rvec = {1.0f, 0.0f, 0.0f}; entity->orient.uvec = {0.0f, 1.0f, 0.0f}; } + + update_side_scroller_camera_pos(player); + apply_side_scroller_aim(player); }, }; @@ -368,16 +532,57 @@ CallHook side_scroller_gr_s if (g_ss_camera_active && is_side_scroller_mode()) { viewer_pos = g_ss_camera_pos; viewer_orient = g_side_scroller_orient; + // Capture the actual rendering FOV for reticle unprojection. + // This includes Hor+ scaling and user FOV settings applied by the render pipeline. + g_ss_render_fov_h_deg = horizontal_fov; + + // Update occlusion state for dithered transparency. + // Ramp fade strength over 200ms (5.0 per second to reach 0.5 target in 100ms, + // or decay at same rate). + constexpr float fade_target = 0.5f; + constexpr float fade_speed = fade_target / 0.2f; // reach target in 200ms + float dt = rf::frametime; + if (g_ss_occlusion_fade_current < fade_target) { + g_ss_occlusion_fade_current = std::min(g_ss_occlusion_fade_current + fade_speed * dt, fade_target); + } + + // Player position is camera pos with X offset removed + g_ss_occlusion.active = true; + g_ss_occlusion.player_x = g_ss_camera_pos.x - camera_offset_x; + g_ss_occlusion.player_y = g_ss_camera_pos.y; + g_ss_occlusion.player_z = g_ss_camera_pos.z; + g_ss_occlusion.camera_x = g_ss_camera_pos.x; + g_ss_occlusion.fade_strength = g_ss_occlusion_fade_current; + g_ss_occlusion.radius = 2.5f; + } + else { + // Fade out when leaving side-scroller mode + if (g_ss_occlusion_fade_current > 0.0f) { + constexpr float fade_speed = 0.5f / 0.2f; + float dt = rf::frametime; + g_ss_occlusion_fade_current = std::max(g_ss_occlusion_fade_current - fade_speed * dt, 0.0f); + g_ss_occlusion.fade_strength = g_ss_occlusion_fade_current; + } + if (g_ss_occlusion_fade_current <= 0.0f) { + g_ss_occlusion.active = false; + } } side_scroller_gr_setup_3d_hook.call_target(viewer_orient, viewer_pos, horizontal_fov, zbuffer_flag, z_scale); - // Draw debug aim line (red) + // Draw debug aim line (red = apply_side_scroller_aim direction) if (g_dbg_aim_valid && is_side_scroller_mode()) { rf::gr::line_arrow( g_dbg_aim_start.x, g_dbg_aim_start.y, g_dbg_aim_start.z, g_dbg_aim_end.x, g_dbg_aim_end.y, g_dbg_aim_end.z, 255, 0, 0); } + // Draw debug fire line (green = entity_calc_fire_pos_hook actual output) + if (g_dbg_fire_valid && is_side_scroller_mode()) { + rf::gr::line_arrow( + g_dbg_fire_start.x, g_dbg_fire_start.y, g_dbg_fire_start.z, + g_dbg_fire_end.x, g_dbg_fire_end.y, g_dbg_fire_end.z, + 0, 255, 0); + } }, }; @@ -386,4 +591,5 @@ void side_scroller_do_patch() side_scroller_player_do_frame_hook.install(); side_scroller_control_read_hook.install(); side_scroller_gr_setup_3d_hook.install(); + entity_calc_fire_pos_hook.install(); } diff --git a/game_patch/misc/side_scroller.h b/game_patch/misc/side_scroller.h index 5a0553039..7cb9d886c 100644 --- a/game_patch/misc/side_scroller.h +++ b/game_patch/misc/side_scroller.h @@ -6,3 +6,16 @@ bool is_side_scroller_mode(); // Called by hud_weapons to get reticle offset for side-scroller mode bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y); + +// Side-scroller occlusion parameters for dithered transparency. +// Read by the D3D11 render pipeline to fade geometry between camera and player. +struct SsOcclusionParams { + bool active = false; + float player_x = 0.0f; + float player_y = 0.0f; + float player_z = 0.0f; + float camera_x = 0.0f; + float fade_strength = 0.0f; // 0.0 = fully opaque, 0.5 = 50% dithered + float radius = 2.5f; // cylinder radius in YZ plane +}; +const SsOcclusionParams& get_ss_occlusion_params(); diff --git a/game_patch/rf/vmesh.h b/game_patch/rf/vmesh.h index 46e32508c..8ec44c50c 100644 --- a/game_patch/rf/vmesh.h +++ b/game_patch/rf/vmesh.h @@ -105,6 +105,8 @@ namespace rf static auto& vmesh_get_materials_array = addr_as_ref(0x00503650); static auto& vmesh_process = addr_as_ref(0x00503360); static auto& vmesh_create_anim_fx = addr_as_ref(0x00502A60); + static auto& vmesh_get_tag_world_pos = addr_as_ref(0x005034F0); static auto& vclip_lookup = addr_as_ref(0x004C1D00); static auto& vclip_play_3d = addr_as_ref 0.5f ? float4(1.0, 1.0, 1.0, 1.0) : tex0.Sample(samp0, input.uv0); @@ -73,6 +89,22 @@ float4 main(VsOutput input) : SV_TARGET clip(target.a - alpha_test); + // Side-scroller occlusion: dithered transparency for detail/mover geometry near player + if (ss_fade_strength > 0.0f && ss_is_detail > 0.5f) { + float3 world_pos = input.world_pos_and_depth.xyz; + // Distance from player axis in YZ plane (cylinder around player) + float2 delta_yz = world_pos.yz - ss_player_pos.yz; + float dist_yz = length(delta_yz); + if (dist_yz < ss_radius) { + // Linear falloff: full fade at center, zero at edge + float fade = ss_fade_strength * saturate(1.0f - dist_yz / ss_radius); + // Dithered discard using Bayer matrix + uint2 pixel = uint2(input.pos.xy) % 4; + float dither = bayer4x4[pixel.y][pixel.x]; + clip(dither - fade); + } + } + float3 light_color = tex1.Sample(samp1, input.uv1).rgb; if (disable_textures < 0.5f) { light_color *= 2; From 3dddc2a1ed272e46e2abd785a89f3e822fec37a6 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 12 Mar 2026 13:04:23 -0230 Subject: [PATCH 5/6] apply alpha windows to the player and the 15 closest entities to the player, draw alpha window cylinder from the camera position instead of X axis plane --- .../graphics/d3d11/gr_d3d11_context.cpp | 20 +++++--- game_patch/misc/side_scroller.cpp | 49 +++++++++++++++++-- game_patch/misc/side_scroller.h | 10 ++-- resources/shaders/standard_ps.hlsl | 39 ++++++++++----- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/game_patch/graphics/d3d11/gr_d3d11_context.cpp b/game_patch/graphics/d3d11/gr_d3d11_context.cpp index a69502ee2..fc992a44f 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_context.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_context.cpp @@ -251,12 +251,15 @@ namespace df::gr::d3d11 std::array fog_color; float pad0; // Side-scroller occlusion (dithered transparency) - std::array ss_player_pos; float ss_fade_strength; - float ss_camera_x; float ss_radius; float ss_is_detail; - float ss_pad2; + float ss_num_entities; + // Each entity position stored as float4 (xyz + pad) for HLSL array packing + std::array ss_entity_pos[max_ss_occlusion_entities]; + // Camera position for computing entity-to-camera cylinder axis + std::array ss_camera_pos; + float pad1; }; static_assert(sizeof(RenderModeBufferData) % 16 == 0); @@ -299,19 +302,20 @@ namespace df::gr::d3d11 const auto& ss = get_ss_occlusion_params(); if (ss.active) { - data.ss_player_pos = {ss.player_x, ss.player_y, ss.player_z}; data.ss_fade_strength = ss.fade_strength; - data.ss_camera_x = ss.camera_x; data.ss_radius = ss.radius; + data.ss_num_entities = static_cast(ss.num_entities); + for (int i = 0; i < ss.num_entities; ++i) { + data.ss_entity_pos[i] = {ss.entity_pos[i].x, ss.entity_pos[i].y, ss.entity_pos[i].z, 0.0f}; + } + data.ss_camera_pos = {ss.camera_pos.x, ss.camera_pos.y, ss.camera_pos.z}; } else { - data.ss_player_pos = {0.0f, 0.0f, 0.0f}; data.ss_fade_strength = 0.0f; - data.ss_camera_x = 0.0f; data.ss_radius = 0.0f; + data.ss_num_entities = 0.0f; } data.ss_is_detail = current_ss_is_detail_ ? 1.0f : 0.0f; - data.ss_pad2 = 0.0f; D3D11_MAPPED_SUBRESOURCE mapped_subres; DF_GR_D3D11_CHECK_HR( diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp index a33cf07e7..7a367668d 100644 --- a/game_patch/misc/side_scroller.cpp +++ b/game_patch/misc/side_scroller.cpp @@ -1,8 +1,10 @@ #include +#include #include #include #include #include +#include #include #include "side_scroller.h" #include "level.h" @@ -546,14 +548,51 @@ CallHook side_scroller_gr_s g_ss_occlusion_fade_current = std::min(g_ss_occlusion_fade_current + fade_speed * dt, fade_target); } - // Player position is camera pos with X offset removed + // Collect positions of all alive entities for occlusion cylinders, + // prioritizing those closest to the local player g_ss_occlusion.active = true; - g_ss_occlusion.player_x = g_ss_camera_pos.x - camera_offset_x; - g_ss_occlusion.player_y = g_ss_camera_pos.y; - g_ss_occlusion.player_z = g_ss_camera_pos.z; - g_ss_occlusion.camera_x = g_ss_camera_pos.x; g_ss_occlusion.fade_strength = g_ss_occlusion_fade_current; g_ss_occlusion.radius = 2.5f; + g_ss_occlusion.num_entities = 0; + + rf::Entity* local_entity = rf::local_player + ? rf::entity_from_handle(rf::local_player->entity_handle) : nullptr; + + struct EntityEntry { + float dist_sq; + rf::Vector3 pos; + }; + static std::vector candidates; + candidates.clear(); + + for (auto& entity : DoublyLinkedList{rf::entity_list}) { + if (rf::entity_is_dying(&entity)) continue; + float dist_sq = 0.0f; + if (local_entity) { + float dx = entity.pos.x - local_entity->pos.x; + float dy = entity.pos.y - local_entity->pos.y; + float dz = entity.pos.z - local_entity->pos.z; + dist_sq = dx * dx + dy * dy + dz * dz; + } + candidates.push_back({dist_sq, entity.pos}); + } + + if (static_cast(candidates.size()) > max_ss_occlusion_entities) { + std::partial_sort(candidates.begin(), + candidates.begin() + max_ss_occlusion_entities, + candidates.end(), + [](const EntityEntry& a, const EntityEntry& b) { return a.dist_sq < b.dist_sq; }); + } + + int count = std::min(static_cast(candidates.size()), max_ss_occlusion_entities); + for (int i = 0; i < count; ++i) { + auto& ep = g_ss_occlusion.entity_pos[i]; + ep.x = candidates[i].pos.x; + ep.y = candidates[i].pos.y; + ep.z = candidates[i].pos.z; + } + g_ss_occlusion.num_entities = count; + g_ss_occlusion.camera_pos = {g_ss_camera_pos.x, g_ss_camera_pos.y, g_ss_camera_pos.z}; } else { // Fade out when leaving side-scroller mode diff --git a/game_patch/misc/side_scroller.h b/game_patch/misc/side_scroller.h index 7cb9d886c..b58316d28 100644 --- a/game_patch/misc/side_scroller.h +++ b/game_patch/misc/side_scroller.h @@ -9,13 +9,13 @@ bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y); // Side-scroller occlusion parameters for dithered transparency. // Read by the D3D11 render pipeline to fade geometry between camera and player. +static constexpr int max_ss_occlusion_entities = 16; struct SsOcclusionParams { bool active = false; - float player_x = 0.0f; - float player_y = 0.0f; - float player_z = 0.0f; - float camera_x = 0.0f; float fade_strength = 0.0f; // 0.0 = fully opaque, 0.5 = 50% dithered - float radius = 2.5f; // cylinder radius in YZ plane + float radius = 2.5f; // cylinder radius perpendicular to entity-camera axis + int num_entities = 0; + struct { float x, y, z; } entity_pos[max_ss_occlusion_entities]; + struct { float x, y, z; } camera_pos = {0.0f, 0.0f, 0.0f}; }; const SsOcclusionParams& get_ss_occlusion_params(); diff --git a/resources/shaders/standard_ps.hlsl b/resources/shaders/standard_ps.hlsl index 338f068be..af7666eb5 100644 --- a/resources/shaders/standard_ps.hlsl +++ b/resources/shaders/standard_ps.hlsl @@ -18,12 +18,13 @@ cbuffer RenderModeBuffer : register(b0) float3 fog_color; float _pad0; // Side-scroller occlusion - float3 ss_player_pos; float ss_fade_strength; - float ss_camera_x; float ss_radius; float ss_is_detail; - float _ss_pad; + float ss_num_entities; + float4 ss_entity_pos[16]; + float3 ss_camera_pos; + float _pad1; }; struct PointLight { @@ -89,19 +90,33 @@ float4 main(VsOutput input) : SV_TARGET clip(target.a - alpha_test); - // Side-scroller occlusion: dithered transparency for detail/mover geometry near player + // Side-scroller occlusion: dithered transparency for detail/mover geometry near entities. + // For each entity, a cylinder is oriented along the entity-to-camera axis so that + // geometry between the entity and camera gets dithered regardless of viewing angle. if (ss_fade_strength > 0.0f && ss_is_detail > 0.5f) { float3 world_pos = input.world_pos_and_depth.xyz; - // Distance from player axis in YZ plane (cylinder around player) - float2 delta_yz = world_pos.yz - ss_player_pos.yz; - float dist_yz = length(delta_yz); - if (dist_yz < ss_radius) { - // Linear falloff: full fade at center, zero at edge - float fade = ss_fade_strength * saturate(1.0f - dist_yz / ss_radius); - // Dithered discard using Bayer matrix + float max_fade = 0.0f; + for (int ei = 0; ei < (int)ss_num_entities; ++ei) { + float3 entity_pos = ss_entity_pos[ei].xyz; + float3 axis = ss_camera_pos - entity_pos; + float axis_len = length(axis); + if (axis_len < 0.001f) continue; + float3 axis_dir = axis / axis_len; + float3 to_pixel = world_pos - entity_pos; + float proj = dot(to_pixel, axis_dir); + // Only affect pixels between entity and camera (along the axis) + if (proj < 0.0f || proj > axis_len) continue; + float3 perp = to_pixel - proj * axis_dir; + float dist = length(perp); + if (dist < ss_radius) { + float fade = ss_fade_strength * saturate(1.0f - dist / ss_radius); + max_fade = max(max_fade, fade); + } + } + if (max_fade > 0.0f) { uint2 pixel = uint2(input.pos.xy) % 4; float dither = bayer4x4[pixel.y][pixel.x]; - clip(dither - fade); + clip(dither - max_fade); } } From 607937e8533422dd8eac1703bace25eb6a28b410 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sun, 15 Mar 2026 03:19:15 -0230 Subject: [PATCH 6/6] rename game style to perspective --- editor_patch/level.cpp | 12 ++++++------ editor_patch/level.h | 17 ++++++++--------- editor_patch/resources.h | 4 ++-- editor_patch/resources.rc | 4 ++-- game_patch/misc/level.h | 10 +++++----- game_patch/misc/side_scroller.cpp | 2 +- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/editor_patch/level.cpp b/editor_patch/level.cpp index 456b97095..e77264383 100644 --- a/editor_patch/level.cpp +++ b/editor_patch/level.cpp @@ -518,11 +518,11 @@ CodeInjection CLevelDialog_OnInitDialog_patch{ SetDlgItemTextA(hdlg, IDC_MESH_AMBIENT_LIGHT_MODIFIER, buffer); CheckDlgButton(hdlg, IDC_RF2_STYLE_GEOMOD, alpine_level_props.rf2_style_geomod ? BST_CHECKED : BST_UNCHECKED); - // Populate game style dropdown - HWND combo = GetDlgItem(hdlg, IDC_GAME_STYLE_COMBO); - SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("Default")); + // Populate perspective dropdown + HWND combo = GetDlgItem(hdlg, IDC_PERSPECTIVE_COMBO); + SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("First person")); SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("Side-scroller")); - SendMessageA(combo, CB_SETCURSEL, alpine_level_props.game_style, 0); + SendMessageA(combo, CB_SETCURSEL, alpine_level_props.perspective, 0); }, }; @@ -548,9 +548,9 @@ CodeInjection CLevelDialog_OnOK_patch{ } alpine_level_props.rf2_style_geomod = IsDlgButtonChecked(hdlg, IDC_RF2_STYLE_GEOMOD) == BST_CHECKED; - int sel = static_cast(SendDlgItemMessageA(hdlg, IDC_GAME_STYLE_COMBO, CB_GETCURSEL, 0, 0)); + int sel = static_cast(SendDlgItemMessageA(hdlg, IDC_PERSPECTIVE_COMBO, CB_GETCURSEL, 0, 0)); if (sel != CB_ERR) { - alpine_level_props.game_style = static_cast(sel); + alpine_level_props.perspective = static_cast(sel); } }, }; diff --git a/editor_patch/level.h b/editor_patch/level.h index 3f8b49b41..55f937293 100644 --- a/editor_patch/level.h +++ b/editor_patch/level.h @@ -331,8 +331,7 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; - // v5 - uint8_t game_style = 0; // 0 = Default, 1 = Side-scroller + uint8_t perspective = 0; // 0 = First person, 1 = Side-scroller // v4 (continued - vectors kept after scalars for clean versioning) std::vector geoable_brush_uids; std::vector geoable_room_uids; // computed at save time, parallel to geoable_brush_uids @@ -340,7 +339,7 @@ struct AlpineLevelProperties std::vector breakable_room_uids; // computed at save time, parallel to breakable_brush_uids std::vector breakable_materials; // material type per entry - static constexpr std::uint32_t current_alpine_chunk_version = 5u; + static constexpr std::uint32_t current_alpine_chunk_version = 4u; // defaults for existing levels, overwritten for maps with these fields in their alpine level props chunk // relevant for maps without alpine level props and maps with older alpine level props versions @@ -353,7 +352,7 @@ struct AlpineLevelProperties override_static_mesh_ambient_light_modifier = false; static_mesh_ambient_light_modifier = 2.0f; rf2_style_geomod = false; - game_style = 0; + perspective = 0; geoable_brush_uids.clear(); geoable_room_uids.clear(); breakable_brush_uids.clear(); @@ -393,8 +392,7 @@ struct AlpineLevelProperties uint8_t mat = (i < breakable_materials.size()) ? breakable_materials[i] : 0; file.write(mat); } - // v5 - file.write(game_style); + file.write(perspective); } void Deserialize(rf::File& file, std::size_t chunk_len) @@ -522,12 +520,13 @@ struct AlpineLevelProperties } } - if (version >= 5) { + // perspective appended to v4 + if (version >= 4) { std::uint8_t u8 = 0; if (!read_bytes(&u8, sizeof(u8))) return; - game_style = u8; - xlog::debug("[AlpineLevelProps] game_style {}", game_style); + perspective = u8; + xlog::debug("[AlpineLevelProps] perspective {}", perspective); } } }; diff --git a/editor_patch/resources.h b/editor_patch/resources.h index f62e906b2..28d45cce7 100644 --- a/editor_patch/resources.h +++ b/editor_patch/resources.h @@ -25,8 +25,8 @@ #define IDC_MATERIAL_COMBO 2008 #define IDC_MATERIAL_LABEL 2009 #define IDC_NO_DEBRIS 2010 -#define IDC_GAME_STYLE_LABEL 2011 -#define IDC_GAME_STYLE_COMBO 2012 +#define IDC_PERSPECTIVE_LABEL 2011 +#define IDC_PERSPECTIVE_COMBO 2012 #define IDD_BRUSH_MODE_PANEL 205 #define IDD_BRUSH_PROPERTIES 270 diff --git a/editor_patch/resources.rc b/editor_patch/resources.rc index 4ba99821c..2e74883e7 100644 --- a/editor_patch/resources.rc +++ b/editor_patch/resources.rc @@ -152,8 +152,8 @@ FONT 8, "MS Sans Serif" LTEXT "New scale value:", -1, 30, 331, 120, 8 EDITTEXT IDC_MESH_AMBIENT_LIGHT_MODIFIER, 90, 328, 30, 14, ES_AUTOHSCROLL AUTOCHECKBOX "Brush-based geomod (RF2-style)", IDC_RF2_STYLE_GEOMOD, 13, 344, 200, 10 - LTEXT "Game Style:", IDC_GAME_STYLE_LABEL, 13, 360, 45, 8 - COMBOBOX IDC_GAME_STYLE_COMBO, 60, 358, 100, 60, WS_TABSTOP | CBS_DROPDOWNLIST + LTEXT "Perspective:", IDC_PERSPECTIVE_LABEL, 13, 360, 45, 8 + COMBOBOX IDC_PERSPECTIVE_COMBO, 60, 358, 100, 60, WS_TABSTOP | CBS_DROPDOWNLIST } LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT diff --git a/game_patch/misc/level.h b/game_patch/misc/level.h index 857dfc0b8..52e7ac802 100644 --- a/game_patch/misc/level.h +++ b/game_patch/misc/level.h @@ -24,8 +24,7 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; - // v5 - uint8_t game_style = 0; // 0 = Default, 1 = Side-scroller + uint8_t perspective = 0; // 0 = First person, 1 = Side-scroller // std::vector geoable_brush_uids; // unnecessary in game std::vector geoable_room_uids; // std::vector breakable_brush_uids; // unnecessary in game @@ -166,12 +165,13 @@ struct AlpineLevelProperties xlog::trace("[AlpineLevelProps] GAME: total breakable entries loaded={}", bcount); } - if (version >= 5) { + // perspective appended to v4 + if (version >= 4) { std::uint8_t u8 = 0; if (!read_bytes(&u8, sizeof(u8))) return; - game_style = u8; - xlog::debug("[AlpineLevelProps] game_style {}", game_style); + perspective = u8; + xlog::debug("[AlpineLevelProps] perspective {}", perspective); } } }; diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp index 7a367668d..28f5fa59d 100644 --- a/game_patch/misc/side_scroller.cpp +++ b/game_patch/misc/side_scroller.cpp @@ -84,7 +84,7 @@ static float g_ss_occlusion_fade_current = 0.0f; // smoothly ramps toward target bool is_side_scroller_mode() { - return AlpineLevelProperties::instance().game_style == 1; + return AlpineLevelProperties::instance().perspective == 1; } const SsOcclusionParams& get_ss_occlusion_params()