mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-24 18:16:27 +02:00
1350 lines
70 KiB
C++
1350 lines
70 KiB
C++
#include "board_break.h"
|
|
#include "board_break_model.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Core/Platform/memory.h"
|
|
#include "Engine/Game/Build/20260929/no_bail.h"
|
|
#include "Engine/Vfs/builtin_packs.h"
|
|
#include "Engine/Vfs/mod_catalog.h"
|
|
#include "Extension/Assets/live_mods.h"
|
|
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
|
#include "Extension/Multiplayer/Remote/native_skater.h"
|
|
#include "Extension/Profile/local_profile_runtime.h"
|
|
#include "Extension/Skater/no_bail.h"
|
|
#include "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 "Extension/UI/Overlay/overlay.h"
|
|
#include <Windows.h>
|
|
#include <shlobj.h>
|
|
#include <algorithm>
|
|
#include <thread>
|
|
#include <sstream>
|
|
#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, "ole32.lib")
|
|
#pragma comment(lib, "shell32.lib")
|
|
|
|
namespace dingosdk::board_break {
|
|
namespace {
|
|
constexpr const char* enabled_preference = "BoardBreak";
|
|
// A slam of `heavy` force or more always breaks the board, one under 60% of it never, and the
|
|
// chance rises smoothly in between (break_chance). Half of all breaks stay joined by the grip tape.
|
|
// A board that is done gives way under the rider on a hard enough landing: the nose goes.
|
|
// (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.
|
|
constexpr float hand_reach = 1.3f, hand_upright = 0.8f;
|
|
// The board rig's pose, in the order the game evaluates it: 1 the rig's place in the world, 2 the
|
|
// deck's mount, 3 the deck, 4 and 8 the trucks, and the spare bones that carry the four stretches
|
|
// (nose, front, back, tail; the tail's hangs on the front's).
|
|
constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, front_truck_bone = 8;
|
|
// The trucks' effectors, each right after its truck's bone: a deck pack's truck model has the
|
|
// baseplates on them (board_break_model.h, baseplate_bone).
|
|
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; }
|
|
|
|
template <class... Args> void say(logging::Level level, std::format_string<Args...> text, Args&&... args) {
|
|
logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward<Args>(args)...));
|
|
}
|
|
|
|
// Written by the physics step, read by the client tick.
|
|
struct Hit {
|
|
std::atomic<std::uint64_t> at{};
|
|
std::atomic<std::int32_t> reason{-1};
|
|
std::atomic<float> force{};
|
|
};
|
|
Hit& last_cause() { static Hit value; return value; }
|
|
Hit& last_wipeout() { static Hit value; return value; }
|
|
|
|
struct Broken {
|
|
std::uintptr_t board{};
|
|
std::uint64_t since{};
|
|
bool gave_way{}; // it went under the rider: it stays broken while it is ridden
|
|
bool asked{}; // it was broken from the console, to be looked at: it stays until it is mended there
|
|
std::string deck; // the deck it was: the one that gets a new board
|
|
};
|
|
struct State {
|
|
// Any thread.
|
|
std::atomic<bool> ready{}, on{true}, wanted_mend{}, turned_off{};
|
|
std::atomic<std::uintptr_t> base{};
|
|
std::atomic<bool> has_deck{}; // one of Board Break's decks is the one in the game
|
|
std::atomic<int> wanted_snap{-1}; // asked for from another thread, done at the next tick
|
|
std::mutex names;
|
|
std::string deck, wanted_deck; // under `names`: the deck in the game, and one asked for (done at the next tick)
|
|
// Client thread from here on.
|
|
std::optional<Broken> broken;
|
|
Break parts; // what the broken board keeps, what flew off, how they hang together
|
|
float angle{}; // how far a hanging end droops (radians), or how deep a fold sags (0 to 1)
|
|
std::uint32_t flights{}; // counts the breaks, so each starts a flight of its own
|
|
bool counted{}, hooked{};
|
|
std::uint64_t wipeouts{}, back_since{}, last_error{}, board_seen{};
|
|
// The ground under the board: its own height while it rolls on it, and what of that was fresh
|
|
// when the board broke (else not a number).
|
|
float ride_height{};
|
|
std::uint64_t ride_when{};
|
|
float break_ground{std::numeric_limits<float>::quiet_NaN()};
|
|
std::mt19937 random{std::random_device{}()};
|
|
// 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<bool> landing_give{}; // the physics thread saw a done board land too hard
|
|
std::atomic<float> landing_speed{};
|
|
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;
|
|
return *value;
|
|
}
|
|
|
|
// What the render pose hook does to the boards, published by the client tick.
|
|
struct RenderEdit {
|
|
std::atomic<std::uintptr_t> holder{}; // the local board's animation holder
|
|
std::atomic<std::uint32_t> kept{}; // the piece it shows (Piece bits), 0 while it is whole
|
|
std::atomic<int> hinge{};
|
|
std::atomic<float> angle{};
|
|
std::atomic<bool> park{}; // a breakable deck is in the game: whole boards hide their stretches
|
|
// 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; }
|
|
|
|
// ---- the board's pose --------------------------------------------------------------------------
|
|
// One bone: scale, rotation and position, four floats each.
|
|
struct PoseBone {
|
|
float scale[4], rotation[4], position[4];
|
|
};
|
|
bool load_bone(std::uintptr_t address, PoseBone& out) noexcept {
|
|
__try {
|
|
std::memcpy(&out, reinterpret_cast<const void*>(address), sizeof(out));
|
|
return true;
|
|
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
|
}
|
|
// Rotation and position of one bone, at its own size; the fourth lane of each is left as it is.
|
|
bool store_bone(std::uintptr_t address, Quat rotation, Vec position) noexcept {
|
|
__try {
|
|
auto* lane = reinterpret_cast<float*>(address);
|
|
lane[0] = lane[1] = lane[2] = 1.0f;
|
|
lane[4] = rotation.x; lane[5] = rotation.y; lane[6] = rotation.z; lane[7] = rotation.w;
|
|
lane[8] = position.x; lane[9] = position.y; lane[10] = position.z;
|
|
return true;
|
|
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
|
}
|
|
Quat facing_of(const PoseBone& bone) noexcept { return unit({bone.rotation[0], bone.rotation[1], bone.rotation[2], bone.rotation[3]}); }
|
|
Vec place_of(const PoseBone& bone) noexcept { return {bone.position[0], bone.position[1], bone.position[2]}; }
|
|
// The pose buffer of a board, or 0 when it is not the rig this knows.
|
|
std::uintptr_t board_pose(std::uintptr_t base, std::uintptr_t holder) {
|
|
const auto pose = multiplayer::read_native_pose_layout(readable, base, holder, 64);
|
|
return pose.buffer && pose.count == rig_bones ? pose.buffer : 0;
|
|
}
|
|
std::uintptr_t bone_at(std::uintptr_t buffer, int index) noexcept { return buffer + static_cast<std::uint32_t>(index) * 0x30ULL; }
|
|
// The board's place in the world as its pose has it.
|
|
bool board_place(std::uintptr_t base, std::uintptr_t holder, Vec& at, Quat& facing) {
|
|
const auto buffer = board_pose(base, holder);
|
|
PoseBone root{};
|
|
if (!buffer || !load_bone(bone_at(buffer, root_bone), root)) return false;
|
|
at = place_of(root);
|
|
facing = facing_of(root);
|
|
return std::isfinite(at.x) && std::isfinite(at.y) && std::isfinite(at.z);
|
|
}
|
|
// Moves a truck with a piece (truck_bone).
|
|
void carry_truck(std::uintptr_t buffer, const Rigid& motion, int index, Vec place) noexcept {
|
|
PoseBone mount{}, truck{};
|
|
if (!load_bone(bone_at(buffer, mount_bone), mount) || !load_bone(bone_at(buffer, index), truck)) return;
|
|
const auto bone = truck_bone(motion, facing_of(mount), place_of(mount), facing_of(truck), place);
|
|
(void)store_bone(bone_at(buffer, index), bone.facing, bone.at);
|
|
}
|
|
|
|
// Keeps the baseplates on the deck (baseplate_bone), from each truck's bone as the animation left
|
|
// it: a truck that a piece has carried off already, in a pose nobody has written since, is left.
|
|
void hold_baseplates(std::uintptr_t buffer) noexcept {
|
|
const auto hold = [&](int truck_index, int plate_index, const TruckRest& rest) {
|
|
PoseBone truck{};
|
|
if (!load_bone(bone_at(buffer, truck_index), truck)) return;
|
|
const Rigid now{facing_of(truck), place_of(truck)};
|
|
if (!(length(now.at - rest.at) < 0.05f)) return;
|
|
const auto bone = baseplate_bone(now, rest);
|
|
(void)store_bone(bone_at(buffer, plate_index), bone.facing, bone.at);
|
|
};
|
|
hold(back_truck_bone, back_plate_bone, back_truck_rest);
|
|
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
|
|
// wherever the board itself is in that frame, so the two part company although both are the one board.
|
|
struct Flight {
|
|
std::mutex mutex;
|
|
// From the tick.
|
|
std::uint32_t id{}; // changes with every break; 0 = no piece in flight
|
|
std::uint32_t mask{}; // the piece's stretches of deck and its trucks
|
|
float ground{}; // world height of the ground under the skater
|
|
float rest{}; // how far above the ground the piece's origin lies once it rests
|
|
Vec kick{}, spin{}; // start speed (m/s) and turning (axis x rad/s), in the deck's own axes
|
|
float ridden{std::numeric_limits<float>::quiet_NaN()}; // the ground the board rolled on as it broke
|
|
// The hook's.
|
|
std::uint32_t running{};
|
|
std::int64_t last{};
|
|
Vec at{}, speed{}, turning{};
|
|
Quat facing{};
|
|
bool landed{}; // lying on what is taken for the ground right now
|
|
bool settled{}; // has landed once: flat from then on, whichever side came up
|
|
bool upside_down{};
|
|
Vec from{}; // where the board was when it broke
|
|
float limit{}; // the tick's first guess of the ground: never taken higher than this
|
|
float tipped{}; // 0..1: how far a half on one truck has tipped onto its broken end
|
|
};
|
|
Flight& flight() { static Flight value; return value; }
|
|
// The stretches of deck that are in flight (0 for none) and, in `piece`, where they are from the
|
|
// board's own place; the piece's trucks are moved here.
|
|
std::uint32_t fly_piece(std::uintptr_t buffer, Rigid& piece) {
|
|
auto& f = flight();
|
|
std::unique_lock lock(f.mutex, std::try_to_lock);
|
|
if (!lock.owns_lock() || !f.id) return 0;
|
|
PoseBone root{}, mount{};
|
|
if (!load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return 0;
|
|
const Vec board_at = place_of(root);
|
|
const Quat board_facing = facing_of(root), deck_turn = facing_of(mount);
|
|
LARGE_INTEGER now{};
|
|
QueryPerformanceCounter(&now);
|
|
static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast<double>(value.QuadPart); }();
|
|
if (f.running != f.id) {
|
|
f.running = f.id;
|
|
f.last = now.QuadPart;
|
|
f.at = f.from = board_at;
|
|
f.facing = board_facing;
|
|
f.speed = turn(board_facing, turn(deck_turn, f.kick));
|
|
f.turning = turn(board_facing, turn(deck_turn, f.spin));
|
|
f.landed = f.settled = f.upside_down = false;
|
|
f.limit = f.ground;
|
|
f.tipped = 0;
|
|
}
|
|
const auto dt = static_cast<float>(std::clamp((now.QuadPart - f.last) / frequency, 0.0, 0.05));
|
|
f.last = now.QuadPart;
|
|
// The ground is where the board itself lies: its lowest point right now, which physics brings
|
|
// to rest on the ground. The skater's height at the slam (the hips, and the skater may be in the
|
|
// air) is only an upper limit. It is the board's height NOW, not the lowest ever seen: one odd
|
|
// pose (a teleport, a frame before the board is placed) must not sink the piece for good, and a
|
|
// pose that is nowhere near the break is passed over altogether.
|
|
const bool plausible = std::isfinite(board_at.x) && std::isfinite(board_at.y) && std::isfinite(board_at.z) && length(board_at - f.from) < 30.0f;
|
|
float lowest = board_at.y + 1.0f;
|
|
if (plausible) {
|
|
for (const Vec corner : {Vec{0, 0, 0}, Vec{0, 0.093f, 0}, Vec{0, 0.141f, 0.41f}, Vec{0, 0.136f, -0.40f}, Vec{0.105f, 0.10f, 0},
|
|
Vec{-0.105f, 0.10f, 0}, Vec{0.0876f, 0, front_axle}, Vec{-0.0876f, 0, front_axle}, Vec{0.0876f, 0, back_axle},
|
|
Vec{-0.0876f, 0, back_axle}})
|
|
lowest = (std::min)(lowest, (board_at + turn(board_facing, turn(deck_turn, corner))).y);
|
|
// Only a board that is still near the piece says anything about the ground under the piece.
|
|
if (std::hypot(board_at.x - f.at.x, board_at.z - f.at.z) < 4.0f) {
|
|
// The ground the board rolled on as it broke is exact, where its lowest point depends on
|
|
// how it has come to lie and on what: that height stays, unless the board has plainly
|
|
// dropped below it since (off a ledge, down a stair).
|
|
if (std::isfinite(f.ridden)) f.ground = lowest < f.ridden - 0.15f ? lowest : f.ridden;
|
|
else f.ground = (std::min)(f.limit, lowest);
|
|
}
|
|
}
|
|
// Where the piece's origin is once it lies on the ground: on its grip when it is upside down,
|
|
// else on its underside or its wheels. A little above it: real ground is rough, and a deck is a
|
|
// centimetre thick. A gap under a piece goes unseen, a piece cut off by the ground does not.
|
|
const float lift = f.mask & (front_truck | back_truck) ? 0.004f : 0.012f;
|
|
const auto lying = [&] {
|
|
const bool flipped = f.settled ? f.upside_down : turn(f.facing, {0, 1, 0}).y < 0;
|
|
return (flipped ? f.ground + 0.141f : f.ground + f.rest) + lift; // upside down it rests on the raised end of its grip side
|
|
};
|
|
if (f.landed && f.at.y > lying() + 0.01f) {
|
|
f.landed = false; // the ground turned out to be lower: it drops the rest of the way
|
|
f.speed = {};
|
|
f.turning = {};
|
|
}
|
|
if (!f.landed) {
|
|
f.speed.y -= 9.81f * dt;
|
|
f.at = f.at + f.speed * dt;
|
|
if (const auto rate = length(f.turning); rate > 1e-4f) f.facing = unit(about(f.turning * (1.0f / rate), rate * dt) * f.facing);
|
|
if (f.speed.y <= 0 && f.at.y <= lying()) { // on its way down: it starts on the ground it will land on
|
|
if (!f.settled) {
|
|
// Down: lying flat, pointing the way it was, the same side up.
|
|
const auto ahead = turn(f.facing, {0, 0, 1});
|
|
f.upside_down = turn(f.facing, {0, 1, 0}).y < 0;
|
|
f.facing = about({0, 1, 0}, std::atan2(ahead.x, ahead.z));
|
|
if (f.upside_down) f.facing = unit(f.facing * about(turn(deck_turn, {0, 0, 1}), 3.14159265f)); // rolled over, about its length
|
|
f.speed = {f.speed.x * 0.3f, 0, f.speed.z * 0.3f};
|
|
f.settled = true;
|
|
} else f.speed = {};
|
|
f.at.y = lying();
|
|
f.landed = true;
|
|
}
|
|
} else {
|
|
f.at = f.at + f.speed * dt; // a short slide
|
|
f.speed = f.speed * (std::max)(0.0f, 1.0f - 7.0f * dt);
|
|
f.at.y = lying(); // and it stays on the ground if that comes up again
|
|
}
|
|
// A half board that came down on its wheels tips onto its broken end.
|
|
const bool on_one_truck = (f.mask & (back_truck | front_truck)) && f.settled && !f.upside_down;
|
|
f.tipped = on_one_truck && f.landed ? (std::min)(1.0f, f.tipped + dt / 0.18f) : on_one_truck ? f.tipped : 0.0f;
|
|
const auto tip = on_root(deck_turn, f.mask & back_truck ? tipped_on_axle(back_axle, 1.0f, back_tip * f.tipped)
|
|
: tipped_on_axle(front_axle, -1.0f, front_tip * f.tipped));
|
|
// Where that is from the board's own place.
|
|
const auto back_to_board = conj(board_facing);
|
|
const auto lies_facing = unit(back_to_board * f.facing);
|
|
const auto lies_at = turn(back_to_board, f.at - board_at);
|
|
piece = {unit(lies_facing * tip.facing), lies_at + turn(lies_facing, tip.at)};
|
|
if (f.mask & back_truck) carry_truck(buffer, piece, back_truck_bone, back_truck_place);
|
|
if (f.mask & front_truck) carry_truck(buffer, piece, front_truck_bone, front_truck_place);
|
|
return f.mask & stretches;
|
|
}
|
|
|
|
// ---- what the hook writes ------------------------------------------------------------------------
|
|
// The local board, broken: the real deck goes away, the trucks come back (their baseplates with
|
|
// them), each stretch goes to where its piece lies and one that belongs to no piece goes away too.
|
|
void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, Hinge hinge, float angle) {
|
|
const auto buffer = board_pose(base, holder);
|
|
PoseBone root{}, mount{}, deck{};
|
|
if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount) ||
|
|
!load_bone(bone_at(buffer, deck_bone), deck))
|
|
return;
|
|
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));
|
|
if (layout.back_truck) carry_truck(buffer, layout.back_motion, back_truck_bone, back_truck_place);
|
|
if (layout.front_truck) carry_truck(buffer, layout.front_motion, front_truck_bone, front_truck_place);
|
|
if (hinge == Hinge::none) {
|
|
Rigid piece{};
|
|
const auto flying = fly_piece(buffer, piece);
|
|
for (std::size_t i = 0; i < layout.stretch.size(); ++i)
|
|
if (flying & (1u << i)) layout.stretch[i] = {true, piece};
|
|
}
|
|
const auto bones = bones_of(layout.stretch);
|
|
for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at);
|
|
}
|
|
// A whole board, any player's: its four stretches go far away from it, and its baseplates sit on
|
|
// the deck.
|
|
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);
|
|
}
|
|
|
|
// ---- 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 (...) {}
|
|
}
|
|
// Written on a thread of its own: this is called from the client tick every few seconds while
|
|
// wear changes, and a write (a folder lookup, a file truncated and written, whatever the virus
|
|
// scanner does on close) held the game's frame up (2026-10-15). The text is taken under the lock;
|
|
// the writes are kept in order by their own.
|
|
void save_feel(Feel& f) {
|
|
try {
|
|
std::ostringstream out;
|
|
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;
|
|
std::thread([text = out.str()] {
|
|
static std::mutex writing;
|
|
std::lock_guard lock(writing);
|
|
try {
|
|
const auto path = settings_path();
|
|
if (path.empty()) return;
|
|
std::error_code error;
|
|
std::filesystem::create_directories(path.parent_path(), error);
|
|
std::ofstream file(path, std::ios::trunc);
|
|
file << text;
|
|
} catch (...) {}
|
|
}).detach();
|
|
} 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 minute 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);
|
|
// (A minute: each look lists every mod's folder, and the world model asked every few seconds
|
|
// on the client thread while a breakable deck was out.)
|
|
if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 60000) 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;
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
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<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);
|
|
// Told as it gets close, so a break that follows is no surprise.
|
|
if (condition_name(worn) == "beat")
|
|
overlay::notify(overlay::NoticeLevel::info, "Your board is beat",
|
|
"It breaks from smaller slams now. New board: ReSkate menu (Insert) > Addons > Board Break.");
|
|
else if (condition_name(worn) == "done")
|
|
overlay::notify(overlay::NoticeLevel::warning, "Your board is done",
|
|
"The next big drop snaps it. New board: ReSkate menu (Insert) > Addons > Board Break.");
|
|
}
|
|
}
|
|
// 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);
|
|
}
|
|
} // namespace
|
|
|
|
std::optional<float> handling_value(std::uint16_t offset) noexcept {
|
|
const auto& t = tuned();
|
|
if (!t.known || !t.wrote) return {};
|
|
for (std::size_t k = 0; k < t.offset.size(); ++k)
|
|
if (t.offset[k] == offset) return t.written[k];
|
|
return {};
|
|
}
|
|
|
|
namespace {
|
|
|
|
// ---- 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);
|
|
// A done board gives way on a landing hard enough for how worn it is.
|
|
if (can_break && rolling && wear_on && !s.landing_give.load(std::memory_order_acquire) &&
|
|
gives_way_on_landing(down, s.sensitivity.load(std::memory_order_relaxed), s.wear_now.load(std::memory_order_relaxed))) {
|
|
s.landing_speed.store(down, std::memory_order_relaxed);
|
|
s.landing_give.store(true, std::memory_order_release);
|
|
}
|
|
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) < 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, deck_now(s)};
|
|
s.back_since = 0;
|
|
crack_sound(break_size(place, joined, s.angle));
|
|
say(logging::Level::info, "the board broke at the {}{} ({}).", place_names[static_cast<std::size_t>(place)],
|
|
joined ? ", held together by the grip tape" : "", why);
|
|
return true;
|
|
}
|
|
void mend(State& s, std::string_view why) {
|
|
if (!s.broken) return;
|
|
say(logging::Level::debug, "the board is whole again after {:.1f} s ({}).", (GetTickCount64() - s.broken->since) / 1000.0, why);
|
|
// 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 {
|
|
bool known{};
|
|
float distance{}, upright{};
|
|
bool in_hand() const noexcept { return known && distance < hand_reach && upright < hand_upright; }
|
|
};
|
|
Hold board_hold(std::uintptr_t base, std::uintptr_t client) {
|
|
Hold out;
|
|
const auto frame = multiplayer::capture_local(base, client, true);
|
|
if (!frame.ready || frame.pose.board.empty()) return out;
|
|
const auto& skater = frame.pose.root.position;
|
|
const auto& board = frame.pose.board.front();
|
|
out.distance = std::hypot(board.position[0] - skater[0], board.position[1] - skater[1], board.position[2] - skater[2]);
|
|
const auto& q = board.rotation; // x, y, z, w: the board's up axis, turned into the world
|
|
out.upright = std::abs(1.0f - 2.0f * (q[0] * q[0] + q[2] * q[2]));
|
|
out.known = std::isfinite(out.distance) && std::isfinite(out.upright);
|
|
return out;
|
|
}
|
|
bool riding(std::uint32_t physics) noexcept {
|
|
// Ground/air and grind families; 300 is the wipeout, 5xx is on foot and getting on.
|
|
return (physics >= 100 && physics < 300) || (physics >= 400 && physics < 500);
|
|
}
|
|
// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next"
|
|
// for the one after the deck in the game. Empty when there is no such deck.
|
|
std::string deck_meant(std::string wanted, const std::string& in_game, const std::vector<std::string>& installed) {
|
|
for (auto& c : wanted) c = c == ' ' ? '_' : static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
if (installed.empty()) return {};
|
|
if (wanted.empty() || wanted == "next") {
|
|
const auto now = std::find(installed.begin(), installed.end(), in_game);
|
|
return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1);
|
|
}
|
|
if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) {
|
|
// The decks the feature was made with keep their numbers whether they are installed or not;
|
|
// the others follow, in the order they are listed.
|
|
const auto number = static_cast<std::size_t>(std::stoi(wanted));
|
|
const auto& known = mods::board_break_decks;
|
|
if (number >= 1 && number <= known.size()) return std::string(known[number - 1]);
|
|
std::size_t next = known.size();
|
|
for (const auto& deck : installed)
|
|
if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck;
|
|
return {};
|
|
}
|
|
return wanted;
|
|
}
|
|
// What the hook is told this tick; `holder` 0 leaves the local board to be treated as any other.
|
|
void publish(State& s, std::uintptr_t base, std::uintptr_t client, std::uintptr_t holder) {
|
|
auto& edit = render_edit();
|
|
const bool broken = s.broken && holder;
|
|
edit.park.store(s.has_deck.load(std::memory_order_acquire), std::memory_order_release);
|
|
edit.angle.store(s.angle, std::memory_order_release);
|
|
edit.hinge.store(static_cast<int>(broken ? s.parts.hinge : Hinge::none), std::memory_order_release);
|
|
edit.kept.store(broken ? s.parts.kept : 0, std::memory_order_release);
|
|
edit.holder.store(holder, std::memory_order_release);
|
|
// The broken-off piece: one flight per break, gone when the board is whole again.
|
|
auto& f = flight();
|
|
std::lock_guard lock(f.mutex);
|
|
if (!broken || !s.parts.loose) f.id = 0;
|
|
else if (f.id != s.flights) {
|
|
const auto frame = multiplayer::capture_local(base, client, true);
|
|
const auto roll = [&](float low, float high) { return std::uniform_real_distribution<float>(low, high)(s.random); };
|
|
const bool loose_end = !(s.parts.loose & (front_truck | back_truck)); // a nose or tail, no truck on it
|
|
const float along = s.parts.loose & nose ? 1.0f : -1.0f; // the way it leaves, along the board
|
|
const float push = loose_end ? 1.0f : 0.55f;
|
|
f.mask = s.parts.loose;
|
|
f.ground = frame.ready ? frame.pose.root.position[1] : 0.0f;
|
|
f.rest = loose_end ? -0.083f : 0.0f; // a bare piece lies on its underside, one with a truck stands on its wheels
|
|
f.ridden = s.break_ground;
|
|
f.kick = {roll(-0.9f, 0.9f) * push, (1.4f + roll(0.0f, 1.2f)) * push, along * (1.2f + roll(0.0f, 1.6f)) * push};
|
|
f.spin = {along * (5.0f + roll(0.0f, 6.0f)) * push, roll(-3.0f, 3.0f) * push, roll(-2.0f, 2.0f) * push};
|
|
f.id = s.flights;
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
bool start(std::uintptr_t base) noexcept {
|
|
auto& s = state();
|
|
if (s.ready.load(std::memory_order_acquire)) return true;
|
|
// Its official mod not installed, or turned off, in the launcher: no Board Break this launch, and
|
|
// nothing of it shows.
|
|
if (const auto mods_root = mods::engine_data_root() / mods::mods_folder; !mods::official_in_use(mods_root, mods::board_break_official)) {
|
|
s.turned_off.store(true, std::memory_order_release);
|
|
logging::write(logging::Level::info, logging::Channel::skater,
|
|
mods::official_off(mods_root, mods::board_break_official) ? "Board Break is off: its official mod is turned off in the launcher."
|
|
: "Board Break is off: its official mod is not installed (launcher, Mods, OFFICIAL).");
|
|
return false;
|
|
}
|
|
if (!no_bail_available()) {
|
|
logging::write(logging::Level::warning, logging::Channel::skater,
|
|
"Board Break cannot follow the skater: its slams come through No Bail's hooks, which did not start.");
|
|
return false;
|
|
}
|
|
s.base.store(base, std::memory_order_release);
|
|
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(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);
|
|
s.deck = in_game;
|
|
}
|
|
s.has_deck.store(!in_game.empty(), std::memory_order_release);
|
|
s.ready.store(true, std::memory_order_release);
|
|
logging::log(logging::Level::info, logging::Channel::skater, "Board Break ready ({}, deck: {}).", s.on.load() ? "on" : "off",
|
|
deck_label(in_game));
|
|
return true;
|
|
}
|
|
|
|
void note_cause(std::int32_t reason, float magnitude) noexcept {
|
|
auto& hit = last_cause();
|
|
const auto now = GetTickCount64();
|
|
// Keep the strongest of a burst: one slam records several causes.
|
|
if (now - hit.at.load(std::memory_order_relaxed) < 250 && magnitude <= hit.force.load(std::memory_order_relaxed)) return;
|
|
hit.force.store(magnitude, std::memory_order_relaxed);
|
|
hit.reason.store(reason, std::memory_order_relaxed);
|
|
hit.at.store(now, std::memory_order_release);
|
|
}
|
|
void note_wipeout(std::uintptr_t causes) noexcept {
|
|
std::array<std::uint8_t, cause_count> set{};
|
|
std::array<float, cause_count> force{};
|
|
auto& hit = last_wipeout();
|
|
std::int32_t reason{-1};
|
|
float strongest{};
|
|
if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force)))
|
|
for (std::size_t i = 0; i < cause_count; ++i)
|
|
if (set[i] && (reason < 0 || force[i] > strongest)) {
|
|
reason = static_cast<std::int32_t>(i);
|
|
strongest = force[i];
|
|
}
|
|
hit.force.store(strongest, std::memory_order_relaxed);
|
|
hit.reason.store(reason, std::memory_order_relaxed);
|
|
hit.at.store(GetTickCount64(), std::memory_order_release);
|
|
}
|
|
|
|
void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
|
auto& edit = render_edit();
|
|
// Without a breakable deck in the game the boards have no stretches: nothing to do.
|
|
if (!edit.park.load(std::memory_order_acquire) || animation_interface <= 0xc0) return;
|
|
const auto base = state().base.load(std::memory_order_acquire);
|
|
const auto holder = edit.holder.load(std::memory_order_acquire);
|
|
const auto kept = edit.kept.load(std::memory_order_acquire);
|
|
const auto error = GetLastError();
|
|
try {
|
|
if (kept && holder && animation_interface == holder + 0xc0)
|
|
draw_broken(base, holder, kept, static_cast<Hinge>(edit.hinge.load(std::memory_order_acquire)), edit.angle.load(std::memory_order_acquire));
|
|
else park_pieces(base, animation_interface - 0xc0);
|
|
} catch (...) { /* A board that is being built or torn down: the next frame. */ }
|
|
SetLastError(error);
|
|
}
|
|
|
|
void on_client_tick(std::uintptr_t client) noexcept {
|
|
auto& s = state();
|
|
if (!s.ready.load(std::memory_order_acquire)) return;
|
|
const auto base = s.base.load(std::memory_order_acquire);
|
|
try {
|
|
const auto now = GetTickCount64();
|
|
// The deck in the game: the launch's, or the one the last live apply of the mods put there.
|
|
const auto deck_in_game = mods::board_break_in_play();
|
|
const bool in_game = !deck_in_game.empty();
|
|
std::string wanted;
|
|
bool changed{};
|
|
{
|
|
std::lock_guard lock(s.names);
|
|
changed = s.deck != deck_in_game;
|
|
s.deck = deck_in_game;
|
|
wanted.swap(s.wanted_deck);
|
|
}
|
|
s.has_deck.store(in_game, std::memory_order_release);
|
|
if (changed || !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.
|
|
std::string detail;
|
|
s.hooked = multiplayer::install_entity_hooks(base, detail);
|
|
if (!s.hooked && now - s.last_error > 10000) {
|
|
s.last_error = now;
|
|
say(logging::Level::warning, "the boards' render pose hook is unavailable ({}).", detail);
|
|
}
|
|
}
|
|
NoBailSkater skater;
|
|
std::uintptr_t board{}, holder{};
|
|
if (in_game && no_bail_skater(skater)) {
|
|
const auto found = multiplayer::read_native_board(readable, base, skater.entity);
|
|
board = found.entity;
|
|
holder = found.holder;
|
|
}
|
|
if (!board) {
|
|
// No local skater or no board (a respawn, a level change): the next board is whole. A
|
|
// broken one that is only out of sight for a moment stays as it is.
|
|
if (s.broken && in_game && now - s.board_seen < 500) return;
|
|
mend(s, "the board is gone");
|
|
s.counted = false;
|
|
s.wanted_snap.store(-1, std::memory_order_release);
|
|
s.wanted_mend.store(false, std::memory_order_release);
|
|
publish(s, base, client, 0);
|
|
return;
|
|
}
|
|
s.board_seen = now;
|
|
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);
|
|
}
|
|
// A done board that landed too hard (the physics thread saw it): the nose goes, and they ride on.
|
|
if (s.landing_give.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken)
|
|
(void)snap(s, board, Place::nose, true, false,
|
|
std::format("a landing at {:.1f} m/s on a board that was done", s.landing_speed.load(std::memory_order_relaxed)));
|
|
watch_physics_state(client, skater.entity);
|
|
const auto watch = watched_physics_state();
|
|
if (s.wanted_mend.exchange(false, std::memory_order_acq_rel)) mend(s, "asked for");
|
|
const bool carried = watch.valid && watch.state >= 500 && watch.state < 600; // on foot and getting on
|
|
if (const int place = s.wanted_snap.exchange(-1, std::memory_order_acq_rel); place >= 0)
|
|
(void)snap(s, board, place < 3 ? std::optional(static_cast<Place>(place)) : std::nullopt, false, carried, "asked for", true);
|
|
if (watch.valid) {
|
|
if (watch.state == 100) {
|
|
// Rolling on the ground, the right way up: the board's own height is the ground's.
|
|
Vec at{};
|
|
Quat facing{};
|
|
if (board_place(base, holder, at, facing) && turn(facing, {0, 1, 0}).y > 0.9f) {
|
|
s.ride_height = at.y;
|
|
s.ride_when = now;
|
|
}
|
|
}
|
|
const bool on = s.on.load(std::memory_order_acquire);
|
|
if (!s.counted) {
|
|
s.wipeouts = watch.wipeouts;
|
|
s.counted = true;
|
|
}
|
|
if (watch.wipeouts != s.wipeouts) {
|
|
s.wipeouts = watch.wipeouts;
|
|
// The cause that made the selector choose Wipeout, or else the last one recorded.
|
|
auto* hit = &last_wipeout();
|
|
if (hit->reason.load() < 0 || now - hit->at.load() > recent_ms) hit = &last_cause();
|
|
const bool known = now - hit->at.load() <= recent_ms && hit->reason.load() >= 0;
|
|
const auto force = known ? hit->force.load() : 0.0f;
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (s.broken) {
|
|
// Whole again once the skater rides it, or has picked it up after the fall. A board that
|
|
// gave way under the rider stays as it is while it is ridden; after a fall it is a
|
|
// broken board like any other.
|
|
const auto hold = board_hold(base, client);
|
|
const bool on_it = riding(watch.state);
|
|
if (s.broken->gave_way && watch.state == addr::no_bail::wipeout_physics_state) s.broken->gave_way = false;
|
|
const bool back = s.broken->gave_way ? !on_it && hold.in_hand()
|
|
: on_it || (watch.state != addr::no_bail::wipeout_physics_state && hold.in_hand());
|
|
// A respawn gives the skater a new physics selector, which the watch reports as state 0
|
|
// until it has chosen once: the board is whole from that moment.
|
|
if (watch.state == 0 && now - s.broken->since > 300) mend(s, "a respawn");
|
|
else if (s.broken->asked) s.back_since = 0;
|
|
else if (!back) s.back_since = 0;
|
|
else if (!s.back_since) s.back_since = now;
|
|
else if (now - s.back_since > 300 && now - s.broken->since > 1200) mend(s, on_it ? "riding again" : "back in hand");
|
|
}
|
|
}
|
|
publish(s, base, client, holder);
|
|
} catch (const std::exception& error) {
|
|
// The skater or its board is being rebuilt: tried again at a later tick.
|
|
const auto now = GetTickCount64();
|
|
if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) {
|
|
s.last_error = now;
|
|
say(logging::Level::debug, "waiting ({}).", error.what());
|
|
}
|
|
} catch (...) { /* A broken board is never worth the client tick. */ }
|
|
}
|
|
|
|
bool available() noexcept { return state().ready.load(std::memory_order_acquire); }
|
|
bool turned_off() noexcept { return state().turned_off.load(std::memory_order_acquire); }
|
|
bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); }
|
|
void set_enabled(bool enabled) noexcept {
|
|
state().on.store(enabled, std::memory_order_release);
|
|
profile_runtime::set_local_preference(enabled_preference, enabled);
|
|
}
|
|
std::string deck() {
|
|
auto& s = state();
|
|
std::lock_guard lock(s.names);
|
|
return s.deck;
|
|
}
|
|
std::string deck_label(std::string_view deck) {
|
|
if (deck.empty()) return "None";
|
|
if (auto title = setup_of(deck).label; !title.empty()) return title;
|
|
std::string name(deck);
|
|
bool start = true;
|
|
for (auto& c : name) {
|
|
if (c == '_') c = ' ';
|
|
else if (start) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
|
start = c == ' ';
|
|
}
|
|
return name;
|
|
}
|
|
std::string choose_deck(std::string wanted) {
|
|
auto& s = state();
|
|
if (!available()) return "Board Break is unavailable for this game build.";
|
|
const auto installed = mods::installed_board_break_decks();
|
|
if (installed.empty()) return "None of Board Break's decks is installed.";
|
|
const auto in_game = deck();
|
|
const auto chosen = deck_meant(wanted, in_game, installed);
|
|
if (chosen.empty()) return "There is no deck " + wanted + ".";
|
|
if (std::find(installed.begin(), installed.end(), chosen) == installed.end())
|
|
return "The " + deck_label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder.";
|
|
if (chosen == in_game) return "The " + deck_label(chosen) + " deck is the one in the game.";
|
|
{
|
|
std::lock_guard lock(s.names);
|
|
s.wanted_deck = chosen;
|
|
}
|
|
return "The " + deck_label(chosen) + " deck: the mods are applied and the level loads again with it.";
|
|
}
|
|
void snap(int place) noexcept { state().wanted_snap.store(place >= 0 && place < 3 ? place : 3, std::memory_order_release); }
|
|
void mend() noexcept { state().wanted_mend.store(true, std::memory_order_release); }
|
|
|
|
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
|