Files
ReSkate/Extension/RoadRash/road_rash.cpp
T
AyeZeeBB ceb21e0b92 Official mods: installed by the player on the OFFICIAL tab instead of downloaded on every launch
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.
2026-10-17 21:39:50 -05:00

382 lines
18 KiB
C++

#include "road_rash.h"
#include "road_rash_model.h"
#include "skater_items.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/20260929/no_bail.h"
#include "Engine/Vfs/builtin_packs.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Extension/HallOfMeat/hall_of_meat_skater.h"
#include "Extension/Multiplayer/Hud/game_ui_state.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/Skater/no_bail.h"
#include <Windows.h>
#include <atomic>
#include <bit>
#include <format>
#include <map>
#include <optional>
#include <string>
namespace dingosdk::road_rash {
namespace {
constexpr const char* enabled_preference = "RoadRash";
constexpr const char* blood_preference = "RoadRashBlood";
constexpr const char* tattoos_preference = "RoadRashCoversTattoos";
// Part of the name of the recipe slot each part's item goes into.
constexpr std::array<std::string_view, part_count> slot_names{"tattoo_legl", "tattoo_arml", "tattoo_legr", "tattoo_armr", "moles"};
constexpr std::uint64_t fall_at_most = 3000, fall_at_least = 400; // milliseconds a fall is watched for its side
struct State {
// Any thread.
std::atomic<bool> ready{}, on{true}, blood{true}, covers{}, turned_off{};
std::atomic<bool> heal{}, changed{}; // asked for from another thread, done at the next tick
std::atomic<int> wanted_bails{-1};
std::atomic<unsigned> bails{};
// Client thread from here on.
Tally tally;
std::array<std::size_t, part_count> designs{};
bool designs_drawn{};
std::uint64_t chance{};
// The bail being watched for its side.
bool counted{};
std::uint64_t wipeouts{}, last_bail{};
std::optional<std::uint64_t> fall_since;
Fall fall;
// What is on the skater.
std::uintptr_t skater{};
bool dirty{true}, worn{}, missing_told{};
bool own{}; // it wears items of ours, as a rule in a recipe of its own: the game passes
// nothing of the outfit on to it then
bool afresh{}; // the marks are to be worked out from the outfit again
std::optional<Feed> feed; // how the game feeds the skater its outfit, as last seen
std::uintptr_t component{}; // the item component that was given a recipe
std::uint64_t checked{}, last_error{}, given_back{}, menu_seen{};
std::uintptr_t slots_of{};
std::array<int, part_count> slots{};
std::map<std::string, std::string> items; // wanted name -> installed item, empty when missing
};
State& state() {
static auto* value = new State;
return *value;
}
template <class... Args> void say(logging::Level level, std::format_string<Args...> text, Args&&... args) {
logging::log(level, logging::Channel::skater, "Road Rash: {}", std::format(text, std::forward<Args>(args)...));
}
bool ours(const std::string& asset) { return lower(asset).find(item_prefix) != std::string::npos; }
// A slot with nothing to see in it: empty, or the game's own "none" item of a face layer.
bool blank(const std::string& asset) { return asset.empty() || lower(asset).ends_with("_empty"); }
// Which design each part wears: drawn afresh whenever the skater is clean.
void draw_designs(State& s) {
if (!s.chance) {
LARGE_INTEGER now{};
QueryPerformanceCounter(&now);
s.chance = static_cast<std::uint64_t>(now.QuadPart) | 1;
}
for (auto& design : s.designs) {
s.chance ^= s.chance << 13;
s.chance ^= s.chance >> 7;
s.chance ^= s.chance << 17;
design = static_cast<std::size_t>((s.chance >> 20) % design_count);
}
s.designs_drawn = true;
}
void clean(State& s) {
s.tally = {};
s.fall_since.reset();
s.fall = {};
s.bails.store(0, std::memory_order_release);
draw_designs(s);
s.dirty = s.afresh = true;
}
std::string levels(const Tally& tally) {
std::string out;
for (const auto part : parts) out += std::format("{}{} {}", out.empty() ? "" : ", ", part_names[index(part)], tally.levels[index(part)]);
return out;
}
void count_bail(State& s, Side side) {
const auto before = s.tally.levels;
count(s.tally, side);
s.bails.store(s.tally.bails, std::memory_order_release);
if (before != s.tally.levels) s.dirty = true;
if (logging::enabled(logging::Level::debug))
say(logging::Level::debug, "bail {} on {} (limbs hit: left {:.1f}, right {:.1f}): {}.", s.tally.bails,
side == Side::left ? "the left side" : side == Side::right ? "the right side" : "both sides", s.fall.left, s.fall.right,
levels(s.tally));
}
// The installed item called `name` at its end; empty when the content mod is not installed.
const std::string& item(State& s, const SkaterItems& m, const std::string& name) {
if (const auto known = s.items.find(name); known != s.items.end()) return known->second;
return s.items.emplace(name, installed_item(m, name)).first->second;
}
// The item a part wears at a level: of its own design or else the first, from its own set or else
// the unmirrored one, at that level or else the nearest lower one that is installed.
std::string wanted_item(State& s, const SkaterItems& m, Part part, int& level) {
for (; level > 0; --level)
for (const auto design : {s.designs[index(part)], std::size_t{0}})
for (const bool mirrored : {part == Part::right_leg, false}) {
const auto& found = item(s, m, item_name(part, mirrored, design, level, s.blood.load(std::memory_order_acquire)));
if (!found.empty()) return found;
}
return {};
}
// The recipe slot of each part, by the slot's name in the skater's recipe template.
void find_slots(State& s, const SkaterItems& m, std::uintptr_t resource) {
s.slots.fill(-1);
const auto slots = m.ptr(resource, 0x28);
const auto count = m.count(slots, 24, multiplayer::max_cosmetic_slots);
for (std::size_t i = 0; i < count; ++i) {
const auto slot = m.ptr(slots, i * 24 + 8) & ~std::uintptr_t{4};
const auto name = lower(m.text(m.ptr(slot, 0x20)));
for (std::size_t part = 0; part < part_count; ++part)
if (s.slots[part] < 0 && name.find(slot_names[part]) != std::string::npos) s.slots[part] = static_cast<int>(i);
}
s.slots_of = resource;
}
// Makes the skater wear the marks of its levels, one item per part in the part's own slot, over
// the outfit it wears. Throws while the skater is still being built: tried again.
void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) {
const auto component = m.component(skater);
auto recipe = m.capture(skater);
const auto resource = m.resource(component);
if (s.slots_of != resource) find_slots(s, m, resource);
bool marked{};
for (const auto& item : recipe.items) marked |= ours(item.asset);
// Whose recipe the skater wears is read from the skater: a respawn hands over what looks like a
// new one and is the same, items and all.
if (const auto fed = feed(m, component); fed.follows_outfit()) {
// The game feeds it its outfit: a new skater, or one given back, which holds our items until
// the game has put the outfit's own back.
SkaterItems::check(!marked || now - s.given_back > 3000, "Waiting for the game to put the outfit back.");
s.own = marked;
s.feed = fed;
} else {
// A recipe of its own: ours when our items are in it or it went to this component. Whose
// else it could be is not ours to change.
SkaterItems::check(s.feed && (marked || component == s.component), "The skater does not follow its outfit: left alone.");
s.own = true;
}
const bool covers = s.covers.load(std::memory_order_acquire);
bool changed{}, missing{}, worn{};
for (const auto part : parts) {
const auto i = index(part);
if (s.slots[i] < 0 || static_cast<std::size_t>(s.slots[i]) >= recipe.items.size()) continue;
auto& slot = recipe.items[static_cast<std::size_t>(s.slots[i])];
int level = s.tally.levels[i];
const auto wanted = level ? wanted_item(s, m, part, level) : std::string{};
if (s.tally.levels[i] && wanted.empty()) missing = true;
// The player's own tattoo or moles stay, unless the wounds may cover them.
if (wanted.empty() || (!ours(slot.asset) && !blank(slot.asset) && !covers)) continue;
worn = true;
auto parameters = slot.parameters;
if (part != Part::face && parameters.size() == std::tuple_size_v<Placement>) {
const auto place = placement(part, level);
for (std::size_t k = 0; k < place.size(); ++k) parameters[k] = std::bit_cast<std::uint32_t>(place[k]);
}
if (slot.asset == wanted && same_parameters(slot.parameters, parameters)) continue;
slot.asset = wanted;
slot.parameters = std::move(parameters);
changed = true;
}
if (missing && !s.missing_told) {
s.missing_told = true;
say(logging::Level::info, "the skater has marks to show, but the Road Rash items are not installed (a content mod holds them).");
}
s.worn = worn;
if (!changed) return;
apply(m, component, recipe);
s.own = true;
s.component = component;
say(logging::Level::debug, "marks changed after {} bail(s): {}.", s.tally.bails, levels(s.tally));
}
// Gives the skater back to the game: the outfit's own items return, the player's own tattoos and
// moles with them, and what the player changes in the game's menus reaches the skater again.
// Throws while its items are updating: tried again.
void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now, const char* why) {
const auto component = m.component(skater);
// One the game feeds already only has to put the outfit's own items back.
if (const auto fed = feed(m, component); fed.follows_outfit() || s.feed) {
hand_back(m, component, fed.follows_outfit() ? fed : *s.feed);
s.given_back = now;
say(logging::Level::debug, "the skater is the game's again ({}).", why);
}
s.own = s.worn = false;
}
// The fall: its side from what the limbs hit, for as long as the body is a ragdoll.
void watch_fall(State& s, const NoBailSkater& skater, std::uint64_t now, bool wiping_out) {
bool ragdoll = wiping_out;
if (hall_of_meat::skater_available()) {
const auto step = hall_of_meat::read_step(skater, 0.0f, false);
add(s.fall, step.body);
ragdoll = step.ragdoll.value_or(wiping_out) || wiping_out;
}
const auto lasted = now - *s.fall_since;
if (lasted < fall_at_least || (ragdoll && lasted < fall_at_most)) return;
count_bail(s, side(s.fall));
s.fall_since.reset();
s.fall = {};
}
}
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 Road Rash 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::road_rash_official)) {
s.turned_off.store(true, std::memory_order_release);
logging::write(logging::Level::info, logging::Channel::skater,
mods::official_off(mods_root, mods::road_rash_official) ? "Road Rash is off: its official mod is turned off in the launcher."
: "Road Rash is off: its official mod is not installed (launcher, Mods, OFFICIAL).");
return false;
}
constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix;
std::array<unsigned char, prefix.size()> actual{};
if (!memory::peek(base + addr::native_cosmetics::recipe_copy, actual) || actual != prefix) {
logging::write(logging::Level::warning, logging::Channel::skater,
"Road Rash cannot change the skater's items: the native recipe copy did not match.");
return false;
}
if (!no_bail_available()) {
logging::write(logging::Level::warning, logging::Channel::skater,
"Road Rash cannot follow the skater: its bails come through No Bail's hooks, which did not start.");
return false;
}
s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release);
s.blood.store(profile_runtime::local_preference(blood_preference).value_or(true), std::memory_order_release);
s.covers.store(profile_runtime::local_preference(tattoos_preference).value_or(false), std::memory_order_release);
s.ready.store(true, std::memory_order_release);
logging::log(logging::Level::info, logging::Channel::skater, "Road Rash ready ({}, blood {}).", s.on.load() ? "on" : "off",
s.blood.load() ? "on" : "off");
return true;
}
void on_client_tick(std::uintptr_t client) noexcept {
auto& s = state();
if (!s.ready.load(std::memory_order_acquire)) return;
try {
if (!s.designs_drawn) draw_designs(s);
if (s.heal.exchange(false, std::memory_order_acq_rel)) clean(s);
if (s.changed.exchange(false, std::memory_order_acq_rel)) s.dirty = s.afresh = true;
if (const int wanted = s.wanted_bails.exchange(-1, std::memory_order_acq_rel); wanted >= 0) {
clean(s);
s.tally.bails = static_cast<unsigned>(wanted);
s.tally.left = s.tally.right = static_cast<float>(wanted) / 2;
grow(s.tally);
s.bails.store(s.tally.bails, std::memory_order_release);
}
NoBailSkater skater;
if (!no_bail_skater(skater)) {
// No local skater (a respawn, a level change). After a level change the game builds the
// next one from the profile, with nothing of ours on it; the count stays, so the marks
// go back on.
s.skater = 0;
s.fall_since.reset();
s.fall = {};
s.counted = false;
s.slots_of = 0;
s.dirty = true;
return;
}
if (skater.entity != s.skater) {
s.skater = skater.entity;
s.dirty = true;
}
const bool on = s.on.load(std::memory_order_acquire);
if (!on && !s.own) return; // switched off, and the skater is the game's: nothing to do
const auto now = GetTickCount64();
bool down{};
if (on) {
watch_physics_state(client, skater.entity);
const auto watch = watched_physics_state();
if (watch.valid) {
const bool wiping_out = watch.state == addr::no_bail::wipeout_physics_state;
if (!s.counted) {
s.wipeouts = watch.wipeouts;
s.counted = true;
}
// One slam can enter the wipeout more than once.
if (watch.wipeouts != s.wipeouts) {
s.wipeouts = watch.wipeouts;
if (!s.fall_since && now - s.last_bail > 1500) {
s.last_bail = now;
s.fall_since = now;
s.fall = {};
}
}
if (s.fall_since) watch_fall(s, skater, now, wiping_out);
down = wiping_out && now - s.last_bail < 6000;
}
} else {
s.fall_since.reset();
}
bool marks{};
if (on)
for (const int level : s.tally.levels) marks |= level > 0;
// The game's menus change the outfit, and a skater that wears a recipe of ours gets none of
// it. So while a menu is up the skater is the game's, as it is with nothing to show or when
// the marks are worked out again; they go back on, over the outfit as it is by then.
const char* why = !marks ? "nothing to show" : s.afresh ? "the marks are worked out again" : nullptr;
if (!why && multiplayer::sample_game_ui_state(skater.base).in_menu) {
why = "a game menu is up";
s.menu_seen = now;
}
if (why) {
if (s.own) give_back(s, SkaterItems{readable, skater.base}, skater.entity, now, why);
s.afresh = false;
s.dirty = true;
return;
}
// New marks go on once the skater is up again, and not before the game has had a second for
// what it still does to the skater as a menu closes. While some are on, a look every few
// seconds whether the game has put the outfit's items over them or built the skater anew.
if (down || s.fall_since || now - s.given_back < 300 || now - s.menu_seen < 1000 ||
!(s.dirty || (s.worn && now - s.checked > 3000)))
return;
s.checked = now;
wear(s, SkaterItems{readable, skater.base}, skater.entity, now);
s.dirty = false;
} catch (const std::exception& error) {
// The skater is being rebuilt, or the catalog is not ready: 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 (...) { /* Marks are 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); }
bool blood() noexcept { return state().blood.load(std::memory_order_acquire); }
bool covers_tattoos() noexcept { return state().covers.load(std::memory_order_acquire); }
void set_enabled(bool enabled) noexcept {
auto& s = state();
s.on.store(enabled, std::memory_order_release);
// Off: the marks go, and so does the count.
(enabled ? s.changed : s.heal).store(true, std::memory_order_release);
profile_runtime::set_local_preference(enabled_preference, enabled);
}
void set_blood(bool blood) noexcept {
auto& s = state();
s.blood.store(blood, std::memory_order_release);
s.changed.store(true, std::memory_order_release);
profile_runtime::set_local_preference(blood_preference, blood);
}
void set_covers_tattoos(bool covers) noexcept {
auto& s = state();
s.covers.store(covers, std::memory_order_release);
s.changed.store(true, std::memory_order_release);
profile_runtime::set_local_preference(tattoos_preference, covers);
}
void heal() noexcept { state().heal.store(true, std::memory_order_release); }
void set_bails(unsigned count) noexcept { state().wanted_bails.store(static_cast<int>((std::min)(count, 1000u)), std::memory_order_release); }
unsigned bails() noexcept { return state().bails.load(std::memory_order_acquire); }
}