mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Merge branch 'main' of https://github.com/Dingo-Shenanigans/ReSkate
This commit is contained in:
commit
1d73fdc1de
23 files changed
+1579
-5
No files matched your search
@@ -295,6 +295,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()
|
||||
|
||||
+180
-2
@@ -12,6 +12,8 @@
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
@@ -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<fs::path> 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<char>(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<wchar_t, 1024> text{};
|
||||
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast<DWORD>(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::size_t>(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<std::string> set_aside;
|
||||
};
|
||||
std::mutex board_break_mutex;
|
||||
std::string chosen_deck;
|
||||
std::optional<BoardBreakApplied> 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<std::filesystem::path> board_break_folders() noexcept {
|
||||
std::vector<fs::path> found;
|
||||
try {
|
||||
std::vector<std::pair<std::string, fs::path>> 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<std::string> installed_board_break_decks() noexcept {
|
||||
std::vector<std::string> 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<std::string> 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<wchar_t, 32768> 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<std::ptrdiff_t>(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.
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#include "mod_list.h"
|
||||
#include "mod_merge.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<Mod> 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<std::string> 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_<Name> (also behind a team name, as a mod manager installs it:
|
||||
// Team-Board_Break_<Name>). 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<std::string_view, 8> 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<std::string> installed_board_break_decks() noexcept;
|
||||
std::vector<std::filesystem::path> 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<std::string> 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <initializer_list>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
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, 4> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <Windows.h>
|
||||
#include <mmsystem.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <cwctype>
|
||||
#include <filesystem>
|
||||
#include <format>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<int, 4> 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 <class... Args> void say(logging::Level level, std::format_string<Args...> text, Args&&... args) {
|
||||
logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward<Args>(args)...));
|
||||
}
|
||||
|
||||
// Written by the physics step, read by the client tick.
|
||||
struct Hit {
|
||||
std::atomic<std::uint64_t> at{};
|
||||
std::atomic<std::int32_t> reason{-1};
|
||||
std::atomic<float> 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<bool> ready{}, on{true}, wanted_mend{};
|
||||
std::atomic<std::uintptr_t> base{};
|
||||
std::atomic<bool> has_deck{}; // one of Board Break's decks is the one in the game
|
||||
std::atomic<int> 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> 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<float>::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<std::uintptr_t> holder{}; // the local board's animation holder
|
||||
std::atomic<std::uint32_t> kept{}; // the piece it shows (Piece bits), 0 while it is whole
|
||||
std::atomic<int> hinge{};
|
||||
std::atomic<float> angle{};
|
||||
std::atomic<bool> 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<const void*>(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<float*>(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<std::uint32_t>(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<float>::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<double>(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<float>(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<std::vector<std::filesystem::path>, 4> sounds = [] {
|
||||
std::array<std::vector<std::filesystem::path>, 4> found;
|
||||
constexpr std::array<const wchar_t*, 4> 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<wchar_t>(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<std::array<std::size_t, 4>, 3> order{{{0, 1, 2, 3}, {1, 2, 0, 3}, {2, 1, 0, 3}}};
|
||||
for (const auto from : order[static_cast<std::size_t>(size)]) {
|
||||
const auto& files = sounds[from];
|
||||
if (files.empty()) continue;
|
||||
const auto& file = files[std::uniform_int_distribution<std::size_t>(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<Place> 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<Place>(std::uniform_int_distribution<int>(0, 2)(s.random));
|
||||
const bool joined = carried || std::uniform_real_distribution<float>(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<float>(0.35f, 1.0f)(s.random)
|
||||
: std::uniform_real_distribution<float>(0.30f, 0.65f)(s.random);
|
||||
s.break_ground = s.ride_when && now - s.ride_when < 400 ? s.ride_height : std::numeric_limits<float>::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<std::size_t>(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<std::string>& installed) {
|
||||
for (auto& c : wanted) c = c == ' ' ? '_' : static_cast<char>(std::tolower(static_cast<unsigned char>(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::size_t>(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<int>(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<float>(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<std::uint8_t, cause_count> set{};
|
||||
std::array<float, cause_count> 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<std::int32_t>(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<Hinge>(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>(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<float>(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<std::uint64_t>)(now - s.rode_at, 250) / 1000.0;
|
||||
if (std::uniform_real_distribution<double>(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<char>(std::toupper(static_cast<unsigned char>(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
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// 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_<Name> (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;
|
||||
}
|
||||
@@ -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 <algorithm>
|
||||
|
||||
// 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<bool>(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<std::string>(args[0]).empty()) {
|
||||
out(board_break::choose_deck(std::get<std::string>(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<std::string>(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<std::string>(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<int>(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
|
||||
@@ -0,0 +1,206 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
// 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<std::string_view, 3> 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<std::uint32_t>(nose), nose, Hinge::none};
|
||||
case Place::mid: return {nose | front | front_truck, back | tail | back_truck, Hinge::none};
|
||||
default: return {whole_board & ~static_cast<std::uint32_t>(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<std::string_view, 3> 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, 4> 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<bool>(kept & front_truck) != static_cast<bool>(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<Rigid, 4> bones_of(std::array<Stretch, 4> 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<std::uintptr_t> selector{};
|
||||
std::atomic<std::uintptr_t> selector{}, causes{}; // the watched skater's selector and cause collector
|
||||
std::atomic<std::uint64_t> until{}, changes{}, wipeouts{};
|
||||
std::atomic<std::uint32_t> state{}, previous{};
|
||||
std::atomic<std::int64_t> 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<std::uintptr_t>(_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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<std::string> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<Name>`; 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.
|
||||
Reference in new issue
Block a user