diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d738d1..6008e99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,6 +291,14 @@ if(DINGOSDK_BUILD_ROAD_RASH_TESTS) target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME road_rash COMMAND dingosdk_road_rash_tests) endif() +option(DINGOSDK_BUILD_BOARD_BREAK_TESTS "Build Board Break regression tests" OFF) +if(DINGOSDK_BUILD_BOARD_BREAK_TESTS) + enable_testing() + # What a break leaves of a board, how likely a slam breaks it, and where its parts lie. + add_executable(dingosdk_board_break_tests Extension/BoardBreak/Test/board_break_tests.cpp) + target_include_directories(dingosdk_board_break_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME board_break COMMAND dingosdk_board_break_tests) +endif() option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS) enable_testing() diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index cd8cc94..a6b1500 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -81,8 +83,146 @@ std::string sdk_identity() { return error ? std::string{} : std::to_string(size) + "-" + std::to_string(written); } +// A mod brings a deck model of its own when its bundle lists name the deck's bundle. Deck graphics +// and grip tapes are items of their own and do not touch it. +bool changes_the_deck(const fs::path& mod) { + constexpr std::string_view deck_bundle = "deck_gen_popsicle_cas_main_bundlereftable"; + std::error_code error; + std::vector lists{mod / L"layout.toc"}; + for (fs::directory_iterator at(mod / L"Win32", error), end; !error && at != end; at.increment(error)) + if (at->is_regular_file(error) && at->path().extension() == L".toc") lists.push_back(at->path()); + for (const auto& list : lists) { + std::ifstream file(list, std::ios::binary); + if (!file) continue; + const std::string bytes{std::istreambuf_iterator(file), {}}; + if (bytes.find(deck_bundle) != std::string::npos) return true; + } + return false; +} +// The player's choice of deck is kept beside ReSkate's other files for the user, so that it is +// there before the profile is: the mods are put together as the game starts. +fs::path board_break_deck_file() { + std::array text{}; + const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast(text.size())); + if (!size || size >= text.size()) return {}; + return fs::path(std::wstring(text.data(), size)) / L"ReSkate" / L"BoardBreak.deck"; +} +// A deck's name is a folder name's end: letters, digits and underscores. +bool deck_name(std::string_view name) { + return !name.empty() && name.size() <= maximum_mod_name && + std::all_of(name.begin(), name.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; }); +} +// Where a deck comes in the menus: the decks the feature was made with in their own order, then +// the others by name. +bool deck_before(const std::string& a, const std::string& b) { + const auto place = [](const std::string& name) { + return static_cast(std::find(board_break_decks.begin(), board_break_decks.end(), name) - board_break_decks.begin()); + }; + return place(a) != place(b) ? place(a) < place(b) : a < b; +} +// The choice made in this run, and what the last live apply gave the game (none since launch: the +// launch's catalogue says). +struct BoardBreakApplied { + std::string deck; + std::vector set_aside; +}; +std::mutex board_break_mutex; +std::string chosen_deck; +std::optional applied_board_break; + } // namespace +std::string board_break_deck_of(std::string_view mod) { + // Team-Board_Break_Wide and Board_Break_Wide are both the deck "wide". + constexpr std::string_view prefix = "board_break_"; + auto name = lower(mod); + if (const auto team = name.rfind('-'); team != std::string::npos) name.erase(0, team + 1); + if (!name.starts_with(prefix) || !deck_name(std::string_view(name).substr(prefix.size()))) return {}; + return name.substr(prefix.size()); +} +std::string wanted_board_break_deck() noexcept { + try { + { + std::lock_guard lock(board_break_mutex); + if (!chosen_deck.empty()) return chosen_deck; + } + std::string choice; + if (const auto path = board_break_deck_file(); !path.empty()) { + std::ifstream file(path); + std::getline(file, choice); + } + while (!choice.empty() && (choice.back() == '\r' || choice.back() == ' ')) choice.pop_back(); + choice = lower(choice); + return deck_name(choice) ? choice : std::string(board_break_decks.front()); + } catch (...) { + return {}; + } +} +bool save_board_break_deck(std::string_view deck) noexcept { + try { + if (!deck_name(deck)) return false; + const auto path = board_break_deck_file(); + if (path.empty()) return false; + std::error_code error; + fs::create_directories(path.parent_path(), error); + std::ofstream file(path, std::ios::trunc); + file << deck << '\n'; + if (!file) return false; + std::lock_guard lock(board_break_mutex); + chosen_deck = deck; + return true; + } catch (...) { + return false; + } +} +std::vector board_break_folders() noexcept { + std::vector found; + try { + std::vector> decks; + 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)) + if (auto deck = board_break_deck_of(at->path().filename().string()); !deck.empty()) decks.emplace_back(std::move(deck), at->path()); + std::sort(decks.begin(), decks.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); }); + for (auto& deck : decks) found.push_back(std::move(deck.second)); + } catch (...) {} + return found; +} +std::vector installed_board_break_decks() noexcept { + std::vector found; + try { + for (const auto& folder : board_break_folders()) { + auto deck = board_break_deck_of(folder.filename().string()); + if (found.empty() || found.back() != deck) found.push_back(std::move(deck)); + } + } catch (...) {} + return found; +} +std::string board_break_in_play() noexcept { + try { + { + std::lock_guard lock(board_break_mutex); + if (applied_board_break) return applied_board_break->deck; + } + return catalog().board_break_deck; + } catch (...) { + return {}; + } +} +std::vector set_aside_for_board_break() { + { + std::lock_guard lock(board_break_mutex); + if (applied_board_break) return applied_board_break->set_aside; + } + return catalog().set_aside; +} +void board_break_applied(const Catalog& catalog) noexcept { + try { + std::lock_guard lock(board_break_mutex); + applied_board_break = BoardBreakApplied{catalog.board_break_deck, catalog.set_aside}; + } catch (...) {} +} + std::filesystem::path engine_data_root() noexcept { try { std::array module_path{}; @@ -135,10 +275,42 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver try { for (const auto& name : list.missing) result.notes.push_back("mods.json names a folder that is not present: " + name); + // Board Break (Extension/BoardBreak/board_break.h). The deck that can break is a mod's own + // copy of the game's one deck model, and any mod that reshapes the deck replaces that same + // model: the one higher in the load order has it. So the deck the player chose goes first + // for this run, whatever mods.json says of the order, and what would fight it is set aside: + // Board Break's other decks, and other mods that bring a deck model. Those are left out + // whole: with the breakable deck in place of its own, such a mod would have its trucks and + // wheels on a deck they were not made for. mods.json is not changed, and their archives are + // placed like a disabled mod's, so another deck can be chosen while the game runs. A choice + // that is not installed falls back to the first deck, then to any that is. + { + const auto enabled_deck = [&](const std::string& deck) { + return std::find_if(list.entries.begin(), list.entries.end(), [&](const auto& entry) { + const auto found = board_break_deck_of(entry.mod.name); + return entry.enabled && !found.empty() && (deck.empty() || found == deck); + }); + }; + auto first = list.entries.end(); + for (const auto& deck : {wanted_board_break_deck(), std::string(board_break_decks.front()), std::string()}) + if (first == list.entries.end()) first = enabled_deck(deck); + if (first != list.entries.end()) { + std::rotate(list.entries.begin(), first, first + 1); + result.notes.push_back("Board Break: " + list.entries.front().mod.name + " loads first, so its deck is the one in the game"); + for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) { + if (!entry->enabled || (board_break_deck_of(entry->mod.name).empty() && !changes_the_deck(entry->mod.directory))) continue; + entry->enabled = false; + result.set_aside.push_back(entry->mod.name); + result.notes.push_back("Board Break: " + entry->mod.name + " brings a deck model too and is set aside for this run"); + } + } + } for (auto& entry : list.entries) { if (!entry.enabled) { - result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name); - result.disabled.push_back(entry.mod.name); + if (std::find(result.set_aside.begin(), result.set_aside.end(), entry.mod.name) == result.set_aside.end()) { + result.notes.push_back("Mod disabled in mods.json: " + entry.mod.name); + result.disabled.push_back(entry.mod.name); + } if (entry.mod.provides_layout) result.inactive.push_back(std::move(entry.mod)); continue; } @@ -217,6 +389,12 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver build + ", the installed game is " + game_build); result.inactive.erase(result.inactive.begin() + static_cast(i)); } + // Board Break's deck is the one in the game when its mod is among those that load and no mod + // above it brings a deck model of its own. + for (const auto& mod : result.mods) { + if (!(result.board_break_deck = board_break_deck_of(mod.name)).empty()) break; + if (changes_the_deck(mod.directory)) break; + } // Any Mods folder gets a patch, even an empty one or one of disabled // mods only: a mod enabled or dropped in while the game runs needs it // mounted from launch. diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 493bd05..add4faa 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -3,9 +3,11 @@ #include "mod_list.h" #include "mod_merge.h" +#include #include #include #include +#include #include namespace dingosdk::mods { @@ -38,8 +40,39 @@ struct Catalog { // Mods left out because they were built for another game version (Mod::outdated), enabled or // not: they need updating. Also in `excluded` when they were enabled. std::vector outdated; + // Board Break: the name of its deck that is the one in the game ("standard", "wide" and so on), + // empty when none is and a board cannot break; and the enabled mods set aside for it, its + // other decks and other mods that bring a deck model. Those are not loaded and nothing about + // them waits to be applied. + std::string board_break_deck; + std::vector set_aside; }; +// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model for every +// board, so each deck that can break is a content mod of its own that replaces it: any mod whose +// folder is named Board_Break_ (also behind a team name, as a mod manager installs it: +// Team-Board_Break_). The name, in small letters, is what the deck goes by; the deck the +// player chose is the one that loads. 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"}; +// The name of the deck a mod folder holds, or nothing for any other mod. +std::string board_break_deck_of(std::string_view mod_folder); +// The deck the player chose ("standard" when nothing was chosen): read whenever the mods are put +// together, at launch and at every live apply. Saving a choice makes it this run's at once. +std::string wanted_board_break_deck() noexcept; +bool save_board_break_deck(std::string_view deck) noexcept; +// The decks whose mod's folder is in Mods/, whatever mods.json says of them, in the menus' order, +// and those folders in the same order. +std::vector installed_board_break_decks() noexcept; +std::vector board_break_folders() 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; +std::vector set_aside_for_board_break(); +void board_break_applied(const Catalog& catalog) noexcept; + // The engine's own data root: the -dataPath argument when present, otherwise // the executable's directory. Mirrors the game so mod paths resolve the same. std::filesystem::path engine_data_root() noexcept; diff --git a/Extension/Assets/live_mods.cpp b/Extension/Assets/live_mods.cpp index d19687d..f33e82b 100644 --- a/Extension/Assets/live_mods.cpp +++ b/Extension/Assets/live_mods.cpp @@ -436,6 +436,7 @@ void apply_now() { std::lock_guard lock(state_mutex); // Set with the manifests, so the client tick sees both in one update. if (needed) reload_ready = true; + mods::board_break_applied(catalog); pending_levels = mods::level_manifest_paths(catalog); applied.emplace(); for (const auto &mod : catalog.mods) applied->push_back(mod.name); diff --git a/Extension/BoardBreak/Test/board_break_tests.cpp b/Extension/BoardBreak/Test/board_break_tests.cpp new file mode 100644 index 0000000..81f0d13 --- /dev/null +++ b/Extension/BoardBreak/Test/board_break_tests.cpp @@ -0,0 +1,155 @@ +// 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 +#include +#include +#include + +using namespace dingosdk::board_break; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +bool near(float a, float b, float within = 1e-4f) { return std::abs(a - b) < within; } +bool near(Vec a, Vec b, float within = 1e-4f) { return length(a - b) < within; } +// Where a point of a part ends up after the part's motion. +Vec moved(const Rigid& motion, Vec point) { return turn(motion.facing, point) + motion.at; } +constexpr Quat level{}; +// The rig's mount: a quarter turn about the upright axis. +const Quat mount = about({0, 1, 0}, 1.5707963f); + +void a_break_leaves_two_pieces_or_one_that_hangs_together() { + for (const auto place : {Place::nose, Place::mid, Place::tail}) { + const auto clean = broken_at(place, false); + check(clean.hinge == Hinge::none && clean.loose, "A clean break has a piece that comes off"); + check((clean.kept | clean.loose) == whole_board && !(clean.kept & clean.loose), "and the two are the whole board, each part once"); + const auto joined = broken_at(place, true); + check(joined.kept == whole_board && !joined.loose && joined.hinge != Hinge::none, "Held by the grip tape, nothing comes off"); + } + check(broken_at(Place::nose, false).loose == nose && broken_at(Place::tail, false).loose == tail, "An end comes off by itself"); + const auto halves = broken_at(Place::mid, false); + check(halves.kept == (nose | front | front_truck) && halves.loose == (back | tail | back_truck), + "In the middle the board keeps its front half, and each half its truck"); + check(broken_at(Place::nose, true).hinge == Hinge::nose && broken_at(Place::mid, true).hinge == Hinge::fold && + broken_at(Place::tail, true).hinge == Hinge::tail, + "and each place hangs together its own way"); +} + +void a_harder_slam_breaks_a_board_more_often() { + check(break_chance(10.0f, 10.0f, 0.25f) == 1.0f && break_chance(30.0f, 10.0f, 0.25f) == 1.0f, "A heavy slam always breaks it"); + check(break_chance(0.0f, 10.0f, 0.25f) == 0.25f && break_chance(6.0f, 10.0f, 0.25f) == 0.25f, "A light one now and then"); + check(near(break_chance(8.0f, 10.0f, 0.25f), 0.625f), "and in between it rises evenly"); + float before{}; + for (float force = 0; force <= 12.0f; force += 0.5f) { + const auto chance = break_chance(force, 10.0f, 0.25f); + check(chance >= before && chance <= 1.0f, "It never falls with more force"); + before = chance; + } +} + +void the_size_of_a_break_picks_its_sound() { + check(break_size(Place::mid, false, 0.0f) == Size::heavy, "A board that comes apart in the middle is the biggest break"); + check(break_size(Place::mid, true, 0.9f) == Size::heavy && break_size(Place::mid, true, 0.4f) == Size::medium, + "A fold right down is as big, a shallow one in between"); + check(break_size(Place::nose, false, 0.0f) == Size::medium && break_size(Place::tail, false, 0.0f) == Size::medium, "An end that comes off is in between"); + check(break_size(Place::nose, true, 0.0f) == Size::light && break_size(Place::tail, true, 0.0f) == Size::light, "One that hangs on is a small one"); +} + +void a_whole_board_keeps_its_stretches_away() { + const auto bones = bones_of({}); + for (std::size_t i = 0; i < 3; ++i) check(near(bones[i].at, away), "A stretch that does not show goes far away"); + check(near(bones[3].at, Vec{}) && near(bones[3].facing.w, 1.0f), "and the tail's, which hangs on the front's, goes with that one"); +} + +void the_tail_is_placed_from_the_front_stretch() { + // The tail's bone hangs on the front stretch's: wherever the two lie, the front's motion + // followed by what goes into the tail's bone is the tail's own motion. + const Rigid front_motion{about({1, 0, 0}, 0.3f), {0.01f, 0.02f, -0.03f}}; + const Rigid tail_motion{unit(about({0, 1, 0}, 1.1f) * about({1, 0, 0}, -0.4f)), {0.5f, -0.2f, 0.7f}}; + for (const bool front_shown : {true, false}) { + std::array stretch{}; + stretch[1] = {front_shown, front_motion}; + stretch[3] = {true, tail_motion}; + const auto bones = bones_of(stretch); + const Rigid parent = front_shown ? front_motion : Rigid{{}, away}; + for (const Vec point : {Vec{0, 0.09f, -0.3f}, Vec{0.1f, 0.1f, -0.4f}}) + check(near(moved(parent, moved(bones[3], point)), moved(tail_motion, point), 2e-3f), "The tail lies where its piece does"); + } +} + +void a_piece_shows_only_its_own_stretches() { + const auto clean = broken_at(Place::mid, false); + const auto layout = lay_out(clean.kept, clean.hinge, 0.0f, 1.0f, mount); + check(layout.stretch[0].shown && layout.stretch[1].shown && !layout.stretch[2].shown && !layout.stretch[3].shown, + "The front half is the nose and the front stretch"); + check(layout.front_truck && !layout.back_truck, "on its front truck"); + const auto bones = bones_of(layout.stretch); + check(near(bones[2].at, away), "and the back stretch is nowhere to be seen"); + // Half a board on one truck tips onto its broken end while it is the right way up, and not on its grip. + const auto broken_end = moved(layout.stretch[1].motion, turn(mount, Vec{0, 0.09f, mid_row})); + check(broken_end.y < 0.09f - 0.03f, "The half tips onto its broken end"); + const auto upside_down = lay_out(clean.kept, clean.hinge, 0.0f, -1.0f, mount); + check(near(upside_down.stretch[1].motion.at, Vec{}), "Lying on its grip it stays as it is"); +} + +void a_folded_board_meets_at_the_break() { + for (const float depth : {0.2f, 0.6f, 1.0f}) { + Rigid front_half, back_half; + folded(depth, front_half, back_half); + // The grip of both halves at the break: the same height, a crack's width apart. + const Vec grip{0, 0.095f, mid_row}; + const auto ahead = moved(front_half, grip), behind = moved(back_half, grip); + check(near(ahead.y, behind.y, 1e-3f), "Both halves' grip is at one height at the break"); + check(near(ahead.z - behind.z, crack, 1e-3f), "with the crack between them"); + check(ahead.y < grip.y, "and the break has sagged"); + // Each half turns about its own axle: the wheels stay where they were, but for the slide along the board. + check(near(moved(front_half, {0, axle_height, front_axle}).y, axle_height, 1e-4f) && + near(moved(back_half, {0, axle_height, back_axle}).y, axle_height, 1e-4f), + "The axles keep their height"); + } + const auto whole = lay_out(whole_board, Hinge::fold, 0.8f, 1.0f, mount); + for (const auto& one : whole.stretch) check(one.shown, "A folded board shows all four stretches"); + check(!near(whole.stretch[1].motion.at, whole.stretch[2].motion.at), "its two halves moved apart"); + check(near(whole.stretch[0].motion.at, whole.stretch[1].motion.at) && near(whole.stretch[2].motion.at, whole.stretch[3].motion.at), + "and each end with its half"); +} + +void a_hanging_end_turns_about_the_break() { + const auto layout = lay_out(whole_board, Hinge::nose, 0.5f, 1.0f, mount); + check(near(layout.stretch[1].motion.at, Vec{}) && near(layout.stretch[2].motion.at, Vec{}) && near(layout.stretch[3].motion.at, Vec{}), + "The rest of the board stays where it is"); + const auto tip = moved(layout.stretch[0].motion, turn(mount, Vec{0, 0.14f, 0.41f})); + check(tip.y < 0.14f - 0.05f, "and the nose droops"); + const auto hinge = moved(hung_on_grip(nose_row, 1.0f, 0.5f), {0, grip_height - 0.004f, nose_row}); + check(near(hinge, Vec{0, grip_height - 0.004f, nose_row + crack * 0.5f}), "It turns about the break line, a hair away from the board"); +} + +void a_truck_goes_with_its_piece() { + // The deck's bone is moved away to hide the real deck, and the truck hangs on it: what goes into + // the truck's bone takes that move back and adds the piece's motion. + const Vec mount_at{0, 0.0856f, 0}; + const auto still = truck_bone({}, mount, mount_at, level, front_truck_place); + check(near(still.at + away, front_truck_place) && near(still.facing.w, 1.0f), "A piece that has not moved leaves its truck where it was"); + const Rigid lifted{{}, {0, 0.25f, 0}}; + const auto up = truck_bone(lifted, mount, mount_at, level, front_truck_place); + check(near(up.at + away, front_truck_place + Vec{0, 0.25f, 0}), "A piece that is lifted takes its truck up with it"); +} +} // namespace + +int main() { + try { + a_break_leaves_two_pieces_or_one_that_hangs_together(); + a_harder_slam_breaks_a_board_more_often(); + the_size_of_a_break_picks_its_sound(); + a_whole_board_keeps_its_stretches_away(); + the_tail_is_placed_from_the_front_stretch(); + a_piece_shows_only_its_own_stretches(); + a_folded_board_meets_at_the_break(); + a_hanging_end_turns_about_the_break(); + a_truck_goes_with_its_piece(); + std::cout << "board break tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "board break test failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/Extension/BoardBreak/board_break.cpp b/Extension/BoardBreak/board_break.cpp new file mode 100644 index 0000000..e8e1596 --- /dev/null +++ b/Extension/BoardBreak/board_break.cpp @@ -0,0 +1,670 @@ +#include "board_break.h" +#include "board_break_model.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/20260929/no_bail.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Extension/Assets/live_mods.h" +#include "Extension/Multiplayer/Remote/native_pose_layout.h" +#include "Extension/Multiplayer/Remote/native_skater.h" +#include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/no_bail.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "winmm.lib") + +namespace dingosdk::board_break { +namespace { +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; +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. +constexpr float hand_reach = 1.3f, hand_upright = 0.8f; +// The board rig's pose, in the order the game evaluates it: 1 the rig's place in the world, 2 the +// deck's mount, 3 the deck, 4 and 8 the trucks, and the spare bones that carry the four stretches +// (nose, front, back, tail; the tail's hangs on the front's). +constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, front_truck_bone = 8; +constexpr std::array carrier{12, 13, 15, 14}; +constexpr std::uint32_t rig_bones = 16; + +bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; } + +template void say(logging::Level level, std::format_string text, Args&&... args) { + logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward(args)...)); +} + +// Written by the physics step, read by the client tick. +struct Hit { + std::atomic at{}; + std::atomic reason{-1}; + std::atomic force{}; +}; +Hit& last_cause() { static Hit value; return value; } +Hit& last_wipeout() { static Hit value; return value; } + +struct Broken { + std::uintptr_t board{}; + 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 +}; +struct State { + // Any thread. + std::atomic ready{}, on{true}, wanted_mend{}; + std::atomic base{}; + std::atomic has_deck{}; // one of Board Break's decks is the one in the game + std::atomic wanted_snap{-1}; // asked for from another thread, done at the next tick + std::mutex names; + std::string deck, wanted_deck; // under `names`: the deck in the game, and one asked for (done at the next tick) + // Client thread from here on. + std::optional broken; + Break parts; // what the broken board keeps, what flew off, how they hang together + float angle{}; // how far a hanging end droops (radians), or how deep a fold sags (0 to 1) + std::uint32_t flights{}; // counts the breaks, so each starts a flight of its own + bool counted{}, hooked{}; + std::uint64_t wipeouts{}, back_since{}, last_error{}, rode_at{}, board_seen{}; + // The ground under the board: its own height while it rolls on it, and what of that was fresh + // when the board broke (else not a number). + float ride_height{}; + std::uint64_t ride_when{}; + float break_ground{std::numeric_limits::quiet_NaN()}; + std::mt19937 random{std::random_device{}()}; +}; +State& state() { + static auto* value = new State; + return *value; +} + +// What the render pose hook does to the boards, published by the client tick. +struct RenderEdit { + std::atomic holder{}; // the local board's animation holder + std::atomic kept{}; // the piece it shows (Piece bits), 0 while it is whole + std::atomic hinge{}; + std::atomic angle{}; + std::atomic park{}; // a breakable deck is in the game: whole boards hide their stretches +}; +RenderEdit& render_edit() { static RenderEdit value; return value; } + +// ---- the board's pose -------------------------------------------------------------------------- +// One bone: scale, rotation and position, four floats each. +struct PoseBone { + float scale[4], rotation[4], position[4]; +}; +bool load_bone(std::uintptr_t address, PoseBone& out) noexcept { + __try { + std::memcpy(&out, reinterpret_cast(address), sizeof(out)); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } +} +// Rotation and position of one bone, at its own size; the fourth lane of each is left as it is. +bool store_bone(std::uintptr_t address, Quat rotation, Vec position) noexcept { + __try { + auto* lane = reinterpret_cast(address); + lane[0] = lane[1] = lane[2] = 1.0f; + lane[4] = rotation.x; lane[5] = rotation.y; lane[6] = rotation.z; lane[7] = rotation.w; + lane[8] = position.x; lane[9] = position.y; lane[10] = position.z; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } +} +Quat facing_of(const PoseBone& bone) noexcept { return unit({bone.rotation[0], bone.rotation[1], bone.rotation[2], bone.rotation[3]}); } +Vec place_of(const PoseBone& bone) noexcept { return {bone.position[0], bone.position[1], bone.position[2]}; } +// The pose buffer of a board, or 0 when it is not the rig this knows. +std::uintptr_t board_pose(std::uintptr_t base, std::uintptr_t holder) { + const auto pose = multiplayer::read_native_pose_layout(readable, base, holder, 64); + return pose.buffer && pose.count == rig_bones ? pose.buffer : 0; +} +std::uintptr_t bone_at(std::uintptr_t buffer, int index) noexcept { return buffer + static_cast(index) * 0x30ULL; } +// The board's place in the world as its pose has it. +bool board_place(std::uintptr_t base, std::uintptr_t holder, Vec& at, Quat& facing) { + const auto buffer = board_pose(base, holder); + PoseBone root{}; + if (!buffer || !load_bone(bone_at(buffer, root_bone), root)) return false; + at = place_of(root); + facing = facing_of(root); + return std::isfinite(at.x) && std::isfinite(at.y) && std::isfinite(at.z); +} +// Moves a truck with a piece (truck_bone). +void carry_truck(std::uintptr_t buffer, const Rigid& motion, int index, Vec place) noexcept { + PoseBone mount{}, truck{}; + if (!load_bone(bone_at(buffer, mount_bone), mount) || !load_bone(bone_at(buffer, index), truck)) return; + const auto bone = truck_bone(motion, facing_of(mount), place_of(mount), facing_of(truck), place); + (void)store_bone(bone_at(buffer, index), bone.facing, bone.at); +} + +// ---- 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 +// wherever the board itself is in that frame, so the two part company although both are the one board. +struct Flight { + std::mutex mutex; + // From the tick. + std::uint32_t id{}; // changes with every break; 0 = no piece in flight + std::uint32_t mask{}; // the piece's stretches of deck and its trucks + float ground{}; // world height of the ground under the skater + float rest{}; // how far above the ground the piece's origin lies once it rests + Vec kick{}, spin{}; // start speed (m/s) and turning (axis x rad/s), in the deck's own axes + float ridden{std::numeric_limits::quiet_NaN()}; // the ground the board rolled on as it broke + // The hook's. + std::uint32_t running{}; + std::int64_t last{}; + Vec at{}, speed{}, turning{}; + Quat facing{}; + bool landed{}; // lying on what is taken for the ground right now + bool settled{}; // has landed once: flat from then on, whichever side came up + bool upside_down{}; + Vec from{}; // where the board was when it broke + float limit{}; // the tick's first guess of the ground: never taken higher than this + float tipped{}; // 0..1: how far a half on one truck has tipped onto its broken end +}; +Flight& flight() { static Flight value; return value; } +// The stretches of deck that are in flight (0 for none) and, in `piece`, where they are from the +// board's own place; the piece's trucks are moved here. +std::uint32_t fly_piece(std::uintptr_t buffer, Rigid& piece) { + auto& f = flight(); + std::unique_lock lock(f.mutex, std::try_to_lock); + if (!lock.owns_lock() || !f.id) return 0; + PoseBone root{}, mount{}; + if (!load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return 0; + const Vec board_at = place_of(root); + const Quat board_facing = facing_of(root), deck_turn = facing_of(mount); + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast(value.QuadPart); }(); + if (f.running != f.id) { + f.running = f.id; + f.last = now.QuadPart; + f.at = f.from = board_at; + f.facing = board_facing; + f.speed = turn(board_facing, turn(deck_turn, f.kick)); + f.turning = turn(board_facing, turn(deck_turn, f.spin)); + f.landed = f.settled = f.upside_down = false; + f.limit = f.ground; + f.tipped = 0; + } + const auto dt = static_cast(std::clamp((now.QuadPart - f.last) / frequency, 0.0, 0.05)); + f.last = now.QuadPart; + // The ground is where the board itself lies: its lowest point right now, which physics brings + // to rest on the ground. The skater's height at the slam (the hips, and the skater may be in the + // air) is only an upper limit. It is the board's height NOW, not the lowest ever seen: one odd + // pose (a teleport, a frame before the board is placed) must not sink the piece for good, and a + // pose that is nowhere near the break is passed over altogether. + const bool plausible = std::isfinite(board_at.x) && std::isfinite(board_at.y) && std::isfinite(board_at.z) && length(board_at - f.from) < 30.0f; + float lowest = board_at.y + 1.0f; + if (plausible) { + for (const Vec corner : {Vec{0, 0, 0}, Vec{0, 0.093f, 0}, Vec{0, 0.141f, 0.41f}, Vec{0, 0.136f, -0.40f}, Vec{0.105f, 0.10f, 0}, + Vec{-0.105f, 0.10f, 0}, Vec{0.0876f, 0, front_axle}, Vec{-0.0876f, 0, front_axle}, Vec{0.0876f, 0, back_axle}, + Vec{-0.0876f, 0, back_axle}}) + lowest = (std::min)(lowest, (board_at + turn(board_facing, turn(deck_turn, corner))).y); + // Only a board that is still near the piece says anything about the ground under the piece. + if (std::hypot(board_at.x - f.at.x, board_at.z - f.at.z) < 4.0f) { + // The ground the board rolled on as it broke is exact, where its lowest point depends on + // how it has come to lie and on what: that height stays, unless the board has plainly + // dropped below it since (off a ledge, down a stair). + if (std::isfinite(f.ridden)) f.ground = lowest < f.ridden - 0.15f ? lowest : f.ridden; + else f.ground = (std::min)(f.limit, lowest); + } + } + // Where the piece's origin is once it lies on the ground: on its grip when it is upside down, + // else on its underside or its wheels. A little above it: real ground is rough, and a deck is a + // centimetre thick. A gap under a piece goes unseen, a piece cut off by the ground does not. + const float lift = f.mask & (front_truck | back_truck) ? 0.004f : 0.012f; + const auto lying = [&] { + const bool flipped = f.settled ? f.upside_down : turn(f.facing, {0, 1, 0}).y < 0; + return (flipped ? f.ground + 0.141f : f.ground + f.rest) + lift; // upside down it rests on the raised end of its grip side + }; + if (f.landed && f.at.y > lying() + 0.01f) { + f.landed = false; // the ground turned out to be lower: it drops the rest of the way + f.speed = {}; + f.turning = {}; + } + if (!f.landed) { + f.speed.y -= 9.81f * dt; + f.at = f.at + f.speed * dt; + if (const auto rate = length(f.turning); rate > 1e-4f) f.facing = unit(about(f.turning * (1.0f / rate), rate * dt) * f.facing); + if (f.speed.y <= 0 && f.at.y <= lying()) { // on its way down: it starts on the ground it will land on + if (!f.settled) { + // Down: lying flat, pointing the way it was, the same side up. + const auto ahead = turn(f.facing, {0, 0, 1}); + f.upside_down = turn(f.facing, {0, 1, 0}).y < 0; + f.facing = about({0, 1, 0}, std::atan2(ahead.x, ahead.z)); + if (f.upside_down) f.facing = unit(f.facing * about(turn(deck_turn, {0, 0, 1}), 3.14159265f)); // rolled over, about its length + f.speed = {f.speed.x * 0.3f, 0, f.speed.z * 0.3f}; + f.settled = true; + } else f.speed = {}; + f.at.y = lying(); + f.landed = true; + } + } else { + f.at = f.at + f.speed * dt; // a short slide + f.speed = f.speed * (std::max)(0.0f, 1.0f - 7.0f * dt); + f.at.y = lying(); // and it stays on the ground if that comes up again + } + // A half board that came down on its wheels tips onto its broken end. + const bool on_one_truck = (f.mask & (back_truck | front_truck)) && f.settled && !f.upside_down; + f.tipped = on_one_truck && f.landed ? (std::min)(1.0f, f.tipped + dt / 0.18f) : on_one_truck ? f.tipped : 0.0f; + const auto tip = on_root(deck_turn, f.mask & back_truck ? tipped_on_axle(back_axle, 1.0f, back_tip * f.tipped) + : tipped_on_axle(front_axle, -1.0f, front_tip * f.tipped)); + // Where that is from the board's own place. + const auto back_to_board = conj(board_facing); + const auto lies_facing = unit(back_to_board * f.facing); + const auto lies_at = turn(back_to_board, f.at - board_at); + piece = {unit(lies_facing * tip.facing), lies_at + turn(lies_facing, tip.at)}; + if (f.mask & back_truck) carry_truck(buffer, piece, back_truck_bone, back_truck_place); + if (f.mask & front_truck) carry_truck(buffer, piece, front_truck_bone, front_truck_place); + return f.mask & stretches; +} + +// ---- what the hook writes ------------------------------------------------------------------------ +// The local board, broken: the real deck goes away, the trucks come back, each stretch goes to +// where its piece lies and one that belongs to no piece goes away too. +void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, Hinge hinge, float angle) { + const auto buffer = board_pose(base, holder); + PoseBone root{}, mount{}, deck{}; + if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount) || + !load_bone(bone_at(buffer, deck_bone), deck)) + return; + // 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)); + if (layout.back_truck) carry_truck(buffer, layout.back_motion, back_truck_bone, back_truck_place); + if (layout.front_truck) carry_truck(buffer, layout.front_motion, front_truck_bone, front_truck_place); + if (hinge == Hinge::none) { + Rigid piece{}; + const auto flying = fly_piece(buffer, piece); + for (std::size_t i = 0; i < layout.stretch.size(); ++i) + if (flying & (1u << i)) layout.stretch[i] = {true, piece}; + } + const auto bones = bones_of(layout.stretch); + for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at); +} +// A whole board, any player's: its four stretches go far away from it. +void park_pieces(std::uintptr_t base, std::uintptr_t holder) { + const auto buffer = board_pose(base, holder); + if (!buffer) return; + 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""}; + 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()); + } + break; + } + return found; + }(); + // 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; + } +} +// `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 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.back_since = 0; + crack_sound(s, 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; +} +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); + s.broken.reset(); + s.parts = {}; +} +// 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 { + bool known{}; + float distance{}, upright{}; + bool in_hand() const noexcept { return known && distance < hand_reach && upright < hand_upright; } +}; +Hold board_hold(std::uintptr_t base, std::uintptr_t client) { + Hold out; + const auto frame = multiplayer::capture_local(base, client, true); + if (!frame.ready || frame.pose.board.empty()) return out; + const auto& skater = frame.pose.root.position; + const auto& board = frame.pose.board.front(); + out.distance = std::hypot(board.position[0] - skater[0], board.position[1] - skater[1], board.position[2] - skater[2]); + const auto& q = board.rotation; // x, y, z, w: the board's up axis, turned into the world + out.upright = std::abs(1.0f - 2.0f * (q[0] * q[0] + q[2] * q[2])); + out.known = std::isfinite(out.distance) && std::isfinite(out.upright); + return out; +} +bool riding(std::uint32_t physics) noexcept { + // Ground/air and grind families; 300 is the wipeout, 5xx is on foot and getting on. + return (physics >= 100 && physics < 300) || (physics >= 400 && physics < 500); +} +// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next" +// for the one after the deck in the game. Empty when there is no such deck. +std::string deck_meant(std::string wanted, const std::string& in_game, const std::vector& installed) { + for (auto& c : wanted) c = c == ' ' ? '_' : static_cast(std::tolower(static_cast(c))); + if (installed.empty()) return {}; + if (wanted.empty() || wanted == "next") { + const auto now = std::find(installed.begin(), installed.end(), in_game); + return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1); + } + if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) { + // The decks the feature was made with keep their numbers whether they are installed or not; + // the others follow, in the order they are listed. + const auto number = static_cast(std::stoi(wanted)); + const auto& known = mods::board_break_decks; + if (number >= 1 && number <= known.size()) return std::string(known[number - 1]); + std::size_t next = known.size(); + for (const auto& deck : installed) + if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck; + return {}; + } + return wanted; +} +// What the hook is told this tick; `holder` 0 leaves the local board to be treated as any other. +void publish(State& s, std::uintptr_t base, std::uintptr_t client, std::uintptr_t holder) { + auto& edit = render_edit(); + const bool broken = s.broken && holder; + edit.park.store(s.has_deck.load(std::memory_order_acquire), std::memory_order_release); + edit.angle.store(s.angle, std::memory_order_release); + edit.hinge.store(static_cast(broken ? s.parts.hinge : Hinge::none), std::memory_order_release); + edit.kept.store(broken ? s.parts.kept : 0, std::memory_order_release); + edit.holder.store(holder, std::memory_order_release); + // The broken-off piece: one flight per break, gone when the board is whole again. + auto& f = flight(); + std::lock_guard lock(f.mutex); + if (!broken || !s.parts.loose) f.id = 0; + else if (f.id != s.flights) { + const auto frame = multiplayer::capture_local(base, client, true); + const auto roll = [&](float low, float high) { return std::uniform_real_distribution(low, high)(s.random); }; + const bool loose_end = !(s.parts.loose & (front_truck | back_truck)); // a nose or tail, no truck on it + const float along = s.parts.loose & nose ? 1.0f : -1.0f; // the way it leaves, along the board + const float push = loose_end ? 1.0f : 0.55f; + f.mask = s.parts.loose; + f.ground = frame.ready ? frame.pose.root.position[1] : 0.0f; + f.rest = loose_end ? -0.083f : 0.0f; // a bare piece lies on its underside, one with a truck stands on its wheels + f.ridden = s.break_ground; + f.kick = {roll(-0.9f, 0.9f) * push, (1.4f + roll(0.0f, 1.2f)) * push, along * (1.2f + roll(0.0f, 1.6f)) * push}; + f.spin = {along * (5.0f + roll(0.0f, 6.0f)) * push, roll(-3.0f, 3.0f) * push, roll(-2.0f, 2.0f) * push}; + f.id = s.flights; + } +} +} // namespace + +bool start(std::uintptr_t base) noexcept { + auto& s = state(); + if (s.ready.load(std::memory_order_acquire)) return true; + if (!no_bail_available()) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Board Break cannot follow the skater: its slams come through No Bail's hooks, which did not start."); + return false; + } + s.base.store(base, std::memory_order_release); + s.on.store(profile_runtime::local_preference(enabled_preference).value_or(true), std::memory_order_release); + const auto in_game = mods::board_break_in_play(); + { + std::lock_guard lock(s.names); + s.deck = in_game; + } + s.has_deck.store(!in_game.empty(), std::memory_order_release); + s.ready.store(true, std::memory_order_release); + logging::log(logging::Level::info, logging::Channel::skater, "Board Break ready ({}, deck: {}).", s.on.load() ? "on" : "off", + deck_label(in_game)); + return true; +} + +void note_cause(std::int32_t reason, float magnitude) noexcept { + auto& hit = last_cause(); + const auto now = GetTickCount64(); + // Keep the strongest of a burst: one slam records several causes. + if (now - hit.at.load(std::memory_order_relaxed) < 250 && magnitude <= hit.force.load(std::memory_order_relaxed)) return; + hit.force.store(magnitude, std::memory_order_relaxed); + hit.reason.store(reason, std::memory_order_relaxed); + hit.at.store(now, std::memory_order_release); +} +void note_wipeout(std::uintptr_t causes) noexcept { + std::array set{}; + std::array force{}; + auto& hit = last_wipeout(); + std::int32_t reason{-1}; + float strongest{}; + if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force))) + for (std::size_t i = 0; i < cause_count; ++i) + if (set[i] && (reason < 0 || force[i] > strongest)) { + reason = static_cast(i); + strongest = force[i]; + } + hit.force.store(strongest, std::memory_order_relaxed); + hit.reason.store(reason, std::memory_order_relaxed); + hit.at.store(GetTickCount64(), std::memory_order_release); +} + +void on_render_pose(std::uintptr_t animation_interface) noexcept { + auto& edit = render_edit(); + // Without a breakable deck in the game the boards have no stretches: nothing to do. + if (!edit.park.load(std::memory_order_acquire) || animation_interface <= 0xc0) return; + const auto base = state().base.load(std::memory_order_acquire); + const auto holder = edit.holder.load(std::memory_order_acquire); + const auto kept = edit.kept.load(std::memory_order_acquire); + const auto error = GetLastError(); + try { + if (kept && holder && animation_interface == holder + 0xc0) + draw_broken(base, holder, kept, static_cast(edit.hinge.load(std::memory_order_acquire)), edit.angle.load(std::memory_order_acquire)); + else park_pieces(base, animation_interface - 0xc0); + } catch (...) { /* A board that is being built or torn down: the next frame. */ } + SetLastError(error); +} + +void on_client_tick(std::uintptr_t client) noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return; + const auto base = s.base.load(std::memory_order_acquire); + try { + const auto now = GetTickCount64(); + // The deck in the game: the launch's, or the one the last live apply of the mods put there. + const auto deck_in_game = mods::board_break_in_play(); + const bool in_game = !deck_in_game.empty(); + std::string wanted; + bool changed{}; + { + std::lock_guard lock(s.names); + changed = s.deck != deck_in_game; + s.deck = deck_in_game; + wanted.swap(s.wanted_deck); + } + s.has_deck.store(in_game, std::memory_order_release); + 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 (in_game && !s.hooked) { + // The boards' render pose hook is shared with multiplayer and first person, and only + // installed when one of them asks. + std::string detail; + s.hooked = multiplayer::install_entity_hooks(base, detail); + if (!s.hooked && now - s.last_error > 10000) { + s.last_error = now; + say(logging::Level::warning, "the boards' render pose hook is unavailable ({}).", detail); + } + } + NoBailSkater skater; + std::uintptr_t board{}, holder{}; + if (in_game && no_bail_skater(skater)) { + const auto found = multiplayer::read_native_board(readable, base, skater.entity); + board = found.entity; + holder = found.holder; + } + if (!board) { + // No local skater or no board (a respawn, a level change): the next board is whole. A + // broken one that is only out of sight for a moment stays as it is. + if (s.broken && in_game && now - s.board_seen < 500) return; + mend(s, "the board is gone"); + s.counted = false; + s.wanted_snap.store(-1, std::memory_order_release); + s.wanted_mend.store(false, std::memory_order_release); + publish(s, base, client, 0); + return; + } + s.board_seen = now; + if (s.broken && s.broken->board != board) mend(s, "the skater has another board"); + 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"); + const bool carried = watch.valid && watch.state >= 500 && watch.state < 600; // on foot and getting on + if (const int place = s.wanted_snap.exchange(-1, std::memory_order_acq_rel); place >= 0) + (void)snap(s, board, place < 3 ? std::optional(static_cast(place)) : std::nullopt, false, carried, "asked for", true); + if (watch.valid) { + if (watch.state == 100) { + // Rolling on the ground, the right way up: the board's own height is the ground's. + Vec at{}; + Quat facing{}; + if (board_place(base, holder, at, facing) && turn(facing, {0, 1, 0}).y > 0.9f) { + s.ride_height = at.y; + s.ride_when = now; + } + } + const bool on = s.on.load(std::memory_order_acquire); + if (!s.counted) { + s.wipeouts = watch.wipeouts; + s.counted = true; + } + if (watch.wipeouts != s.wipeouts) { + s.wipeouts = watch.wipeouts; + // The cause that made the selector choose Wipeout, or else the last one recorded. + auto* hit = &last_wipeout(); + 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)); + } + // 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) + (void)snap(s, board, Place::nose, true, false, "it gave way while riding"); + } + s.rode_at = now; + if (s.broken) { + // Whole again once the skater rides it, or has picked it up after the fall. A board that + // gave way under the rider stays as it is while it is ridden; after a fall it is a + // broken board like any other. + const auto hold = board_hold(base, client); + const bool on_it = riding(watch.state); + if (s.broken->gave_way && watch.state == addr::no_bail::wipeout_physics_state) s.broken->gave_way = false; + const bool back = s.broken->gave_way ? !on_it && hold.in_hand() + : on_it || (watch.state != addr::no_bail::wipeout_physics_state && hold.in_hand()); + // A respawn gives the skater a new physics selector, which the watch reports as state 0 + // until it has chosen once: the board is whole from that moment. + if (watch.state == 0 && now - s.broken->since > 300) mend(s, "a respawn"); + else if (s.broken->asked) s.back_since = 0; + else if (!back) s.back_since = 0; + else if (!s.back_since) s.back_since = now; + else if (now - s.back_since > 300 && now - s.broken->since > 1200) mend(s, on_it ? "riding again" : "back in hand"); + } + } + publish(s, base, client, holder); + } catch (const std::exception& error) { + // The skater or its board is being rebuilt: tried again at a later tick. + const auto now = GetTickCount64(); + if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) { + s.last_error = now; + say(logging::Level::debug, "waiting ({}).", error.what()); + } + } catch (...) { /* A broken board is never worth the client tick. */ } +} + +bool available() noexcept { return state().ready.load(std::memory_order_acquire); } +bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); } +void set_enabled(bool enabled) noexcept { + state().on.store(enabled, std::memory_order_release); + profile_runtime::set_local_preference(enabled_preference, enabled); +} +std::string deck() { + auto& s = state(); + std::lock_guard lock(s.names); + return s.deck; +} +std::string deck_label(std::string_view deck) { + if (deck.empty()) return "None"; + std::string name(deck); + bool start = true; + for (auto& c : name) { + if (c == '_') c = ' '; + else if (start) c = static_cast(std::toupper(static_cast(c))); + start = c == ' '; + } + return name; +} +std::string choose_deck(std::string wanted) { + auto& s = state(); + if (!available()) return "Board Break is unavailable for this game build."; + const auto installed = mods::installed_board_break_decks(); + if (installed.empty()) return "None of Board Break's decks is installed."; + const auto in_game = deck(); + const auto chosen = deck_meant(wanted, in_game, installed); + if (chosen.empty()) return "There is no deck " + wanted + "."; + if (std::find(installed.begin(), installed.end(), chosen) == installed.end()) + return "The " + deck_label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder."; + if (chosen == in_game) return "The " + deck_label(chosen) + " deck is the one in the game."; + { + std::lock_guard lock(s.names); + s.wanted_deck = chosen; + } + return "The " + deck_label(chosen) + " deck: the mods are applied and the level loads again with it."; +} +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); } +} // namespace dingosdk::board_break diff --git a/Extension/BoardBreak/board_break.h b/Extension/BoardBreak/board_break.h new file mode 100644 index 0000000..5771e9d --- /dev/null +++ b/Extension/BoardBreak/board_break.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include + +// Board Break: a hard slam can break the local skater's board. It snaps at the nose, in the middle +// or at the tail; half the time the grip tape holds the pieces together and the board folds or an +// end hangs, otherwise a piece comes off, flies and lies where it lands. The board is whole again +// once the skater has picked it up, rides again or respawns. How hard the slam was (the force of +// the bail cause that led to the wipeout) decides whether the board breaks: a heavy one always, a +// lighter one now and then. +// +// The pieces are not drawn by ReSkate: they are in the deck model of a content mod, one mod for +// each deck shape, any folder named Board_Break_ (Engine/Vfs/mod_catalog.h). The game has one +// deck model for every board; such a mod replaces it with the same deck plus four stretches of it +// (nose, front, back, tail) cut along the break rows, each on a spare bone of the board's rig +// (contrib/board-break.md says how such a model is made). This only moves bones, +// in the boards' render pose hook (Extension/Multiplayer/Remote/native_skater.h): +// - a broken board has its real deck moved far away, the trucks moved back to where they were, +// and each stretch moved to where its piece lies; +// - a whole board has its stretches moved far away, every frame, on every board in the game: the +// game draws a deck's wood and grip as layers that do not hide what lies just under them, so +// stretches left inside the deck show as cracks. +// Nothing is scaled: the game's replay records where a bone is and how it is turned, not its size, +// so a break made of moves plays back in the replay editor as it happened. +// +// Without such a mod installed and loading first nothing ever breaks. With one, mods.json is not +// changed: the deck the player chose goes first for the run, and the other decks and any mod that +// reshapes the deck are set aside (mod_catalog.cpp). Choosing another deck applies the mods afresh +// and loads the level again (Extension/Assets/live_mods.h); the game reads the model then. +namespace dingosdk::board_break { +// Requires the validated build, No Bail started and the local profile loaded: the switch starts +// from the saved choice (on until a player changes it). +bool start(std::uintptr_t image_base) noexcept; +// Client thread, every tick, with the local client. +void on_client_tick(std::uintptr_t client) noexcept; +// From No Bail's hooks, on the physics thread: a bail cause recorded for the watched skater, and +// the moment its selector chose Wipeout (`causes` is its cause collector, read before it resets). +void note_cause(std::int32_t reason, float magnitude) noexcept; +void note_wipeout(std::uintptr_t causes) noexcept; +// From the boards' render pose hook, after a board's pose has been handed to the renderer and +// after another player's pose has been written into it (`animation_interface` is the board's +// animation holder + 0xc0): what is written to the pose here is what this frame draws. +void on_render_pose(std::uintptr_t animation_interface) noexcept; + +// Any thread. +bool available() noexcept; +bool enabled() noexcept; // off: boards hold; one that is broken stays so until it is picked up +void set_enabled(bool enabled) noexcept; +// The name of the deck in the game ("standard", "wide" and so on), empty when none of Board Break's +// decks is: boards cannot break then. And a deck's name as the menus show it ("Powell"; "None"). +std::string deck(); +std::string deck_label(std::string_view deck); +// Asks for another deck: its name, its number in the list `boardbreak deck` prints, or "next" for +// the one after the deck in the game. Says what happens, for the console; the mods are applied and +// the level loaded again from the next tick. +std::string choose_deck(std::string wanted); +// Done at the next tick: breaks the board at `place` (0 nose, 1 middle, 2 tail; anything else is +// left to chance), to be looked at: that break stays until the board is mended here or the skater +// respawns. Or makes a broken board whole. +void snap(int place) noexcept; +void mend() noexcept; +} diff --git a/Extension/BoardBreak/board_break_commands.cpp b/Extension/BoardBreak/board_break_commands.cpp new file mode 100644 index 0000000..76b2694 --- /dev/null +++ b/Extension/BoardBreak/board_break_commands.cpp @@ -0,0 +1,75 @@ +#include "board_break.h" +#include "board_break_model.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Extension/Console/commands.h" +#include + +// Board Break's console switches; the menu's (Mod Options > Player) send them too. +namespace dingosdk::console { +void register_board_break_commands(Commands ®istry) { + const auto unavailable = "Board Break is unavailable for this game build."; + auto on = variable("boardbreak", "A hard slam can snap your board at the nose, in the middle or at the tail " + "(needs one of the Board Break deck packs)", + Group::gameplay, argument("0|1", Type::boolean)); + on.inspect = [unavailable](const Model &) { + const auto deck = board_break::deck(); + return boolean_state(board_break::available(), board_break::enabled(), unavailable, + deck.empty() ? std::string("no Board Break deck is in the game") : "the " + board_break::deck_label(deck) + " deck"); + }; + on.run = [](const Model &, const Values &args, const Output &) { board_break::set_enabled(std::get(args[0])); }; + registry.add(std::move(on)); + + auto which = argument("deck", Type::text, true); + which.complete = [](const Model &, auto) { + auto names = mods::installed_board_break_decks(); + names.insert(names.begin(), "next"); + return names; + }; + auto deck = action("boardbreak deck", "Which deck every board has, by its name or its number, or next; the level loads again with it", + Group::gameplay, {std::move(which)}); + deck.run = [](const Model &, const Values &args, const Output &out) { + if (!args.empty() && !std::get(args[0]).empty()) { + out(board_break::choose_deck(std::get(args[0]))); + return; + } + // No deck named: which one is in the game, and which there are. The decks the feature was + // made with keep their numbers; the others follow. + const auto installed = mods::installed_board_break_decks(); + const auto &known = mods::board_break_decks; + std::string list; + std::size_t number{}; + const auto add = [&](std::string_view name, bool there) { + const auto place = ++number; + list += (place > 1 ? ", " : "") + std::to_string(place) + " " + board_break::deck_label(name) + (there ? "" : " (not installed)"); + }; + for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end()); + for (const auto &name : installed) + if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true); + out("In the game: " + board_break::deck_label(board_break::deck()) + ". Decks: " + list + "."); + }; + registry.add(std::move(deck)); + + auto where = argument("nose|mid|tail", Type::text, true); + where.complete = [](const Model &, auto) { + return std::vector(board_break::place_names.begin(), board_break::place_names.end()); + }; + auto snap = action("boardbreak snap", "Break your board now, at the nose, in the middle, at the tail or wherever, to look at a break: " + "it stays broken until `boardbreak mend` or a respawn. A piece only comes off while you ride", + Group::gameplay, {std::move(where)}); + snap.run = [](const Model &, const Values &args, const Output &out) { + const auto wanted = args.empty() ? std::string{} : std::get(args[0]); + const auto &names = board_break::place_names; + const auto named = std::find(names.begin(), names.end(), wanted); + board_break::snap(named == names.end() ? -1 : static_cast(named - names.begin())); + out(board_break::deck().empty() ? "No Board Break deck is in the game: the board holds." : "Snapped."); + }; + registry.add(std::move(snap)); + + auto mend = action("boardbreak mend", "Make a broken board whole again", Group::gameplay); + mend.run = [](const Model &, const Values &, const Output &out) { + board_break::mend(); + out("Mended."); + }; + registry.add(std::move(mend)); +} +} // namespace dingosdk::console diff --git a/Extension/BoardBreak/board_break_model.h b/Extension/BoardBreak/board_break_model.h new file mode 100644 index 0000000..0eb842b --- /dev/null +++ b/Extension/BoardBreak/board_break_model.h @@ -0,0 +1,206 @@ +#pragma once +#include +#include +#include +#include +#include + +// Board Break's rules and geometry, apart from the game: where a board breaks, what is left of it, +// and where each part of a broken board lies. Everything is measured on the deck, in the deck +// model's own axes: metres, y up, z along the board with the nose at +z. +namespace dingosdk::board_break { +// ---- rigid motions ---------------------------------------------------------------------------- +// Quaternions are x, y, z, w (Hamilton), as the game's poses store them. +struct Vec { + float x{}, y{}, z{}; +}; +struct Quat { + float x{}, y{}, z{}, w{1.0f}; +}; +constexpr Vec operator+(Vec a, Vec b) noexcept { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +constexpr Vec operator-(Vec a, Vec b) noexcept { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +constexpr Vec operator*(Vec a, float s) noexcept { return {a.x * s, a.y * s, a.z * s}; } +inline float length(Vec a) noexcept { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); } +constexpr Quat operator*(Quat a, Quat b) noexcept { // b first, then a + return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z}; +} +constexpr Quat conj(Quat q) noexcept { return {-q.x, -q.y, -q.z, q.w}; } +inline Quat unit(Quat q) noexcept { + const auto n = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w); + return n > 1e-6f ? Quat{q.x / n, q.y / n, q.z / n, q.w / n} : Quat{}; +} +inline Quat about(Vec axis, float radians) noexcept { + const auto s = std::sin(radians / 2); + return {axis.x * s, axis.y * s, axis.z * s, std::cos(radians / 2)}; +} +constexpr Vec turn(Quat q, Vec v) noexcept { + const Vec u{q.x, q.y, q.z}; + const Vec c{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x}; + const Vec d{u.y * c.z - u.z * c.y, u.z * c.x - u.x * c.z, u.x * c.y - u.y * c.x}; + return v + c * (2 * q.w) + d * 2; +} +// Turned, then shifted. +struct Rigid { + Quat facing{}; + Vec at{}; +}; + +// ---- breaks ----------------------------------------------------------------------------------- +// A deck breaks across one of three rows: just ahead of the front bolts, in the middle, just behind +// the back bolts. That cuts it into four stretches; each truck rides on the stretch its bolts are in. +enum Piece : std::uint32_t { nose = 1, front = 2, back = 4, tail = 8, front_truck = 16, back_truck = 32 }; +constexpr std::uint32_t whole_board = nose | front | back | tail | front_truck | back_truck; +constexpr std::uint32_t stretches = nose | front | back | tail; +enum class Place { nose, mid, tail }; +constexpr std::array place_names{"nose", "mid", "tail"}; +constexpr float nose_row = 0.260f, mid_row = 0.009f, tail_row = -0.260f; +// How the grip tape holds a break together: not at all, or with the nose hanging, the board folded +// in the middle, the tail hanging. +enum class Hinge { none, nose, fold, tail }; +// What a break leaves: the piece the board keeps, the piece that comes off and flies (none when +// the grip tape holds), and how the two hang together. +struct Break { + std::uint32_t kept{}, loose{}; + Hinge hinge{}; +}; +constexpr Break broken_at(Place place, bool joined) noexcept { + if (joined) return {whole_board, 0, place == Place::nose ? Hinge::nose : place == Place::mid ? Hinge::fold : Hinge::tail}; + // A clean break: the board keeps the larger piece, and of two halves the front one. + switch (place) { + case Place::nose: return {whole_board & ~static_cast(nose), nose, Hinge::none}; + case Place::mid: return {nose | front | front_truck, back | tail | back_truck, Hinge::none}; + default: return {whole_board & ~static_cast(tail), tail, Hinge::none}; + } +} +// How big a break is, for its sound. An end that hangs on by the grip tape is a small one; an end +// that comes off, or a board that folds a little, is in between; a board that folds right down or +// comes apart in the middle is the biggest. `fold_depth` is 0 to 1, how far a fold sags. +enum class Size { light, medium, heavy }; +constexpr std::array size_names{"light", "medium", "heavy"}; +constexpr Size break_size(Place place, bool joined, float fold_depth) noexcept { + if (place == Place::mid) return !joined || fold_depth >= 0.7f ? Size::heavy : Size::medium; + return joined ? Size::light : Size::medium; +} +// How likely a slam of `force` breaks the board: always from `heavy` up, with `light` up to 60% of +// it, rising evenly in between. +constexpr float break_chance(float force, float heavy, float light) noexcept { + if (heavy <= 0 || force >= heavy) return 1.0f; + const auto low = 0.6f * heavy; + return force <= low ? light : light + (1.0f - light) * (force - low) / (heavy - low); +} + +// ---- where the parts of a broken board lie ------------------------------------------------------ +constexpr float axle_height = 0.028f, front_axle = 0.2226f, back_axle = -0.2225f; +// How far each half of a board tips: a touch short of where its first splinter meets the ground +// (0.351 and 0.325 rad, measured on the mesh), so the end rests on the ground instead of digging in. +constexpr float front_tip = 0.340f, back_tip = 0.315f; +// Half a board on one truck does not stand level: it tips about its axle until the broken end lies +// on the ground. `towards` is +1 when the broken end is on the nose side of that axle. +inline Rigid tipped_on_axle(float axle_z, float towards, float angle) noexcept { + const auto turned = about({1, 0, 0}, towards * angle); + const Vec axle{0, axle_height, axle_z}; + return {turned, axle - turn(turned, axle)}; +} +// A piece that hangs on by the grip tape: turned about the break line just under the deck's top, +// and drawn a hair away from the board, so the tape opens into a ragged crack with the wood of the +// broken end in it. `towards` is +1 for the nose side of the break. +constexpr float crack = 0.0025f; // how far the tape gapes along the board (m) +constexpr float grip_height = 0.0935f; +inline Rigid hung_on_grip(float break_z, float towards, float angle) noexcept { + const auto turned = about({1, 0, 0}, towards * angle); + const Vec hinge{0, grip_height - 0.004f, break_z}; + return {turned, hinge - turn(turned, hinge) + Vec{0, 0, towards * crack * 0.5f}}; +} +// A board folded in the middle: each half turns about its own axle, the front by `depth` of the +// way to the ground and the back as far as brings its grip to the same height, and the two move +// along the board until the grip of both all but meets again at the break, where the tape holds. +inline void folded(float depth, Rigid& front_half, Rigid& back_half) noexcept { + constexpr float grip_y = 0.095f; + constexpr float rise = grip_y - axle_height, ahead = front_axle - mid_row, behind = mid_row - back_axle; + const float a = front_tip * std::clamp(depth, 0.0f, 1.0f); + const float height = rise * std::cos(a) - ahead * std::sin(a); // of the grip at the break, above the axles + const float b = std::acos(std::clamp(height / std::hypot(rise, behind), -1.0f, 1.0f)) - std::atan2(behind, rise); + front_half = tipped_on_axle(front_axle, -1.0f, a); + back_half = tipped_on_axle(back_axle, 1.0f, b); + const float front_end = front_axle - (rise * std::sin(a) + ahead * std::cos(a)); + const float back_end = back_axle + (rise * std::sin(b) + behind * std::cos(b)); + front_half.at.z += (back_end - front_end + crack) * 0.5f; // and a crack's width more: the tape gapes + back_half.at.z -= (back_end - front_end + crack) * 0.5f; +} +// The deck sits on the board's rig a quarter turn about the upright axis (the rig's mount carries +// it), and the stretches hang on the rig's root: a motion worked out on the deck, carried over to +// the root's axes. +inline Rigid on_root(Quat mount, const Rigid& motion) noexcept { + return {unit(mount * motion.facing * conj(mount)), turn(mount, motion.at)}; +} + +// Where something goes that is not to be seen: far below the board. The game's replay keeps where +// a bone is and how it is turned, and nothing of its scale, so a part is never hidden by shrinking. +constexpr Vec away{0.0f, -2000.0f, 0.0f}; +// One of the four stretches of deck (nose, front, back, tail, in that order everywhere): whether +// it shows, and its motion from the place it has in a whole board. +struct Stretch { + bool shown{}; + Rigid motion{}; +}; +// A broken board as it lies: its stretches and its trucks, in the rig root's axes. +struct Layout { + std::array stretch{}; + bool front_truck{}, back_truck{}; + Rigid front_motion{}, back_motion{}; +}; +// The piece a board keeps (`kept`, Piece bits), hanging together as `hinge` says. `angle` is how far +// an end droops (radians) or how deep a fold sags (0 to 1); `up` is how far the board is the right +// way up (1 on its wheels, -1 on its grip); `mount` is the rig's mount turn. +inline Layout lay_out(std::uint32_t kept, Hinge hinge, float angle, float up, Quat mount) noexcept { + // `ahead` moves the front of the board (and all of it unless told otherwise), `rear` the back half. + Rigid ahead{}, rear{}, hung{}; + if (hinge == Hinge::fold) { + // The break sags towards the ground under the board. On its grip the fold would push nose + // and tail into the ground instead, so there it stays shallow. + const auto level = std::clamp((up + 0.2f) / 0.8f, 0.0f, 1.0f); + folded(angle * (0.3f + 0.7f * level), ahead, rear); + } else if (hinge == Hinge::nose) hung = hung_on_grip(nose_row, 1.0f, angle); + else if (hinge == Hinge::tail) hung = hung_on_grip(tail_row, -1.0f, angle); + else if (static_cast(kept & front_truck) != static_cast(kept & back_truck)) { + // Half a deck on one truck: while the board is the right way up it tips onto its broken end + // instead of standing level in the air. + const auto level = std::clamp((up - 0.3f) / 0.5f, 0.0f, 1.0f); + const auto amount = level * level * (3.0f - 2.0f * level); + ahead = kept & front_truck ? tipped_on_axle(front_axle, -1.0f, front_tip * amount) : tipped_on_axle(back_axle, 1.0f, back_tip * amount); + } + ahead = on_root(mount, ahead); + hung = on_root(mount, hung); + rear = hinge == Hinge::fold ? on_root(mount, rear) : ahead; + Layout out; + out.stretch = {{{(kept & nose) != 0, hinge == Hinge::nose ? hung : ahead}, + {(kept & front) != 0, ahead}, + {(kept & back) != 0, rear}, + {(kept & tail) != 0, hinge == Hinge::fold ? rear : hinge == Hinge::tail ? hung : ahead}}}; + out.front_truck = (kept & front_truck) != 0; + out.back_truck = (kept & back_truck) != 0; + out.front_motion = ahead; + out.back_motion = rear; + return out; +} +// What goes into the stretches' bones. The first three hang on the rig's root; the tail's hangs on +// the front stretch's, so its place is given from there. A stretch that does not show goes away. +inline std::array bones_of(std::array stretch) noexcept { + for (auto& one : stretch) + if (!one.shown) one.motion = {{}, away}; + const auto& from = stretch[1].motion; + const auto& to = stretch[3].motion; + return {{stretch[0].motion, stretch[1].motion, stretch[2].motion, + {unit(conj(from.facing) * to.facing), turn(conj(from.facing), to.at - from.at)}}}; +} +// A truck hangs on the deck's bone, which is moved `away` to hide the real deck, and that bone sits +// on the mount. To move the truck with a piece: the piece's motion seen from the deck, the deck's +// move taken back. `steer` is the truck's own turn, which it keeps; `place` where it sits on the deck. +constexpr Vec back_truck_place{0.0005f, -0.0183f, -0.2345f}, front_truck_place{0.0005f, -0.0183f, 0.2347f}; +inline Rigid truck_bone(const Rigid& motion, Quat mount, Vec mount_at, Quat steer, Vec place) noexcept { + const auto facing = unit(conj(mount) * motion.facing * mount); + const auto at = turn(conj(mount), turn(motion.facing, mount_at) + motion.at - mount_at); + return {unit(facing * steer), turn(facing, place) + at - away}; +} +} // namespace dingosdk::board_break diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 42b626d..a8245e6 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -227,6 +227,7 @@ const Commands &game_commands() { register_trainer_commands(*result); register_hall_of_meat_commands(*result); register_road_rash_commands(*result); + register_board_break_commands(*result); return result; }(); return *registry; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 9ff6d7f..71ae717 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -28,6 +28,7 @@ void register_item_commands(Commands &); void register_trainer_commands(Commands &); void register_hall_of_meat_commands(Commands &); void register_road_rash_commands(Commands &); +void register_board_break_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. void request_debug(overlay::DebugAction, bool enabled = false, float value = 0); void request_feature(overlay::OfflineFeatureGroup, bool enabled); diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 10362bd..ff94f0f 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -3,6 +3,7 @@ #include "puppet_cost.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/Multiplayer/Session/peer_slots.h" +#include "Extension/BoardBreak/board_break.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/20260929/engine.h" @@ -344,6 +345,8 @@ bool render_pose_hook(std::uintptr_t animation_interface, std::uintptr_t render_ const PeerScope scope(slot); apply_render_pose(animation_interface, render_data); } + // Last, so that what it does to a board's pose (another player's included) is what is drawn. + if (result) board_break::on_render_pose(animation_interface); return result; } void animation_hook(std::uintptr_t component, std::uintptr_t update) { diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 0863af8..1aeb968 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -1,4 +1,5 @@ #include "no_bail.h" +#include "Extension/BoardBreak/board_break.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" @@ -63,7 +64,7 @@ struct Protection { }; Protection& protection() { static auto* value = new Protection; return *value; } struct StateWatch { - std::atomic selector{}; + std::atomic selector{}, causes{}; // the watched skater's selector and cause collector std::atomic until{}, changes{}, wipeouts{}; std::atomic state{}, previous{}; std::atomic since{}, previous_ticks{}; // performance-counter ticks @@ -180,6 +181,7 @@ bool suppress_cause(std::uintptr_t causes, std::int32_t reason, std::uintptr_t c } void record_cause(std::uintptr_t causes, std::int32_t reason, float magnitude) { const auto caller = reinterpret_cast(_ReturnAddress()); + if (causes == state_watch().causes.load(std::memory_order_acquire)) board_break::note_cause(reason, magnitude); const bool protect = suppress_cause(causes, reason, caller); // Do not force the native recovery/stumble predicate (recovery_predicate). Its // result is also exported to animation at +0x9e, even without a collision. @@ -219,7 +221,11 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) { w.previous.store(before, std::memory_order_relaxed); w.previous_ticks.store(now.QuadPart - w.since.exchange(now.QuadPart, std::memory_order_relaxed), std::memory_order_relaxed); w.changes.fetch_add(1, std::memory_order_relaxed); - if (chosen == wipeout_physics_state) w.wipeouts.fetch_add(1, std::memory_order_relaxed); + if (chosen == wipeout_physics_state) { + // Before the count: the tick that sees the new wipeout finds its cause there. + board_break::note_wipeout(w.causes.load(std::memory_order_acquire)); + w.wipeouts.fetch_add(1, std::memory_order_relaxed); + } } } return chosen; @@ -420,6 +426,7 @@ void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept if (!protection().ready.load(std::memory_order_acquire) || !resolve(client, entity, owner)) return; if (w.selector.exchange(owner.selector, std::memory_order_acq_rel) != owner.selector) w.state.store(0, std::memory_order_release); + w.causes.store(owner.causes, std::memory_order_release); w.until.store(GetTickCount64() + 500, std::memory_order_release); } PhysicsStateWatch watched_physics_state() noexcept { diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index ba0b843..ea76881 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -140,6 +140,18 @@ int main() { check(capture.command == "roadrashblood 0", "The blood switches off"); activate(state, model, cb, "road-rash-heal", ""); check(capture.command == "roadrash heal", "and the skater heals"); + // Board Break's switch sits there too, with the deck that is in the game; another deck waits for a deck pack. + check(rash_row("board-break").title == "Board Break: Off" && rash_row("board-break").command.empty(), "Board Break waits until it started"); + model.board_break = {true, true, false, "None"}; + check(rash_row("board-break").title == "Board Break: On" && rash_row("board-break-deck").command.empty(), + "Without a deck pack there is no deck to change"); + activate(state, model, cb, "board-break", ""); + check(capture.command == "boardbreak 0", "Board Break turns off"); + model.board_break = {true, true, true, "Wide"}; + check(rash_row("board-break-deck").title == "Board Break deck: Wide" && rash_row("board-break-deck").command == "board-break-deck", + "The deck in the game shows"); + activate(state, model, cb, "board-break-deck", ""); + check(capture.command == "boardbreak deck next", "and steps to the next one"); model.multiplayer.local_name = "steam_name"; model.player_card = {true, "Card Name", {}}; const auto card_page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index cc46e7c..bc8d10f 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -208,6 +208,10 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks console(m.road_rash.blood ? "roadrashblood 0" : "roadrashblood 1", m.road_rash.available && m.road_rash.enabled); } else if (command == "road-rash-heal") { console("roadrash heal", m.road_rash.available && m.road_rash.enabled); + } else if (command == "board-break") { + 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-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -308,6 +312,9 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); button(p.main, "road-rash-heal", "Heal Road Rash", "road-rash-heal", m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); + 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-wear", "Board wear", m.offline.board_wear.effective, m.offline.board_wear.available && cb.queue_offline_feature); button(p.main, "board-wear-reset", "Reset board wear", "board-wear-reset", diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index b1b85c1..05f00fa 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1098,6 +1098,9 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash"); if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood"); if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal"); + if (list.setting("Board Break", on_off(m.board_break.enabled), m.board_break.available && console)) act("board-break"); + if (list.setting("Board Break deck", m.board_break.deck, m.board_break.available && m.board_break.has_deck && console)) + act("board-break-deck"); const auto &wear = m.offline.board_wear; if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear"); if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset"); diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index 6155886..0134907 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -50,11 +50,18 @@ void draw_installed_mods(SkateMenu& menu) { const auto excluded = [&](const std::string& name) { return std::ranges::any_of(launch.excluded, [&](const mods::Mod& mod) { return _stricmp(mod.name.c_str(), name.c_str()) == 0; }); }; + // Board Break's other decks and other mods that bring a deck model: enabled, not loaded, and + // nothing about them waits to be applied. + const auto aside = mods::set_aside_for_board_break(); + const auto set_aside = [&](const mods::ModEntry& entry) { + return entry.enabled && !in_effect(entry.mod.name) && + std::ranges::any_of(aside, [&](const std::string& other) { return _stricmp(other.c_str(), entry.mod.name.c_str()) == 0; }); + }; std::size_t loaded{}, pending{}; for (const auto& entry : list.entries) { const bool effect = in_effect(entry.mod.name); if (effect) ++loaded; - if (entry.enabled != effect && !excluded(entry.mod.name)) ++pending; + if (entry.enabled != effect && !excluded(entry.mod.name) && !set_aside(entry)) ++pending; } // Mods still in effect whose folder has been deleted: applying unloads them. std::vector removed; @@ -155,6 +162,10 @@ void draw_installed_mods(SkateMenu& menu) { colour = skate_theme::danger; hover = "Not loaded: it could not be merged cleanly, so none of it is used.\n\n" "Reinstall the whole mod folder, or rebuild it with a current ReSkate Studio."; + } else if (set_aside(entry)) { + status = "SET ASIDE"; + hover = "Set aside while a Board Break deck is the one in the game: the game has one deck model, so one " + "mod's deck loads at a time. Pick another deck with Board Break deck, or disable Board Break's decks."; } else if (entry.enabled && effect) { status = "LOADED"; colour = skate_theme::good; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 460a7de..bc3dc3e 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -216,6 +216,13 @@ struct RoadRashModel { bool available = false, enabled = false, blood = false; }; +// Board Break's switch (Extension/BoardBreak/board_break.h): started, switched on, and which of +// its decks is the one in the game (none: no deck pack loads, and no board can break). +struct BoardBreakModel { + bool available = false, enabled = false, has_deck = false; + std::string deck = "None"; +}; + struct Model { std::string state = "Waiting for native state"; std::string detail; @@ -236,6 +243,7 @@ struct Model { PlayerCardModel player_card; HallOfMeatModel hall_of_meat; RoadRashModel road_rash; + BoardBreakModel board_break; ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index b51acb7..b745cf9 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -178,6 +178,12 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood, rash_console && model.road_rash.enabled)) send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0"); + bool board_break = model.board_break.enabled; + if (toggle_row(menu, "Board Break", model.board_break.has_deck + ? "A hard slam can snap your board: at the nose, in the middle or at the tail. Whole again once you pick it up." + : "A hard slam can snap your board. Needs one of the Board Break deck packs.", + board_break, model.board_break.available && callbacks.queue_console_command)) + send_console(menu, callbacks, board_break ? "boardbreak 1" : "boardbreak 0"); end_card(); begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls"); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index e6dc18f..b5e52e4 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -10,6 +10,7 @@ #include "Engine/Game/World/client_state.h" #include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/RoadRash/road_rash.h" +#include "Extension/BoardBreak/board_break.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -791,6 +792,7 @@ void update_model(std::uintptr_t client, TickState& frame) { if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. dingosdk::hall_of_meat::on_client_tick(); dingosdk::road_rash::on_client_tick(client); + dingosdk::board_break::on_client_tick(client); if (!has_request && now < r.next_model && state == r.previous_state) return; r.next_model = now + 500; DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)"); @@ -930,6 +932,9 @@ void update_model(std::uintptr_t client, TickState& frame) { model.player_card = dingosdk::local_profile_player_card(); model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()}; model.road_rash = {dingosdk::road_rash::available(), dingosdk::road_rash::enabled(), dingosdk::road_rash::blood()}; + 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)}; model.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 1250f56..5f5cecd 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -16,6 +16,7 @@ #include "Extension/Boot/offline_boot.h" #include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/RoadRash/road_rash.h" +#include "Extension/BoardBreak/board_break.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -413,6 +414,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); const bool rash = dingosdk::road_rash::start(r.base); record("{\"event\":\"road_rash_initialized\",\"active\":" + std::string(rash ? "true" : "false") + "}"); + const bool board_break = dingosdk::board_break::start(r.base); + record("{\"event\":\"board_break_initialized\",\"active\":" + std::string(board_break ? "true" : "false") + "}"); const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base); record("{\"event\":\"noclip_velocity_initialized\",\"active\":" + std::string(noclip_velocity ? "true" : "false") + "}"); const bool loading_screens = dingosdk::loading_screen::start(r.base); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d95bfbf..c964877 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -132,6 +132,8 @@ add_library(dingosdk_runtime SHARED Extension/HallOfMeat/hall_of_meat_slow_motion.cpp Extension/RoadRash/road_rash.cpp Extension/RoadRash/road_rash_commands.cpp + Extension/BoardBreak/board_break.cpp + Extension/BoardBreak/board_break_commands.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 new file mode 100644 index 0000000..a4f23e1 --- /dev/null +++ b/contrib/board-break.md @@ -0,0 +1,116 @@ +# Board Break + +A hard slam can break the local skater's board: at the nose, in the middle or at the tail. Half the +time the grip tape holds the pieces together and the board folds or an end hangs; otherwise a piece +comes off, flies and lies where it lands, truck and wheels with it. The board is whole again once +the skater has picked it up, rides again or respawns. Built into ReSkate; the broken pieces +themselves come from content mods, one for each deck shape. + +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)), +[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). + +## What the player gets + +- A slam of force 10 or more always breaks the board, a lighter one a quarter of the time, rising + in between (the force is the bail cause's that led to the wipeout). Now and then a board gives way + while it is ridden: the nose goes. +- `boardbreak 0|1`, `boardbreak deck [name|number|next]`, `boardbreak snap [nose|mid|tail]` to look + at a break (that one stays until `boardbreak mend` or a respawn). The switch and the deck are in + the menus beside Road Rash (Mod Options > Player, and the overlay's Skater page). The switch is + saved with the profile. +- A break plays back in the replay editor as it happened. +- Without a deck pack installed nothing breaks and nothing is changed. + +## How it works + +- **One deck model.** Every board in the game draws the same deck mesh + (`characters/skateboard/unlicensed/deck/generic/popsicle/2022/deck_gen_popsicle_mesh`); deck + graphics and grip tapes are items on top of it, and an item cannot bring a mesh of its own. So a + breakable deck is a content mod that replaces that mesh: the same deck, plus four stretches of it + (nose, front, back, tail) cut along three rows of vertices the deck already has, each rigidly + skinned to a bone of the board's rig that the game does not use (Trajectory, Connect, Ground, and + Connect2 for the tail, which hangs on Connect). +- **Only bones move.** `board_render_pose` (the hook multiplayer already has for other players' + boards) runs after a board's pose has been handed to the renderer, which is the one moment a + change to the pose is still drawn and not yet rewritten: the board's animation writes every bone + again every frame. A broken board gets its Deck bone moved 2 km down, the trucks (which hang on + it) moved back by as much, and each stretch moved to where its piece lies. The piece that came + off is flown by the hook itself, from the board's place at the break: a kick, gravity, the + ground, and written relative to wherever the board is in each frame. +- **Nothing is scaled.** The first version shrank the deck and scaled the stretches up. The game's + replay records where a bone is and how it is turned, and not its size: a shrunk deck played back + as a small whole board, and stretches that were scaled up not at all. Moves play back exactly. +- **Whole boards hide their stretches too.** The deck is a hollow shell (the wood has no top face, + the grip is a sheet over it), and the game draws wood and grip as layers that do not hide what + lies just under them: stretches left inside a whole deck showed as cracks through the grip, + fainter the deeper they lay and never gone (tried down to 4 mm). So every frame, on every board + that passes the hook (the local one, other players'), the stretches of a whole board are moved + away like a broken board's deck. The call into Board Break is the last thing the hook does, after + another player's pose has been written, so that pose's bones do not bring them back. +- **Which deck is in the game** is a question for the mod list, because any mod that reshapes the + deck replaces the same mesh and the one higher in the order has it. When the mods are put + together, the Board Break deck the player chose goes first, and what would fight it is set + aside for the run: the other Board Break decks, and other mods whose bundle lists name the + deck's bundle. Those are left out whole (with the breakable deck in place of its own, a wide-deck + mod has its trucks and wheels on a deck they were not made for). mods.json is not changed; the + mod list shows them as SET ASIDE. +- **Another deck without a restart.** The deck mesh is in the items superbundle, which a live apply + of the mods already refreshes, and the game reads the mesh when a level loads. Choosing a deck + saves the choice, applies the mods and loads the level again (live_mods.h). +- **The choice** is a line in `%LOCALAPPDATA%\ReSkate\BoardBreak.deck`, not a profile preference: + the mods are put together before the profile is there. +- **The sound** is a wave file from the `sounds` folder of an installed deck pack (subfolders + `light`, `medium`, `heavy`, by the size of the break), played straight to the speakers. +- **The slam's force** comes through No Bail's existing hooks: the cause it records for the watched + skater, and the cause collector at the moment the selector chooses Wipeout. Nothing new is hooked. +- **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. +- Off, the board holds. Without a deck pack in the game the hook returns at once. + +## The deck packs + +A content mod (no code) in a folder named `Board_Break_`; 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 pack 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 + triangles are the donor's own, so a shape is made by moving vertices, never by adding any. Keep + every vertex where it is along the board: the break rows (z = 0.260, 0.009 and -0.260 m) and the + truck bolts stay put. +- For each of the four stretches, append a copy of every part's vertices and triangles, skinned to + that stretch's bone, with every vertex on the far side of its break rows folded onto the row's + cross-section (top skin onto its top line, bottom skin onto its bottom line). That leaves the + deck's own edge faces standing in the gap as the broken end. Push the row itself along the board + by a jagged line, the same line for both sides of a break, so the pieces fit together. +- Build the stretches at their own size, in the place they have in the whole deck. +- The standard deck's pack also carries `sounds/light|medium|heavy/*.wav`. + +## Seen working in game + +Offline, on main at f4292d6, with eight deck packs installed: breaks from real slams at the nose, +in the middle and at the tail, clean and held by the grip, the board whole again in hand and on +respawn; breaks from the console on foot (folded, nose and tail hanging) that stay until mended. +The grip side of a whole board, close up in the board menu, shows no cracks. A deck chosen while +the game runs is the one in the board menu and in the world after the level has loaded again +(about 20 s), and the one the next launch starts with. With a wide-deck mod switched on and first +in the order, the breakable deck is still the one in the game and the mod is set aside; so is an +older deck mod of Board Break's own. The mod list shows the decks that are set aside as such, with +nothing waiting to be applied. The switch on the overlay's Skater page turns it off and on. On the +older build the feature was developed on, a middle break played back in the replay editor. + +## Not tried yet + +- Multiplayer: other players' boards are only handled by the code path (their stretches are moved + away), not seen; what peers see of a broken board, with and without the packs. +- The online route: all of the above ran on the offline profile. +- The replay editor on this branch (the replay was checked on the older build the feature was + developed on). +- The rows in Mod Options > Player in the game itself (their view has a test; the overlay's switch + was used). +- A level change in the middle of a break.