Merge pull request #257 from FourthStudio/board-break/wear-and-tear

Board Break: wear and tear, rail snaps, break and body sounds, settings
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-15 13:14:03 -05:00
commit de272c1439
20 files changed
+1410 -54

No files matched your search

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