diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 94f7fb9a..2327a324 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,7 @@ Version 1.3.0 (Bakeapple): Not yet released [@nickalreadyinuse](https://github.com/nickalreadyinuse) - Optimize network performance for `af_obj_update` packets and bot decommission logic - Sync animation state for crouched players in first person spectate view +- Implement enemy and teammate footstep audio for weapons other than pistol in multiplayer. Enabled by default with server permission, `cl_footsteps` to toggle. Version 1.2.2 (Willow): Released Jan-04-2026 -------------------------------- diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index bc59d432..70942306 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -148,6 +148,7 @@ struct AlpineGameSettings bool always_clamp_official_lightmaps = false; bool static_bomb_code = false; bool entity_pain_sounds = true; + bool footsteps = true; static constexpr int min_gib_chunk_count = 7; static constexpr int max_gib_chunk_count = 100; int gib_chunk_count = 14; diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 7112fc42..fa5dfdae 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -21,6 +21,7 @@ #include "../sound/sound.h" #include "../misc/alpine_settings.h" #include "../object/object.h" +#include "../object/object_private.h" void af_send_packet(rf::Player* player, const void* data, int len, bool is_reliable) { @@ -1253,6 +1254,8 @@ static void build_af_server_info_packet(af_server_info_packet& pkt) af |= af_server_info_flags::SIF_LOCATION_PINGING; if (g_alpine_server_config_active_rules.spawn_delay.enabled) af |= af_server_info_flags::SIF_DELAYED_SPAWNS; + if (g_alpine_server_config.allow_footsteps) + af |= af_server_info_flags::SIF_ALLOW_FOOTSTEPS; if (g_alpine_server_config.signal_cfg_changed) { af |= af_server_info_flags::SIF_SERVER_CFG_CHANGED; for (rf::Player& player : SinglyLinkedList{rf::player_list}) { @@ -1423,6 +1426,7 @@ static void af_process_server_info_packet(const void* data, size_t len, const rf server_info.gaussian_spread = (pkt.af_flags & af_server_info_flags::SIF_GAUSSIAN_SPREAD) != 0; server_info.location_pinging = (pkt.af_flags & af_server_info_flags::SIF_LOCATION_PINGING) != 0; server_info.delayed_spawns = (pkt.af_flags & af_server_info_flags::SIF_DELAYED_SPAWNS) != 0; + server_info.allow_footsteps = (pkt.af_flags & af_server_info_flags::SIF_ALLOW_FOOTSTEPS) != 0; if ((pkt.af_flags & af_server_info_flags::SIF_SERVER_CFG_CHANGED) != 0) { g_remote_server_cfg_popup.set_cfg_changed(); @@ -1430,6 +1434,9 @@ static void af_process_server_info_packet(const void* data, size_t len, const rf server_info.semi_auto_cooldown = static_cast(pkt.semi_auto_cooldown); + // Update footstep activation based on new server permissions + evaluate_footsteps(); + //xlog::warn("af_server_info processed - gt {}, cooldown {}", pkt.game_type, server_info.semi_auto_cooldown.value()); } diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index a57b2952..5a9ba7d6 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -183,6 +183,7 @@ enum af_server_info_flags : uint32_t { SIF_LOCATION_PINGING = 1u << 10, SIF_DELAYED_SPAWNS = 1u << 11, SIF_SERVER_CFG_CHANGED = 1u << 12, + SIF_ALLOW_FOOTSTEPS = 1u << 13, }; // Subset of `rf::NetGameFlags`. diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index 564d8d08..d7cb3c0d 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -1077,6 +1077,10 @@ static void apply_known_key_in_order(AlpineServerConfig& cfg, const std::string& if (auto v = node.value()) cfg.allow_unlimited_fps = *v; } + else if (key == "allow_footsteps") { + if (auto v = node.value()) + cfg.allow_footsteps = *v; + } else if (key == "use_sp_damage_calculation") { if (auto v = node.value()) cfg.use_sp_damage_calculation = *v; @@ -1858,6 +1862,7 @@ void print_alpine_dedicated_server_config_info(std::string& output, bool verbose std::format_to(iter, " Allow lightmap only mode: {}\n", cfg.allow_lightmaps_only); std::format_to(iter, " Allow disable muzzle flash: {}\n", cfg.allow_disable_muzzle_flash); std::format_to(iter, " Allow disable 240 FPS cap: {}\n", cfg.allow_unlimited_fps); + std::format_to(iter, " Allow footsteps: {}\n", cfg.allow_footsteps); std::format_to(iter, " SP-style damage calculation: {}\n", cfg.use_sp_damage_calculation); std::format_to(iter, " Exclude bots from player count: {}\n", cfg.exclude_bots_from_player_count); diff --git a/game_patch/multi/kill.cpp b/game_patch/multi/kill.cpp index 15d72552..e4a6779a 100644 --- a/game_patch/multi/kill.cpp +++ b/game_patch/multi/kill.cpp @@ -18,6 +18,7 @@ #include "server_internal.h" #include "alpine_packets.h" #include "../misc/player.h" +#include "../object/object_private.h" bool kill_messages = true; @@ -353,6 +354,9 @@ FunHook multi_level_init_hook{ reset_gungame_notifications(); weapon_manager.initialize_score_to_weapon_map(); } + + // Re-evaluate footstep state when level loads + evaluate_footsteps(); }, }; diff --git a/game_patch/multi/multi.h b/game_patch/multi/multi.h index aade8314..403fee94 100644 --- a/game_patch/multi/multi.h +++ b/game_patch/multi/multi.h @@ -100,6 +100,7 @@ struct AlpineFactionServerInfo bool delayed_spawns = false; int koth_score_limit = 0; int dc_score_limit = 0; + bool allow_footsteps = false; }; enum class AlpineRestrictVerdict : uint8_t diff --git a/game_patch/multi/network.cpp b/game_patch/multi/network.cpp index 9b1237d4..a6979cd2 100644 --- a/game_patch/multi/network.cpp +++ b/game_patch/multi/network.cpp @@ -45,6 +45,7 @@ #include "../misc/player.h" #include "../misc/alpine_settings.h" #include "../object/object.h" +#include "../object/object_private.h" #include "../os/console.h" #include "../purefaction/pf.h" #include "../sound/sound.h" @@ -1318,6 +1319,9 @@ CallHook send_join_accept_packet_ho if (server_delayed_spawns()) { ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::delayed_spawns; } + if (server_allow_footsteps()) { + ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::allow_footsteps; + } auto [buf, new_len] = extend_packet_bytes(data, len, &ext_data, sizeof(ext_data)); //auto [new_data, new_len] = extend_packet_fixed(data, len, ext_data); return send_join_accept_packet_hook.call_target(addr, buf.get(), new_len); @@ -1350,6 +1354,7 @@ CodeInjection process_join_accept_injection{ server_info.gaussian_spread = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::gaussian_spread); server_info.location_pinging = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::location_pinging); server_info.delayed_spawns = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::delayed_spawns); + server_info.allow_footsteps = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_footsteps); constexpr float default_fov = 90.0f; if (!!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::max_fov) && ext_data.max_fov >= default_fov) { @@ -1359,6 +1364,9 @@ CodeInjection process_join_accept_injection{ server_info.semi_auto_cooldown = ext_data.semi_auto_cooldown; } g_af_server_info = std::optional{server_info}; + + // Update footstep activation based on server permissions + evaluate_footsteps(); } else { g_af_server_info.reset(); @@ -1949,6 +1957,9 @@ FunHook multi_stop_hook{ static_cast(rf::local_player); *player_add_data = PlayerAdditionalData{}; } + + // Re-evaluate footstep state when leaving multiplayer + evaluate_footsteps(); multi_stop_hook.call_target(); }, }; diff --git a/game_patch/multi/network.h b/game_patch/multi/network.h index 0e7fc606..352f0a92 100644 --- a/game_patch/multi/network.h +++ b/game_patch/multi/network.h @@ -114,6 +114,7 @@ struct AlpineFactionJoinAcceptPacketExt gaussian_spread = 1u << 9, location_pinging = 1u << 10, delayed_spawns = 1u << 11, + allow_footsteps = 1u << 12, } flags = Flags::none; float max_fov; diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index dbe2ba9a..4d3277d7 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -3223,6 +3223,11 @@ bool server_gaussian_spread() return g_alpine_server_config.gaussian_spread; } +bool server_allow_footsteps() +{ + return g_alpine_server_config.allow_footsteps; +} + 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 ddd807c3..10d3b581 100644 --- a/game_patch/multi/server.h +++ b/game_patch/multi/server.h @@ -28,6 +28,7 @@ bool server_allow_disable_muzzle_flash(); bool server_apply_click_limiter(); bool server_allow_unlimited_fps(); bool server_gaussian_spread(); +bool server_allow_footsteps(); 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 a9f36d49..886758dd 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -679,6 +679,7 @@ struct AlpineServerConfig bool allow_disable_screenshake = true; bool allow_disable_muzzle_flash = true; bool allow_unlimited_fps = false; + bool allow_footsteps = true; bool use_sp_damage_calculation = false; bool exclude_bots_from_player_count = false; AlpineRestrictConfig alpine_restricted_config; diff --git a/game_patch/object/entity.cpp b/game_patch/object/entity.cpp index 4e46275c..96a5e6d8 100644 --- a/game_patch/object/entity.cpp +++ b/game_patch/object/entity.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include #include "../misc/achievements.h" #include "../misc/alpine_settings.h" @@ -17,6 +19,11 @@ #include "../rf/os/frametime.h" #include "../rf/os/os.h" #include "../rf/sound/sound.h" +#include "../rf/object.h" +#include "../rf/vmesh.h" +#include "../rf/character.h" +#include "../rf/math/vector.h" +#include "../multi/multi.h" rf::Timestamp g_player_jump_timestamp; @@ -437,6 +444,117 @@ void apply_entity_sim_distance() { rf::entity_sim_distance = g_alpine_game_config.entity_sim_distance; } +// Fix for footstep audio bug in multiplayer where remote players' footsteps +// only play when they have a pistol equipped. Only pistol walk/run animations +// have footstep triggers defined in entity.tbl; other weapons lack them. +// We hook the animation update function (0x0051bce3) to inject trigger values +// into skeletons that have empty trigger names, using VMVF time range data. + +bool g_footsteps_active = false; + +// Footstep trigger positions as percentages of animation duration +static constexpr float footstep_walk_left_pct = 0.15f; +static constexpr float footstep_walk_right_pct = 0.55f; +static constexpr float footstep_run_left_pct = 0.12f; +static constexpr float footstep_run_right_pct = 0.52f; + +CodeInjection footstep_trigger_fixup_injection{ + 0x0051bce3, + [](auto& regs) { + if (!g_footsteps_active) return; + + uint32_t* anims_array = reinterpret_cast(static_cast(regs.edx)); + int anim_index = static_cast(regs.ebp); + rf::Skeleton* skeleton = reinterpret_cast(anims_array[anim_index]); + if (!skeleton) return; + + // Only inject if trigger names are empty (no existing footstep data) + if (skeleton->triggers[0].name[0] != '\0') return; + + const char* name = skeleton->mvf_filename; + if (!name || name[0] == '\0') return; + + bool is_walk = std::strstr(name, "walk") != nullptr; + bool is_run = !is_walk && std::strstr(name, "run") != nullptr; + if (!is_walk && !is_run) return; + + if (!skeleton->animation_data) return; + + uint8_t* data = static_cast(skeleton->animation_data); + if (data[0] != 'V' || data[1] != 'M' || data[2] != 'V' || data[3] != 'F') return; + + int start_time = *reinterpret_cast(data + 0x10); + int end_time = *reinterpret_cast(data + 0x14); + int duration = end_time - start_time; + if (duration <= 0) return; + + float left_pct = is_walk ? footstep_walk_left_pct : footstep_run_left_pct; + float right_pct = is_walk ? footstep_walk_right_pct : footstep_run_right_pct; + + int left_trigger = start_time + static_cast(duration * left_pct); + int right_trigger = start_time + static_cast(duration * right_pct); + + std::strncpy(skeleton->triggers[0].name, "footstep_left", 15); + skeleton->triggers[0].name[15] = '\0'; + skeleton->triggers[0].value = left_trigger; + + std::strncpy(skeleton->triggers[1].name, "footstep_right", 15); + skeleton->triggers[1].name[15] = '\0'; + skeleton->triggers[1].value = right_trigger; + + static std::unordered_set logged_injected; + if (logged_injected.insert(name).second) { + xlog::info("Footstep fix: {} injected left={} right={} (start={} end={} dur={} type={})", + name, left_trigger, right_trigger, start_time, end_time, duration, + is_walk ? "walk" : "run"); + } + } +}; + +void evaluate_footsteps() +{ + // Check both client preference and server permission + bool client_wants_fix = g_alpine_game_config.footsteps; + bool server_allows_fix = false; + + // Determine server permission: + // - Single-player: always allowed + // - Server (hosting): always allowed + // - Client on Alpine Faction server: check server permission + // - Client on legacy server: NOT allowed (compatibility) + if (!rf::is_multi) { + server_allows_fix = true; + } + else if (rf::is_server) { + server_allows_fix = true; + } + else { + // Client: only allow if Alpine Faction server permits it + const auto& server_info = get_af_server_info(); + if (server_info.has_value()) { + server_allows_fix = server_info->allow_footsteps; + } + } + + g_footsteps_active = client_wants_fix && server_allows_fix; + + xlog::info("Footstep fix: client_wants={}, server_allows={}, active={}", + client_wants_fix, server_allows_fix, g_footsteps_active); +} + +ConsoleCommand2 cl_footsteps_cmd{ + "cl_footsteps", + []() { + g_alpine_game_config.footsteps = !g_alpine_game_config.footsteps; + evaluate_footsteps(); + rf::console::print("Footsteps: {} (active: {})", + g_alpine_game_config.footsteps ? "enabled" : "disabled", + g_footsteps_active ? "yes" : "no"); + }, + "Toggle footstep trigger injection for all weapons", + "cl_footsteps", +}; + FunHook entity_maybe_play_pain_sound_hook{ 0x004196F0, [](rf::Entity* ep, float percent_damage) { if (g_alpine_game_config.entity_pain_sounds) { @@ -512,6 +630,10 @@ void entity_do_patch() // Restore cut stock game feature for entities and corpses exploding into chunks entity_blood_throw_gibs_hook.install(); + // Footstep fix: inject trigger frames on-the-fly when animations have empty triggers + evaluate_footsteps(); + footstep_trigger_fixup_injection.install(); + // Commands sp_exposuredamage_cmd.register_cmd(); cl_gorelevel_cmd.register_cmd(); @@ -519,4 +641,5 @@ void entity_do_patch() cl_gibvelocityscale_cmd.register_cmd(); cl_giblifetimems_cmd.register_cmd(); cl_painsounds_cmd.register_cmd(); + cl_footsteps_cmd.register_cmd(); } diff --git a/game_patch/object/object_private.h b/game_patch/object/object_private.h index badc9abd..21c66d1d 100644 --- a/game_patch/object/object_private.h +++ b/game_patch/object/object_private.h @@ -13,3 +13,7 @@ void entity_do_patch(); void item_do_patch(); void particle_do_patch(); void obj_light_apply_patch(); + +// Footstep fix state (controlled by client config AND server permission) +extern bool g_footsteps_active; +void evaluate_footsteps();