mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 09:05:15 +02:00
The launcher still reads the list at start and looks up each mod's latest version, moves in a copy installed from Thunderstore and takes away retired mods, but it downloads nothing by itself. Each card on the OFFICIAL tab says NOT INSTALLED, INSTALLED, UPDATE AVAILABLE, DOWNLOADING or UPDATING, with an INSTALL or UPDATE TO vX button (official_mods::install, on a thread of its own), OPEN FOLDER once installed, and the On / Off switch beside its pills. The banner counts how many are installed. Play waits for an install under way. Board Break and Road Rash start only when their official mod is installed and on (mods::official_in_use), and say in the log which it is not; the menus leave them out otherwise, as when turned off. The live test (RESKATE_OFFICIAL_LIVE) checks that nothing downloads until asked, then installs each.
1339 lines
70 KiB
C++
1339 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);
|
|
}
|
|
|
|
// ---- 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
|