diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9103d947..2d02b2aa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -57,6 +57,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 + [@AL2009man](https://github.com/AL2009man) - Add support for binding controls to additional mouse buttons and `Alt` keys diff --git a/game_patch/debug/debug.cpp b/game_patch/debug/debug.cpp index caff77b3..2dbc2032 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,287 @@ 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); +} + +// 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 && !g_dbg_cspheres) + 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; + + // 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; + } + + 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); + } + } + } + } + + // 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); + } + } + } + } + } +} + 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 ec0f771c..48c9d5a4 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_cspheres = false; DebugFlagDesc g_debug_flags[] = { {addr_as_ref(0x0062F3AA), "thruster"}, @@ -51,6 +53,8 @@ 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}, + {g_dbg_cspheres, "cspheres", false, true}, }; ConsoleCommand2 debug_cmd{ @@ -78,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]", + "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 fd4b3060..47b9fc1e 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_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 9a755879..f4b03ca0 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 be784947..a9f0fdd2 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -1265,6 +1265,10 @@ 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; + } } // apply base config toml tables @@ -2160,6 +2164,7 @@ 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); // 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 9727a8b7..d035754c 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include #include @@ -44,10 +46,794 @@ #include "../rf/gr/gr_font.h" #include "../rf/ui.h" #include "../rf/sound/sound.h" +#include "../rf/vmesh.h" #include "../sound/sound.h" #include "../main/main.h" #include "../graphics/gr.h" +// --- 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; + +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). +// 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, + [](auto& regs) { + s_current_collide_entity = static_cast(regs.esi); + }, +}; + +// 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 +// 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. + // 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; + 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, + &transform_pos, &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 (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; + } + } + }; + + // 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; + + 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, + &transform_pos, &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) +{ + // 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); + + 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). + // 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; + 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; + 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; + + 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; +} + +// 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. +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 (accept the nearest root whose hit lies below y_bot) --- + { + rf::Vector3 cap_center{cx, y_bot, cz}; + 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 (accept the nearest root whose hit lies above y_top) --- + { + rf::Vector3 cap_center{cx, y_top, cz}; + 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; + } + } + } + } + } + + 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 the nearest root whose projection s <= 0) --- + { + 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 the nearest root whose projection s >= 1) --- + { + 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; + } + } + } + } + } + + 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; +} + +// 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{ + 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); + + // 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; + + HitboxGeometry geo = compute_hitbox_geometry(entity, bbox_min, bbox_max); + + rf::Vector3 dir = p1 - p0; + float best_t = 2.0f; + + ix_ray_capsule_vertical(geo.lower_bot, geo.lower_top, geo.radius, p0, dir, &best_t); + + if (geo.split) { + ix_ray_cylinder_general(geo.upper_a, geo.upper_b, geo.radius, p0, dir, &best_t); + + if (geo.has_head) { + float head_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; + } + } + } + + 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() @@ -1223,6 +2009,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 6ef634a4..dc8c9d2b 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 = 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; @@ -154,6 +155,37 @@ 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); + +// 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 f531f749..c4cca6b9 100644 --- a/game_patch/multi/network.cpp +++ b/game_patch/multi/network.cpp @@ -1668,6 +1668,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::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) bool use_footer = g_joining_client_version == ClientSoftware::AlpineFaction @@ -1803,6 +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); + // 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 691147dd..62f9c842 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, + hybrid_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 8e7801c5..7389b828 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -2248,10 +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) { - multi_lag_comp_weapon_fire_hook.call_target(ep, wp); + { + 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); @@ -3818,6 +3829,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 67683dca..17c95ca1 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 4fdf9bd4..1f3fc9d7 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -789,6 +789,7 @@ struct AlpineServerConfig bool allow_outlines = false; bool allow_outlines_xray = true; bool use_sp_damage_calculation = false; + bool legacy_hitboxes = true; 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 6b3f39b7..2fc35d8c 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);