From 268e379b108caee4dcb8744f622fc8da5a95c29a Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 28 May 2026 07:14:15 -0400 Subject: [PATCH 1/3] hitbox --- docs/CHANGELOG.md | 4 + game_patch/debug/debug.cpp | 308 ++++++++++++ game_patch/debug/debug_cmd.cpp | 5 +- game_patch/debug/debug_internal.h | 2 + game_patch/multi/dedi_cfg.cpp | 10 + game_patch/multi/multi.cpp | 746 +++++++++++++++++++++++++++++ game_patch/multi/multi.h | 6 + game_patch/multi/network.cpp | 4 + game_patch/multi/network.h | 1 + game_patch/multi/server.cpp | 7 + game_patch/multi/server.h | 1 + game_patch/multi/server_internal.h | 2 + game_patch/rf/vmesh.h | 1 + 13 files changed, 1096 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 007286fff..28bffd87f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,9 @@ Version 1.4.0 (Lupin): Not yet released - Add detection of manually loaded levels - Highlight an active level in a server's rotation via background color instead of text color +[@nickalreadyinuse](https://github.com/nickalreadyinuse) +- Add hybrid (sphere, capsule, cylinder) hitbox system with bone tracking for torso and head + ### Bug fixes [@GooberRF](https://github.com/GooberRF) - Fix team balance not properly randomizing the distribution order of equal-scoring human players @@ -179,6 +182,7 @@ Version 1.3.0 (Bakeapple): Released Apr-22-2026 [@AL2009man](https://github.com/AL2009man) - Add `ms_scale` toggle to use various mouse sensitivity scaling options between Classic (original scaling), Raw and Modern (id Tech/Source). + [@natarii](https://github.com/natarii) - Implement FFLink client functionality in launcher diff --git a/game_patch/debug/debug.cpp b/game_patch/debug/debug.cpp index caff77b3f..b1684aac9 100644 --- a/game_patch/debug/debug.cpp +++ b/game_patch/debug/debug.cpp @@ -1,7 +1,14 @@ +#include +#include #include "debug_internal.h" #include +#include #include #include "../os/console.h" +#include "../rf/entity.h" +#include "../rf/vmesh.h" +#include "../rf/player/player.h" +#include "../multi/multi.h" #define DEBUG_PERF 1 @@ -201,9 +208,310 @@ void debug_init() #endif } +static void draw_3d_capsule_general(const rf::Vector3& cap_a, const rf::Vector3& cap_b, float radius) +{ + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + + rf::Vector3 axis = cap_b - cap_a; + float axis_len = axis.len(); + + constexpr int num_segments = 24; + constexpr int half_arc = num_segments / 4; + constexpr float two_pi = 6.2831853f; + constexpr float half_pi = 1.5707963f; + + rf::Vector3 v_axis; + if (axis_len > 1e-6f) { + v_axis = axis * (1.0f / axis_len); + } + else { + v_axis = {0.0f, 1.0f, 0.0f}; + } + + rf::Vector3 helper = (std::abs(v_axis.y) < 0.9f) + ? rf::Vector3{0.0f, 1.0f, 0.0f} + : rf::Vector3{1.0f, 0.0f, 0.0f}; + rf::Vector3 u_axis = v_axis.cross(helper); + u_axis.normalize(); + rf::Vector3 w_axis = v_axis.cross(u_axis); + + rf::Vector3 a_pts[num_segments]; + rf::Vector3 b_pts[num_segments]; + + for (int i = 0; i < num_segments; i++) { + float angle = two_pi * static_cast(i) / static_cast(num_segments); + rf::Vector3 offset = u_axis * (radius * std::cos(angle)) + w_axis * (radius * std::sin(angle)); + a_pts[i] = cap_a + offset; + b_pts[i] = cap_b + offset; + } + + for (int i = 0; i < num_segments; i++) { + int next = (i + 1) % num_segments; + rf::gr::line_vec(a_pts[i], a_pts[next], mode); + rf::gr::line_vec(b_pts[i], b_pts[next], mode); + + if (i % (num_segments / 4) == 0) { + rf::gr::line_vec(a_pts[i], b_pts[i], mode); + } + } + + auto draw_hemisphere_arc = [&](const rf::Vector3& center, float sign, const rf::Vector3& arc_dir) { + rf::Vector3 prev; + for (int i = 0; i <= half_arc; i++) { + float phi = half_pi * static_cast(i) / static_cast(half_arc); + float r_eq = radius * std::cos(phi); + float r_ax = radius * std::sin(phi) * sign; + rf::Vector3 pt = center + arc_dir * r_eq + v_axis * r_ax; + if (i > 0) + rf::gr::line_vec(prev, pt, mode); + prev = pt; + } + }; + + draw_hemisphere_arc(cap_a, -1.0f, u_axis); + draw_hemisphere_arc(cap_a, -1.0f, w_axis); + draw_hemisphere_arc(cap_a, -1.0f, -u_axis); + draw_hemisphere_arc(cap_a, -1.0f, -w_axis); + + draw_hemisphere_arc(cap_b, 1.0f, u_axis); + draw_hemisphere_arc(cap_b, 1.0f, w_axis); + draw_hemisphere_arc(cap_b, 1.0f, -u_axis); + draw_hemisphere_arc(cap_b, 1.0f, -w_axis); +} + +static void draw_3d_cylinder_general(const rf::Vector3& cyl_a, const rf::Vector3& cyl_b, float radius) +{ + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + + rf::Vector3 axis = cyl_b - cyl_a; + float axis_len = axis.len(); + + constexpr int num_segments = 24; + constexpr float two_pi = 6.2831853f; + + rf::Vector3 v_axis; + if (axis_len > 1e-6f) { + v_axis = axis * (1.0f / axis_len); + } + else { + v_axis = {0.0f, 1.0f, 0.0f}; + } + + rf::Vector3 helper = (std::abs(v_axis.y) < 0.9f) + ? rf::Vector3{0.0f, 1.0f, 0.0f} + : rf::Vector3{1.0f, 0.0f, 0.0f}; + rf::Vector3 u_axis = v_axis.cross(helper); + u_axis.normalize(); + rf::Vector3 w_axis = v_axis.cross(u_axis); + + rf::Vector3 a_pts[num_segments]; + rf::Vector3 b_pts[num_segments]; + + for (int i = 0; i < num_segments; i++) { + float angle = two_pi * static_cast(i) / static_cast(num_segments); + rf::Vector3 offset = u_axis * (radius * std::cos(angle)) + w_axis * (radius * std::sin(angle)); + a_pts[i] = cyl_a + offset; + b_pts[i] = cyl_b + offset; + } + + for (int i = 0; i < num_segments; i++) { + int next = (i + 1) % num_segments; + // Rings at each end + rf::gr::line_vec(a_pts[i], a_pts[next], mode); + rf::gr::line_vec(b_pts[i], b_pts[next], mode); + + // Connecting lines at cardinal points + if (i % (num_segments / 4) == 0) { + rf::gr::line_vec(a_pts[i], b_pts[i], mode); + } + } + + // Cross-lines on disc caps + rf::gr::line_vec(a_pts[0], a_pts[num_segments / 2], mode); + rf::gr::line_vec(a_pts[num_segments / 4], a_pts[3 * num_segments / 4], mode); + rf::gr::line_vec(b_pts[0], b_pts[num_segments / 2], mode); + rf::gr::line_vec(b_pts[num_segments / 4], b_pts[3 * num_segments / 4], mode); +} + +static void draw_3d_aabb(const rf::Vector3& bbox_min, const rf::Vector3& bbox_max) +{ + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + + // 8 corners of the box + rf::Vector3 c[8] = { + {bbox_min.x, bbox_min.y, bbox_min.z}, + {bbox_max.x, bbox_min.y, bbox_min.z}, + {bbox_max.x, bbox_min.y, bbox_max.z}, + {bbox_min.x, bbox_min.y, bbox_max.z}, + {bbox_min.x, bbox_max.y, bbox_min.z}, + {bbox_max.x, bbox_max.y, bbox_min.z}, + {bbox_max.x, bbox_max.y, bbox_max.z}, + {bbox_min.x, bbox_max.y, bbox_max.z}, + }; + + // Bottom face + rf::gr::line_vec(c[0], c[1], mode); + rf::gr::line_vec(c[1], c[2], mode); + rf::gr::line_vec(c[2], c[3], mode); + rf::gr::line_vec(c[3], c[0], mode); + // Top face + rf::gr::line_vec(c[4], c[5], mode); + rf::gr::line_vec(c[5], c[6], mode); + rf::gr::line_vec(c[6], c[7], mode); + rf::gr::line_vec(c[7], c[4], mode); + // Vertical edges + rf::gr::line_vec(c[0], c[4], mode); + rf::gr::line_vec(c[1], c[5], mode); + rf::gr::line_vec(c[2], c[6], mode); + rf::gr::line_vec(c[3], c[7], mode); +} + +static void render_hitboxes() +{ + if (!g_dbg_hitboxes) + return; + + auto& multi_entity_bbox_size = addr_as_ref(0x007C6A70); + auto& server_info = get_af_server_info(); + bool legacy = server_info ? server_info->legacy_hitboxes : g_alpine_server_config.legacy_hitboxes; + + rf::Entity* ep = rf::entity_list.next; + while (ep != &rf::entity_list) { + rf::Entity* entity = ep; + ep = ep->next; + + if (!entity->vmesh) + continue; + + if (rf::local_player && entity->handle == rf::local_player->entity_handle) + continue; + + // Match engine bbox computation: entity->pos ± half_size, + // then for crouching only bbox_max.y is lowered (feet stay on floor) + rf::Vector3 half_size = multi_entity_bbox_size; + rf::Vector3 bbox_min = entity->pos - half_size; + rf::Vector3 bbox_max = entity->pos + half_size; + bool crouching = rf::entity_is_crouching(entity); + if (crouching) { + bbox_max.y -= multi_entity_bbox_size.y * 0.5f; + } + + if (legacy) { + // Legacy mode: draw the AABB that the engine actually uses for collision + rf::gr::set_color(255, 128, 0, 255); + draw_3d_aabb(bbox_min, bbox_max); + continue; + } + + float cx = (bbox_min.x + bbox_max.x) * 0.5f; + float cz = (bbox_min.z + bbox_max.z) * 0.5f; + float radius = (bbox_max.x - bbox_min.x) * 0.5f; + + // Extend effective top for crouching (models don't always crouch as low as the engine bbox) + float effective_max_y = bbox_max.y; + if (crouching) { + float h = bbox_max.y - bbox_min.y; + effective_max_y += h * 0.3f; + } + + float split_ratio = crouching ? 0.35f : 0.55f; + float bbox_height = effective_max_y - bbox_min.y; + float split_y = bbox_min.y + bbox_height * split_ratio; + float upper_height = effective_max_y - split_y; + + // Lower capsule (green): inset bottom so hemisphere doesn't extend below bbox + rf::gr::set_color(0, 255, 0, 255); + rf::Vector3 lower_bot{cx, bbox_min.y + radius, cz}; + rf::Vector3 lower_top{cx, split_y, cz}; + draw_3d_capsule_general(lower_bot, lower_top, radius); + + // Torso cylinder (cyan) + head sphere (magenta) + rf::Vector3 upper_a{cx, split_y, cz}; + float head_dist; + float head_radius; + rf::Vector3 head_world_pos; + rf::Vector3 tilt_axis = compute_tilt_axis(entity, upper_a, &head_dist, &head_radius, &head_world_pos); + rf::gr::set_color(0, 255, 255, 255); + float upper_len = upper_height; + if (head_dist > 0.0f) + upper_len = std::min(upper_len, head_dist); + + // Shorten so flat top stops just before head sphere (matching collision) + if (head_radius > 0.0f && head_dist > 0.0f) { + float max_endpoint = head_dist - head_radius; + upper_len = std::min(upper_len, max_endpoint); + } + upper_len = std::max(upper_len, 0.0f); + upper_len *= 1.05f; + + rf::Vector3 upper_b = upper_a + tilt_axis * upper_len; + draw_3d_cylinder_general(upper_a, upper_b, radius); + + // Head sphere + if (head_radius > 0.0f && head_dist > 0.0f) { + rf::gr::set_color(255, 0, 255, 255); + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + rf::gr::sphere(head_world_pos, head_radius, mode); + } + + // Collision spheres (yellow) — toggle via g_dbg_hitboxes_show_cspheres + if (!g_dbg_hitboxes_show_cspheres) + continue; + int num_cspheres = rf::vmesh_get_num_cspheres(entity->vmesh); + for (int i = 0; i < num_cspheres; i++) { + rf::Vector3 sphere_pos; + if (rf::vmesh_get_csphere_pos(entity->vmesh, i, &sphere_pos, &entity->pos, &entity->orient)) { + rf::Vector3 local_pos; + float sphere_radius; + if (rf::vmesh_get_csphere(entity->vmesh, i, &local_pos, &sphere_radius)) { + rf::gr::set_color(255, 255, 0, 180); + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_ALPHA, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + rf::gr::sphere(sphere_pos, sphere_radius, mode); + } + } + } + } +} + void debug_render() { debug_cmd_render(); + render_hitboxes(); } void debug_render_ui() diff --git a/game_patch/debug/debug_cmd.cpp b/game_patch/debug/debug_cmd.cpp index ec0f771c1..2b08cb193 100644 --- a/game_patch/debug/debug_cmd.cpp +++ b/game_patch/debug/debug_cmd.cpp @@ -19,6 +19,8 @@ struct DebugFlagDesc bool g_dbg_geometry_rendering_stats = false; bool g_dbg_static_lights = false; bool g_dbg_particle_emitters = false; +bool g_dbg_hitboxes = false; +bool g_dbg_hitboxes_show_cspheres = false; DebugFlagDesc g_debug_flags[] = { {addr_as_ref(0x0062F3AA), "thruster"}, @@ -51,6 +53,7 @@ DebugFlagDesc g_debug_flags[] = { {addr_as_ref(0x009BB5A4), "lightmap", true}, // show_lightmaps {addr_as_ref(0x009BB5A8), "nolightmap", true}, // fullbright {addr_as_ref(0x009BB5B0), "show_invisible_faces", true}, + {g_dbg_hitboxes, "hitbox", false, true}, }; ConsoleCommand2 debug_cmd{ @@ -78,7 +81,7 @@ ConsoleCommand2 debug_cmd{ nullptr, "debug [thruster | light | light2 | push_climb_reg | geo_reg | glass | mover | ignite | movemode | perf |\n" "perfbar | waypoint | network | particlestats | weapon | event | trigger | objrender | roomstats | trans |\n" - "room | portal | lightmap | nolightmap | show_invisible_faces]", + "room | portal | lightmap | nolightmap | show_invisible_faces | hitbox]", }; void debug_cmd_multi_init() diff --git a/game_patch/debug/debug_internal.h b/game_patch/debug/debug_internal.h index fd4b30606..08ea6215d 100644 --- a/game_patch/debug/debug_internal.h +++ b/game_patch/debug/debug_internal.h @@ -151,6 +151,8 @@ class DebugNameValueBoxTwoColumns StringBuffer<2048> value_buffer_; }; +extern bool g_dbg_hitboxes; +extern bool g_dbg_hitboxes_show_cspheres; void debug_cmd_init(); void debug_cmd_render(); void debug_cmd_render_ui(); diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index 38c8d39ed..62e45a6df 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -1219,6 +1219,14 @@ static void apply_known_key_in_order(AlpineServerConfig& cfg, const std::string& if (auto v = node.value()) cfg.allow_outlines_xray = *v; } + else if (key == "legacy_hitboxes") { + if (auto v = node.value()) + cfg.legacy_hitboxes = *v; + } + else if (key == "exclude_bots_from_player_count") { + if (auto v = node.value()) + cfg.exclude_bots_from_player_count = *v; + } } // apply base config toml tables @@ -2089,6 +2097,8 @@ void print_alpine_dedicated_server_config_info(std::string& output, bool verbose std::format_to(iter, " SP-style damage calculation: {}\n", cfg.use_sp_damage_calculation); std::format_to(iter, " Allow outlines: {}\n", cfg.allow_outlines); std::format_to(iter, " Allow outlines xray: {}\n", cfg.allow_outlines_xray); + std::format_to(iter, " Legacy hitboxes: {}\n", cfg.legacy_hitboxes); + std::format_to(iter, " Exclude bots from player count: {}\n", cfg.exclude_bots_from_player_count); // inactivity std::format_to(iter, " Identify inactive players: {}\n", cfg.inactivity_config.enabled); diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 6489c080c..f4ec0699f 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include #include @@ -40,9 +42,749 @@ #include "../rf/ai.h" #include "../rf/item.h" #include "../rf/sound/sound.h" +#include "../rf/vmesh.h" #include "../main/main.h" #include "../graphics/gr.h" +// --- Improved hitboxes: split capsule collision with head tracking --- + +// Lag compensation: when true, capsule collision uses rewound physics data +static bool s_in_lag_comp_raycast = false; + +void set_lag_comp_flag(bool active) { + s_in_lag_comp_raycast = active; +} + +// Entity pointer captured from the collision loop +static rf::Entity* s_current_collide_entity = nullptr; + +// Cache: VMesh::mesh pointer -> head csphere index (per-model, stable across instances) +static std::unordered_map s_head_csphere_cache; + +// Capture ESI (entity pointer) at start of bbox computation in collide_linesegment_level_for_multi +static CodeInjection capture_entity_injection{ + 0x0049C7D5, + [](auto& regs) { + s_current_collide_entity = static_cast(regs.esi); + }, +}; + +// Look up historical eye forward vector from ObjInterp during lag compensation. +// Matches the rewound p_data.pos against ObjInterp.pos_array to find the temporal index, +// then reads the corresponding eye_phb_array entry to reconstruct the eye orientation. +static rf::Vector3 get_historical_eye_fvec(const rf::Entity* entity) +{ + // Fallback: current eye orientation + const rf::Vector3& current_fvec = entity->eye_orient.fvec; + + const rf::ObjInterp* interp = entity->obj_interp; + if (!interp) + return current_fvec; + + // ObjInterp layout (from Ghidra analysis): + // +0x000: pos_array[20] — Vector3[20] (240 bytes) + // +0x1E0: eye_phb_array[20] — Vector3[20] (240 bytes) + // +0x534: num — int (number of stored keyframes) + const char* base = interp->data_; + const auto* pos_array = reinterpret_cast(base + 0x000); + const auto* eye_phb_array = reinterpret_cast(base + 0x1E0); + int num_frames = *reinterpret_cast(base + 0x534); + + if (num_frames < 1) + return current_fvec; + + int count = std::min(num_frames, 20); + + // Find the pos_array entry closest to the rewound p_data.pos + const rf::Vector3& rewound_pos = entity->p_data.pos; + float best_dist_sq = 1e30f; + int best_index = -1; + + for (int i = 0; i < count; i++) { + float dx = pos_array[i].x - rewound_pos.x; + float dy = pos_array[i].y - rewound_pos.y; + float dz = pos_array[i].z - rewound_pos.z; + float dist_sq = dx * dx + dy * dy + dz * dz; + if (dist_sq < best_dist_sq) { + best_dist_sq = dist_sq; + best_index = i; + } + } + + if (best_index < 0) + return current_fvec; + + // eye_phb is (pitch, heading, bank) in radians + const rf::Vector3& eye_phb = eye_phb_array[best_index]; + rf::Matrix3 mat; + // set_from_angles(pitch, roll, yaw) — eye_phb.x=pitch, eye_phb.z=bank(roll), eye_phb.y=heading(yaw) + mat.set_from_angles(eye_phb.x, eye_phb.z, eye_phb.y); + return mat.fvec; +} + +// Find the world position of the head collision sphere. +// Uses highest world-space Y to identify head csphere, with per-model caching +// to avoid oscillation at extreme downward pitch where head/torso Y values converge. +static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos, + float* out_radius = nullptr) +{ + if (!entity->vmesh) + return false; + + int num = rf::vmesh_get_num_cspheres(entity->vmesh); + if (num <= 0) + return false; + + // During lag comp, use rewound p_data pos/orient; otherwise use live entity pos/orient + const rf::Vector3& transform_pos = s_in_lag_comp_raycast ? entity->p_data.pos : entity->pos; + const rf::Matrix3& transform_orient = s_in_lag_comp_raycast ? entity->p_data.orient : entity->orient; + + // Find the csphere with highest world-space Y + int highest_y_index = -1; + float highest_y = -1e30f; + rf::Vector3 highest_y_pos; + + for (int i = 0; i < num; i++) { + rf::Vector3 pos; + if (rf::vmesh_get_csphere_pos(entity->vmesh, i, &pos, + const_cast(&transform_pos), + const_cast(&transform_orient))) { + if (pos.y > highest_y) { + highest_y = pos.y; + highest_y_index = i; + highest_y_pos = pos; + } + } + } + + if (highest_y_index < 0) + return false; + + // Helper to retrieve csphere radius for a given index (reduced by 10% for tighter head hitbox) + auto get_csphere_radius = [&](int index, float* radius) { + if (radius) { + rf::Vector3 local_pos; + float r; + if (rf::vmesh_get_csphere(entity->vmesh, index, &local_pos, &r)) { + *radius = r * 1.0f; + } + } + }; + + // At non-extreme pitch, highest-Y is reliable — update cache + // During lag comp, use historical eye forward vector for pitch check + float sin_pitch = s_in_lag_comp_raycast + ? get_historical_eye_fvec(entity).y + : entity->eye_orient.fvec.y; + bool extreme_pitch = std::abs(sin_pitch) > 0.5f; + void* mesh_key = entity->vmesh->mesh; + + if (!extreme_pitch) { + s_head_csphere_cache[mesh_key] = highest_y_index; + *out_pos = highest_y_pos; + get_csphere_radius(highest_y_index, out_radius); + return true; + } + + // At extreme pitch, use cached index to avoid oscillation + auto it = s_head_csphere_cache.find(mesh_key); + if (it != s_head_csphere_cache.end()) { + int cached_index = it->second; + if (cached_index >= 0 && cached_index < num) { + rf::Vector3 cached_pos; + if (rf::vmesh_get_csphere_pos(entity->vmesh, cached_index, &cached_pos, + const_cast(&transform_pos), + const_cast(&transform_orient))) { + *out_pos = cached_pos; + get_csphere_radius(cached_index, out_radius); + return true; + } + } + } + + // No cache yet — fall through to highest-Y + *out_pos = highest_y_pos; + get_csphere_radius(highest_y_index, out_radius); + return true; +} + +// Pitch-based tilt axis fallback when head csphere is unavailable +static rf::Vector3 compute_tilt_axis_from_pitch(const rf::Entity* entity) +{ + // During lag comp, use historical eye forward vector instead of live eye_orient + const rf::Vector3 fvec = s_in_lag_comp_raycast + ? get_historical_eye_fvec(entity) + : entity->eye_orient.fvec; + + constexpr float threshold = 0.01f; + float cos_pitch_raw = std::sqrt(fvec.x * fvec.x + fvec.z * fvec.z); + + rf::Vector3 fwd_xz; + if (cos_pitch_raw > threshold) { + float inv_cos = 1.0f / cos_pitch_raw; + fwd_xz = {fvec.x * inv_cos, 0.0f, fvec.z * inv_cos}; + } + else { + // During lag comp, use rewound p_data.orient for body direction fallback + const rf::Vector3& body_fvec = s_in_lag_comp_raycast + ? entity->p_data.orient.fvec + : entity->orient.fvec; + float body_len = std::sqrt(body_fvec.x * body_fvec.x + body_fvec.z * body_fvec.z); + if (body_len > threshold) { + float inv_len = 1.0f / body_len; + fwd_xz = {body_fvec.x * inv_len, 0.0f, body_fvec.z * inv_len}; + } + else { + fwd_xz = {1.0f, 0.0f, 0.0f}; + } + } + + float raw_pitch = std::asin(std::clamp(fvec.y, -1.0f, 1.0f)); + float fraction = (raw_pitch >= 0.0f) ? (40.0f / 90.0f) : (43.0f / 90.0f); + float pitch = raw_pitch * fraction; + float sin_pitch = std::sin(pitch); + float cos_pitch = std::cos(pitch); + + return rf::Vector3{ + -fwd_xz.x * sin_pitch, + cos_pitch, + -fwd_xz.z * sin_pitch + }; +} + +rf::Vector3 compute_tilt_axis(const rf::Entity* entity, const rf::Vector3& split_point, + float* head_dist_out, + float* head_radius_out, + rf::Vector3* head_pos_out) +{ + rf::Vector3 pitch_axis = compute_tilt_axis_from_pitch(entity); + + if (head_dist_out) + *head_dist_out = -1.0f; + if (head_radius_out) + *head_radius_out = -1.0f; + + rf::Vector3 head_pos; + float head_radius; + if (find_head_csphere_pos(entity, &head_pos, &head_radius)) { + if (head_pos_out) { + // Offset head hitbox backward (opposite look direction) + // During lag comp, use historical eye forward vector + const rf::Vector3 fwd = s_in_lag_comp_raycast + ? get_historical_eye_fvec(entity) + : entity->eye_orient.fvec; + *head_pos_out = head_pos - fwd * (head_radius * 0.30f); + } + if (head_radius_out) + *head_radius_out = head_radius; + rf::Vector3 dir = head_pos - split_point; + float len = dir.len(); + if (len > 0.01f) { + if (head_dist_out) + *head_dist_out = len; + return dir * (1.0f / len); + } + } + + return pitch_axis; +} + +// Test ray against a sphere centered at `center` with given `radius`. +// Returns true if hit found with t in [t_min, t_max], writes nearest t to `t_out`. +static bool ix_ray_sphere(const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + const rf::Vector3& center, float radius, + float t_min, float t_max, float* t_out) +{ + float ox = ray_origin.x - center.x; + float oy = ray_origin.y - center.y; + float oz = ray_origin.z - center.z; + + float a = ray_dir.x * ray_dir.x + ray_dir.y * ray_dir.y + ray_dir.z * ray_dir.z; + float b = 2.0f * (ox * ray_dir.x + oy * ray_dir.y + oz * ray_dir.z); + float c = ox * ox + oy * oy + oz * oz - radius * radius; + + float disc = b * b - 4.0f * a * c; + if (disc < 0.0f) + return false; + + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + + float t0 = (-b - sqrt_disc) * inv_2a; + if (t0 >= t_min && t0 <= t_max) { + *t_out = t0; + return true; + } + + float t1 = (-b + sqrt_disc) * inv_2a; + if (t1 >= t_min && t1 <= t_max) { + *t_out = t1; + return true; + } + + return false; +} + +// Vertical-axis ray-capsule intersection (optimized for Y-aligned capsules). +// cap_a and cap_b are hemisphere centers sharing the same X and Z. +// Updates *best_t if a closer hit is found. Returns true if any hit found. +static bool ix_ray_capsule_vertical(const rf::Vector3& cap_a, const rf::Vector3& cap_b, + float radius, + const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + float* best_t) +{ + float cx = cap_a.x; + float cz = cap_a.z; + float y_bot = std::min(cap_a.y, cap_b.y); + float y_top = std::max(cap_a.y, cap_b.y); + + // Degenerate: capsule too short, use single sphere + if (y_bot >= y_top) { + rf::Vector3 center{cx, y_bot, cz}; + float t; + if (ix_ray_sphere(ray_origin, ray_dir, center, radius, 0.0f, 1.0f, &t) && t < *best_t) { + *best_t = t; + return true; + } + return false; + } + + bool found = false; + constexpr float epsilon = 1e-12f; + + // --- Cylinder body (infinite cylinder in XZ, clamped to Y slab) --- + float ox = ray_origin.x - cx; + float oz = ray_origin.z - cz; + float a = ray_dir.x * ray_dir.x + ray_dir.z * ray_dir.z; + float b_cyl = 2.0f * (ox * ray_dir.x + oz * ray_dir.z); + float c_cyl = ox * ox + oz * oz - radius * radius; + + if (a >= epsilon) { + float disc = b_cyl * b_cyl - 4.0f * a * c_cyl; + if (disc >= 0.0f) { + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + float t0 = (-b_cyl - sqrt_disc) * inv_2a; + float t1 = (-b_cyl + sqrt_disc) * inv_2a; + + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y >= y_bot && hit_y <= y_top) { + *best_t = t; + found = true; + break; + } + } + } + } + } + else { + // Ray nearly vertical - check if inside XZ circle + if (c_cyl <= 0.0f) { + if (std::abs(ray_dir.y) >= epsilon) { + float inv_dy = 1.0f / ray_dir.y; + float ty0 = (y_bot - ray_origin.y) * inv_dy; + float ty1 = (y_top - ray_origin.y) * inv_dy; + if (ty0 > ty1) std::swap(ty0, ty1); + float t_enter = std::max(ty0, 0.0f); + if (t_enter <= ty1 && t_enter <= 1.0f && t_enter < *best_t) { + *best_t = t_enter; + found = true; + } + } + else if (ray_origin.y >= y_bot && ray_origin.y <= y_top && 0.0f < *best_t) { + *best_t = 0.0f; + found = true; + } + } + } + + // --- Bottom hemisphere --- + { + rf::Vector3 cap_center{cx, y_bot, cz}; + float t; + if (ix_ray_sphere(ray_origin, ray_dir, cap_center, radius, 0.0f, 1.0f, &t)) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y <= y_bot && t < *best_t) { + *best_t = t; + found = true; + } + } + } + + // --- Top hemisphere --- + { + rf::Vector3 cap_center{cx, y_top, cz}; + float t; + if (ix_ray_sphere(ray_origin, ray_dir, cap_center, radius, 0.0f, 1.0f, &t)) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y >= y_top && t < *best_t) { + *best_t = t; + found = true; + } + } + } + + return found; +} + +// General-axis ray-capsule intersection for capsule from cap_a to cap_b with given radius. +// Updates *best_t if a closer hit is found. Returns true if any hit found. +static bool ix_ray_capsule_general(const rf::Vector3& cap_a, const rf::Vector3& cap_b, + float radius, + const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + float* best_t) +{ + rf::Vector3 v = cap_b - cap_a; + float v_dot_v = v.dot_prod(v); + + constexpr float epsilon = 1e-12f; + + // Degenerate: A == B, test single sphere + if (v_dot_v < epsilon) { + float t; + if (ix_ray_sphere(ray_origin, ray_dir, cap_a, radius, 0.0f, 1.0f, &t) && t < *best_t) { + *best_t = t; + return true; + } + return false; + } + + float inv_v_dot_v = 1.0f / v_dot_v; + rf::Vector3 w = ray_origin - cap_a; + + // Project ray_dir and w perpendicular to capsule axis V + float d_dot_v = ray_dir.dot_prod(v); + float w_dot_v = w.dot_prod(v); + + rf::Vector3 d_perp = ray_dir - v * (d_dot_v * inv_v_dot_v); + rf::Vector3 w_perp = w - v * (w_dot_v * inv_v_dot_v); + + float a = d_perp.dot_prod(d_perp); + float b = 2.0f * w_perp.dot_prod(d_perp); + float c = w_perp.dot_prod(w_perp) - radius * radius; + + bool found = false; + + // --- Cylinder body --- + if (a >= epsilon) { + float disc = b * b - 4.0f * a * c; + if (disc >= 0.0f) { + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + float t0 = (-b - sqrt_disc) * inv_2a; + float t1 = (-b + sqrt_disc) * inv_2a; + + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + // Check projection along capsule axis: s in [0, 1] means cylinder body + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s >= 0.0f && s <= 1.0f) { + *best_t = t; + found = true; + break; + } + } + } + } + } + + // --- Hemisphere at A (accept if projection s <= 0) --- + { + float t; + if (ix_ray_sphere(ray_origin, ray_dir, cap_a, radius, 0.0f, 1.0f, &t) && t < *best_t) { + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s <= 0.0f) { + *best_t = t; + found = true; + } + } + } + + // --- Hemisphere at B (accept if projection s >= 1) --- + { + float t; + if (ix_ray_sphere(ray_origin, ray_dir, cap_b, radius, 0.0f, 1.0f, &t) && t < *best_t) { + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s >= 1.0f) { + *best_t = t; + found = true; + } + } + } + + return found; +} + +// Vertical-axis ray-cylinder intersection (flat disc caps, Y-aligned). +// cyl_bot and cyl_top are the flat disc centers sharing the same X and Z. +// Updates *best_t if a closer hit is found. Returns true if any hit found. +static bool ix_ray_cylinder_vertical(const rf::Vector3& cyl_bot, const rf::Vector3& cyl_top, + float radius, + const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + float* best_t) +{ + float cx = cyl_bot.x; + float cz = cyl_bot.z; + float y_bot = std::min(cyl_bot.y, cyl_top.y); + float y_top = std::max(cyl_bot.y, cyl_top.y); + + if (y_bot >= y_top) + return false; + + bool found = false; + constexpr float epsilon = 1e-12f; + + // --- Cylinder side wall (infinite cylinder in XZ, clamped to Y slab) --- + float ox = ray_origin.x - cx; + float oz = ray_origin.z - cz; + float a = ray_dir.x * ray_dir.x + ray_dir.z * ray_dir.z; + float b_cyl = 2.0f * (ox * ray_dir.x + oz * ray_dir.z); + float c_cyl = ox * ox + oz * oz - radius * radius; + + if (a >= epsilon) { + float disc = b_cyl * b_cyl - 4.0f * a * c_cyl; + if (disc >= 0.0f) { + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + float t0 = (-b_cyl - sqrt_disc) * inv_2a; + float t1 = (-b_cyl + sqrt_disc) * inv_2a; + + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y >= y_bot && hit_y <= y_top) { + *best_t = t; + found = true; + break; + } + } + } + } + } + else { + // Ray nearly vertical - check if inside XZ circle + if (c_cyl <= 0.0f) { + if (std::abs(ray_dir.y) >= epsilon) { + float inv_dy = 1.0f / ray_dir.y; + float ty0 = (y_bot - ray_origin.y) * inv_dy; + float ty1 = (y_top - ray_origin.y) * inv_dy; + if (ty0 > ty1) std::swap(ty0, ty1); + float t_enter = std::max(ty0, 0.0f); + if (t_enter <= ty1 && t_enter <= 1.0f && t_enter < *best_t) { + *best_t = t_enter; + found = true; + } + } + else if (ray_origin.y >= y_bot && ray_origin.y <= y_top && 0.0f < *best_t) { + *best_t = 0.0f; + found = true; + } + } + } + + // --- Bottom disc cap (y = y_bot) --- + if (std::abs(ray_dir.y) >= epsilon) { + float t = (y_bot - ray_origin.y) / ray_dir.y; + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hx = ray_origin.x + ray_dir.x * t - cx; + float hz = ray_origin.z + ray_dir.z * t - cz; + if (hx * hx + hz * hz <= radius * radius) { + *best_t = t; + found = true; + } + } + } + + // --- Top disc cap (y = y_top) --- + if (std::abs(ray_dir.y) >= epsilon) { + float t = (y_top - ray_origin.y) / ray_dir.y; + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hx = ray_origin.x + ray_dir.x * t - cx; + float hz = ray_origin.z + ray_dir.z * t - cz; + if (hx * hx + hz * hz <= radius * radius) { + *best_t = t; + found = true; + } + } + } + + return found; +} + +// General-axis ray-cylinder intersection (flat disc caps) for cylinder from cyl_a to cyl_b. +// Updates *best_t if a closer hit is found. Returns true if any hit found. +static bool ix_ray_cylinder_general(const rf::Vector3& cyl_a, const rf::Vector3& cyl_b, + float radius, + const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + float* best_t) +{ + rf::Vector3 v = cyl_b - cyl_a; + float v_dot_v = v.dot_prod(v); + + constexpr float epsilon = 1e-12f; + + // Degenerate: A == B, no volume + if (v_dot_v < epsilon) + return false; + + float inv_v_dot_v = 1.0f / v_dot_v; + rf::Vector3 w = ray_origin - cyl_a; + + // Project ray_dir and w perpendicular to cylinder axis V + float d_dot_v = ray_dir.dot_prod(v); + float w_dot_v = w.dot_prod(v); + + rf::Vector3 d_perp = ray_dir - v * (d_dot_v * inv_v_dot_v); + rf::Vector3 w_perp = w - v * (w_dot_v * inv_v_dot_v); + + float a = d_perp.dot_prod(d_perp); + float b = 2.0f * w_perp.dot_prod(d_perp); + float c = w_perp.dot_prod(w_perp) - radius * radius; + + bool found = false; + + // --- Cylinder body --- + if (a >= epsilon) { + float disc = b * b - 4.0f * a * c; + if (disc >= 0.0f) { + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + float t0 = (-b - sqrt_disc) * inv_2a; + float t1 = (-b + sqrt_disc) * inv_2a; + + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s >= 0.0f && s <= 1.0f) { + *best_t = t; + found = true; + break; + } + } + } + } + } + + // --- Disc cap at A (plane: dot(p - A, v) = 0) --- + if (std::abs(d_dot_v) >= epsilon) { + float t = -w_dot_v / d_dot_v; + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + // Hit point relative to A; on this plane, axial component is 0, so |h|² = radial distance² + rf::Vector3 h = w + ray_dir * t; + if (h.dot_prod(h) <= radius * radius) { + *best_t = t; + found = true; + } + } + } + + // --- Disc cap at B (plane: dot(p - A, v) = |v|²) --- + if (std::abs(d_dot_v) >= epsilon) { + float t = (v_dot_v - w_dot_v) / d_dot_v; + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + // Hit point relative to B; on this plane, axial component is 0, so |h|² = radial distance² + rf::Vector3 h = w + ray_dir * t - v; + if (h.dot_prod(h) <= radius * radius) { + *best_t = t; + found = true; + } + } + } + + return found; +} + +// Hook the ix_linesegment_boundingbox call at 0x0049C862 (multi entity collision path) +// Replaces AABB intersection with split capsule (lower vertical + upper tilted toward head) +static CallHook +ix_linesegment_capsule_hook{ + 0x0049C862, + [](const rf::Vector3& bbox_min, const rf::Vector3& bbox_max, + const rf::Vector3& p0, const rf::Vector3& p1, rf::Vector3* hit_point) { + + if (g_alpine_server_config.legacy_hitboxes) + return ix_linesegment_capsule_hook.call_target(bbox_min, bbox_max, p0, p1, hit_point); + + rf::Entity* entity = s_current_collide_entity; + + float cx = (bbox_min.x + bbox_max.x) * 0.5f; + float cz = (bbox_min.z + bbox_max.z) * 0.5f; + float radius = (bbox_max.x - bbox_min.x) * 0.5f; + + // When crouching, the engine lowers bbox_max.y by half_size.y * 0.5 (only the top). + // Some character models (e.g. miner1 with non-pistol weapons) don't crouch that low - extend upward + bool crouching = entity && rf::entity_is_crouching(entity); + float effective_max_y = bbox_max.y; + if (crouching) { + float bbox_height = bbox_max.y - bbox_min.y; + effective_max_y += bbox_height * 0.3f; + } + + float split_ratio = crouching ? 0.35f : 0.55f; + float bbox_height = effective_max_y - bbox_min.y; + float split_y = bbox_min.y + bbox_height * split_ratio; + float upper_height = effective_max_y - split_y; + + rf::Vector3 dir = p1 - p0; + float best_t = 2.0f; + + // Split into lower (vertical) + upper (tilted) if entity available and bbox tall enough + if (entity && upper_height > radius) { + // Lower capsule (vertical): inset bottom so hemisphere doesn't extend below bbox + rf::Vector3 lower_bot{cx, bbox_min.y + radius, cz}; + rf::Vector3 lower_top{cx, split_y, cz}; + ix_ray_capsule_vertical(lower_bot, lower_top, radius, p0, dir, &best_t); + + // Torso cylinder (tilted): starts at split point, shortened at top for head sphere + rf::Vector3 upper_a{cx, split_y, cz}; + float head_dist; + float head_radius; + rf::Vector3 head_world_pos; + rf::Vector3 tilt_axis = compute_tilt_axis(entity, upper_a, &head_dist, &head_radius, &head_world_pos); + float upper_len = upper_height; + if (head_dist > 0.0f) + upper_len = std::min(upper_len, head_dist); + + // Shorten so flat top stops just before head sphere + if (head_radius > 0.0f && head_dist > 0.0f) { + float max_endpoint = head_dist - head_radius; + upper_len = std::min(upper_len, max_endpoint); + } + upper_len = std::max(upper_len, 0.0f); + upper_len *= 1.05f; + + rf::Vector3 upper_b = upper_a + tilt_axis * upper_len; + ix_ray_cylinder_general(upper_a, upper_b, radius, p0, dir, &best_t); + + // Head sphere intersection + if (head_radius > 0.0f && head_dist > 0.0f) { + float head_t; + if (ix_ray_sphere(p0, dir, head_world_pos, head_radius, 0.0f, 1.0f, &head_t) && head_t < best_t) { + best_t = head_t; + } + } + } + else { + // Fallback: single vertical capsule + rf::Vector3 bot{cx, bbox_min.y, cz}; + rf::Vector3 top{cx, bbox_max.y, cz}; + ix_ray_capsule_vertical(bot, top, radius, p0, dir, &best_t); + } + + if (best_t > 1.0f) + return false; + + if (hit_point) { + hit_point->x = p0.x + dir.x * best_t; + hit_point->y = p0.y + dir.y * best_t; + hit_point->z = p0.z + dir.z * best_t; + } + return true; + }, +}; + // Note: this must be called from DLL init function // Note: we can't use global variable because that would lead to crash when launcher loads this DLL to check dependencies static rf::CmdLineParam& get_url_cmd_line_param() @@ -1042,6 +1784,7 @@ ConsoleCommand2 set_handicap_cmd{ "Set desired multiplayer damage reduction handicap", }; + CallHook obj_apply_damage_lava_hook{ { 0x004212A1, @@ -1099,6 +1842,9 @@ void multi_do_patch() level_download_init(); multi_ban_apply_patch(); + // Improved hitboxes: split capsule with head tracking + capture_entity_injection.install(); + ix_linesegment_capsule_hook.install(); // Fix lava damage sometimes being attributed to a player obj_apply_damage_lava_hook.install(); diff --git a/game_patch/multi/multi.h b/game_patch/multi/multi.h index 52c1969ad..901c33e26 100644 --- a/game_patch/multi/multi.h +++ b/game_patch/multi/multi.h @@ -102,6 +102,7 @@ struct AlpineFactionServerInfo bool geo_chunk_physics = false; bool location_pinging = false; bool delayed_spawns = false; + bool legacy_hitboxes = false; int koth_score_limit = 0; int dc_score_limit = 0; bool allow_footsteps = false; @@ -150,6 +151,11 @@ bool rotation_autodl_in_progress(); void rotation_autodl_start(size_t levels_count, std::vector unique_levels); void multi_ban_apply_patch(); std::expected get_level_file_version(const std::string& file_name); +rf::Vector3 compute_tilt_axis(const rf::Entity* entity, const rf::Vector3& split_point, + float* head_dist_out = nullptr, + float* head_radius_out = nullptr, + rf::Vector3* head_pos_out = nullptr); +void set_lag_comp_flag(bool active); void print_player_info(rf::Player* player, bool new_join); void server_set_player_weapon(rf::Player* pp, rf::Entity* ep, int weapon_type); void start_level_in_multi(std::string filename); diff --git a/game_patch/multi/network.cpp b/game_patch/multi/network.cpp index 6f44d6c4c..95ef6bf5a 100644 --- a/game_patch/multi/network.cpp +++ b/game_patch/multi/network.cpp @@ -1600,6 +1600,9 @@ CallHook send_join_accept_packet_ho if (server_clear_stale_movement_input()) { ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input; } + if (server_legacy_hitboxes()) { + ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::legacy_hitboxes; + } // AF 1.3+ clients: use footer-based format for forward compatibility // Older clients: use legacy raw struct (they don't know about the footer) bool use_footer = g_joining_client_version == ClientSoftware::AlpineFaction @@ -1692,6 +1695,7 @@ CodeInjection process_join_accept_injection{ server_info.allow_outlines = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines); server_info.allow_outlines_xray = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines_xray); server_info.clear_stale_movement_input = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input); + server_info.legacy_hitboxes = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::legacy_hitboxes); constexpr float default_fov = 90.0f; if (!!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::max_fov) && ext_data.max_fov >= default_fov) { diff --git a/game_patch/multi/network.h b/game_patch/multi/network.h index 84a3c281e..a49daab44 100644 --- a/game_patch/multi/network.h +++ b/game_patch/multi/network.h @@ -187,6 +187,7 @@ struct AlpineFactionJoinAcceptPacketExt allow_outlines = 1u << 14, allow_outlines_xray = 1u << 15, clear_stale_movement_input = 1u << 16, + legacy_hitboxes = 1u << 17, } flags = Flags::none; float max_fov = 0.0f; diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index af8258028..b17692af7 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -2216,7 +2216,9 @@ FunHook multi_lag_comp_handle_hit_hook FunHook multi_lag_comp_weapon_fire_hook{ 0x0046F7E0, [](rf::Entity *ep, rf::Weapon *wp) { + set_lag_comp_flag(true); multi_lag_comp_weapon_fire_hook.call_target(ep, wp); + set_lag_comp_flag(false); rf::Player* pp = rf::player_from_entity_handle(ep->handle); if (pp && pp->stats) { auto* stats = static_cast(pp->stats); @@ -3770,6 +3772,11 @@ bool server_allow_footsteps() return g_alpine_server_config.allow_footsteps; } +bool server_legacy_hitboxes() +{ + return g_alpine_server_config.legacy_hitboxes; +} + std::tuple server_features_require_alpine_client() { bool requires_alpine = false; // alpine required to spawn diff --git a/game_patch/multi/server.h b/game_patch/multi/server.h index 37b691cca..255695020 100644 --- a/game_patch/multi/server.h +++ b/game_patch/multi/server.h @@ -33,6 +33,7 @@ bool server_gaussian_spread(); bool server_geo_chunk_physics(); bool server_clear_stale_movement_input(); bool server_allow_footsteps(); +bool server_legacy_hitboxes(); std::tuple server_features_require_alpine_client(); void server_reliable_socket_ready(rf::Player* player); bool server_weapon_items_give_full_ammo(); diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 8f2103f26..b52a4a30f 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -749,6 +749,8 @@ struct AlpineServerConfig bool allow_outlines = false; bool allow_outlines_xray = true; bool use_sp_damage_calculation = false; + bool legacy_hitboxes = false; + bool exclude_bots_from_player_count = false; AlpineRestrictConfig alpine_restricted_config; InactivityConfig inactivity_config; DamageNotificationConfig damage_notification_config; diff --git a/game_patch/rf/vmesh.h b/game_patch/rf/vmesh.h index 0f617cb51..e96a91ccc 100644 --- a/game_patch/rf/vmesh.h +++ b/game_patch/rf/vmesh.h @@ -94,6 +94,7 @@ namespace rf }; static_assert(sizeof(MeshMaterial) == 0xC8); + static auto& vmesh_get_csphere_pos = addr_as_ref(0x00503290); static auto& vmesh_get_type = addr_as_ref(0x00502B00); static auto& vmesh_get_name = addr_as_ref(0x00503470); static auto& vmesh_get_num_cspheres = addr_as_ref(0x00503250); From 12958db102d785931f62e7f1dc9e0135e1f092ee Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 7 Jul 2026 16:38:02 -0230 Subject: [PATCH 2/3] fixes --- docs/CHANGELOG.md | 1 - game_patch/debug/debug.cpp | 163 ++++++-------- game_patch/debug/debug_cmd.cpp | 5 +- game_patch/debug/debug_internal.h | 2 +- game_patch/main/main.cpp | 1 + game_patch/multi/dedi_cfg.cpp | 5 - game_patch/multi/multi.cpp | 344 ++++++++++++++++------------- game_patch/multi/multi.h | 28 ++- game_patch/multi/network.cpp | 8 +- game_patch/multi/network.h | 2 +- game_patch/multi/server_internal.h | 3 +- 11 files changed, 301 insertions(+), 261 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b75454644..2d02b2aaf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -228,7 +228,6 @@ Version 1.3.0 (Bakeapple): Released Apr-22-2026 [@AL2009man](https://github.com/AL2009man) - Add `ms_scale` toggle to use various mouse sensitivity scaling options between Classic (original scaling), Raw and Modern (id Tech/Source). - [@natarii](https://github.com/natarii) - Implement FFLink client functionality in launcher diff --git a/game_patch/debug/debug.cpp b/game_patch/debug/debug.cpp index b1684aac9..2dbc20324 100644 --- a/game_patch/debug/debug.cpp +++ b/game_patch/debug/debug.cpp @@ -387,9 +387,14 @@ static void draw_3d_aabb(const rf::Vector3& bbox_min, const rf::Vector3& bbox_ma rf::gr::line_vec(c[3], c[7], mode); } +// Fraction of the bbox height by which the engine lowers a crouched entity's bbox_max.y (matches +// the FMUL constant at 0x005893C0 in the stock collision path). Used to reconstruct the same bbox +// the server hit-tests against before handing it to compute_hitbox_geometry(). +static constexpr float k_crouch_bbox_top_fraction = 0.5f; + static void render_hitboxes() { - if (!g_dbg_hitboxes) + if (!g_dbg_hitboxes && !g_dbg_cspheres) return; auto& multi_entity_bbox_size = addr_as_ref(0x007C6A70); @@ -407,101 +412,73 @@ static void render_hitboxes() if (rf::local_player && entity->handle == rf::local_player->entity_handle) continue; - // Match engine bbox computation: entity->pos ± half_size, - // then for crouching only bbox_max.y is lowered (feet stay on floor) - rf::Vector3 half_size = multi_entity_bbox_size; - rf::Vector3 bbox_min = entity->pos - half_size; - rf::Vector3 bbox_max = entity->pos + half_size; - bool crouching = rf::entity_is_crouching(entity); - if (crouching) { - bbox_max.y -= multi_entity_bbox_size.y * 0.5f; - } - - if (legacy) { - // Legacy mode: draw the AABB that the engine actually uses for collision - rf::gr::set_color(255, 128, 0, 255); - draw_3d_aabb(bbox_min, bbox_max); - continue; - } - - float cx = (bbox_min.x + bbox_max.x) * 0.5f; - float cz = (bbox_min.z + bbox_max.z) * 0.5f; - float radius = (bbox_max.x - bbox_min.x) * 0.5f; - - // Extend effective top for crouching (models don't always crouch as low as the engine bbox) - float effective_max_y = bbox_max.y; - if (crouching) { - float h = bbox_max.y - bbox_min.y; - effective_max_y += h * 0.3f; - } + // Hybrid (or legacy) hitbox volume — independent toggle: `debug hitbox` + if (g_dbg_hitboxes) { + // Match engine bbox computation: entity->pos ± half_size, + // then for crouching only bbox_max.y is lowered (feet stay on floor) + rf::Vector3 half_size = multi_entity_bbox_size; + rf::Vector3 bbox_min = entity->pos - half_size; + rf::Vector3 bbox_max = entity->pos + half_size; + bool crouching = rf::entity_is_crouching(entity); + if (crouching) { + bbox_max.y -= multi_entity_bbox_size.y * k_crouch_bbox_top_fraction; + } - float split_ratio = crouching ? 0.35f : 0.55f; - float bbox_height = effective_max_y - bbox_min.y; - float split_y = bbox_min.y + bbox_height * split_ratio; - float upper_height = effective_max_y - split_y; - - // Lower capsule (green): inset bottom so hemisphere doesn't extend below bbox - rf::gr::set_color(0, 255, 0, 255); - rf::Vector3 lower_bot{cx, bbox_min.y + radius, cz}; - rf::Vector3 lower_top{cx, split_y, cz}; - draw_3d_capsule_general(lower_bot, lower_top, radius); - - // Torso cylinder (cyan) + head sphere (magenta) - rf::Vector3 upper_a{cx, split_y, cz}; - float head_dist; - float head_radius; - rf::Vector3 head_world_pos; - rf::Vector3 tilt_axis = compute_tilt_axis(entity, upper_a, &head_dist, &head_radius, &head_world_pos); - rf::gr::set_color(0, 255, 255, 255); - float upper_len = upper_height; - if (head_dist > 0.0f) - upper_len = std::min(upper_len, head_dist); - - // Shorten so flat top stops just before head sphere (matching collision) - if (head_radius > 0.0f && head_dist > 0.0f) { - float max_endpoint = head_dist - head_radius; - upper_len = std::min(upper_len, max_endpoint); - } - upper_len = std::max(upper_len, 0.0f); - upper_len *= 1.05f; - - rf::Vector3 upper_b = upper_a + tilt_axis * upper_len; - draw_3d_cylinder_general(upper_a, upper_b, radius); - - // Head sphere - if (head_radius > 0.0f && head_dist > 0.0f) { - rf::gr::set_color(255, 0, 255, 255); - auto mode = rf::gr::Mode{ - rf::gr::TEXTURE_SOURCE_NONE, - rf::gr::COLOR_SOURCE_VERTEX, - rf::gr::ALPHA_SOURCE_VERTEX, - rf::gr::ALPHA_BLEND_NONE, - rf::gr::ZBUFFER_TYPE_FULL, - rf::gr::FOG_NOT_ALLOWED, - }; - rf::gr::sphere(head_world_pos, head_radius, mode); + if (legacy) { + // Legacy mode: draw the AABB that the engine actually uses for collision + rf::gr::set_color(255, 128, 0, 255); + draw_3d_aabb(bbox_min, bbox_max); + } + else { + // Compute the exact same volume the server hit-tests (shared with the collision path). + HitboxGeometry geo = compute_hitbox_geometry(entity, bbox_min, bbox_max); + + // Lower capsule (green) + rf::gr::set_color(0, 255, 0, 255); + draw_3d_capsule_general(geo.lower_bot, geo.lower_top, geo.radius); + + if (geo.split) { + // Torso cylinder (cyan) + rf::gr::set_color(0, 255, 255, 255); + draw_3d_cylinder_general(geo.upper_a, geo.upper_b, geo.radius); + + // Head sphere (magenta) + if (geo.has_head) { + rf::gr::set_color(255, 0, 255, 255); + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_NONE, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + rf::gr::sphere(geo.head_pos, geo.head_radius, mode); + } + } + } } - // Collision spheres (yellow) — toggle via g_dbg_hitboxes_show_cspheres - if (!g_dbg_hitboxes_show_cspheres) - continue; - int num_cspheres = rf::vmesh_get_num_cspheres(entity->vmesh); - for (int i = 0; i < num_cspheres; i++) { - rf::Vector3 sphere_pos; - if (rf::vmesh_get_csphere_pos(entity->vmesh, i, &sphere_pos, &entity->pos, &entity->orient)) { - rf::Vector3 local_pos; - float sphere_radius; - if (rf::vmesh_get_csphere(entity->vmesh, i, &local_pos, &sphere_radius)) { - rf::gr::set_color(255, 255, 0, 180); - auto mode = rf::gr::Mode{ - rf::gr::TEXTURE_SOURCE_NONE, - rf::gr::COLOR_SOURCE_VERTEX, - rf::gr::ALPHA_SOURCE_VERTEX, - rf::gr::ALPHA_BLEND_ALPHA, - rf::gr::ZBUFFER_TYPE_FULL, - rf::gr::FOG_NOT_ALLOWED, - }; - rf::gr::sphere(sphere_pos, sphere_radius, mode); + // Engine collision spheres (yellow) — independent toggle: `debug cspheres` + if (g_dbg_cspheres) { + int num_cspheres = rf::vmesh_get_num_cspheres(entity->vmesh); + for (int i = 0; i < num_cspheres; i++) { + rf::Vector3 sphere_pos; + if (rf::vmesh_get_csphere_pos(entity->vmesh, i, &sphere_pos, &entity->pos, &entity->orient)) { + rf::Vector3 local_pos; + float sphere_radius; + if (rf::vmesh_get_csphere(entity->vmesh, i, &local_pos, &sphere_radius)) { + rf::gr::set_color(255, 255, 0, 180); + auto mode = rf::gr::Mode{ + rf::gr::TEXTURE_SOURCE_NONE, + rf::gr::COLOR_SOURCE_VERTEX, + rf::gr::ALPHA_SOURCE_VERTEX, + rf::gr::ALPHA_BLEND_ALPHA, + rf::gr::ZBUFFER_TYPE_FULL, + rf::gr::FOG_NOT_ALLOWED, + }; + rf::gr::sphere(sphere_pos, sphere_radius, mode); + } } } } diff --git a/game_patch/debug/debug_cmd.cpp b/game_patch/debug/debug_cmd.cpp index 2b08cb193..48c9d5a49 100644 --- a/game_patch/debug/debug_cmd.cpp +++ b/game_patch/debug/debug_cmd.cpp @@ -20,7 +20,7 @@ bool g_dbg_geometry_rendering_stats = false; bool g_dbg_static_lights = false; bool g_dbg_particle_emitters = false; bool g_dbg_hitboxes = false; -bool g_dbg_hitboxes_show_cspheres = false; +bool g_dbg_cspheres = false; DebugFlagDesc g_debug_flags[] = { {addr_as_ref(0x0062F3AA), "thruster"}, @@ -54,6 +54,7 @@ DebugFlagDesc g_debug_flags[] = { {addr_as_ref(0x009BB5A8), "nolightmap", true}, // fullbright {addr_as_ref(0x009BB5B0), "show_invisible_faces", true}, {g_dbg_hitboxes, "hitbox", false, true}, + {g_dbg_cspheres, "cspheres", false, true}, }; ConsoleCommand2 debug_cmd{ @@ -81,7 +82,7 @@ ConsoleCommand2 debug_cmd{ nullptr, "debug [thruster | light | light2 | push_climb_reg | geo_reg | glass | mover | ignite | movemode | perf |\n" "perfbar | waypoint | network | particlestats | weapon | event | trigger | objrender | roomstats | trans |\n" - "room | portal | lightmap | nolightmap | show_invisible_faces | hitbox]", + "room | portal | lightmap | nolightmap | show_invisible_faces | hitbox | cspheres]", }; void debug_cmd_multi_init() diff --git a/game_patch/debug/debug_internal.h b/game_patch/debug/debug_internal.h index 08ea6215d..47b9fc1e3 100644 --- a/game_patch/debug/debug_internal.h +++ b/game_patch/debug/debug_internal.h @@ -152,7 +152,7 @@ class DebugNameValueBoxTwoColumns }; extern bool g_dbg_hitboxes; -extern bool g_dbg_hitboxes_show_cspheres; +extern bool g_dbg_cspheres; void debug_cmd_init(); void debug_cmd_render(); void debug_cmd_render_ui(); diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index 9a7558798..f4b03ca00 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -308,6 +308,7 @@ FunHook level_init_post_hook{ populate_world_hud_sprite_events(); populate_fullscreen_overlay_events(); reset_achievement_state_info(); + hitbox_reset_head_csphere_cache(); // drop per-model head-csphere cache (mesh pointers change per level) multi_level_init_post_gametypes(); apply_geoable_flags(); apply_breakable_materials(); diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index c608262b7..a9f0fdd27 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -1269,10 +1269,6 @@ static void apply_known_key_in_order(AlpineServerConfig& cfg, const std::string& if (auto v = node.value()) cfg.legacy_hitboxes = *v; } - else if (key == "exclude_bots_from_player_count") { - if (auto v = node.value()) - cfg.exclude_bots_from_player_count = *v; - } } // apply base config toml tables @@ -2169,7 +2165,6 @@ void print_alpine_dedicated_server_config_info(std::string& output, bool verbose std::format_to(iter, " Allow outlines: {}\n", cfg.allow_outlines); std::format_to(iter, " Allow outlines xray: {}\n", cfg.allow_outlines_xray); std::format_to(iter, " Legacy hitboxes: {}\n", cfg.legacy_hitboxes); - std::format_to(iter, " Exclude bots from player count: {}\n", cfg.exclude_bots_from_player_count); // inactivity std::format_to(iter, " Identify inactive players: {}\n", cfg.inactivity_config.enabled); diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 6c2d1e850..8791803b7 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -53,6 +53,15 @@ // --- Improved hitboxes: split capsule collision with head tracking --- +// Hybrid hitbox tuning constants. Shared by the server-side collision test +// (ix_linesegment_capsule_hook) and the client-side debug visualizer (render_hitboxes) via +// compute_hitbox_geometry(), so the drawn volume always matches the volume that is hit-tested. +static constexpr float k_hitbox_crouch_top_extension = 0.3f; // extend effective top when crouching +static constexpr float k_hitbox_split_ratio_crouch = 0.35f; // height fraction of lower/torso split, crouched +static constexpr float k_hitbox_split_ratio_standing = 0.55f; // ... standing +static constexpr float k_hitbox_torso_len_scale = 1.05f; // torso cylinder length overshoot toward head +static constexpr float k_hitbox_head_back_offset_frac = 0.30f; // pull head sphere back along look dir (x head radius) + // Lag compensation: when true, capsule collision uses rewound physics data static bool s_in_lag_comp_raycast = false; @@ -63,9 +72,19 @@ void set_lag_comp_flag(bool active) { // Entity pointer captured from the collision loop static rf::Entity* s_current_collide_entity = nullptr; -// Cache: VMesh::mesh pointer -> head csphere index (per-model, stable across instances) +// Cache: VMesh::mesh pointer -> head csphere index (per-model, stable across instances). +// Keyed by the shared VMesh::mesh pointer, which is freed on level unload and may be recycled +// by a different model on the next level. Cleared on every level load to avoid a recycled +// pointer resolving to a stale head-csphere index. See hitbox_reset_head_csphere_cache(). static std::unordered_map s_head_csphere_cache; +// Clear per-model head-csphere cache. Must be called on level load (mesh pointers are +// invalidated when the previous level's models are freed). +void hitbox_reset_head_csphere_cache() +{ + s_head_csphere_cache.clear(); +} + // Capture ESI (entity pointer) at start of bbox computation in collide_linesegment_level_for_multi static CodeInjection capture_entity_injection{ 0x0049C7D5, @@ -74,58 +93,13 @@ static CodeInjection capture_entity_injection{ }, }; -// Look up historical eye forward vector from ObjInterp during lag compensation. -// Matches the rewound p_data.pos against ObjInterp.pos_array to find the temporal index, -// then reads the corresponding eye_phb_array entry to reconstruct the eye orientation. -static rf::Vector3 get_historical_eye_fvec(const rf::Entity* entity) -{ - // Fallback: current eye orientation - const rf::Vector3& current_fvec = entity->eye_orient.fvec; - - const rf::ObjInterp* interp = entity->obj_interp; - if (!interp) - return current_fvec; - - // ObjInterp layout (from Ghidra analysis): - // +0x000: pos_array[20] — Vector3[20] (240 bytes) - // +0x1E0: eye_phb_array[20] — Vector3[20] (240 bytes) - // +0x534: num — int (number of stored keyframes) - const char* base = interp->data_; - const auto* pos_array = reinterpret_cast(base + 0x000); - const auto* eye_phb_array = reinterpret_cast(base + 0x1E0); - int num_frames = *reinterpret_cast(base + 0x534); - - if (num_frames < 1) - return current_fvec; - - int count = std::min(num_frames, 20); - - // Find the pos_array entry closest to the rewound p_data.pos - const rf::Vector3& rewound_pos = entity->p_data.pos; - float best_dist_sq = 1e30f; - int best_index = -1; - - for (int i = 0; i < count; i++) { - float dx = pos_array[i].x - rewound_pos.x; - float dy = pos_array[i].y - rewound_pos.y; - float dz = pos_array[i].z - rewound_pos.z; - float dist_sq = dx * dx + dy * dy + dz * dz; - if (dist_sq < best_dist_sq) { - best_dist_sq = dist_sq; - best_index = i; - } - } - - if (best_index < 0) - return current_fvec; - - // eye_phb is (pitch, heading, bank) in radians - const rf::Vector3& eye_phb = eye_phb_array[best_index]; - rf::Matrix3 mat; - // set_from_angles(pitch, roll, yaw) — eye_phb.x=pitch, eye_phb.z=bank(roll), eye_phb.y=heading(yaw) - mat.set_from_angles(eye_phb.x, eye_phb.z, eye_phb.y); - return mat.fvec; -} +// Note on lag compensation and eye angle: +// The head/torso tilt is driven by the target's eye pitch. RF's server-side lag comp rewinds +// the target's POSITION and body orientation (used below via p_data.pos/orient), but it does not +// record the target's eye/aim angle. There is therefore no rewound eye angle to reconstruct, +// so the tilt uses the live (latest-received) eye orientation. +// The error is confined to the tilt of the torso cylinder + head sphere and is only significant +// when the target flicks vertically inside the lag-comp window. // Find the world position of the head collision sphere. // Uses highest world-space Y to identify head csphere, with per-model caching @@ -165,22 +139,20 @@ static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos if (highest_y_index < 0) return false; - // Helper to retrieve csphere radius for a given index (reduced by 10% for tighter head hitbox) + // Helper to retrieve csphere radius for a given index (full csphere radius, no scaling applied) auto get_csphere_radius = [&](int index, float* radius) { if (radius) { rf::Vector3 local_pos; float r; if (rf::vmesh_get_csphere(entity->vmesh, index, &local_pos, &r)) { - *radius = r * 1.0f; + *radius = r; } } }; - // At non-extreme pitch, highest-Y is reliable — update cache - // During lag comp, use historical eye forward vector for pitch check - float sin_pitch = s_in_lag_comp_raycast - ? get_historical_eye_fvec(entity).y - : entity->eye_orient.fvec.y; + // At non-extreme pitch, highest-Y is reliable — update cache. + // Uses live eye pitch (no rewound eye angle exists; see note above). + float sin_pitch = entity->eye_orient.fvec.y; bool extreme_pitch = std::abs(sin_pitch) > 0.5f; void* mesh_key = entity->vmesh->mesh; @@ -216,10 +188,8 @@ static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos // Pitch-based tilt axis fallback when head csphere is unavailable static rf::Vector3 compute_tilt_axis_from_pitch(const rf::Entity* entity) { - // During lag comp, use historical eye forward vector instead of live eye_orient - const rf::Vector3 fvec = s_in_lag_comp_raycast - ? get_historical_eye_fvec(entity) - : entity->eye_orient.fvec; + // Live eye orientation (no rewound eye angle exists; see note above). + const rf::Vector3 fvec = entity->eye_orient.fvec; constexpr float threshold = 0.01f; float cos_pitch_raw = std::sqrt(fvec.x * fvec.x + fvec.z * fvec.z); @@ -273,12 +243,10 @@ rf::Vector3 compute_tilt_axis(const rf::Entity* entity, const rf::Vector3& split float head_radius; if (find_head_csphere_pos(entity, &head_pos, &head_radius)) { if (head_pos_out) { - // Offset head hitbox backward (opposite look direction) - // During lag comp, use historical eye forward vector - const rf::Vector3 fwd = s_in_lag_comp_raycast - ? get_historical_eye_fvec(entity) - : entity->eye_orient.fvec; - *head_pos_out = head_pos - fwd * (head_radius * 0.30f); + // Offset head hitbox backward (opposite look direction). + // Live eye orientation (no rewound eye angle exists; see note above). + const rf::Vector3 fwd = entity->eye_orient.fvec; + *head_pos_out = head_pos - fwd * (head_radius * k_hitbox_head_back_offset_frac); } if (head_radius_out) *head_radius_out = head_radius; @@ -330,6 +298,36 @@ static bool ix_ray_sphere(const rf::Vector3& ray_origin, const rf::Vector3& ray_ return false; } +// Compute both intersection parameters (t0 <= t1) of the ray with a sphere. +// Returns true if the quadratic has real roots. Unlike ix_ray_sphere this does not +// filter by range or region, so callers can test each root against a hemisphere region +// (a hemisphere's valid hit may be the *far* root when the near root is on the other cap). +static bool ix_ray_sphere_roots(const rf::Vector3& ray_origin, const rf::Vector3& ray_dir, + const rf::Vector3& center, float radius, + float* t0_out, float* t1_out) +{ + float ox = ray_origin.x - center.x; + float oy = ray_origin.y - center.y; + float oz = ray_origin.z - center.z; + + float a = ray_dir.x * ray_dir.x + ray_dir.y * ray_dir.y + ray_dir.z * ray_dir.z; + if (a < 1e-12f) // degenerate (zero-length ray) + return false; + + float b = 2.0f * (ox * ray_dir.x + oy * ray_dir.y + oz * ray_dir.z); + float c = ox * ox + oy * oy + oz * oz - radius * radius; + + float disc = b * b - 4.0f * a * c; + if (disc < 0.0f) + return false; + + float sqrt_disc = std::sqrt(disc); + float inv_2a = 1.0f / (2.0f * a); + *t0_out = (-b - sqrt_disc) * inv_2a; // near root + *t1_out = (-b + sqrt_disc) * inv_2a; // far root + return true; +} + // Vertical-axis ray-capsule intersection (optimized for Y-aligned capsules). // cap_a and cap_b are hemisphere centers sharing the same X and Z. // Updates *best_t if a closer hit is found. Returns true if any hit found. @@ -405,28 +403,38 @@ static bool ix_ray_capsule_vertical(const rf::Vector3& cap_a, const rf::Vector3& } } - // --- Bottom hemisphere --- + // --- Bottom hemisphere (accept the nearest root whose hit lies below y_bot) --- { rf::Vector3 cap_center{cx, y_bot, cz}; - float t; - if (ix_ray_sphere(ray_origin, ray_dir, cap_center, radius, 0.0f, 1.0f, &t)) { - float hit_y = ray_origin.y + ray_dir.y * t; - if (hit_y <= y_bot && t < *best_t) { - *best_t = t; - found = true; + float t0, t1; + if (ix_ray_sphere_roots(ray_origin, ray_dir, cap_center, radius, &t0, &t1)) { + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y <= y_bot) { + *best_t = t; + found = true; + break; + } + } } } } - // --- Top hemisphere --- + // --- Top hemisphere (accept the nearest root whose hit lies above y_top) --- { rf::Vector3 cap_center{cx, y_top, cz}; - float t; - if (ix_ray_sphere(ray_origin, ray_dir, cap_center, radius, 0.0f, 1.0f, &t)) { - float hit_y = ray_origin.y + ray_dir.y * t; - if (hit_y >= y_top && t < *best_t) { - *best_t = t; - found = true; + float t0, t1; + if (ix_ray_sphere_roots(ray_origin, ray_dir, cap_center, radius, &t0, &t1)) { + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float hit_y = ray_origin.y + ray_dir.y * t; + if (hit_y >= y_top) { + *best_t = t; + found = true; + break; + } + } } } } @@ -495,26 +503,36 @@ static bool ix_ray_capsule_general(const rf::Vector3& cap_a, const rf::Vector3& } } - // --- Hemisphere at A (accept if projection s <= 0) --- + // --- Hemisphere at A (accept the nearest root whose projection s <= 0) --- { - float t; - if (ix_ray_sphere(ray_origin, ray_dir, cap_a, radius, 0.0f, 1.0f, &t) && t < *best_t) { - float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; - if (s <= 0.0f) { - *best_t = t; - found = true; + float t0, t1; + if (ix_ray_sphere_roots(ray_origin, ray_dir, cap_a, radius, &t0, &t1)) { + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s <= 0.0f) { + *best_t = t; + found = true; + break; + } + } } } } - // --- Hemisphere at B (accept if projection s >= 1) --- + // --- Hemisphere at B (accept the nearest root whose projection s >= 1) --- { - float t; - if (ix_ray_sphere(ray_origin, ray_dir, cap_b, radius, 0.0f, 1.0f, &t) && t < *best_t) { - float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; - if (s >= 1.0f) { - *best_t = t; - found = true; + float t0, t1; + if (ix_ray_sphere_roots(ray_origin, ray_dir, cap_b, radius, &t0, &t1)) { + for (float t : {t0, t1}) { + if (t >= 0.0f && t <= 1.0f && t < *best_t) { + float s = (w_dot_v + d_dot_v * t) * inv_v_dot_v; + if (s >= 1.0f) { + *best_t = t; + found = true; + break; + } + } } } } @@ -701,7 +719,71 @@ static bool ix_ray_cylinder_general(const rf::Vector3& cyl_a, const rf::Vector3& return found; } -// Hook the ix_linesegment_boundingbox call at 0x0049C862 (multi entity collision path) +// Compute the hybrid hitbox volume (lower capsule + tilted torso cylinder + head sphere) from an +// already crouch-adjusted multiplayer bounding box. Shared by the collision test and debug renderer +// so the drawn volume always matches the volume that is hit-tested. +HitboxGeometry compute_hitbox_geometry(rf::Entity* entity, + const rf::Vector3& bbox_min, const rf::Vector3& bbox_max) +{ + HitboxGeometry geo; + + float cx = (bbox_min.x + bbox_max.x) * 0.5f; + float cz = (bbox_min.z + bbox_max.z) * 0.5f; + geo.radius = (bbox_max.x - bbox_min.x) * 0.5f; + + // When crouching the engine lowers bbox_max.y, but some character models (e.g. miner1 with + // non-pistol weapons) don't crouch that low — extend the effective top back up. + bool crouching = entity && rf::entity_is_crouching(entity); + float effective_max_y = bbox_max.y; + if (crouching) + effective_max_y += (bbox_max.y - bbox_min.y) * k_hitbox_crouch_top_extension; + + float split_ratio = crouching ? k_hitbox_split_ratio_crouch : k_hitbox_split_ratio_standing; + float bbox_height = effective_max_y - bbox_min.y; + float split_y = bbox_min.y + bbox_height * split_ratio; + float upper_height = effective_max_y - split_y; + + // Fallback: entity unavailable or bbox too short to split — a single bbox-tall vertical capsule. + if (!(entity && upper_height > geo.radius)) { + geo.split = false; + geo.lower_bot = {cx, bbox_min.y, cz}; + geo.lower_top = {cx, bbox_max.y, cz}; + return geo; + } + + geo.split = true; + + // Lower capsule (vertical): inset bottom by radius so the hemisphere doesn't extend below bbox. + geo.lower_bot = {cx, bbox_min.y + geo.radius, cz}; + geo.lower_top = {cx, split_y, cz}; + + // Torso cylinder (tilted): starts at the split point, shortened at the top for the head sphere. + geo.upper_a = {cx, split_y, cz}; + float head_dist; + float head_radius; + rf::Vector3 head_world_pos; + rf::Vector3 tilt_axis = compute_tilt_axis(entity, geo.upper_a, &head_dist, &head_radius, &head_world_pos); + + float upper_len = upper_height; + if (head_dist > 0.0f) + upper_len = std::min(upper_len, head_dist); + // Shorten so the flat top stops just before the head sphere. + if (head_radius > 0.0f && head_dist > 0.0f) + upper_len = std::min(upper_len, head_dist - head_radius); + upper_len = std::max(upper_len, 0.0f); + upper_len *= k_hitbox_torso_len_scale; + geo.upper_b = geo.upper_a + tilt_axis * upper_len; + + if (head_radius > 0.0f && head_dist > 0.0f) { + geo.has_head = true; + geo.head_pos = head_world_pos; + geo.head_radius = head_radius; + } + + return geo; +} + +// Multi entity collision path // Replaces AABB intersection with split capsule (lower vertical + upper tilted toward head) static CallHook ix_linesegment_capsule_hook{ @@ -712,71 +794,30 @@ ix_linesegment_capsule_hook{ if (g_alpine_server_config.legacy_hitboxes) return ix_linesegment_capsule_hook.call_target(bbox_min, bbox_max, p0, p1, hit_point); + // Consume the entity captured by capture_entity_injection immediately before this call and + // clear it, so a stale entity from a previous iteration can never be reused if the engine + // ever reaches this call site without the injection having run first (falls back to the + // entity-less single-capsule path instead). rf::Entity* entity = s_current_collide_entity; + s_current_collide_entity = nullptr; - float cx = (bbox_min.x + bbox_max.x) * 0.5f; - float cz = (bbox_min.z + bbox_max.z) * 0.5f; - float radius = (bbox_max.x - bbox_min.x) * 0.5f; - - // When crouching, the engine lowers bbox_max.y by half_size.y * 0.5 (only the top). - // Some character models (e.g. miner1 with non-pistol weapons) don't crouch that low - extend upward - bool crouching = entity && rf::entity_is_crouching(entity); - float effective_max_y = bbox_max.y; - if (crouching) { - float bbox_height = bbox_max.y - bbox_min.y; - effective_max_y += bbox_height * 0.3f; - } - - float split_ratio = crouching ? 0.35f : 0.55f; - float bbox_height = effective_max_y - bbox_min.y; - float split_y = bbox_min.y + bbox_height * split_ratio; - float upper_height = effective_max_y - split_y; + HitboxGeometry geo = compute_hitbox_geometry(entity, bbox_min, bbox_max); rf::Vector3 dir = p1 - p0; float best_t = 2.0f; - // Split into lower (vertical) + upper (tilted) if entity available and bbox tall enough - if (entity && upper_height > radius) { - // Lower capsule (vertical): inset bottom so hemisphere doesn't extend below bbox - rf::Vector3 lower_bot{cx, bbox_min.y + radius, cz}; - rf::Vector3 lower_top{cx, split_y, cz}; - ix_ray_capsule_vertical(lower_bot, lower_top, radius, p0, dir, &best_t); - - // Torso cylinder (tilted): starts at split point, shortened at top for head sphere - rf::Vector3 upper_a{cx, split_y, cz}; - float head_dist; - float head_radius; - rf::Vector3 head_world_pos; - rf::Vector3 tilt_axis = compute_tilt_axis(entity, upper_a, &head_dist, &head_radius, &head_world_pos); - float upper_len = upper_height; - if (head_dist > 0.0f) - upper_len = std::min(upper_len, head_dist); - - // Shorten so flat top stops just before head sphere - if (head_radius > 0.0f && head_dist > 0.0f) { - float max_endpoint = head_dist - head_radius; - upper_len = std::min(upper_len, max_endpoint); - } - upper_len = std::max(upper_len, 0.0f); - upper_len *= 1.05f; + ix_ray_capsule_vertical(geo.lower_bot, geo.lower_top, geo.radius, p0, dir, &best_t); - rf::Vector3 upper_b = upper_a + tilt_axis * upper_len; - ix_ray_cylinder_general(upper_a, upper_b, radius, p0, dir, &best_t); + if (geo.split) { + ix_ray_cylinder_general(geo.upper_a, geo.upper_b, geo.radius, p0, dir, &best_t); - // Head sphere intersection - if (head_radius > 0.0f && head_dist > 0.0f) { + if (geo.has_head) { float head_t; - if (ix_ray_sphere(p0, dir, head_world_pos, head_radius, 0.0f, 1.0f, &head_t) && head_t < best_t) { + if (ix_ray_sphere(p0, dir, geo.head_pos, geo.head_radius, 0.0f, 1.0f, &head_t) && head_t < best_t) { best_t = head_t; } } } - else { - // Fallback: single vertical capsule - rf::Vector3 bot{cx, bbox_min.y, cz}; - rf::Vector3 top{cx, bbox_max.y, cz}; - ix_ray_capsule_vertical(bot, top, radius, p0, dir, &best_t); - } if (best_t > 1.0f) return false; @@ -1823,7 +1864,6 @@ ConsoleCommand2 set_handicap_cmd{ "Set desired multiplayer damage reduction handicap", }; - CallHook obj_apply_damage_lava_hook{ { 0x004212A1, diff --git a/game_patch/multi/multi.h b/game_patch/multi/multi.h index 7f06ef84e..dc8c9d2b5 100644 --- a/game_patch/multi/multi.h +++ b/game_patch/multi/multi.h @@ -102,7 +102,7 @@ struct AlpineFactionServerInfo bool geo_chunk_physics = false; bool location_pinging = false; bool delayed_spawns = false; - bool legacy_hitboxes = false; + bool legacy_hitboxes = true; // assume legacy until a server advertises the hybrid_hitboxes flag int koth_score_limit = 0; int dc_score_limit = 0; bool allow_footsteps = false; @@ -159,7 +159,33 @@ rf::Vector3 compute_tilt_axis(const rf::Entity* entity, const rf::Vector3& split float* head_dist_out = nullptr, float* head_radius_out = nullptr, rf::Vector3* head_pos_out = nullptr); + +// Hybrid hitbox volume computed from an entity's (already crouch-adjusted) multiplayer bounding box. +// Shared by the server-side collision ray-test and the client-side debug visualizer so both always +// describe the exact same volume. +struct HitboxGeometry { + float radius = 0.0f; + + // Lower vertical capsule (hemisphere centers). When `split` is false this single bbox-tall + // capsule is the entire hitbox and the torso/head fields are unused. + rf::Vector3 lower_bot; + rf::Vector3 lower_top; + + bool split = false; // true: also has an upper torso cylinder (+ optional head sphere) + + // Upper torso cylinder (tilted toward the head), flat disc caps at upper_a..upper_b. + rf::Vector3 upper_a; + rf::Vector3 upper_b; + + bool has_head = false; // true: head sphere is valid + rf::Vector3 head_pos; + float head_radius = 0.0f; +}; + +HitboxGeometry compute_hitbox_geometry(rf::Entity* entity, + const rf::Vector3& bbox_min, const rf::Vector3& bbox_max); void set_lag_comp_flag(bool active); +void hitbox_reset_head_csphere_cache(); void print_player_info(rf::Player* player, bool new_join); void server_set_player_weapon(rf::Player* pp, rf::Entity* ep, int weapon_type); void start_level_in_multi(std::string filename); diff --git a/game_patch/multi/network.cpp b/game_patch/multi/network.cpp index f0b59469b..c4cca6b98 100644 --- a/game_patch/multi/network.cpp +++ b/game_patch/multi/network.cpp @@ -1668,8 +1668,8 @@ CallHook send_join_accept_packet_ho if (server_clear_stale_movement_input()) { ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input; } - if (server_legacy_hitboxes()) { - ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::legacy_hitboxes; + if (!server_legacy_hitboxes()) { + ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::hybrid_hitboxes; } // AF 1.3+ clients: use footer-based format for forward compatibility // Older clients: use legacy raw struct (they don't know about the footer) @@ -1806,7 +1806,9 @@ CodeInjection process_join_accept_injection{ server_info.allow_outlines = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines); server_info.allow_outlines_xray = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines_xray); server_info.clear_stale_movement_input = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input); - server_info.legacy_hitboxes = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::legacy_hitboxes); + // hybrid_hitboxes present => capsule system; absent (old servers / default legacy mode) => legacy AABB + bool server_uses_hybrid_hitboxes = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::hybrid_hitboxes); + server_info.legacy_hitboxes = !server_uses_hybrid_hitboxes; constexpr float default_fov = 90.0f; if (!!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::max_fov) && ext_data.max_fov >= default_fov) { diff --git a/game_patch/multi/network.h b/game_patch/multi/network.h index 6e0d15362..62f9c8420 100644 --- a/game_patch/multi/network.h +++ b/game_patch/multi/network.h @@ -187,7 +187,7 @@ struct AlpineFactionJoinAcceptPacketExt allow_outlines = 1u << 14, allow_outlines_xray = 1u << 15, clear_stale_movement_input = 1u << 16, - legacy_hitboxes = 1u << 17, + hybrid_hitboxes = 1u << 17, } flags = Flags::none; float max_fov = 0.0f; diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 05463c5e6..1f3fc9d75 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -789,8 +789,7 @@ struct AlpineServerConfig bool allow_outlines = false; bool allow_outlines_xray = true; bool use_sp_damage_calculation = false; - bool legacy_hitboxes = false; - bool exclude_bots_from_player_count = false; + bool legacy_hitboxes = true; AlpineRestrictConfig alpine_restricted_config; InactivityConfig inactivity_config; DamageNotificationConfig damage_notification_config; From f5d345b1759a6855a2b9e8fbad23c7cf889a99c6 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 7 Jul 2026 18:08:58 -0230 Subject: [PATCH 3/3] fix --- game_patch/multi/multi.cpp | 17 ++++++++++------- game_patch/multi/server.cpp | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 8791803b7..d035754c1 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -114,9 +114,11 @@ static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos if (num <= 0) return false; - // During lag comp, use rewound p_data pos/orient; otherwise use live entity pos/orient - const rf::Vector3& transform_pos = s_in_lag_comp_raycast ? entity->p_data.pos : entity->pos; - const rf::Matrix3& transform_orient = s_in_lag_comp_raycast ? entity->p_data.orient : entity->orient; + // During lag comp, use rewound p_data pos/orient; otherwise use live entity pos/orient. + // Local copies: vmesh_get_csphere_pos takes non-const pointers, so copying avoids casting away + // const and guarantees the engine cannot mutate live/rewound entity state through them. + rf::Vector3 transform_pos = s_in_lag_comp_raycast ? entity->p_data.pos : entity->pos; + rf::Matrix3 transform_orient = s_in_lag_comp_raycast ? entity->p_data.orient : entity->orient; // Find the csphere with highest world-space Y int highest_y_index = -1; @@ -126,8 +128,7 @@ static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos for (int i = 0; i < num; i++) { rf::Vector3 pos; if (rf::vmesh_get_csphere_pos(entity->vmesh, i, &pos, - const_cast(&transform_pos), - const_cast(&transform_orient))) { + &transform_pos, &transform_orient)) { if (pos.y > highest_y) { highest_y = pos.y; highest_y_index = i; @@ -170,8 +171,7 @@ static bool find_head_csphere_pos(const rf::Entity* entity, rf::Vector3* out_pos if (cached_index >= 0 && cached_index < num) { rf::Vector3 cached_pos; if (rf::vmesh_get_csphere_pos(entity->vmesh, cached_index, &cached_pos, - const_cast(&transform_pos), - const_cast(&transform_orient))) { + &transform_pos, &transform_orient)) { *out_pos = cached_pos; get_csphere_radius(cached_index, out_radius); return true; @@ -273,6 +273,9 @@ static bool ix_ray_sphere(const rf::Vector3& ray_origin, const rf::Vector3& ray_ float oz = ray_origin.z - center.z; float a = ray_dir.x * ray_dir.x + ray_dir.y * ray_dir.y + ray_dir.z * ray_dir.z; + if (a < 1e-12f) // degenerate (zero-length ray) — avoid divide-by-zero / NaN + return false; + float b = 2.0f * (ox * ray_dir.x + oy * ray_dir.y + oz * ray_dir.z); float c = ox * ox + oy * oy + oz * oz - radius * radius; diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 3b5c150c2..7389b8281 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -2248,12 +2248,21 @@ FunHook multi_lag_comp_handle_hit_hook }, }; +// RAII: keep the hitbox lag-comp flag set only for the duration of the rewound collision query, +// clearing it on any exit path (early return / exception in a future refactor) so it can never +// leak into unrelated collision tests. +struct LagCompRaycastScope { + LagCompRaycastScope() { set_lag_comp_flag(true); } + ~LagCompRaycastScope() { set_lag_comp_flag(false); } +}; + FunHook multi_lag_comp_weapon_fire_hook{ 0x0046F7E0, [](rf::Entity *ep, rf::Weapon *wp) { - set_lag_comp_flag(true); - multi_lag_comp_weapon_fire_hook.call_target(ep, wp); - set_lag_comp_flag(false); + { + LagCompRaycastScope lag_comp_scope; + multi_lag_comp_weapon_fire_hook.call_target(ep, wp); + } rf::Player* pp = rf::player_from_entity_handle(ep->handle); if (pp && pp->stats) { auto* stats = static_cast(pp->stats);