From b999f37e7bd101f93f5d5fe924e54349c890ccda Mon Sep 17 00:00:00 2001 From: Fourth Studio Date: Thu, 15 Oct 2026 19:22:21 +0200 Subject: [PATCH] Board Break: wear and tear, rail snaps, break and body sounds, settings Boards wear: hard landings, hits onto rails and ledges, grinding and slams wear the board of the deck in the game, kept per deck. From a fifth worn it steers lazier and its trucks go soft (the game's own physics tuning, outside multiplayer sessions only); a worn board breaks from less and, worn out, can give way under the rider. A board that broke is a new one once it is whole again, and the game's own board wear is cleared with it. A boardslide, lipslide or darkslide that comes down too hard onto a rail can snap the board, and the rider goes down with it: No Bail can now be asked for a wipeout, which goes in as a cause before the selector picks. Sounds play through XAudio2: a crack by the size of the break, metal when the board broke on metal (the material comes from the game's contact effects; which are metal is marked in play or listed by a pack), and the body's hits in a bail by how hard they land. The settings are a card on the overlay's Skater page, two rows in Mod Options and console verbs, kept in BoardBreak.settings. A deck may name itself and set its wheels wider apart in a deck.ini; Old School and Pig join the decks the feature knows. --- Engine/Vfs/mod_catalog.cpp | 14 + Engine/Vfs/mod_catalog.h | 7 +- .../BoardBreak/Test/board_break_tests.cpp | 61 ++ Extension/BoardBreak/board_break.cpp | 680 +++++++++++++++++- Extension/BoardBreak/board_break.h | 20 + Extension/BoardBreak/board_break_audio.cpp | 166 +++++ Extension/BoardBreak/board_break_audio.h | 18 + Extension/BoardBreak/board_break_commands.cpp | 73 ++ Extension/BoardBreak/board_break_feel.h | 171 +++++ Extension/Multiplayer/Remote/native_vfx.cpp | 13 + Extension/Multiplayer/Remote/native_vfx.h | 8 + Extension/Skater/no_bail.cpp | 32 +- Extension/Skater/no_bail.h | 6 + Extension/UI/NativeMenu/native_tools_view.h | 8 + Extension/UI/Overlay/modding_menu.cpp | 24 +- Extension/UI/Overlay/overlay.h | 6 + Extension/UI/Overlay/skate_menu_skater.cpp | 51 ++ Runtime/client_tick.cpp | 14 + cmake/Runtime.cmake | 1 + contrib/board-break.md | 91 ++- 20 files changed, 1410 insertions(+), 54 deletions(-) create mode 100644 Extension/BoardBreak/board_break_audio.cpp create mode 100644 Extension/BoardBreak/board_break_audio.h create mode 100644 Extension/BoardBreak/board_break_feel.h diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index 39d0dfc..a881e79 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -220,6 +220,20 @@ std::vector board_break_folders() noexcept { } catch (...) {} return found; } +std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept { + try { + const auto wanted = lower(deck); + std::error_code error; + for (fs::directory_iterator at(engine_data_root() / L"Mods", error), end; !error && at != end; at.increment(error)) { + if (!at->is_directory(error)) continue; + const auto inside = decks_inside(at->path()); + for (const auto& one : inside) + if (one.first == wanted) return one.second; + if (inside.empty() && board_break_deck_of(at->path().filename().string()) == wanted) return at->path(); + } + } catch (...) {} + return {}; +} std::vector installed_board_break_decks() noexcept { std::vector found; try { diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 6662234..9b6f8c6 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -63,8 +63,8 @@ struct Catalog { // A deck goes by its name in small letters. These are the decks the feature was made with, in the // order the menus list them, and the console also knows them by their place here (deck 1 is the // first). Any other deck comes after them, by its name: a new shape needs no change to ReSkate. -inline constexpr std::array board_break_decks{"standard", "wide", "concave", "powell", - "slim", "egg", "square", "shaped"}; +inline constexpr std::array board_break_decks{"standard", "wide", "concave", "powell", "slim", + "egg", "square", "shaped", "oldschool", "pig"}; // The name of the deck a mod holds going by the mod's name (a folder's, or a pack's deck's), or // nothing; and the folder name of the pack that one of a pack's decks is listed under, or nothing. std::string board_break_deck_of(std::string_view mod); @@ -78,6 +78,9 @@ bool save_board_break_deck(std::string_view deck) noexcept; // beside its decks. std::vector installed_board_break_decks() noexcept; std::vector board_break_folders() noexcept; +// The folder with a deck's own files (a pack's decks/, or the Board_Break_ folder): what +// it says of itself (deck.ini) is there. Empty when that deck is not installed. +std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept; // What the mods in effect give the game: the launch's catalogue, or the one the last live apply // read (live_mods tells with board_break_applied). std::string board_break_in_play() noexcept; diff --git a/Extension/BoardBreak/Test/board_break_tests.cpp b/Extension/BoardBreak/Test/board_break_tests.cpp index 8dff1f8..1eeb748 100644 --- a/Extension/BoardBreak/Test/board_break_tests.cpp +++ b/Extension/BoardBreak/Test/board_break_tests.cpp @@ -1,6 +1,7 @@ // Board Break's rules, apart from the game: what a break leaves of a board, how likely a slam // breaks it, and where the parts of a broken board lie. #include "Extension/BoardBreak/board_break_model.h" +#include "Extension/BoardBreak/board_break_feel.h" #include #include #include @@ -148,6 +149,60 @@ void a_baseplate_stays_on_the_deck_while_its_truck_steers() { } } } + +void the_settings_keep_to_their_ranges() { + Settings s; + check(set_setting_value(s, "sensitivity", 9999) && s.sensitivity == sensitivity_max, "A percent is kept within its range"); + check(set_setting_value(s, "always", 5) && s.always && setting_value(s, "always") == 1, "a switch is on or off"); + check(set_setting_value(s, "joined", -3) && s.joined == 0, "and nothing goes below its least"); + check(!set_setting_value(s, "nonsense", 1) && setting_value(s, "nonsense") == -1, "An unknown name is no setting"); + for (const auto& info : setting_info) check(setting_value(s, info.name) >= 0, "Every setting has a value"); +} + +void a_worn_or_touchy_board_breaks_sooner() { + check(slam_chance(heavy_slam, 100, 0.0f) == 1.0f, "A heavy slam always breaks a new board"); + check(slam_chance(6.0f, 100, 0.0f) < slam_chance(6.0f, 100, 1.0f), "A worn board breaks from less"); + check(slam_chance(heavy_slam * 0.5f, 200, 0.0f) == 1.0f, "At 200% half the force always breaks it"); + check(slam_chance(8.0f, 50, 0.0f) < slam_chance(8.0f, 100, 0.0f), "at 50% it takes more"); + check(slam_chance(0.0f, 400, 1.0f) <= 0.95f, "and a stumble never breaks it for certain"); +} + +void every_hit_wears_a_board() { + check(landing_wear(3.0f) == 0.0f && landing_wear(5.0f) > 0.0f && landing_wear(9.0f) > landing_wear(5.0f), "Harder landings wear more, light ones none"); + check(rail_wear(3.0f, true) > rail_wear(3.0f, false), "A slide wears the deck more than a grind on the trucks"); + check(grind_wear_per_second(true) > grind_wear_per_second(false), "and sliding more than grinding"); + check(slam_wear(2.0f) == 0.0f && slam_wear(12.0f) > slam_wear(7.0f), "A stumble costs nothing, a hard slam a lot"); + check(add_wear(0.5f, 0.1f, 0) == 0.5f && near(add_wear(0.5f, 0.1f, 200), 0.7f) && add_wear(0.95f, 1.0f, 100) == 1.0f, + "Wear goes at the player's rate, and never past worn out"); +} + +void a_worn_board_handles_worse() { + check(handling(0.0f) == Handling{} && handling(0.2f) == Handling{}, "A board under a fifth worn handles as new"); + const auto half = handling(0.6f), done = handling(1.0f); + check(half.steering < 1.0f && done.steering < half.steering && done.spring < half.spring && done.twist > half.twist, + "The more worn, the lazier the turn and the softer the trucks"); + check(done.steering > 0.5f && done.spring > 0.2f, "but it can still be ridden"); + check(gives_way_per_second(1.0f) > 50 * gives_way_per_second(0.0f), "A worn-out board gives way under the rider far more often"); +} + +void a_board_across_its_way_slides() { + const Vec level_up{0, 1, 0}, ahead{0, 0, 1}, across{1, 0, 0}, upside_down{0, -1, 0}, going{0, -1, 5}; + check(contact_of(ahead, level_up, going) == Contact::grind, "Along the rail it grinds on its trucks"); + check(contact_of(across, level_up, going) == Contact::slide, "Across it, it slides on the deck"); + check(contact_of(across, upside_down, going) == Contact::darkslide, "On its grip it is a darkslide"); + check(contact_of(across, level_up, {0, -2, 0.1f}) == Contact::grind, "A board that hardly moves along counts as grinding"); + check(rail_chance(9.0f, Contact::grind, 100, 0.0f) == 0.0f, "A grind on the trucks never snaps the board"); + check(rail_chance(2.0f, Contact::slide, 100, 0.0f) == 0.0f && rail_chance(6.0f, Contact::slide, 100, 0.0f) == 1.0f, + "A hop onto a rail is safe, a drop onto it snaps the board"); + check(rail_chance(3.5f, Contact::darkslide, 100, 0.0f) > rail_chance(3.5f, Contact::slide, 100, 0.0f), "a darkslide sooner"); +} + +void a_board_breaks_where_it_hit() { + check(place_of_break(0.8f, false, 0.1f) == Place::nose && place_of_break(-0.8f, false, 0.1f) == Place::tail, "Nose first at the nose, tail first at the tail"); + check(place_of_break(0.8f, true, 0.1f) == Place::mid, "Across a rail in the middle"); + check(place_of_break(0.0f, false, 0.2f) == Place::mid && place_of_break(0.0f, false, 0.9f) == Place::tail, "Flat mostly in the middle"); + check(body_loudness(2.0f) == 0.0f && body_loudness(body_full) == 1.0f && body_loudness(5.0f) < body_loudness(9.0f), "Harder hits sound louder"); +} } // namespace int main() { @@ -162,6 +217,12 @@ int main() { a_hanging_end_turns_about_the_break(); a_truck_goes_with_its_piece(); a_baseplate_stays_on_the_deck_while_its_truck_steers(); + the_settings_keep_to_their_ranges(); + a_worn_or_touchy_board_breaks_sooner(); + every_hit_wears_a_board(); + a_worn_board_handles_worse(); + a_board_across_its_way_slides(); + a_board_breaks_where_it_hit(); std::cout << "board break tests passed\n"; return 0; } catch (const std::exception& error) { diff --git a/Extension/BoardBreak/board_break.cpp b/Extension/BoardBreak/board_break.cpp index 9b9a1ee..144f464 100644 --- a/Extension/BoardBreak/board_break.cpp +++ b/Extension/BoardBreak/board_break.cpp @@ -9,25 +9,39 @@ #include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Skater/no_bail.h" +#include "board_break_audio.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include "Extension/HallOfMeat/hall_of_meat_model.h" +#include "Extension/HallOfMeat/hall_of_meat_skater.h" +#include "Extension/Multiplayer/Remote/native_vfx.h" +#include "Extension/Multiplayer/Session/session.h" +#include "Extension/Progression/script_natives.h" +#include "Extension/Skater/physics_tuning.h" #include -#include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include +#include #include #include #include +#include #include #include -#pragma comment(lib, "winmm.lib") +#pragma comment(lib, "ole32.lib") +#pragma comment(lib, "shell32.lib") namespace dingosdk::board_break { namespace { @@ -35,8 +49,8 @@ constexpr const char* enabled_preference = "BoardBreak"; // A slam of `heavy` force or more always breaks the board; a lighter one with `light`, rising to // certain between 60% of `heavy` and `heavy` (break_chance). Half of all breaks stay joined by the // grip tape. Now and then a board gives way under the rider: per second of riding, the nose goes. -constexpr float heavy = 10.0f, light = 0.25f, joined_share = 0.5f; -constexpr double gives_way = 0.0001; +// (board_break_feel.h holds the forces, the chances and what wear does.) +namespace sstate = addr::skater_state; constexpr std::size_t cause_count = 35; // the collector's cause bytes (+0x28) and forces (+0x4c) constexpr std::uint64_t recent_ms = 1500; // a cause belongs to a wipeout this soon before it // The board is back in the skater's hand: close to the skater and not lying flat. @@ -50,6 +64,8 @@ constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, constexpr int back_plate_bone = 5, front_plate_bone = 9; constexpr std::array carrier{12, 13, 15, 14}; constexpr std::uint32_t rig_bones = 16; +// The wheels, each on its truck's bone: back right and left, front right and left. +constexpr std::array wheel_bones{6, 7, 10, 11}; bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; } @@ -71,6 +87,7 @@ struct Broken { std::uint64_t since{}; bool gave_way{}; // it went under the rider: it stays broken while it is ridden bool asked{}; // it was broken from the console, to be looked at: it stays until it is mended there + std::string deck; // the deck it was: the one that gets a new board }; struct State { // Any thread. @@ -93,6 +110,23 @@ struct State { std::uint64_t ride_when{}; float break_ground{std::numeric_limits::quiet_NaN()}; std::mt19937 random{std::random_device{}()}; + // The player's settings as the physics thread reads them (feel() holds the real ones). + std::atomic always{}, rails{true}, wear_on{true}, body_sounds{true}; + std::atomic sensitivity{100}; + // Wear: the board in the game's (for the physics thread), and what the physics thread has + // added since the tick took it. + std::atomic wear_now{}, wear_added{}; + // A board that came down too hard onto a rail, seen by the physics thread: the tick breaks it. + std::atomic rail_snap{}; + std::atomic rail_contact{}; + std::atomic rail_speed{}; + // The board's long axis (towards the nose) and its up axis in the world, from the tick. + std::mutex axes_mutex; + Vec along{}, up{}; + std::uint64_t axes_at{}; + // Client thread: the tuning set for wear, and when; when the settings were last saved. + std::uint64_t handled_at{}, saved_at{}, session_checked{}; + bool in_session{}, setup_read{}, effects_hooked{}; }; State& state() { static auto* value = new State; @@ -106,6 +140,8 @@ struct RenderEdit { std::atomic hinge{}; std::atomic angle{}; std::atomic park{}; // a breakable deck is in the game: whole boards hide their stretches + // The deck's own trucks (deck.ini: axle): how far apart the wheels sit against a standard board's. + std::atomic axle{1.0f}; }; RenderEdit& render_edit() { static RenderEdit value; return value; } @@ -170,6 +206,28 @@ void hold_baseplates(std::uintptr_t buffer) noexcept { hold(front_truck_bone, front_plate_bone, front_truck_rest); } +// A deck's own trucks (deck.ini: axle): the wheels further apart on a wider deck, closer together on +// a narrower one. The game puts each wheel on its truck's bone along the axle, about 0.0876 m out; +// that place is set here rather than scaled, so a pose that comes by twice is not moved twice. Only +// what is drawn changes: the board rides as it always does. +constexpr float wheel_offset = 0.0876f; +void spread_wheels(std::uintptr_t buffer) noexcept { + const float axle = render_edit().axle.load(std::memory_order_relaxed); + if (axle == 1.0f) return; + for (const int index : wheel_bones) { + PoseBone wheel{}; + if (!load_bone(bone_at(buffer, index), wheel)) continue; + int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits + for (int i = 1; i < 3; ++i) + if (std::abs(wheel.position[i]) > std::abs(wheel.position[way])) way = i; + const float out = std::abs(wheel.position[way]); + if (!(out > 0.05f && out < 0.16f)) continue; // not where the game puts a wheel + __try { + reinterpret_cast(bone_at(buffer, index))[8 + way] = std::copysign(wheel_offset * axle, wheel.position[way]); + } __except (EXCEPTION_EXECUTE_HANDLER) {} + } +} + // ---- the piece that comes off -------------------------------------------------------------------- // The client tick starts a flight (a new id) when the board breaks; the render pose hook moves the // piece through the world from there (a kick, gravity, the ground) and writes it relative to @@ -303,6 +361,7 @@ void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, !load_bone(bone_at(buffer, deck_bone), deck)) return; hold_baseplates(buffer); + spread_wheels(buffer); // From where its animation put it this frame; a pose nobody has written since the last move stays as it is. if (const Vec at = place_of(deck); length(at) < 100.0f) (void)store_bone(bone_at(buffer, deck_bone), facing_of(deck), at + away); auto layout = lay_out(kept, hinge, angle, turn(facing_of(root), {0, 1, 0}).y, facing_of(mount)); @@ -323,61 +382,447 @@ void park_pieces(std::uintptr_t base, std::uintptr_t holder) { const auto buffer = board_pose(base, holder); if (!buffer) return; hold_baseplates(buffer); + spread_wheels(buffer); const auto bones = bones_of({}); for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at); } -// ---- breaking and mending -------------------------------------------------------------------------- -// The crack of the deck, straight to the speakers (not through the game's mixer): a wave file out -// of the `sounds` folder of one of Board Break's deck mods. Its subfolders light, medium and heavy -// hold the sounds for a break of that size, as many as one likes under any names; a size with none -// of its own takes the nearest size that has some, and wave files in the folder itself serve when -// no subfolder does. -void crack_sound(State& s, Size size) { - static const std::array, 4> sounds = [] { - std::array, 4> found; - constexpr std::array names{L"light", L"medium", L"heavy", L""}; +// ---- the player's settings, the deck's setup, wear ------------------------------------------------- +// Kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings, a line each: the settings by name (as the +// console knows them), each deck's wear (wear.=0.37), the materials marked as metal. +struct Feel { + std::mutex mutex; + Settings settings; + std::map wear; // by deck + std::set metal; // marked by the player + std::set pack_metal; // a pack's list (sounds/metal/materials.txt) + bool loaded{}, dirty{}; +}; +Feel& feel() { + static auto* value = new Feel; + return *value; +} +std::filesystem::path settings_path() { + PWSTR raw{}; + std::filesystem::path result; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw) + result = std::filesystem::path(raw) / L"ReSkate" / L"BoardBreak.settings"; + if (raw) CoTaskMemFree(raw); + return result; +} +std::string trimmed(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return std::string(text); +} +template bool number(std::string_view text, T& out) { + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), out); + return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(); +} +// Numbers out of a line of them ("12 40, 77"). +std::set numbers_in(std::string_view text) { + std::set out; + std::string one; + const auto take = [&] { + unsigned value{}; + if (!one.empty() && number(std::string_view(one), value) && value <= 0xffff) out.insert(static_cast(value)); + one.clear(); + }; + for (const char c : text) { + if (c >= '0' && c <= '9') one += c; + else take(); + } + take(); + return out; +} +// Under the lock. +void load_feel(Feel& f) { + if (f.loaded) return; + f.loaded = true; + try { + std::ifstream in(settings_path()); + std::string line; + while (std::getline(in, line)) { + const auto eq = line.find('='); + if (eq == std::string::npos) continue; + const auto key = trimmed(std::string_view(line).substr(0, eq)); + const auto value = trimmed(std::string_view(line).substr(eq + 1)); + if (key.starts_with("wear.")) { + float worn{}; + if (number(std::string_view(value), worn) && std::isfinite(worn)) f.wear[key.substr(5)] = std::clamp(worn, 0.0f, 1.0f); + } else if (key == "metal") f.metal = numbers_in(value); + else if (int setting{}; number(std::string_view(value), setting)) (void)set_setting_value(f.settings, key, setting); + } + } catch (...) {} +} +void save_feel(Feel& f) { + try { + const auto path = settings_path(); + if (path.empty()) return; + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream out(path, std::ios::trunc); + for (const auto& info : setting_info) out << info.name << "=" << setting_value(f.settings, info.name) << "\n"; + for (const auto& [deck, worn] : f.wear) out << "wear." << deck << "=" << std::format("{:.4f}", worn) << "\n"; + if (!f.metal.empty()) { + out << "metal="; + for (const auto m : f.metal) out << m << " "; + out << "\n"; + } + f.dirty = false; + } catch (...) {} +} +// The settings into the atomics the physics thread reads. +void mirror(State& s, const Settings& st) { + s.always.store(st.always, std::memory_order_relaxed); + s.rails.store(st.rails, std::memory_order_relaxed); + s.wear_on.store(st.wear, std::memory_order_relaxed); + s.body_sounds.store(st.body_sounds, std::memory_order_relaxed); + s.sensitivity.store(st.sensitivity, std::memory_order_relaxed); +} +Settings settings_now() { + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + return f.settings; +} +std::string deck_now(State& s) { + std::lock_guard lock(s.names); + return s.deck; +} + +// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's; +// more on a wider deck) and `label`, its name in the menus. +struct DeckSetup { + float axle = 1.0f; + std::string label; +}; +DeckSetup read_deck_setup(std::string_view deck) { + DeckSetup out; + if (deck.empty()) return out; + try { + const auto folder = mods::board_break_deck_folder(deck); + if (folder.empty()) return out; + std::ifstream in(folder / L"deck.ini"); + std::string line; + while (std::getline(in, line)) { + if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash); + const auto eq = line.find('='); + if (eq == std::string::npos) continue; + auto key = trimmed(std::string_view(line).substr(0, eq)); + for (auto& c : key) c = static_cast(std::tolower(static_cast(c))); + const auto value = trimmed(std::string_view(line).substr(eq + 1)); + float amount{}; + if (key == "axle" && number(std::string_view(value), amount) && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f); + else if (key == "label") out.label = value; + } + } catch (...) {} + return out; +} + +// The same, kept a few seconds per deck: the menus ask every frame. +DeckSetup setup_of(std::string_view deck) { + struct Kept { + DeckSetup setup; + std::uint64_t at{}; + }; + static std::mutex mutex; + static std::map> kept; + const auto now = GetTickCount64(); + { + std::lock_guard lock(mutex); + if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 3000) return at->second.setup; + } + auto setup = read_deck_setup(deck); + std::lock_guard lock(mutex); + kept[std::string(deck)] = {setup, now}; + return setup; +} + +// ---- sounds --------------------------------------------------------------------------------------- +// Out of the `sounds` folder of one of Board Break's deck mods: light, medium and heavy for the +// crack of a break that size, metal for a board breaking on metal, body for the body's hits in a +// bail; as many wave files in each as one likes, under any names. A crack size with none of its own +// takes the nearest that has some, and wave files in the folder itself serve when no subfolder does. +// sounds/metal/materials.txt may list the level's materials that are metal. +void load_sounds() { + static std::once_flag once; + std::call_once(once, [] { + const auto wavs = [](const std::filesystem::path& folder) { + std::vector found; + std::error_code failed; + for (std::filesystem::directory_iterator at(folder, failed), end; !failed && at != end; at.increment(failed)) { + auto extension = at->path().extension().wstring(); + for (auto& c : extension) c = static_cast(towlower(c)); + if (at->is_regular_file(failed) && extension == L".wav") found.push_back(at->path()); + } + std::sort(found.begin(), found.end()); + return found; + }; for (const auto& mod : mods::board_break_folders()) { std::error_code failed; - if (!std::filesystem::is_directory(mod / L"sounds", failed)) continue; - for (std::size_t i = 0; i < names.size(); ++i) { - for (std::filesystem::directory_iterator at(mod / L"sounds" / names[i], failed), end; !failed && at != end; at.increment(failed)) { - auto extension = at->path().extension().wstring(); - for (auto& c : extension) c = static_cast(towlower(c)); - if (at->is_regular_file(failed) && extension == L".wav") found[i].push_back(at->path()); - } - std::sort(found[i].begin(), found[i].end()); + const auto folder = mod / L"sounds"; + if (!std::filesystem::is_directory(folder, failed)) continue; + const auto loose = wavs(folder); + constexpr std::array, 5> kinds{{{audio::Kind::light, L"light"}, {audio::Kind::medium, L"medium"}, + {audio::Kind::heavy, L"heavy"}, {audio::Kind::metal, L"metal"}, {audio::Kind::body, L"body"}}}; + for (const auto& [kind, name] : kinds) { + auto files = wavs(folder / name); + if (files.empty() && kind <= audio::Kind::heavy) files = loose; + audio::load(kind, files); } + try { + std::ifstream in(folder / L"metal" / L"materials.txt"); + std::string line, all; + while (std::getline(in, line)) + if (const auto hash = line.find('#'); hash != 0) all += " " + line.substr(0, hash); + auto& f = feel(); + std::lock_guard lock(f.mutex); + f.pack_metal = numbers_in(all); + } catch (...) {} break; } - return found; - }(); + }); +} +void crack_sound(Size size) { + if (!settings_now().crack_sounds) return; // Nearest first: a medium break without sounds of its own takes a heavy one before a light one. - static constexpr std::array, 3> order{{{0, 1, 2, 3}, {1, 2, 0, 3}, {2, 1, 0, 3}}}; - for (const auto from : order[static_cast(size)]) { - const auto& files = sounds[from]; - if (files.empty()) continue; - const auto& file = files[std::uniform_int_distribution(0, files.size() - 1)(s.random)]; - (void)PlaySoundW(file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT); - return; + static constexpr std::array, 3> order{{{audio::Kind::light, audio::Kind::medium, audio::Kind::heavy}, + {audio::Kind::medium, audio::Kind::heavy, audio::Kind::light}, + {audio::Kind::heavy, audio::Kind::medium, audio::Kind::light}}}; + for (const auto kind : order[static_cast(size)]) + if (audio::has(kind)) return audio::play(kind, 1.0f); +} + +// ---- what a board broke on -------------------------------------------------------------------------- +// The material of the level the board touched most in the last half second, near `at`: the game's +// own contact effects say (Extension/Multiplayer/Remote/native_vfx.h). +std::optional material_near(Vec at, std::uint64_t within_ms, float reach) { + std::map count; + for (const auto& one : multiplayer::recent_local_impacts(within_ms)) { + const auto& p = one.impact.position; + if (std::hypot(p[0] - at.x, p[1] - at.y, p[2] - at.z) <= reach) ++count[one.impact.material]; + } + std::optional best; + int most{}; + for (const auto& [material, times] : count) + if (times > most) best = material, most = times; + return best; +} +bool is_metal(std::uint16_t material) { + auto& f = feel(); + std::lock_guard lock(f.mutex); + return f.metal.contains(material) || f.pack_metal.contains(material); +} + +// ---- wear ---------------------------------------------------------------------------------------- +// Client thread: what the physics thread added goes onto the deck in the game's board. +void take_wear(State& s, const std::string& deck, float extra = 0.0f) { + const float added = s.wear_added.exchange(0.0f, std::memory_order_acq_rel) + extra; + if (deck.empty() || added <= 0.0f) return; + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + auto& worn = f.wear[deck]; + const auto before = worn; + worn = add_wear(worn, added, f.settings.wear_rate); + s.wear_now.store(worn, std::memory_order_relaxed); + if (worn - before >= 0.01f) say(logging::Level::debug, "a hard hit: the board is {:.1f}% worn now (+{:.1f}).", worn * 100.0f, (worn - before) * 100.0f); + if (worn != before) f.dirty = true; + if (condition_name(before) != condition_name(worn)) + say(logging::Level::info, "the {} board is {} now ({:.0f}% worn).", deck_label(deck), condition_name(worn), worn * 100.0f); +} +// A board that broke is a new one once it is whole again: no wear, no scratches. +void new_board(State& s, const std::string& deck) { + if (deck.empty()) return; + auto& f = feel(); + { + std::lock_guard lock(f.mutex); + load_feel(f); + f.wear[deck] = 0.0f; + f.dirty = true; + } + if (deck == deck_now(s)) { + s.wear_now.store(0.0f, std::memory_order_relaxed); + s.wear_added.store(0.0f, std::memory_order_relaxed); + profile_runtime::reset_board_wear(); } } +void add_physics_wear(State& s, float amount) { + if (!(amount > 0.0f)) return; + float current = s.wear_added.load(std::memory_order_relaxed); + while (!s.wear_added.compare_exchange_weak(current, current + amount, std::memory_order_acq_rel)) {} +} +// What wear does to the board's handling: four values of the game's physics tuning, the game's own +// value times handling()'s factor. A value the player changed (the trainer) is left alone; outside +// a multiplayer session only, which shares tuning between its players. +struct Tuned { + bool tried{}, known{}, wrote{}; + std::array offset{}; + std::array game{}, written{}; +}; +Tuned& tuned() { + static Tuned value; + return value; +} +void apply_handling(std::uintptr_t base, std::uintptr_t entity, const Handling& want) { + auto& t = tuned(); + if (!t.known) { + physics_tuning::prepare(); + const auto model = physics_tuning::game_tuning(); + if (!model) return; + constexpr std::array names{"PhysicsSteering.GeneralScalar", "PhysicsTrucks.StaticTruckSpringStrength", + "PhysicsTrucks.StaticTruckDamping", "PhysicsTrucks.TruckTwistLimit"}; + std::size_t found{}; + for (std::size_t i = 0; i < model->fields.size() && i < model->field_names.size(); ++i) + for (std::size_t k = 0; k < names.size(); ++k) + if (model->field_names[i] == names[k] && model->fields[i].real && model->fields[i].offset + 4u <= model->image.size()) { + t.offset[k] = model->fields[i].offset; + std::memcpy(&t.game[k], model->image.data() + t.offset[k], 4); + ++found; + } + if (!t.tried) { + t.tried = true; + if (found != names.size()) say(logging::Level::warning, "wear cannot change the handling: {} of the 4 tuning values found.", found); + } + if (found != names.size()) return; + t.known = true; + } + physics_tuning::Values live; + if (!physics_tuning::read_live(base, live)) return; + const std::array factor{want.steering, want.spring, want.damping, want.twist}; + bool change{}; + for (std::size_t k = 0; k < factor.size(); ++k) { + if (t.offset[k] + 4u > live.image.size()) return; + float now{}; + std::memcpy(&now, live.image.data() + t.offset[k], 4); + const auto same = [](float a, float b) { return std::abs(a - b) <= 1e-4f * (std::max)(1.0f, std::abs(b)); }; + if (!same(now, t.game[k]) && !(t.wrote && same(now, t.written[k]))) continue; + const float target = t.game[k] * factor[k]; + t.written[k] = target; + if (!same(now, target)) { + std::memcpy(live.image.data() + t.offset[k], &target, 4); + change = true; + } + } + t.wrote = true; + if (change) (void)physics_tuning::write_live(base, entity, live); +} + +// ---- the board's axes, for the physics thread ------------------------------------------------------- +void note_axes(State& s, std::uintptr_t base, std::uintptr_t holder) { + const auto buffer = board_pose(base, holder); + PoseBone root{}, mount{}; + if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return; + const auto facing = unit(facing_of(root) * facing_of(mount)); + std::lock_guard lock(s.axes_mutex); + s.along = turn(facing, {0, 0, 1}); + s.up = turn(facing, {0, 1, 0}); + s.axes_at = GetTickCount64(); +} +bool board_axes(State& s, Vec& along, Vec& up) { + std::lock_guard lock(s.axes_mutex); + if (!s.axes_at || GetTickCount64() - s.axes_at > 250) return false; + along = s.along; + up = s.up; + return true; +} + +// ---- every physics step of the local skater (No Bail's step listeners) ------------------------------ +// The board's root body velocity (as the grind exit control reads it). +bool board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { + std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; + std::uint32_t count{}; + if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || + !memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) || + type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) || + !memory::peek(bodies, count) || count != sstate::board_body_count) + return false; + const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; + if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity)) + return false; + return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); +} +struct StepWatch { + bool was_air{}; + float air_down{}; // how fast the board was coming down, in its last step in the air + std::array body_at{}; + std::uint64_t any_at{}; +}; +void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire) || !s.on.load(std::memory_order_relaxed)) return; + static StepWatch w; // the physics thread's own + thread_local std::mt19937 roll{std::random_device{}()}; + const auto error = GetLastError(); + std::uint32_t physics{}; + if (memory::peek(skater.context + sstate::physics_state_offset, physics)) { + std::array v{}; + const bool known = board_velocity(skater, v); + const bool air = physics == 200 || physics == 201; + const bool grinding = physics >= 400 && physics < 500; + const bool rolling = physics >= 100 && physics < 200; + const bool wear_on = s.wear_on.load(std::memory_order_relaxed); + Vec along{}, up{}; + const auto contact = known && board_axes(s, along, up) ? contact_of(along, up, {v[0], v[1], v[2]}) : Contact::grind; + if (air && known) w.air_down = -v[1]; + if (w.was_air && (rolling || grinding)) { + const float down = w.air_down; + if (wear_on) add_physics_wear(s, rolling ? landing_wear(down) : rail_wear(down, contact != Contact::grind)); + const bool can_break = s.has_deck.load(std::memory_order_relaxed) && !s.rail_snap.load(std::memory_order_acquire); + if (can_break && grinding && contact != Contact::grind && s.rails.load(std::memory_order_relaxed)) { + const float chance = s.always.load(std::memory_order_relaxed) ? (down > 1.5f ? 1.0f : 0.0f) + : rail_chance(down, contact, s.sensitivity.load(std::memory_order_relaxed), + s.wear_now.load(std::memory_order_relaxed)); + if (std::uniform_real_distribution(0.0f, 1.0f)(roll) < chance) { + s.rail_contact.store(static_cast(contact), std::memory_order_relaxed); + s.rail_speed.store(down, std::memory_order_relaxed); + s.rail_snap.store(true, std::memory_order_release); + // The board snaps under the rider: the rider goes down with it. Gently: the game reads the + // cause's force as how hard it was hit, and a slam's force (10 and more) with the board + // flat on the ground threw the rider high into the air. + no_bail_request_wipeout(2.5f); + } + } + } + if (grinding && wear_on) add_physics_wear(s, grind_wear_per_second(contact != Contact::grind) * seconds); + w.was_air = air; + } + // The body's hits in a bail, each where and as hard as it hit. + if (s.body_sounds.load(std::memory_order_relaxed) && hall_of_meat::skater_available()) { + const auto step = hall_of_meat::read_step(skater, seconds, wipeout); + if (step.ragdoll.value_or(false)) { + const auto now = GetTickCount64(); + for (std::size_t i = 1; i < skater_body::count; ++i) { + const auto& c = step.body.bodies[i]; + if (skater_body::foot(static_cast(i)) || !c.touching || c.impact < body_hit) continue; + if (now - w.body_at[i] < 220 || now - w.any_at < 70) continue; + w.body_at[i] = w.any_at = now; + audio::play(audio::Kind::body, body_loudness(c.impact)); + break; + } + } + } + SetLastError(error); +} + +// ---- breaking and mending -------------------------------------------------------------------------- // `carried`: the skater is on foot with the board. The grip tape always holds then: a piece that // came off would have to fall to the ground under the skater's feet, and where that is the board // in a hand does not tell (a loose piece takes the ground from where the board rolled or lies). bool snap(State& s, std::uintptr_t board, std::optional at, bool gave_way, bool carried, std::string_view why, bool asked = false) { if (!s.has_deck.load(std::memory_order_acquire) || s.broken || !board) return false; const auto place = at ? *at : static_cast(std::uniform_int_distribution(0, 2)(s.random)); - const bool joined = carried || std::uniform_real_distribution(0.0f, 1.0f)(s.random) < joined_share; + const bool joined = carried || std::uniform_real_distribution(0.0f, 1.0f)(s.random) < settings_now().joined / 100.0f; const auto now = GetTickCount64(); s.parts = broken_at(place, joined); s.angle = s.parts.hinge == Hinge::fold ? std::uniform_real_distribution(0.35f, 1.0f)(s.random) : std::uniform_real_distribution(0.30f, 0.65f)(s.random); s.break_ground = s.ride_when && now - s.ride_when < 400 ? s.ride_height : std::numeric_limits::quiet_NaN(); ++s.flights; - s.broken = Broken{board, now, gave_way, asked}; + s.broken = Broken{board, now, gave_way, asked, deck_now(s)}; s.back_since = 0; - crack_sound(s, break_size(place, joined, s.angle)); + crack_sound(break_size(place, joined, s.angle)); say(logging::Level::debug, "the board broke at the {}{} ({}).", place_names[static_cast(place)], joined ? ", held together by the grip tape" : "", why); return true; @@ -385,9 +830,21 @@ bool snap(State& s, std::uintptr_t board, std::optional at, bool gave_way void mend(State& s, std::string_view why) { if (!s.broken) return; say(logging::Level::debug, "the board is whole again after {:.1f} s ({}).", (GetTickCount64() - s.broken->since) / 1000.0, why); + // A board that really broke is a new one now; one broken from the console was only looked at. + if (!s.broken->asked) new_board(s, s.broken->deck); s.broken.reset(); s.parts = {}; } +// A board that broke on metal (a rail, a bench's edge, a car) sounds like it. +void metal_sound(std::uintptr_t base, std::uintptr_t holder) { + Vec at{}; + Quat facing{}; + if (!board_place(base, holder, at, facing)) return; + const auto material = material_near(at, 600, 3.0f); + const bool metal = material && is_metal(*material); + if (metal && settings_now().crack_sounds) audio::play(audio::Kind::metal, 1.0f); + say(logging::Level::debug, "it broke on material {}{}.", material ? std::to_string(*material) : std::string("unknown"), metal ? " (metal)" : ""); +} // Where the board is relative to the skater: how far, and how flat it lies (1 = flat on the ground // either way up, 0 = on its edge, as when it is carried). struct Hold { @@ -473,6 +930,16 @@ bool start(std::uintptr_t base) noexcept { } s.base.store(base, std::memory_order_release); s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release); + { + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + mirror(s, f.settings); + } + // Rail snaps, wear and the body's hits come from each physics step of the local skater. + if (!add_no_bail_step_listener(&on_step)) + logging::write(logging::Level::warning, logging::Channel::skater, + "Board Break: No Bail has no room for its physics step listener: no rail snaps, wear or body sounds."); const auto in_game = mods::board_break_in_play(); { std::lock_guard lock(s.names); @@ -545,11 +1012,32 @@ void on_client_tick(std::uintptr_t client) noexcept { wanted.swap(s.wanted_deck); } s.has_deck.store(in_game, std::memory_order_release); + if (changed || !s.setup_read) { + s.setup_read = true; + const auto setup = read_deck_setup(deck_in_game); + render_edit().axle.store(setup.axle, std::memory_order_release); + { + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + const auto worn = f.wear.find(deck_in_game); + s.wear_now.store(worn == f.wear.end() ? 0.0f : worn->second, std::memory_order_relaxed); + } + if (in_game) load_sounds(); + if (in_game && setup.axle != 1.0f) + say(logging::Level::info, "the {} deck's wheels sit {:.2f} as far apart as a standard board's.", deck_label(deck_in_game), setup.axle); + } if (changed) say(logging::Level::info, "the deck in the game is now: {}{}.", deck_label(deck_in_game), in_game ? "" : " (boards cannot break)"); if (!wanted.empty() && wanted != deck_in_game && mods::save_board_break_deck(wanted)) { mend(s, "another deck"); say(logging::Level::info, "deck {} chosen. {}", deck_label(wanted), live_mods::apply(true)); } + if (NoBailSkater probe; in_game && !s.effects_hooked && no_bail_skater(probe)) { + // What a board broke on comes from the game's contact effects (native_vfx.h). The hook is + // installed once, in a world (multiplayer would do it there too). + s.effects_hooked = true; + multiplayer::prepare_effects(base); + } if (in_game && !s.hooked) { // The boards' render pose hook is shared with multiplayer and first person, and only // installed when one of them asks. @@ -579,7 +1067,33 @@ void on_client_tick(std::uintptr_t client) noexcept { return; } s.board_seen = now; + note_axes(s, base, holder); if (s.broken && s.broken->board != board) mend(s, "the skater has another board"); + // What wear has done, onto the board; and what that does to its handling. + take_wear(s, deck_in_game); + if (now - s.handled_at > 500) { + s.handled_at = now; + if (now - s.session_checked > 2000) { + s.session_checked = now; + const auto session = multiplayer::model(); + s.in_session = session.active || session.connected; + } + const auto st = settings_now(); + const bool worn = s.on.load(std::memory_order_acquire) && st.wear && !s.in_session; + apply_handling(base, skater.entity, worn ? handling(s.wear_now.load(std::memory_order_relaxed)) : Handling{}); + } + if (now - s.saved_at > 5000) { + s.saved_at = now; + auto& f = feel(); + std::lock_guard lock(f.mutex); + if (f.dirty) save_feel(f); + } + // A board that came down too hard onto a rail in a slide (the physics thread saw it). + if (s.rail_snap.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken) { + const auto contact = static_cast(s.rail_contact.load(std::memory_order_relaxed)); + const auto what = std::format("a {} onto a rail at {:.1f} m/s", contact_names[(std::min)(contact, contact_names.size() - 1)], s.rail_speed.load()); + if (snap(s, board, Place::mid, false, false, what)) metal_sound(base, holder); + } watch_physics_state(client, skater.entity); const auto watch = watched_physics_state(); if (s.wanted_mend.exchange(false, std::memory_order_acq_rel)) mend(s, "asked for"); @@ -608,13 +1122,24 @@ void on_client_tick(std::uintptr_t client) noexcept { if (hit->reason.load() < 0 || now - hit->at.load() > recent_ms) hit = &last_cause(); const bool known = now - hit->at.load() <= recent_ms && hit->reason.load() >= 0; const auto force = known ? hit->force.load() : 0.0f; - if (on && !s.broken && std::uniform_real_distribution(0.0f, 1.0f)(s.random) < break_chance(force, heavy, light)) - (void)snap(s, board, std::nullopt, false, watch.previous >= 500 && watch.previous < 600, std::format("a slam of force {:.1f}", force)); + const auto st = settings_now(); + if (on && st.wear) take_wear(s, deck_in_game, slam_wear(force)); + const float chance = st.always ? 1.0f : slam_chance(force, st.sensitivity, s.wear_now.load(std::memory_order_relaxed)); + if (on && !s.broken && std::uniform_real_distribution(0.0f, 1.0f)(s.random) < chance) { + // Nose first it goes at the nose, tail first at the tail, flat mostly in the middle. + Vec along{}, up{}; + const float slope = board_axes(s, along, up) ? -along.y : 0.0f; + const auto place = place_of_break(slope, false, std::uniform_real_distribution(0.0f, 1.0f)(s.random)); + if (snap(s, board, place, false, watch.previous >= 500 && watch.previous < 600, + std::format("a slam of force {:.1f}{}", force, st.always ? ", set to always break" : ""))) + metal_sound(base, holder); + } } // Now and then a board simply gives way under the rider: the nose goes. if (on && !s.broken && s.rode_at && riding(watch.state)) { const auto seconds = (std::min)(now - s.rode_at, 250) / 1000.0; - if (std::uniform_real_distribution(0.0, 1.0)(s.random) < gives_way * seconds) + const float worn = s.wear_on.load(std::memory_order_relaxed) ? s.wear_now.load(std::memory_order_relaxed) : 0.0f; + if (std::uniform_real_distribution(0.0, 1.0)(s.random) < gives_way_per_second(worn) * seconds) (void)snap(s, board, Place::nose, true, false, "it gave way while riding"); } s.rode_at = now; @@ -660,6 +1185,7 @@ std::string deck() { } std::string deck_label(std::string_view deck) { if (deck.empty()) return "None"; + if (auto title = setup_of(deck).label; !title.empty()) return title; std::string name(deck); bool start = true; for (auto& c : name) { @@ -688,4 +1214,84 @@ std::string choose_deck(std::string wanted) { } void snap(int place) noexcept { state().wanted_snap.store(place >= 0 && place < 3 ? place : 3, std::memory_order_release); } void mend() noexcept { state().wanted_mend.store(true, std::memory_order_release); } + +Settings settings() noexcept { + try { + return settings_now(); + } catch (...) { return {}; } +} +bool set_setting(std::string_view name, int value) noexcept { + try { + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + if (!set_setting_value(f.settings, name, value)) return false; + mirror(state(), f.settings); + save_feel(f); + return true; + } catch (...) { return false; } +} +float wear() noexcept { return state().wear_now.load(std::memory_order_relaxed); } +void set_wear(float value) noexcept { + try { + auto& s = state(); + const auto deck = deck_now(s); + if (deck.empty()) return; + value = std::clamp(value, 0.0f, 1.0f); + { + auto& f = feel(); + std::lock_guard lock(f.mutex); + load_feel(f); + f.wear[deck] = value; + save_feel(f); + } + s.wear_now.store(value, std::memory_order_relaxed); + if (value == 0.0f) profile_runtime::reset_board_wear(); + } catch (...) {} +} +std::string metal_command(std::string_view action) { + auto& f = feel(); + if (action == "mark" || action == "unmark") { + const auto material = material_near({}, 1500, std::numeric_limits::infinity()); + if (!material) return "No contacts in the last second and a half: grind the rail first, then mark it."; + std::lock_guard lock(f.mutex); + load_feel(f); + if (action == "mark") f.metal.insert(*material); + else f.metal.erase(*material); + save_feel(f); + return std::format("Material {} is {} metal.", *material, action == "mark" ? "now" : "no longer"); + } + std::lock_guard lock(f.mutex); + load_feel(f); + if (action == "clear") { + f.metal.clear(); + save_feel(f); + return "No material is marked as metal now (a pack's own list still counts)."; + } + std::string list; + for (const auto m : f.metal) list += (list.empty() ? "" : ", ") + std::to_string(m); + std::string pack; + for (const auto m : f.pack_metal) pack += (pack.empty() ? "" : ", ") + std::to_string(m); + return "Metal: " + (list.empty() ? std::string("none marked") : list) + "; from the deck pack: " + (pack.empty() ? std::string("none") : pack) + "."; +} +std::string materials_report() { + std::map count; + for (const auto& one : multiplayer::recent_local_impacts(3000)) ++count[one.impact.material]; + if (count.empty()) return "No contacts in the last 3 seconds."; + std::string out = "Materials touched in the last 3 s:"; + for (const auto& [material, times] : count) out += std::format(" {} ({}x{})", material, times, is_metal(material) ? ", metal" : ""); + return out + "."; +} +std::string status() { + auto& s = state(); + if (!available()) return "Board Break is unavailable for this game build."; + const auto st = settings_now(); + const auto deck = deck_now(s); + const auto worn = s.wear_now.load(std::memory_order_relaxed); + return std::format("Board Break {}; deck {}; the board is {} ({:.0f}% worn); sensitivity {}%, always break {}, rail snaps {}, wear {} at {}%, " + "grip holds {}% of breaks, crack sounds {}, body sounds {}.", + s.on.load() ? "on" : "off", deck_label(deck), condition_name(worn), worn * 100.0f, + st.sensitivity, st.always ? "on" : "off", st.rails ? "on" : "off", st.wear ? "on" : "off", st.wear_rate, st.joined, + st.crack_sounds ? "on" : "off", st.body_sounds ? "on" : "off"); +} } // namespace dingosdk::board_break diff --git a/Extension/BoardBreak/board_break.h b/Extension/BoardBreak/board_break.h index 5771e9d..c79e378 100644 --- a/Extension/BoardBreak/board_break.h +++ b/Extension/BoardBreak/board_break.h @@ -1,4 +1,5 @@ #pragma once +#include "board_break_feel.h" #include #include #include @@ -60,4 +61,23 @@ std::string choose_deck(std::string wanted); // respawns. Or makes a broken board whole. void snap(int place) noexcept; void mend() noexcept; + +// ---- the feel of it ------------------------------------------------------------------------------- +// The player's settings (board_break_feel.h), kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings. +// Any thread; set_setting saves at once and is false for no such setting. +Settings settings() noexcept; +bool set_setting(std::string_view name, int value) noexcept; +// Wear and tear: how worn the board of the deck in the game is (0 new, 1 worn out), kept per deck +// with the settings. A board that broke is a new one once it is whole again. A worn board handles +// worse (softer trucks, a lazier turn: the game's own physics tuning, only outside multiplayer +// sessions, which share tuning), breaks more easily and, worn out, can give way under the rider. +// Its scratches are the game's own board wear (Mod Options > Board wear), reset with it. +float wear() noexcept; +void set_wear(float wear) noexcept; +// Metal: the level's materials a board breaking on them sounds like metal on. They are learned +// from the contacts while grinding (`boardbreak metal mark` marks what the board is on now as +// metal), and a pack can bring a list (sounds/metal/materials.txt). For the console. +std::string metal_command(std::string_view action); +std::string materials_report(); +std::string status(); } diff --git a/Extension/BoardBreak/board_break_audio.cpp b/Extension/BoardBreak/board_break_audio.cpp new file mode 100644 index 0000000..b4cd452 --- /dev/null +++ b/Extension/BoardBreak/board_break_audio.cpp @@ -0,0 +1,166 @@ +#include "board_break_audio.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "xaudio2.lib") +#pragma comment(lib, "winmm.lib") +#pragma comment(lib, "ole32.lib") + +namespace dingosdk::board_break::audio { +namespace { +// One wave file, read whole: its format as the file gives it and its samples. +struct Sound { + std::filesystem::path file; + std::vector format; // WAVEFORMATEX, or the larger WAVEFORMATEXTENSIBLE + std::vector samples; +}; +// A RIFF WAVE file's "fmt " and "data" chunks; false for anything else. +bool read_wave(const std::filesystem::path& file, Sound& out) { + std::ifstream in(file, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (bytes.size() < 12 || std::memcmp(bytes.data(), "RIFF", 4) || std::memcmp(bytes.data() + 8, "WAVE", 4)) return false; + std::size_t at = 12; + while (at + 8 <= bytes.size()) { + std::uint32_t size{}; + std::memcpy(&size, bytes.data() + at + 4, 4); + const auto body = at + 8; + if (body + size > bytes.size()) size = static_cast(bytes.size() - body); + if (!std::memcmp(bytes.data() + at, "fmt ", 4) && size >= 16) out.format.assign(bytes.begin() + body, bytes.begin() + body + size); + else if (!std::memcmp(bytes.data() + at, "data", 4)) out.samples.assign(bytes.begin() + body, bytes.begin() + body + size); + at = body + size + (size & 1); + } + if (out.format.size() < 16 || out.samples.empty()) return false; + std::uint16_t tag{}; + std::memcpy(&tag, out.format.data(), 2); + if (tag != WAVE_FORMAT_PCM && tag != WAVE_FORMAT_IEEE_FLOAT && tag != WAVE_FORMAT_EXTENSIBLE) return false; + // XAudio2 reads a whole WAVEFORMATEX (cbSize included). + if (out.format.size() < sizeof(WAVEFORMATEX)) out.format.resize(sizeof(WAVEFORMATEX), 0); + out.file = file; + return true; +} + +struct Request { + Kind kind{}; + float volume{}; +}; +struct Engine { + std::mutex mutex; + std::condition_variable wake; + std::deque queue; + std::array, kinds> sounds; + std::array loaded{}; + std::thread worker; + std::atomic started{}, failed{}; + std::mt19937 random{std::random_device{}()}; +}; +Engine& engine() { + static auto* value = new Engine; + return *value; +} + +void run(Engine& e) { + const auto com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + IXAudio2* xaudio{}; + IXAudio2MasteringVoice* master{}; + if (FAILED(XAudio2Create(&xaudio, 0, XAUDIO2_DEFAULT_PROCESSOR)) || FAILED(xaudio->CreateMasteringVoice(&master))) { + if (xaudio) xaudio->Release(); + xaudio = nullptr; + e.failed.store(true, std::memory_order_release); + logging::log(logging::Level::warning, logging::Channel::skater, + "Board Break: no sound device for its own sounds; the cracks play one at a time."); + } + std::vector voices; + for (;;) { + Request request; + const Sound* sound{}; + { + std::unique_lock lock(e.mutex); + e.wake.wait(lock, [&] { return !e.queue.empty(); }); + request = e.queue.front(); + e.queue.pop_front(); + auto& list = e.sounds[static_cast(request.kind)]; + if (list.empty()) continue; + sound = &list[std::uniform_int_distribution(0, list.size() - 1)(e.random)]; + } + if (!xaudio) { + // Without XAudio2: the cracks still sound, one at a time. + if (request.kind <= Kind::heavy) (void)PlaySoundW(sound->file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT); + continue; + } + // Voices whose sound has played out go; at most a couple of dozen at once. + std::erase_if(voices, [](IXAudio2SourceVoice* voice) { + XAUDIO2_VOICE_STATE state{}; + voice->GetState(&state, XAUDIO2_VOICE_NOSAMPLESPLAYED); + if (state.BuffersQueued) return false; + voice->DestroyVoice(); + return true; + }); + if (voices.size() >= 24) { + voices.front()->DestroyVoice(); + voices.erase(voices.begin()); + } + IXAudio2SourceVoice* voice{}; + if (FAILED(xaudio->CreateSourceVoice(&voice, reinterpret_cast(sound->format.data())))) continue; + XAUDIO2_BUFFER buffer{}; + buffer.AudioBytes = static_cast(sound->samples.size()); + buffer.pAudioData = sound->samples.data(); + buffer.Flags = XAUDIO2_END_OF_STREAM; + voice->SetVolume(std::clamp(request.volume, 0.0f, 1.0f)); + if (FAILED(voice->SubmitSourceBuffer(&buffer)) || FAILED(voice->Start())) { + voice->DestroyVoice(); + continue; + } + voices.push_back(voice); + } + (void)com; +} +} + +void load(Kind kind, const std::vector& files) noexcept { + auto& e = engine(); + std::vector read; + for (const auto& file : files) { + try { + Sound sound; + if (read_wave(file, sound)) read.push_back(std::move(sound)); + else logging::log(logging::Level::warning, logging::Channel::skater, "Board Break: {} is not a wave file it can play.", + file.filename().string()); + } catch (...) {} + } + std::lock_guard lock(e.mutex); + const auto at = static_cast(kind); + if (e.loaded[at]) return; + e.loaded[at] = true; + e.sounds[at] = std::move(read); +} +bool has(Kind kind) noexcept { + auto& e = engine(); + std::lock_guard lock(e.mutex); + return !e.sounds[static_cast(kind)].empty(); +} +void play(Kind kind, float volume) noexcept { + auto& e = engine(); + try { + if (!e.started.exchange(true)) { + e.worker = std::thread([&e] { run(e); }); + e.worker.detach(); + } + std::lock_guard lock(e.mutex); + if (e.queue.size() < 32) e.queue.push_back({kind, volume}); + } catch (...) { return; } + e.wake.notify_one(); +} +} diff --git a/Extension/BoardBreak/board_break_audio.h b/Extension/BoardBreak/board_break_audio.h new file mode 100644 index 0000000..29e868f --- /dev/null +++ b/Extension/BoardBreak/board_break_audio.h @@ -0,0 +1,18 @@ +#pragma once +#include +#include + +// Board Break's sounds: wave files out of a deck pack's `sounds` folder, played straight to the +// speakers (not through the game's mixer), over each other and at the loudness asked for, so a +// crack, the board's metal and the body's hits of one bail can all be heard. XAudio2 on a thread +// of its own; without it (no sound device) the cracks fall back to PlaySound, one at a time. +namespace dingosdk::board_break::audio { +enum class Kind { light, medium, heavy, metal, body }; +inline constexpr std::size_t kinds = 5; +// The wave files of a kind (16-bit or float PCM, any rate and channels). Any thread; a kind is +// given its files once, later calls for it change nothing. +void load(Kind kind, const std::vector& files) noexcept; +bool has(Kind kind) noexcept; +// One of them, at random, at `volume` (0 to 1). Any thread; returns at once. +void play(Kind kind, float volume) noexcept; +} diff --git a/Extension/BoardBreak/board_break_commands.cpp b/Extension/BoardBreak/board_break_commands.cpp index 76b2694..6ae13c0 100644 --- a/Extension/BoardBreak/board_break_commands.cpp +++ b/Extension/BoardBreak/board_break_commands.cpp @@ -71,5 +71,78 @@ void register_board_break_commands(Commands ®istry) { out("Mended."); }; registry.add(std::move(mend)); + // The player's settings (board_break_feel.h), kept: with no value each says what it is. + struct Setting { + const char *name, *description; + }; + const Setting settings[]{ + {"always", "Board Break: every slam breaks the board, and every slide that comes down onto a rail (for replay clips)"}, + {"sensitivity", "Board Break: how easily a board breaks, percent (100; 200 takes half the force, 50 twice as much)"}, + {"joined", "Board Break: percent of breaks the grip tape holds together (50)"}, + {"rails", "Board Break: a boardslide or darkslide that comes down too hard onto a rail can snap the board"}, + {"wear", "Board Break: boards wear out with every hit, handle worse and break more easily as they do"}, + {"wearrate", "Board Break: how fast boards wear, percent (100)"}, + {"cracks", "Board Break: the crack (and metal) sound of a break"}, + {"bodysounds", "Board Break: the body's hits in a bail, where and as hard as it hits"}, + }; + for (const auto &setting : settings) { + auto entry = action(std::string("boardbreak ") + setting.name, setting.description, Group::gameplay, + {argument("value", Type::text, true)}); + entry.run = [name = std::string(setting.name)](const Model &, const Values &values, const Output &out) { + const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); + if (!text || text->empty()) { + out(name + " is " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "."); + return; + } + int value{}; + if (*text == "on" || *text == "yes") value = 1; + else if (*text == "off" || *text == "no") value = 0; + else { + try { + value = std::stoi(*text); + } catch (...) { + out("Give a number (or on/off)."); + return; + } + } + out(board_break::set_setting(name, value) ? name + " is now " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "." + : "No such setting."); + }; + registry.add(std::move(entry)); + } + + auto worn = action("boardbreak worn", "How worn the board of the deck in the game is, percent; 0 is a new board", Group::gameplay, + {argument("percent", Type::text, true)}); + worn.run = [](const Model &, const Values &values, const Output &out) { + const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); + if (text && !text->empty()) { + try { + board_break::set_wear(std::stof(*text) / 100.0f); + } catch (...) { + out("Give a percent, 0 to 100."); + return; + } + } + const auto wear = board_break::wear(); + out("The board is " + std::string(board_break::condition_name(wear)) + ", " + std::to_string(static_cast(wear * 100.0f + 0.5f)) + "% worn."); + }; + registry.add(std::move(worn)); + + auto status = action("boardbreak status", "Everything Board Break is set to, and how worn the board is", Group::gameplay); + status.run = [](const Model &, const Values &, const Output &out) { out(board_break::status()); }; + registry.add(std::move(status)); + + auto metal = action("boardbreak metal", "Which materials a board breaking on them sounds like metal on: mark (what you grind now), unmark, clear, list", + Group::gameplay, {argument("mark|unmark|clear|list", Type::text, true)}); + metal.run = [](const Model &, const Values &values, const Output &out) { + const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); + out(board_break::metal_command(text ? *text : std::string("list"))); + }; + registry.add(std::move(metal)); + + auto materials = action("boardbreak materials", "The level's materials your skater touched in the last 3 seconds, by number (to find a rail's)", + Group::gameplay); + materials.run = [](const Model &, const Values &, const Output &out) { out(board_break::materials_report()); }; + registry.add(std::move(materials)); } } // namespace dingosdk::console diff --git a/Extension/BoardBreak/board_break_feel.h b/Extension/BoardBreak/board_break_feel.h new file mode 100644 index 0000000..e337c85 --- /dev/null +++ b/Extension/BoardBreak/board_break_feel.h @@ -0,0 +1,171 @@ +#pragma once +#include "board_break_model.h" +#include +#include +#include +#include + +// How a board wears and how hard it has to be hit to break, apart from the game: the player's +// settings, the damage each kind of hit does, what a worn board does to its handling, and what kind +// of contact a board makes with a rail. Plain numbers, so the tests can hold them. +namespace dingosdk::board_break { +// ---- the player's settings ---------------------------------------------------------------------- +// Each is kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings and set from the console as +// `boardbreak ` (the menus send the same). +struct Settings { + bool always = false; // every slam breaks the board, for replay clips + int sensitivity = 100; // percent: at 200 half the force breaks a board, at 50 twice as much + int joined = 50; // percent of breaks the grip tape holds together + bool rails = true; // a hard boardslide or darkslide onto a rail or ledge can break it + bool wear = true; // boards wear out, handle worse and break more easily as they do + int wear_rate = 100; // percent: how fast they wear + bool crack_sounds = true; + bool body_sounds = true; // the body's hits during a bail +}; +inline constexpr int sensitivity_min = 10, sensitivity_max = 400, rate_min = 0, rate_max = 500; +struct SettingInfo { + std::string_view name; + bool on_off; // else a percent + int low, high; +}; +inline constexpr std::array setting_info{{ + {"always", true, 0, 1}, {"sensitivity", false, sensitivity_min, sensitivity_max}, {"joined", false, 0, 100}, + {"rails", true, 0, 1}, {"wear", true, 0, 1}, {"wearrate", false, rate_min, rate_max}, + {"cracks", true, 0, 1}, {"bodysounds", true, 0, 1}, +}}; +// The setting `name` as a number (on/off as 1/0), or -1 for no such setting. +inline int setting_value(const Settings& s, std::string_view name) noexcept { + if (name == "always") return s.always; + if (name == "sensitivity") return s.sensitivity; + if (name == "joined") return s.joined; + if (name == "rails") return s.rails; + if (name == "wear") return s.wear; + if (name == "wearrate") return s.wear_rate; + if (name == "cracks") return s.crack_sounds; + if (name == "bodysounds") return s.body_sounds; + return -1; +} +// Sets it, kept within its range; false for no such setting. +inline bool set_setting_value(Settings& s, std::string_view name, int value) noexcept { + for (const auto& info : setting_info) { + if (info.name != name) continue; + value = std::clamp(value, info.low, info.high); + if (name == "always") s.always = value != 0; + else if (name == "sensitivity") s.sensitivity = value; + else if (name == "joined") s.joined = value; + else if (name == "rails") s.rails = value != 0; + else if (name == "wear") s.wear = value != 0; + else if (name == "wearrate") s.wear_rate = value; + else if (name == "cracks") s.crack_sounds = value != 0; + else s.body_sounds = value != 0; + return true; + } + return false; +} + +// ---- how hard a slam has to be ------------------------------------------------------------------ +// The slam forces measured in play run from about 2 (a stumble) to 10 and more (a hard slam). +inline constexpr float heavy_slam = 10.0f, light_chance = 0.25f; +// A worn board takes less: at the end of its life about half the force it took new. +inline constexpr float worn_strength = 0.5f; +// How likely a slam of `force` breaks the board, with the player's sensitivity (percent) and the +// board's wear (0 new, 1 worn out). +inline float slam_chance(float force, int sensitivity, float wear) noexcept { + const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f; + const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f); + const float light = std::clamp(light_chance * s * (1.0f + wear), 0.0f, 0.95f); + return break_chance(force, heavy_slam * strength / s, light); +} + +// ---- wear and tear ------------------------------------------------------------------------------ +// A board's wear runs from 0 (new) to 1 (worn out). Every hit adds some, at the player's rate +// (percent of these). A fresh board lasts a good while: a few hundred hard landings, or some +// dozens of big drops, or half an hour of boardslides. +// A landing, by how fast the board was coming down as it touched (m/s): nothing up to an ollie off +// a curb's speed, then more and more. +inline constexpr float landing_free = 3.5f; +inline float landing_wear(float down_speed) noexcept { + const float over = (std::max)(0.0f, down_speed - landing_free); + return (std::min)(0.0008f * over * over, 0.04f); +} +// Onto a rail or ledge: the board's own hit, harder on a slide (the deck takes it) than on the trucks. +inline float rail_wear(float down_speed, bool slide) noexcept { + const float hit = landing_wear(down_speed + (slide ? 1.0f : 0.0f)); + return hit + (slide ? 0.001f : 0.0003f); +} +// Every second of grinding or sliding. +inline float grind_wear_per_second(bool slide) noexcept { return slide ? 0.0005f : 0.00015f; } +// A slam, by its force. +inline float slam_wear(float force) noexcept { return std::clamp((force - 4.0f) / 6.0f, 0.0f, 1.0f) * 0.025f; } +inline float add_wear(float wear, float amount, int rate) noexcept { + return std::clamp(wear + amount * std::clamp(rate, rate_min, rate_max) / 100.0f, 0.0f, 1.0f); +} +// What wear does to the board's handling, as factors on the game's own tuning: the steering answers +// less, the trucks' springs and their damping go soft (the board wobbles and leans further), and +// they twist further. Nothing shows until a board is a fifth worn. +struct Handling { + float steering = 1, spring = 1, damping = 1, twist = 1; + bool operator==(const Handling&) const = default; +}; +inline Handling handling(float wear) noexcept { + const float w = std::clamp((wear - 0.2f) / 0.8f, 0.0f, 1.0f); + const float eased = w * w * (3.0f - 2.0f * w); + return {1.0f - 0.25f * eased, 1.0f - 0.6f * eased, 1.0f - 0.5f * eased, 1.0f + 0.4f * eased}; +} +// A worn board can give way under the rider: per second of riding. +inline double gives_way_per_second(float wear) noexcept { + const double w = std::clamp((wear - 0.8f) / 0.2f, 0.0f, 1.0f); + return 0.0001 + w * w * 0.01; +} +// What a board's state is called on the menus. +inline std::string_view condition_name(float wear) noexcept { + return wear < 0.15f ? "new" : wear < 0.4f ? "used" : wear < 0.65f ? "worn" : wear < 0.85f ? "beat" : "done"; +} + +// ---- the board on a rail ------------------------------------------------------------------------ +enum class Contact { grind, slide, darkslide }; +constexpr std::array contact_names{"grind", "slide", "darkslide"}; +// From the board's long axis and its up axis in the world, and how it moves: a board lying across +// its way (a boardslide, a lipslide) slides, one on its grip is in a darkslide, the rest grind on +// their trucks. A board that hardly moves grinds. +inline Contact contact_of(Vec along, Vec up, Vec velocity) noexcept { + if (up.y < -0.35f) return Contact::darkslide; + const float a = std::hypot(along.x, along.z), v = std::hypot(velocity.x, velocity.z); + if (a < 0.2f || v < 0.5f) return Contact::grind; + const float cosine = std::abs((along.x * velocity.x + along.z * velocity.z) / (a * v)); + return cosine < 0.5f ? Contact::slide : Contact::grind; +} +// How likely a board snaps as it comes down onto a rail in a slide, by how fast it was coming down +// (m/s): from 3 m/s up (a hop onto a rail is about 2), certain 3 m/s above that; a darkslide on the +// grip goes sooner. Grinds on the trucks do not snap a board. Sensitivity and wear as for a slam. +inline float rail_chance(float down_speed, Contact contact, int sensitivity, float wear) noexcept { + if (contact == Contact::grind) return 0.0f; + const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f; + const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f); + const float start = (contact == Contact::darkslide ? 2.2f : 3.0f) * strength / s; + const float span = 3.0f * strength / s; + return std::clamp((down_speed - start) / span, 0.0f, 1.0f); +} + +// ---- where a board breaks ----------------------------------------------------------------------- +// From how the board came down: nose first it breaks at the nose, tail first at the tail, flat (or +// across a rail) in the middle. `slope` is how far the nose points down (the board's long axis' +// downward part, -1 to 1); `roll` is 0 to 1, chance. Flat landings still break at an end now and +// then, as real boards do. +inline Place place_of_break(float slope, bool across_rail, float roll) noexcept { + if (across_rail) return Place::mid; + if (slope > 0.35f) return roll < 0.75f ? Place::nose : Place::mid; + if (slope < -0.35f) return roll < 0.75f ? Place::tail : Place::mid; + return roll < 0.5f ? Place::mid : roll < 0.75f ? Place::nose : Place::tail; +} + +// ---- a body's hit, for its sound ----------------------------------------------------------------- +// The ragdoll's hits along their normal (Extension/HallOfMeat/hall_of_meat_model.h): a graze is 2 to +// 3 m/s, a fall on flat ground 3 to 9, a drop from height 13 and more. Below `body_hit` nothing is +// heard; the loudness rises to full at `body_full`. +inline constexpr float body_hit = 3.0f, body_full = 11.0f; +inline float body_loudness(float speed) noexcept { + if (speed < body_hit) return 0.0f; + return std::clamp(0.3f + 0.7f * (speed - body_hit) / (body_full - body_hit), 0.3f, 1.0f); +} +} diff --git a/Extension/Multiplayer/Remote/native_vfx.cpp b/Extension/Multiplayer/Remote/native_vfx.cpp index 4e44b05..e051517 100644 --- a/Extension/Multiplayer/Remote/native_vfx.cpp +++ b/Extension/Multiplayer/Remote/native_vfx.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include // Other players' skaters and skateboards are the game's own entities, and the game gives each @@ -76,6 +77,7 @@ struct State { BuildEffects build_effects{}; std::mutex mutex; std::vector captured; // the local game's, since the last drain + std::deque recent; // the local game's, the last few seconds' (recent_local_impacts) std::vector registered; // every effects component seen registering, newest last std::vector pairs; std::array seen{}; // materials this game's own contacts named @@ -132,6 +134,8 @@ Address impact_hook(Address manager, Address key, const void *native) { s.seen[impact.material] = true; if (s.captured.size() >= 16) s.captured.erase(s.captured.begin()); s.captured.push_back(impact); + if (s.recent.size() >= 96) s.recent.pop_front(); + s.recent.push_back({impact, GetTickCount64()}); ++s.captured_count; } } @@ -359,6 +363,15 @@ std::vector drain_impacts() { if (out.size() > max_impacts) out.erase(out.begin(), out.end() - max_impacts); return out; } +std::vector recent_local_impacts(std::uint64_t within_ms) { + auto &s = state(); + const auto now = GetTickCount64(); + std::lock_guard lock(s.mutex); + std::vector out; + for (const auto &one : s.recent) + if (now - one.at <= within_ms) out.push_back(one); + return out; +} void play_impact(const Impact &impact) noexcept { auto &s = state(); if (!s.installed || GetCurrentThreadId() != s.client_thread || !valid_impact(impact)) return; diff --git a/Extension/Multiplayer/Remote/native_vfx.h b/Extension/Multiplayer/Remote/native_vfx.h index 22878cc..a32000c 100644 --- a/Extension/Multiplayer/Remote/native_vfx.h +++ b/Extension/Multiplayer/Remote/native_vfx.h @@ -9,6 +9,14 @@ namespace dingosdk::multiplayer { void prepare_effects(std::uintptr_t base) noexcept; // The local game's contacts since the last call, oldest first: the newest max_impacts. std::vector drain_impacts(); +// The same contacts as the game played their effects (sparks, dust, landings), newest last, from +// the last `within_ms` milliseconds, kept apart from the drain: what a board broke on (Board Break). +// `at` is GetTickCount64's. Any thread. +struct LocalImpact { + Impact impact; + std::uint64_t at{}; +}; +std::vector recent_local_impacts(std::uint64_t within_ms); // For the current PeerScope slot's skater: the game's effect for one of that player's contacts. void play_impact(const Impact &impact) noexcept; // For the current PeerScope slot's skater, after its outfit changed: the effects its costume diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 647d6da..8d2c3eb 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -21,6 +21,9 @@ using SkeletonResponse = void (*)(std::uintptr_t, float, bool); using PublishAnimation = void (*)(std::uintptr_t); using ResetCauses = void (*)(std::uintptr_t); constexpr std::uintptr_t highest = memory::highest_user_address; +// The cause most real slams record (Board Break's logs: cause 6 at forces of 9.6 to 10.7): a wipeout +// asked for goes in as one of those. +constexpr std::int32_t wanted_wipeout_reason = 6; // The hooks re-resolve the local skater's ownership (some 35 fields) on every // protected physics step: guarded same-process copies, not a system call each. template bool read(std::uintptr_t address, T& value) noexcept { return memory::peek(address, value); } @@ -72,6 +75,12 @@ struct StateWatch { std::atomic since{}, previous_ticks{}; // performance-counter ticks }; StateWatch& state_watch() { static auto* value = new StateWatch; return *value; } +// A wipeout asked for (no_bail_request_wipeout): the cause goes in at the watched selector's next pick. +struct WantedWipeout { + std::atomic magnitude{}; + std::atomic until{}; +}; +WantedWipeout& wanted_wipeout() { static auto* value = new WantedWipeout; return *value; } // Recheck live local ownership at use time. A retained physics address alone // must never protect another skater after a respawn or level change. @@ -208,12 +217,28 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) { LastError error; hold_off_board(selector, current); } + // A wipeout asked for (no_bail_request_wipeout): its cause goes in as the game's contact tests + // record theirs, and the pick is Wipeout, as some of those tests return it directly. + bool force_wipeout{}; + if (auto& wanted = wanted_wipeout(); wanted.until.load(std::memory_order_acquire) && + selector == state_watch().selector.load(std::memory_order_acquire)) { + LastError error; + if (const auto until = wanted.until.exchange(0, std::memory_order_acq_rel); GetTickCount64() < until) + if (const auto causes = state_watch().causes.load(std::memory_order_acquire); causes && !protected_owner(selector, &Owner::selector)) { + record_cause(causes, wanted_wipeout_reason, wanted.magnitude.load(std::memory_order_relaxed)); + force_wipeout = true; + } + } // Clear shared requests before selection so native ground/air/walking // transitions can still run. Some contact tests return Wipeout directly; // retain the current state only for that result, never ordinary Offboard. const bool filtered = filter_requests(selector, &Owner::selector); - const auto next = protection().choose_original(selector, current); + auto next = protection().choose_original(selector, current); LastError error; + if (force_wipeout && next != wipeout_physics_state) { + logging::log(logging::Level::debug, logging::Channel::skater, "No Bail: a wipeout was asked for (state {} would have been {}).", current, next); + next = wipeout_physics_state; + } const auto chosen = filtered && next == wipeout_physics_state && protected_owner(selector, &Owner::selector) ? current : next; auto& w = state_watch(); if (selector == w.selector.load(std::memory_order_acquire) && GetTickCount64() < w.until.load(std::memory_order_acquire)) { @@ -433,6 +458,11 @@ bool no_bail_skater(NoBailSkater& skater) noexcept { return published_skater(0, void set_no_bail_step_observer(NoBailStepObserver observer) noexcept { protection().step_observer.store(observer, std::memory_order_release); } +void no_bail_request_wipeout(float magnitude) noexcept { + auto& wanted = wanted_wipeout(); + wanted.magnitude.store(magnitude, std::memory_order_relaxed); + wanted.until.store(GetTickCount64() + 250, std::memory_order_release); +} bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept { if (!listener) return false; auto& slots = protection().listeners; diff --git a/Extension/Skater/no_bail.h b/Extension/Skater/no_bail.h index a9c8e97..377a8bb 100644 --- a/Extension/Skater/no_bail.h +++ b/Extension/Skater/no_bail.h @@ -45,5 +45,11 @@ void set_no_bail_step_observer(NoBailStepObserver observer) noexcept; // false when all slots are taken. inline constexpr std::size_t no_bail_step_listener_slots = 4; bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept; +// Any thread: the watched local skater bails at its next physics step, as from a slam of +// `magnitude` (Board Break: a board that snaps on a rail throws the rider). The cause goes in just +// before the game's selector picks the next state, the way the game's own contact tests record +// theirs, so the game makes the wipeout itself; while No Bail protects the skater it is dropped like +// any other. Expires unused after a quarter of a second. +void no_bail_request_wipeout(float magnitude) noexcept; void remove_no_bail_step_listener(NoBailStepObserver listener) noexcept; } diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index 1ccf69c..b9778cb 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -212,6 +212,10 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks console(m.board_break.enabled ? "boardbreak 0" : "boardbreak 1", m.board_break.available); } else if (command == "board-break-deck") { console("boardbreak deck next", m.board_break.available && m.board_break.has_deck); + } else if (command == "board-break-always") { + console(m.board_break.always ? "boardbreak always 0" : "boardbreak always 1", m.board_break.available); + } else if (command == "board-break-wear") { + console(m.board_break.wear ? "boardbreak wear 0" : "boardbreak wear 1", m.board_break.available); } else if (command == "grind-exits") { console(m.grind_exit.enabled ? "grindexit exits off" : "grindexit exits on", m.grind_exit.available); } else if (command == "grind-exit-sides") { @@ -327,6 +331,10 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 toggle(p.main, "board-break", "Board Break", m.board_break.enabled, m.board_break.available && cb.queue_console_command); button(p.main, "board-break-deck", "Board Break deck: " + m.board_break.deck, "board-break-deck", m.board_break.available && m.board_break.has_deck && cb.queue_console_command); + toggle(p.main, "board-break-always", "Board Break: always break", m.board_break.always, + m.board_break.available && m.board_break.enabled && cb.queue_console_command); + toggle(p.main, "board-break-wear", "Board Break: wear and tear", m.board_break.wear, + m.board_break.available && m.board_break.enabled && cb.queue_console_command); const auto& grind = m.grind_exit; const bool grind_console = grind.available && cb.queue_console_command; toggle(p.main, "grind-exits", "Grind exit triggers", grind.enabled, grind_console); diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index 847bbf9..f42f325 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -4,6 +4,7 @@ #include "Engine/Vfs/initfs.h" #include "Engine/Vfs/mod_catalog.h" #include "Extension/Assets/live_mods.h" +#include "Extension/BoardBreak/board_break.h" #include "Extension/UI/skate_theme.h" #include "Engine/Core/Log/logging.h" @@ -21,14 +22,17 @@ namespace menu { // deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the // level again. A button for each deck the feature was made with, greyed out while it is not // installed, and for any other deck that is; the Mods folder is read again every 1.5 s while the -// card shows. +// card shows. A deck goes by the name its deck.ini gives it (label), else by its folder's. void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { static std::vector installed; + static std::vector titles; // each installed deck's name for people static std::string chosen; static ULONGLONG next_scan{}; if (const auto now = GetTickCount64(); now >= next_scan) { installed = mods::installed_board_break_decks(); chosen = mods::wanted_board_break_deck(); + titles.clear(); + for (const auto& deck : installed) titles.push_back(board_break::deck_label(deck)); next_scan = now + 1500; } if (!model.board_break.available || installed.empty()) return; @@ -39,12 +43,18 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV for (const auto& deck : installed) if (std::find(decks.begin(), decks.end(), deck) == decks.end()) decks.push_back(deck); const auto label = [&](const std::string& deck) { - auto name = deck; - bool start = true; - for (auto& c : name) { - if (c == '_') c = ' '; - else if (start && c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); - start = c == ' '; + std::string name; + if (const auto at = std::find(installed.begin(), installed.end(), deck); + at != installed.end() && static_cast(at - installed.begin()) < titles.size()) + name = titles[static_cast(at - installed.begin())]; + else { + name = deck; + bool start = true; + for (auto& c : name) { + if (c == '_') c = ' '; + else if (start && c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); + start = c == ' '; + } } const auto place = std::find(decks.begin(), decks.end(), deck) - decks.begin() + 1; return std::to_string(place) + " " + name; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index af9e2fe..c756a7c 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -231,6 +231,12 @@ struct RoadRashModel { struct BoardBreakModel { bool available = false, enabled = false, has_deck = false; std::string deck = "None"; + // Its settings (Extension/BoardBreak/board_break_feel.h) and how worn the board is (0 to 1, and + // its word). + bool always = false, rails = true, wear = true, crack_sounds = true, body_sounds = true; + int sensitivity = 100, joined = 50, wear_rate = 100; + float worn = 0; + std::string condition = "new"; }; // Grind exit control's switches (Extension/Skater/grind_exit_control.h): started, switched on, diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index baa033f..9d8c069 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -143,6 +143,56 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal end_card(); } +// Board Break's settings (Extension/BoardBreak/board_break_feel.h): how a board breaks, wears and +// sounds. Each is a console verb, kept with the profile. +void board_break_settings_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + const auto& bb = model.board_break; + if (!bb.available) return; + const bool console = bb.enabled && callbacks.queue_console_command; + std::string subtitle = !bb.has_deck ? "No Board Break deck is in the game" + : "Your board: " + std::to_string(static_cast(bb.worn * 100.0f + 0.5f)) + "% worn (" + bb.condition + ")"; + begin_card(menu, "board-break-settings", "BOARD BREAK SETTINGS", subtitle.c_str()); + if (!bb.enabled) note("Switch Board Break on to change these."); + const auto flag = [&](const char* label, const char* hint, bool on, const char* verb) { + bool value = on; + if (toggle_row(menu, label, hint, value, console)) send_console(menu, callbacks, std::string("boardbreak ") + verb + (value ? " 1" : " 0")); + }; + flag("Always break", "Every slam breaks the board, and every slide that comes down onto a rail: for replay clips.", bb.always, "always"); + flag("Rail snaps", "A boardslide or darkslide that comes down too hard onto a rail or ledge can snap the board, and you go down with it.", + bb.rails, "rails"); + flag("Wear and tear", "Every hit wears your board. A worn board turns lazier, wobbles and breaks more easily; worn out, it can give way. " + "Turn on Board wear (Mod Options) to see the scratches.", bb.wear, "wear"); + flag("Break sounds", "The crack of a break, and metal when it breaks on metal.", bb.crack_sounds, "cracks"); + flag("Body hit sounds", "Each hit of your body in a bail, as hard as it hits.", bb.body_sounds, "bodysounds"); + // Sent once a slider is let go; the dragged value shows until the setting comes back. + const auto percent = [&](const char* id, const char* label, const char* hint, int value, int low, int high, int step, const char* verb) { + static std::array edited{-1, -1, -1}; + static std::array edited_at{}; + const auto slot = std::string_view(id) == "bb-sensitivity" ? 0u : std::string_view(id) == "bb-joined" ? 1u : 2u; + int shown = edited[slot] >= 0 ? edited[slot] : value; + field(menu, label, hint); + ImGui::BeginDisabled(!console); + if (ImGui::SliderInt((std::string("##") + id).c_str(), &shown, low, high, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { + edited[slot] = std::clamp((shown + step / 2) / step * step, low, high); + edited_at[slot] = ImGui::GetTime(); + } + if (ImGui::IsItemDeactivatedAfterEdit() && edited[slot] >= 0) { + send_console(menu, callbacks, std::string("boardbreak ") + verb + " " + std::to_string(edited[slot])); + edited_at[slot] = ImGui::GetTime(); + } + if (edited[slot] >= 0 && !ImGui::IsItemActive() && (value == edited[slot] || ImGui::GetTime() - edited_at[slot] > 2.0)) edited[slot] = -1; + ImGui::EndDisabled(); + }; + percent("bb-sensitivity", "Break sensitivity", "How easily a board breaks. 200% takes half the force, 50% twice as much.", bb.sensitivity, 10, 400, 10, + "sensitivity"); + percent("bb-joined", "Grip tape holds", "How many breaks the grip tape holds together instead of a piece coming off.", bb.joined, 0, 100, 5, "joined"); + percent("bb-wear-rate", "Wear speed", "How fast boards wear. 0% keeps a board as it is.", bb.wear_rate, 0, 500, 10, "wearrate"); + ImGui::BeginDisabled(!console || !bb.has_deck); + if (ImGui::Button("New board", ImVec2(-FLT_MIN, 0))) send_console(menu, callbacks, "boardbreak worn 0"); + ImGui::EndDisabled(); + end_card(); +} + void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& debug = model.debug; begin_card(menu, "movement", "FLIGHT & BAILS"); @@ -187,6 +237,7 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c send_console(menu, callbacks, board_break ? "boardbreak 1" : "boardbreak 0"); end_card(); board_break_deck_card(menu, model, callbacks); + board_break_settings_card(menu, model, callbacks); // Grind exit control (Extension/Skater/grind_exit_control.h). const auto& grind = model.grind_exit; diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 61ee3b0..df1167c 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -970,6 +970,20 @@ void update_model(std::uintptr_t client, TickState& frame) { const auto breakable_deck = dingosdk::board_break::deck(); model.board_break = {dingosdk::board_break::available(), dingosdk::board_break::enabled(), !breakable_deck.empty(), dingosdk::board_break::deck_label(breakable_deck)}; + { + const auto feel = dingosdk::board_break::settings(); + auto& bb = model.board_break; + bb.always = feel.always; + bb.rails = feel.rails; + bb.wear = feel.wear; + bb.crack_sounds = feel.crack_sounds; + bb.body_sounds = feel.body_sounds; + bb.sensitivity = feel.sensitivity; + bb.joined = feel.joined; + bb.wear_rate = feel.wear_rate; + bb.worn = dingosdk::board_break::wear(); + bb.condition = std::string(dingosdk::board_break::condition_name(bb.worn)); + } model.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(), dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(), dingosdk::grind_exit_control::strength()}; diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 1ce5edf..531ac5c 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -155,6 +155,7 @@ add_library(dingosdk_runtime SHARED Extension/RoadRash/road_rash_commands.cpp Extension/BoardBreak/board_break.cpp Extension/BoardBreak/board_break_commands.cpp + Extension/BoardBreak/board_break_audio.cpp Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp diff --git a/contrib/board-break.md b/contrib/board-break.md index 0a7bd3f..cab94e1 100644 --- a/contrib/board-break.md +++ b/contrib/board-break.md @@ -6,9 +6,18 @@ comes off, flies and lies where it lands, truck and wheels with it. The board is the skater has picked it up, rides again or respawns. Built into ReSkate; the broken pieces themselves come from a content mod with the deck shapes in it. -Code: [board_break.cpp](../Extension/BoardBreak/board_break.cpp) (client tick and the boards' -render pose hook), [board_break_model.h](../Extension/BoardBreak/board_break_model.h) (the rules and -the geometry on their own, with [tests](../Extension/BoardBreak/Test/board_break_tests.cpp)), +Boards also wear: every hard landing, rail hit, grind and slam takes something out of the board of +the deck in the game, and a worn board steers lazier, wobbles, breaks from less and in the end can +give way under the rider. A boardslide or darkslide that comes down too hard onto a rail can snap +the board and throw the rider. A break cracks (and sounds like metal when it happened on metal), +and the body's hits in a bail are heard where and as hard as they land. The player sets all of it. + +Code: [board_break.cpp](../Extension/BoardBreak/board_break.cpp) (client tick, the boards' +render pose hook, wear, rail snaps), [board_break_model.h](../Extension/BoardBreak/board_break_model.h) +(the rules and the geometry on their own) and +[board_break_feel.h](../Extension/BoardBreak/board_break_feel.h) (the settings, what each hit wears, +what wear does, rail contacts), both with [tests](../Extension/BoardBreak/Test/board_break_tests.cpp), +[board_break_audio.cpp](../Extension/BoardBreak/board_break_audio.cpp) (the sounds), [board_break_commands.cpp](../Extension/BoardBreak/board_break_commands.cpp) (console), and the deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp). @@ -24,6 +33,27 @@ deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp) press asks for that deck, the mods are applied and the level loads again with it. The switch is saved with the profile. - A break plays back in the replay editor as it happened. +- **Wear and tear.** Each deck's board wears from 0% (new) to 100% (worn out): a landing coming down + faster than 3.5 m/s, a hit onto a rail or ledge (harder on a slide than on the trucks), every second + of grinding or sliding, and a slam of force 4 or more. The menus call it new, used, worn, beat or + done. From a fifth worn the board answers the steering less and its trucks go soft and twist + further; worn out, it takes half the force to break and can give way under the rider. A board + that broke is a new one once it is whole again; a worn one is renewed with the button New board + or `boardbreak worn 0`. The scratches are the game's own board wear (Mod Options > Board wear), + cleared with every new board. +- **Rail snaps.** A boardslide, lipslide or darkslide that comes down onto a rail or ledge at 3 m/s + or more (2.2 on the grip) can snap the board in the middle, certainly 3 m/s above that, and the + rider goes down with it. Grinds on the trucks never do. +- **Sounds.** A crack by the size of the break, a metal one on top when the board broke on metal, + and the body's hits in a bail, louder the harder they land. +- **Settings**, on the overlay's Skater page in a card BOARD BREAK SETTINGS (with how worn the board + is): always break (for replay clips), rail snaps, wear and tear, break sounds, body hit sounds, + break sensitivity (10% to 400%), how many breaks the grip tape holds (0% to 100%), wear speed (0% + to 500%), and New board. Always break and wear and tear are also rows in the game's Mod Options. + The console has them all: `boardbreak always|sensitivity|joined|rails|wear|wearrate|cracks|bodysounds + [value]`, `boardbreak worn [percent]`, `boardbreak status`, and for metal `boardbreak metal + mark|unmark|clear|list` and `boardbreak materials`. They are kept, with each deck's wear and the + materials marked as metal, in `%LOCALAPPDATA%\ReSkate\BoardBreak.settings`. - Without a deck pack installed nothing breaks and nothing is changed. ## How it works @@ -83,6 +113,35 @@ deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp) - **On foot the grip tape always holds.** A loose piece takes the ground from where the board rolled or lies; a board in the skater's hand does not tell where the ground is, and a piece that came off there hung in the air at hand height. +- **Every physics step of the local skater** (a step listener of No Bail's) is where wear, rail + snaps and the body's hits are seen. How fast the board came down is its root body's speed in its + last step in the air; a landing is that step followed by rolling (physics states 1xx), a hit onto + a rail by grinding (4xx). What kind of contact it is comes from the board's axes, from the client + tick, against how it moves (`contact_of`): across its way it slides, on its grip it is a darkslide, + else it grinds on its trucks. +- **What wear does to the handling** is four values of the game's own physics tuning + (`PhysicsSteering.GeneralScalar`, `PhysicsTrucks.StaticTruckSpringStrength`, `StaticTruckDamping` + and `TruckTwistLimit`), each the game's value times a factor of the wear. A value the player + changed (the trainer) is left alone, and nothing is changed in a multiplayer session, whose + players share their tuning. +- **A rail snap throws the rider** through No Bail (`no_bail_request_wipeout`): the cause goes in + just before the game's selector picks the next state, the way the game's own contact tests + record theirs, and the pick becomes Wipeout. The force is kept low (2.5): the game reads a cause's + force as how hard the rider was hit, and a slam's (10 and more) with the board flat on a rail + threw the rider high into the air. +- **Metal.** Each contact effect the game plays (sparks, dust, landings) names a material of the + level (Extension/Multiplayer/Remote/native_vfx.h, `recent_local_impacts`); Board Break installs + that hook once in a world, as multiplayer would. The level's materials have no names that can be + read, so which are metal is a list: marked in play with `boardbreak metal mark` while grinding a + metal rail, and brought by a pack in `sounds/metal/materials.txt`. +- **The body's hits** are the ragdoll's contacts that Hall of Meat reads each step, one sound at a + time, none below a graze. +- **The sounds** play through XAudio2 on a thread of their own, several at once and each at its own + loudness; 16-bit and 32-bit float wave files both play. +- **Wider decks, wider trucks.** The game puts each wheel on its truck's bone along the axle, about + 0.0876 m out. A deck that says so (deck.ini `axle`) has its wheels set further apart, or closer, + by the render pose hook: set, not scaled, so a pose that comes by twice is not moved twice, and + only what is drawn changes. The deck's own truck model is made to match. - Off, the board holds. Without a deck pack in the game the hook returns at once. ## The deck packs @@ -106,9 +165,9 @@ A deck is a content mod's copy of the deck model (no code), and comes in one of bones would draw the baseplates turning with the hangers, and such a version reads the pack's own layout, never `decks/`. -The name is what the deck goes by. The eight the feature was made with are standard, wide, -concave, powell, slim, egg, square and shaped, and the console also knows those by number. Any -other deck that follows the recipe works, with no change to ReSkate: +The name is what the deck goes by. The ten the feature was made with are standard, wide, +concave, powell, slim, egg, square, shaped, oldschool and pig, and the console also knows those by +number. Any other deck that follows the recipe works, with no change to ReSkate: - Start from the deck mesh as ReSkate Studio exports it (three parts on the bone Deck: the grip sheet, the wood, and a plain copy of the whole deck). Studio only takes back a mesh whose @@ -124,7 +183,13 @@ other deck that follows the recipe works, with no change to ReSkate: - The truck model for a deck inside a pack: the game's own, with every vertex that is on the bone Deck moved to `eff_truckBack` (behind the middle of the board) or `eff_truckFront` (ahead of it). Nothing else changes; Studio takes it back as it is. -- The sounds are `sounds/light|medium|heavy/*.wav` in the folder of any mod that brings a deck. +- The sounds are `sounds/light|medium|heavy|metal|body/*.wav` in the folder of any mod that brings + a deck (a crack size without sounds of its own takes the nearest that has some), and + `sounds/metal/materials.txt` may list the level's materials that are metal, by number. +- A deck may say something of itself in a `deck.ini` in its folder (a pack's `decks//`, or the + `Board_Break_` folder): `label = Old School`, its name in the menus, and `axle = 1.17`, how + far apart its wheels sit against a standard board's (0.8 to 1.6), for a deck wider or narrower + than the standard one. ## Seen working in game @@ -155,6 +220,11 @@ overlay's deck card: the mods applied in 3.8 s, the level loaded again in 15 s w which then broke. In the game's Mod Options > Player page, on 2.0.6: one press on Board Break deck is one deck further and one reload. +Wear, rail snaps, sounds and settings, in the player's own game offline, on the first build of +them (on main at 6c45e96): boardslides that came down onto rails at 4.6 to 7.0 m/s snapped the +board in the middle; the settings card switched always break on; boards wore, at that time far +too fast (the rates are a third of that now). + ## Not tried yet - Multiplayer: other players' boards are only handled by the code path (their stretches are moved @@ -168,3 +238,10 @@ deck is one deck further and one reload. baseplates would follow the hangers. - A mod that brings a truck model of its own beside a deck pack: the pack's is the one that loads. - A level change in the middle of a break. +- The rider going down with a board that snapped on a rail, and the material a board broke on: both + changed after the build that was played. No material is marked as metal yet, so the metal sound + waits for `boardbreak metal mark` or a pack's list. +- The body's hit sounds in a real bail. +- Wear's effect on the handling, felt in play, and a worn-out board giving way. +- The Old School and Pig decks, and wheels set further apart by `axle`, in play. +- This branch as it is, on main at 6df4f9a: built without warnings and its tests run, not played.