From 1b0bb2b12f106ec67df36a959338b54a16f4ea23 Mon Sep 17 00:00:00 2001 From: Fourth Studio Date: Sat, 10 Oct 2026 19:55:29 +0200 Subject: [PATCH] Road Rash: bails leave marks on the skater that build up After 10 bails the leg of the side that took more of them shows road dust and a graze, its arm one bail later; at 14 the marks are bigger and the other side starts; at 20 they are raw wounds and the face is bruised. A mark never gets smaller until the player heals. The worst wounds bleed, or are dry and scabbed with the blood switch off. The marks are cosmetic items of a content mod (tattoo items for the limbs, moles items for the face, named RoadRash__[_]). Road Rash puts them into the local skater's recipe with the game's own recipe copy, after the validation the multiplayer code runs on a peer's outfit. Nothing is written to the outfit or the profile's skater, and without the item pack nothing shows and nothing is changed. A bail is the physics state selector choosing Wipeout (No Bail's watch). Its side comes from the ragdoll's contacts through Hall of Meat's reader: impact and slide of the arm and leg bodies that touch something other than the skater's own board, summed per side while the body is a ragdoll. Outfit menus. The game feeds a skater's items either from the player's outfit or from a recipe of the skater's own: after the recipe copy the skater in the world no longer gets what the player changes in the menus. So the skater is given back to the game while a game menu is up, with nothing to show, on healing, when a switch changes and when switched off: the four words of the item component the copy had changed go back to what they were and the component is told its feed changed, and the game puts the outfit's own items back and feeds the skater again. The marks go back on, over the outfit as it is by then, a second after the menu has gone. Whose recipe a skater wears is read from the skater at every look: a respawn hands over the same skater, items and all, as if it were new. The player's own tattoos and moles stay unless roadrashtattoos is on, and come back the same way. The recipe handed to the copy names no item, only hashes, like the game's own for the local skater: the game changes that skater's items where they lie, and a name left beside one would stop every later read of the recipe, the multiplayer code's too. Console: roadrash, roadrashblood, roadrashtattoos, roadrash heal, roadrash bails . Menus: Road Rash, Road Rash blood and Heal Road Rash beside Hall of Meat (Mod Options > Player, the overlay's skater page, the native tools view). The three choices are saved with the profile. No new hooks. The rules (side of a fall, levels per count, item names, decal placement, comparing parameters the game has rounded) are in road_rash_model.h with their own test target, DINGOSDK_BUILD_ROAD_RASH_TESTS. Notes and what is not tried yet: contrib/road-rash.md. --- CMakeLists.txt | 8 + Extension/Console/commands.cpp | 1 + Extension/Console/commands.h | 1 + Extension/RoadRash/Test/road_rash_tests.cpp | 160 ++++++++ Extension/RoadRash/road_rash.cpp | 369 ++++++++++++++++++ Extension/RoadRash/road_rash.h | 50 +++ Extension/RoadRash/road_rash_commands.cpp | 49 +++ Extension/RoadRash/road_rash_model.h | 166 ++++++++ Extension/RoadRash/skater_items.h | 125 ++++++ .../Test/native_tools_view_tests.cpp | 16 + Extension/UI/NativeMenu/native_tools_view.h | 11 + Extension/UI/Overlay/hub_page.cpp | 3 + Extension/UI/Overlay/overlay.h | 7 + Extension/UI/Overlay/skate_menu_skater.cpp | 9 + Runtime/client_tick.cpp | 3 + Runtime/runtime_init.cpp | 3 + cmake/Runtime.cmake | 2 + contrib/road-rash.md | 95 +++++ 18 files changed, 1078 insertions(+) create mode 100644 Extension/RoadRash/Test/road_rash_tests.cpp create mode 100644 Extension/RoadRash/road_rash.cpp create mode 100644 Extension/RoadRash/road_rash.h create mode 100644 Extension/RoadRash/road_rash_commands.cpp create mode 100644 Extension/RoadRash/road_rash_model.h create mode 100644 Extension/RoadRash/skater_items.h create mode 100644 contrib/road-rash.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 22c3319..c3bb7b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,14 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives) endif() +option(DINGOSDK_BUILD_ROAD_RASH_TESTS "Build Road Rash regression tests" OFF) +if(DINGOSDK_BUILD_ROAD_RASH_TESTS) + enable_testing() + # The side of a fall, the marks a count of bails leaves, the items' names and where a decal goes. + add_executable(dingosdk_road_rash_tests Extension/RoadRash/Test/road_rash_tests.cpp) + target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME road_rash COMMAND dingosdk_road_rash_tests) +endif() option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS) enable_testing() diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 7103eb5..085a680 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -225,6 +225,7 @@ const Commands &game_commands() { register_perf_commands(*result); register_trainer_commands(*result); register_hall_of_meat_commands(*result); + register_road_rash_commands(*result); return result; }(); return *registry; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 1afee71..ed5cdf8 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -24,6 +24,7 @@ void register_multiplayer_commands(Commands &); void register_perf_commands(Commands &); void register_trainer_commands(Commands &); void register_hall_of_meat_commands(Commands &); +void register_road_rash_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. void request_debug(overlay::DebugAction, bool enabled = false, float value = 0); void request_feature(overlay::OfflineFeatureGroup, bool enabled); diff --git a/Extension/RoadRash/Test/road_rash_tests.cpp b/Extension/RoadRash/Test/road_rash_tests.cpp new file mode 100644 index 0000000..f133c00 --- /dev/null +++ b/Extension/RoadRash/Test/road_rash_tests.cpp @@ -0,0 +1,160 @@ +// Road Rash's rules, with made-up falls: the side of a fall, the marks a count of bails leaves, the +// names of the items that draw them and where a limb's decal goes. +#include "Extension/RoadRash/road_rash_model.h" +#include +#include +#include +#include +#include +#include +#include + +using namespace dingosdk; +using namespace dingosdk::road_rash; +using skater_body::Bone; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +bool near(float a, float b) { return std::abs(a - b) < 1e-4f; } + +// One physics step in which `bone` hits the ground this hard and slides along it this fast. +skater_body::Contacts hit(Bone bone, float impact, float slide = 0.0f) { + skater_body::Contacts contacts; + auto& body = contacts.bodies[skater_body::index(bone)]; + body.touching = true; + body.impact = impact; + body.slide = {slide, 0.0f, 0.0f}; + body.hit.world = true; + return contacts; +} +int level(const Tally& tally, Part part) { return tally.levels[index(part)]; } +Tally after(unsigned bails, Side side) { + Tally tally; + for (unsigned i = 0; i < bails; ++i) count(tally, side); + return tally; +} + +void a_fall_is_on_the_side_whose_limbs_took_it() { + Fall fall; + check(side(fall) == Side::both, "A fall nothing felt is on neither side"); + add(fall, hit(Bone::left_forearm, 3.0f, 2.0f)); + add(fall, hit(Bone::left_upleg, 2.0f)); + add(fall, hit(Bone::right_hand, 1.0f)); + check(near(fall.left, 7.0f) && near(fall.right, 1.0f), "Impact and slide of each side's limbs add up"); + check(side(fall) == Side::left, "The left limbs took most of it"); + Fall other; + add(other, hit(Bone::right_leg, 4.0f)); + add(other, hit(Bone::right_arm, 1.0f, 3.0f)); + check(side(other) == Side::right, "and the right ones of this one"); + Fall even; + add(even, hit(Bone::left_leg, 3.0f)); + add(even, hit(Bone::right_leg, 2.6f)); + check(side(even) == Side::both, "A fall both sides felt alike is on both"); + Fall light; + add(light, hit(Bone::left_hand, 0.5f)); + check(side(light) == Side::both, "A touch is no fall on a side"); +} + +void feet_trunk_head_and_the_own_board_count_for_no_side() { + Fall fall; + for (const auto bone : {Bone::left_foot, Bone::left_toe, Bone::right_foot, Bone::right_toe, Bone::hips, Bone::spine2, Bone::neck1}) + add(fall, hit(bone, 9.0f, 9.0f)); + check(fall.left == 0.0f && fall.right == 0.0f, "Feet stand on the ground all the time; trunk and head have no side"); + auto board = hit(Bone::left_hand, 5.0f); + board.bodies[skater_body::index(Bone::left_hand)].hit = {}; + board.bodies[skater_body::index(Bone::left_hand)].hit.board = true; + add(fall, board); + check(fall.left == 0.0f, "A hand on the skater's own board hit nothing"); + auto untouched = hit(Bone::right_leg, 5.0f); + untouched.bodies[skater_body::index(Bone::right_leg)].touching = false; + add(fall, untouched); + check(fall.right == 0.0f, "A body that touches nothing hit nothing"); +} + +void marks_come_with_the_bails() { + check(after(9, Side::left).levels == std::array{}, "Nine bails leave no mark"); + const auto ten = after(10, Side::left); + check(level(ten, Part::left_leg) == 1 && level(ten, Part::left_arm) == 0 && level(ten, Part::right_leg) == 0, + "The tenth marks the leg of the side that took them"); + const auto eleven = after(11, Side::left); + check(level(eleven, Part::left_arm) == 1 && level(eleven, Part::right_leg) == 0, "the arm follows one bail later"); + const auto fourteen = after(14, Side::left); + check(level(fourteen, Part::left_leg) == 2 && level(fourteen, Part::right_leg) == 1 && level(fourteen, Part::right_arm) == 0, + "At fourteen the leg is worse and the other side's leg starts"); + const auto twenty = after(20, Side::left); + check(level(twenty, Part::left_leg) == 3 && level(twenty, Part::left_arm) == 2 && level(twenty, Part::face) == 1, + "At twenty the leg is at its worst and the face shows it"); + const auto all = after(25, Side::left); + check(level(all, Part::left_leg) == 3 && level(all, Part::left_arm) == 3 && level(all, Part::right_leg) == 3 && + level(all, Part::right_arm) == 3 && level(all, Part::face) == 2, "At twenty-five everything is at its worst"); + check(after(200, Side::both).levels == all.levels, "and stays there"); +} + +void the_side_that_took_more_goes_first_and_no_mark_gets_smaller() { + const auto right = after(10, Side::right); + check(level(right, Part::right_leg) == 1 && level(right, Part::left_leg) == 0, "Falls on the right mark the right leg first"); + // Ten on the left, then the right side overtakes it: the left keeps what it has. + auto tally = after(10, Side::left); + for (int i = 0; i < 11; ++i) count(tally, Side::right); + check(!left_first(tally) && tally.bails == 21, "The right side took more by now"); + check(level(tally, Part::right_leg) == 3 && level(tally, Part::left_leg) >= 2, "It is ahead now, and the left leg kept its marks"); + // Falls on both sides count half for each; the last one-sided fall breaks the tie. + Tally even; + count(even, Side::right); + count(even, Side::left); + for (int i = 0; i < 8; ++i) count(even, Side::both); + check(near(even.left, even.right) && left_first(even) && level(even, Part::left_leg) == 1 && level(even, Part::right_leg) == 0, + "A tie goes to the side of the last one-sided fall"); +} + +void items_are_named_by_set_design_and_level() { + check(item_name(Part::left_leg, false, 0, 1, true) == "roadrash_leg_1", "The first design has no letter"); + check(item_name(Part::right_leg, true, 0, 2, true) == "roadrash_legr_2", "The right leg has its own, mirrored set"); + check(item_name(Part::right_leg, false, 0, 2, true) == "roadrash_leg_2", "or shares the left one's"); + check(item_name(Part::left_arm, false, 1, 3, true) == "roadrash_arm_b_3_blood", "The worst level comes with blood"); + check(item_name(Part::right_arm, true, 2, 3, false) == "roadrash_arm_c_3_dry", "or dry; arms share one set"); + check(item_name(Part::face, false, 0, 1, true) == "roadrash_face_1" && item_name(Part::face, false, 1, 2, false) == "roadrash_face_b_2_dry", + "The face's worst level is its second"); + check(item_name(Part::left_leg, false, 9, 1, true) == "roadrash_leg_1", "An unknown design is the first"); +} + +void a_limbs_decal_sits_on_its_knee_or_elbow() { + const auto left = placement(Part::left_leg, 1), right = placement(Part::right_leg, 1); + check(near(left[0] + right[0], 1.0f) && near(left[1], right[1]) && near(left[2], right[2]), "The right limb mirrors the left across the layout"); + check(near(left[2], 20.0f / 76.0f) && near(left[3], 0.5f), "A level's size is its centimetres over the limb's scale, upright"); + const auto worst = placement(Part::left_leg, 3); + check(worst[2] > left[2] && worst[1] < left[1], "A worse level is bigger and reaches further down the limb"); + check(placement(Part::left_arm, 3)[2] > placement(Part::left_arm, 1)[2] && placement(Part::left_arm, 9)[2] == placement(Part::left_arm, 3)[2], + "Arms likewise; a level past the worst is the worst"); +} + +void parameters_the_game_worked_out_again_are_the_same() { + const auto words = [](std::initializer_list values) { + std::vector out; + for (const float value : values) out.push_back(std::bit_cast(value)); + return out; + }; + const auto given = words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f, 0.5f}); + auto back = given; + back[2] = std::bit_cast(80.0f / 255.0f) + 1; // the same size, one bit off + check(same_parameters(given, given) && same_parameters(given, back), "A last bit of difference is no change"); + check(!same_parameters(given, words({0.365f, 0.515f, 16.0f / 51.0f, 0.5f, 0.5f})), "A centimetre down the limb is one"); + check(!same_parameters(given, words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f})), "and so is another count of them"); +} +} + +int main() { + try { + a_fall_is_on_the_side_whose_limbs_took_it(); + feet_trunk_head_and_the_own_board_count_for_no_side(); + marks_come_with_the_bails(); + the_side_that_took_more_goes_first_and_no_mark_gets_smaller(); + items_are_named_by_set_design_and_level(); + a_limbs_decal_sits_on_its_knee_or_elbow(); + parameters_the_game_worked_out_again_are_the_same(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Road Rash tests passed.\n"; + return 0; +} diff --git a/Extension/RoadRash/road_rash.cpp b/Extension/RoadRash/road_rash.cpp new file mode 100644 index 0000000..dcc81a6 --- /dev/null +++ b/Extension/RoadRash/road_rash.cpp @@ -0,0 +1,369 @@ +#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 "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 +#include +#include +#include +#include +#include +#include + +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 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 ready{}, on{true}, blood{true}, covers{}; + std::atomic heal{}, changed{}; // asked for from another thread, done at the next tick + std::atomic wanted_bails{-1}; + std::atomic bails{}; + // Client thread from here on. + Tally tally; + std::array 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 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; // 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 slots{}; + std::map items; // wanted name -> installed item, empty when missing +}; +State& state() { + static auto* value = new State; + return *value; +} + +template void say(logging::Level level, std::format_string text, Args&&... args) { + logging::log(level, logging::Channel::skater, "Road Rash: {}", std::format(text, std::forward(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(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((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(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(s.slots[i]) >= recipe.items.size()) continue; + auto& slot = recipe.items[static_cast(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) { + const auto place = placement(part, level); + for (std::size_t k = 0; k < place.size(); ++k) parameters[k] = std::bit_cast(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; + constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix; + std::array 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(true), 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(wanted); + s.tally.left = s.tally.right = static_cast(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 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((std::min)(count, 1000u)), std::memory_order_release); } +unsigned bails() noexcept { return state().bails.load(std::memory_order_acquire); } +} diff --git a/Extension/RoadRash/road_rash.h b/Extension/RoadRash/road_rash.h new file mode 100644 index 0000000..f3c86a6 --- /dev/null +++ b/Extension/RoadRash/road_rash.h @@ -0,0 +1,50 @@ +#pragma once +#include + +// Road Rash: bails leave marks on the local skater that build up over a session. After ten bails +// the leg and then the arm of the side that took more of them show road dust and a graze, later +// real scrapes and bruises, and from twenty raw wounds on knee, shin, elbow, forearm and knuckles +// and a bruised face; the other side follows a few bails behind. A mark never gets smaller until +// the skater is healed (the console's `roadrash heal`) or the game is closed. +// +// The marks are not drawn by ReSkate: they are cosmetic items of a content mod, tattoo items for +// the limbs and moles items for the face (road_rash_model.h has their names), which this puts into +// the skater's recipe while it plays, through the game's own recipe copy (skater_items.h). Without +// that mod installed nothing ever shows. A slot that holds one of the player's own tattoos or moles +// keeps it, unless the player lets the wounds cover it; what was there comes back with healing. +// Nothing is saved to the profile's outfit: the game builds the skater from the profile again at +// every level change, and the marks are put back on from the session's count. +// +// The game feeds a skater's items either from the player's outfit or from a recipe of the skater's +// own, never both: one that wears a recipe handed to it gets nothing of what the player changes in +// the game's menus. So the marks are on only while the game is played. While a game menu is up, +// with nothing to show, on healing and when switched off, the skater is given back to the game, +// which puts the outfit's own items back and feeds it again (skater_items.h); the marks go back on, +// over the outfit as it is by then, a second after the menu has gone. +// +// Which side a bail hit comes from the ragdoll's own contacts, as Hall of Meat reads them +// (Extension/HallOfMeat/hall_of_meat_skater.h); a bail is the physics state selector choosing +// Wipeout (Extension/Skater/no_bail.h). Nothing is hooked for it. +namespace dingosdk::road_rash { +// Requires the validated build, No Bail started and the local profile loaded: the switches start +// from the saved choices (on, with blood, tattoos kept, until a player changes them). +bool start(std::uintptr_t image_base) noexcept; +// Client thread, every tick, with the local client. +void on_client_tick(std::uintptr_t client) noexcept; + +// The switches, any thread; a change applies at the next tick and is saved with the profile. +bool available() noexcept; +bool enabled() noexcept; // off: the marks come off and no bail is counted +bool blood() noexcept; // off: the worst wounds are dry, with scabs +bool covers_tattoos() noexcept; // on: wounds take the place of the player's own tattoos and moles +void set_enabled(bool enabled) noexcept; +void set_blood(bool blood) noexcept; +void set_covers_tattoos(bool covers) noexcept; +// Any thread, done at the next tick: takes every mark off and starts counting again. +void heal() noexcept; +// Any thread, done at the next tick: counts as if the skater had bailed this often, half of it on +// each side, to look at a stage without slamming for it. +void set_bails(unsigned count) noexcept; +// How many bails are counted now. +unsigned bails() noexcept; +} diff --git a/Extension/RoadRash/road_rash_commands.cpp b/Extension/RoadRash/road_rash_commands.cpp new file mode 100644 index 0000000..5485791 --- /dev/null +++ b/Extension/RoadRash/road_rash_commands.cpp @@ -0,0 +1,49 @@ +#include "road_rash.h" +#include "Extension/Console/commands.h" + +// Road Rash's console switches; the menu's (Mod Options > Player) send them too. +namespace dingosdk::console { +void register_road_rash_commands(Commands ®istry) { + const auto unavailable = "Road Rash is unavailable for this game build."; + auto rash = variable("roadrash", "Bails leave marks on your skater that build up: dust, scrapes, bruises, wounds " + "(needs the Road Rash item pack)", + Group::gameplay, argument("0|1", Type::boolean)); + rash.inspect = [unavailable](const Model &) { + return boolean_state(road_rash::available(), road_rash::enabled(), unavailable, + std::to_string(road_rash::bails()) + " bail(s) counted"); + }; + rash.run = [](const Model &, const Values &args, const Output &) { road_rash::set_enabled(std::get(args[0])); }; + registry.add(std::move(rash)); + + auto blood = variable("roadrashblood", "Fresh blood on Road Rash's worst wounds; off, they are dry and scabbed", + Group::gameplay, argument("0|1", Type::boolean)); + blood.inspect = [unavailable](const Model &) { return boolean_state(road_rash::available(), road_rash::blood(), unavailable); }; + blood.run = [](const Model &, const Values &args, const Output &) { road_rash::set_blood(std::get(args[0])); }; + registry.add(std::move(blood)); + + auto tattoos = variable("roadrashtattoos", "Road Rash's wounds take the place of your own tattoos and moles until you heal; " + "off, a limb that wears a tattoo gets no wounds", + Group::gameplay, argument("0|1", Type::boolean)); + tattoos.inspect = [unavailable](const Model &) { + return boolean_state(road_rash::available(), road_rash::covers_tattoos(), unavailable); + }; + tattoos.run = [](const Model &, const Values &args, const Output &) { road_rash::set_covers_tattoos(std::get(args[0])); }; + registry.add(std::move(tattoos)); + + auto heal = action("roadrash heal", "Take Road Rash's marks off and start counting bails again", Group::gameplay); + heal.run = [](const Model &, const Values &, const Output &out) { + road_rash::heal(); + out("Healed."); + }; + registry.add(std::move(heal)); + + auto bails = action("roadrash bails", "Count as if you had bailed this often, to look at a stage (10, 14 and 20 are the steps)", + Group::gameplay, {argument("count", Type::unsigned_integer)}); + bails.run = [](const Model &, const Values &args, const Output &out) { + const auto count = std::get(args[0]); + road_rash::set_bails(static_cast(count > 1000 ? 1000 : count)); + out("Counted."); + }; + registry.add(std::move(bails)); +} +} // namespace dingosdk::console diff --git a/Extension/RoadRash/road_rash_model.h b/Extension/RoadRash/road_rash_model.h new file mode 100644 index 0000000..44a41b7 --- /dev/null +++ b/Extension/RoadRash/road_rash_model.h @@ -0,0 +1,166 @@ +#pragma once +#include "Engine/Game/Skater/skater_body.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Road Rash's rules, apart from the game: which side of the body a bail hit, how many bails it +// takes for a limb's marks to show and to get worse, what the items that draw them are called and +// where on a limb they sit. Plain data and pure functions (Test/road_rash_tests.cpp). +namespace dingosdk::road_rash { +// Where marks can sit: the skater has one tattoo layer per arm and leg, and one moles layer on the face. +enum class Part : std::uint8_t { left_leg, left_arm, right_leg, right_arm, face }; +inline constexpr std::size_t part_count = 5; +inline constexpr std::array parts{Part::left_leg, Part::left_arm, Part::right_leg, Part::right_arm, Part::face}; +inline constexpr std::array part_names{"left leg", "left arm", "right leg", "right arm", "face"}; +constexpr std::size_t index(Part part) noexcept { return static_cast(part); } +constexpr bool leg(Part part) noexcept { return part == Part::left_leg || part == Part::right_leg; } +constexpr bool left(Part part) noexcept { return part == Part::left_leg || part == Part::left_arm; } + +// A limb's marks have three levels, the face's two. +inline constexpr int worst_limb = 3, worst_face = 2; +constexpr int worst(Part part) noexcept { return part == Part::face ? worst_face : worst_limb; } +// The bails it takes for the first marks, the bigger ones and the worst ones. +inline constexpr std::array steps{10, 14, 20}; +// The face follows the worst level of the limbs, and gets worse four bails later. +inline constexpr unsigned face_later = 4; +// How many bails after the leg of the side that took more falls a limb follows: that side's arm, +// then the other side's leg and arm. +inline constexpr unsigned arm_later = 1, other_side_later = 4; + +// ---- a fall ------------------------------------------------------------------------------- + +enum class Side : std::uint8_t { both, left, right }; +// How hard the limbs of each side hit something in a fall, summed over its physics steps: the speed +// into the surface and the speed along it, metres per second. +struct Fall { + float left{}, right{}; +}; +// The side a body is on; feet and toes (they touch the ground all the time), the trunk and the +// head count for neither. +constexpr Side side_of(skater_body::Bone bone) noexcept { + using skater_body::Bone; + switch (bone) { + case Bone::left_hand: case Bone::left_forearm: case Bone::left_arm: case Bone::left_shoulder: + case Bone::left_leg: case Bone::left_upleg: return Side::left; + case Bone::right_hand: case Bone::right_forearm: case Bone::right_arm: case Bone::right_shoulder: + case Bone::right_leg: case Bone::right_upleg: return Side::right; + default: return Side::both; + } +} +// Adds one physics step's contacts. A body that only touches the skater's own board hit nothing. +inline void add(Fall& fall, const skater_body::Contacts& contacts) noexcept { + for (std::size_t i = 0; i < skater_body::count; ++i) { + const auto& body = contacts.bodies[i]; + const auto side = side_of(static_cast(i)); + if (side == Side::both || !body.touching || (body.hit.board && !body.hit.world && !body.hit.vehicle)) continue; + const float hit = body.impact + game::length(body.slide); + (side == Side::left ? fall.left : fall.right) += hit; + } +} +// The side that took the fall: the one whose limbs took three fifths of it or more. A fall the +// limbs hardly felt, or felt alike, is on both. +inline Side side(const Fall& fall) noexcept { + const float total = fall.left + fall.right; + if (!(total >= 2.0f)) return Side::both; + const float share = fall.left / total; + return share >= 0.6f ? Side::left : share <= 0.4f ? Side::right : Side::both; +} + +// ---- the session -------------------------------------------------------------------------- + +// The level `bails` give a part, when the left (or the right) side took more of them. +constexpr int level_for(Part part, unsigned bails, bool left_first) noexcept { + if (part == Part::face) return bails >= steps[2] + face_later ? 2 : bails >= steps[2] ? 1 : 0; + const unsigned later = (left(part) == left_first ? 0 : other_side_later) + (leg(part) ? 0 : arm_later); + if (bails < later) return 0; + const unsigned count = bails - later; + return count >= steps[2] ? 3 : count >= steps[1] ? 2 : count >= steps[0] ? 1 : 0; +} +// Bails counted since the skater was last clean, and the marks they left. A mark never gets +// smaller: only healing takes it off. +struct Tally { + unsigned bails{}; + float left{}, right{}; // falls on each side; one on both counts half for each + bool last_left{}; // the side of the last one-sided fall: it breaks a tie + std::array levels{}; +}; +constexpr bool left_first(const Tally& tally) noexcept { + return tally.left > tally.right || (tally.left == tally.right && tally.last_left); +} +constexpr void grow(Tally& tally) noexcept { + const bool first = left_first(tally); + for (const auto part : parts) + tally.levels[index(part)] = (std::max)(tally.levels[index(part)], level_for(part, tally.bails, first)); +} +constexpr void count(Tally& tally, Side side) noexcept { + ++tally.bails; + if (side == Side::both) { + tally.left += 0.5f; + tally.right += 0.5f; + } else { + (side == Side::left ? tally.left : tally.right) += 1.0f; + tally.last_left = side == Side::left; + } + grow(tally); +} + +// ---- the items ---------------------------------------------------------------------------- + +// The marks are cosmetic items of a content mod: tattoo items for the limbs, moles items for the +// face, found by the end of their names: RoadRash__[_][_Dry|_Blood]. +// Set Leg, Arm, Face; LegR is the left leg's set mirrored, for the right leg: a decal sits +// on a limb like a sticker read from the front, so only a mirrored one puts the marks +// of the left leg's outer side on the right leg's outer side. +// design nothing for the first, B_ and C_ for the others: each part wears one design for as +// long as its marks last. +// level 1 .. the part's worst; the worst comes dry (scabs) and with fresh blood. +inline constexpr std::string_view item_prefix = "roadrash_"; +inline constexpr std::size_t design_count = 3; +inline constexpr std::array design_letters{"", "b_", "c_"}; +// In lower case, as names are compared. `mirrored` asks for the right leg's own set. +inline std::string item_name(Part part, bool mirrored, std::size_t design, int level, bool blood) { + std::string name(item_prefix); + name += part == Part::face ? "face_" : !leg(part) ? "arm_" : mirrored ? "legr_" : "leg_"; + name += design_letters[design < design_count ? design : 0]; + name += std::to_string(level); + if (level >= worst(part)) name += blood ? "_blood" : "_dry"; + return name; +} + +// ---- where a limb's decal goes ------------------------------------------------------------ + +// A tattoo slot places its item with five numbers: x across and y up the limb's piece of the +// body's tattoo layout (0..1), its size, its turn (0.5 is upright) and one unused. +using Placement = std::array; +// The middle of the kneecap or the point of the elbow on a left limb (the right one mirrors x), +// how much y changes per centimetre down the limb, and how many centimetres a size of 1 covers. +struct Limb { + float x, y, y_per_cm, cm_per_size; +}; +inline constexpr Limb leg_spot{0.37f, 0.59f, 0.01073f, 76.0f}, arm_spot{0.635f, 0.505f, 0.01192f, 51.0f}; +// A level's picture covers `side` centimetres with its centre `down` centimetres below that point. +struct Cut { + float side, down; +}; +inline constexpr std::array leg_cuts{{{20, 0}, {27, 2}, {38, 7}}}, arm_cuts{{{16, 0}, {24, 3}, {46, 16}}}; +constexpr Placement placement(Part part, int level) noexcept { + const auto& spot = leg(part) ? leg_spot : arm_spot; + const auto& cut = (leg(part) ? leg_cuts : arm_cuts)[static_cast(std::clamp(level, 1, worst_limb) - 1)]; + return {left(part) ? spot.x : 1.0f - spot.x, spot.y - cut.down * spot.y_per_cm, cut.side / spot.cm_per_size, 0.5f, 0.5f}; +} +// A slot's parameters are words that hold floats. The game works them out again when it updates the +// skater: what it hands back is as good as what it was given, but not equal to the last bit. +inline bool same_parameters(const std::vector& a, const std::vector& b) noexcept { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (a[i] != b[i] && !(std::abs(std::bit_cast(a[i]) - std::bit_cast(b[i])) < 0.002f)) return false; + return true; +} +} diff --git a/Extension/RoadRash/skater_items.h b/Extension/RoadRash/skater_items.h new file mode 100644 index 0000000..19d56f9 --- /dev/null +++ b/Extension/RoadRash/skater_items.h @@ -0,0 +1,125 @@ +#pragma once +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/native_cosmetics.h" +#include "Extension/Multiplayer/Remote/native_cosmetics_layout.h" +#include +#include +#include +#include +#include +#include +#include + +// The local skater's cosmetic recipe while it plays: what Road Rash needs to read it, to hand the +// game a changed one and to give the skater back to the game afterwards. Client thread only. Everything is read through guarded reads and checked +// as the multiplayer code checks a peer's outfit (native_cosmetics_layout.h); a failed check throws. +namespace dingosdk::road_rash { +using multiplayer::CosmeticRecipe; +using multiplayer::CosmeticSlot; +inline bool readable(std::uintptr_t address, void* out, std::size_t size) { + return size ? memory::peek_bytes(address, out, size) : address >= 0x10000; +} +using SkaterItems = multiplayer::CosmeticMemory; + +inline std::string lower(std::string_view text) { + std::string out(text); + for (auto& c : out) + if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); + return out; +} +// The installed cosmetic whose name ends in `ending` (lower case), or nothing. +inline std::string installed_item(const SkaterItems& m, std::string_view ending) { + const auto manager = m.ptr(m.base, addr::engine::cosmetics_manager); + m.check(m.ptr(manager) == m.base + addr::engine::cosmetics_manager_vtable && m.get(manager, 0xa8) == 1, + "Cosmetic catalog is not ready."); + const auto buckets = m.ptr(manager, 0x30); + const auto size = m.get(manager, 0x38), total = m.get(manager, 0x3c); + m.check(size && size <= 16384 && total && total <= 8192, "Cosmetic catalog exceeds bounds."); + std::uint32_t visited{}; + for (std::uint32_t bucket = 0; bucket < size && visited < total; ++bucket) { + auto node = m.ptr(buckets, bucket * 8ULL); + for (std::uint32_t chain = 0; node && chain < total && visited < total; ++chain, ++visited, node = m.ptr(node, 0x10)) { + const auto asset = m.ptr(node, 8) & ~std::uintptr_t{4}; + if (!asset) continue; + auto name = m.text(m.ptr(asset, 0x38)); + if (lower(name).ends_with(ending)) return name; + } + } + return {}; +} +// A recipe as the game keeps the local skater's: every item by its hash, with no name beside it. +// The game changes an item of that skater where it lies, hash and parameters only; a name the +// recipe carried would stay behind and no longer be the item's. +struct LentRecipe { + multiplayer::CosmeticArray scalars, mask{0}; + multiplayer::CosmeticArray items; + std::vector> parameters; + multiplayer::NativeCosmeticRecipe value; + // Holds arrays of its own in the game's layout; the native copy copies out of them. + explicit LentRecipe(const CosmeticRecipe& recipe) : scalars(recipe.scalars.size()), items(recipe.items.size()) { + std::copy(recipe.scalars.begin(), recipe.scalars.end(), scalars.data()); + parameters.reserve(recipe.items.size()); + for (std::size_t i = 0; i < recipe.items.size(); ++i) { + const auto& item = recipe.items[i]; + parameters.emplace_back(item.parameters.size()); + std::copy(item.parameters.begin(), item.parameters.end(), parameters.back().data()); + items.data()[i] = {"", parameters.back().data(), multiplayer::cosmetic_asset_hash(item.asset), item.slot}; + } + value = {scalars.data(), items.data(), mask.data()}; + } + LentRecipe(const LentRecipe&) = delete; + LentRecipe& operator=(const LentRecipe&) = delete; +}; +// Hands the skater a recipe through the game's own recipe copy, the call the multiplayer code makes +// for other players' outfits: the copy marks the skater for its normal rebuild. +inline void apply(const SkaterItems& m, std::uintptr_t component, const CosmeticRecipe& recipe) { + constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix; + m.check(m.get>(m.base, addr::native_cosmetics::recipe_copy) == prefix, + "Native cosmetic copy function differs."); + m.validate(m.resource(component), recipe); + LentRecipe lent(recipe); + reinterpret_cast( + m.base + addr::native_cosmetics::recipe_copy)(component, &lent.value); + m.check(m.ptr(component, 0x158) != reinterpret_cast(lent.value.items), + "Native cosmetic copy did not take ownership."); +} + +// How the game feeds a skater's items. The local skater's follow the player's outfit: an item +// chosen in the game's menus reaches the skater in the world as it is chosen. A recipe handed over +// with `apply` becomes the skater's own, and from then on the game passes nothing of the outfit on +// to it (the multiplayer code relies on that for other players' skaters). These are the four words +// of the item component the recipe copy changes for it: on build 20260929 they read 2, 0, 2, 0 +// while the outfit is followed and 1, 2, 1, 2 with a recipe of the skater's own. +struct Feed { + std::array words{}; + bool operator==(const Feed&) const = default; + // The copy leaves a 2 in the second word (the multiplayer code checks that one too). + bool follows_outfit() const noexcept { return words[1] != 2; } +}; +inline constexpr std::array feed_words{0x138, 0x13c, 0x144, 0x148}; +// What the component still has to work through: changed items, and a changed feed. +inline constexpr std::size_t items_changed = 0x10a, feed_changed = 0x10d; +inline Feed feed(const SkaterItems& m, std::uintptr_t component) { + Feed out; + for (std::size_t i = 0; i < feed_words.size(); ++i) out.words[i] = m.get(component, feed_words[i]); + return out; +} +// Gives a skater that wears a recipe of its own back to the game. `before` is how the game fed it +// before the recipe; with those words back and the component told that its feed changed, the game +// puts the outfit's own items back at its next update and passes the outfit's changes on again. +inline void hand_back(const SkaterItems& m, std::uintptr_t component, const Feed& before) { + m.check(!m.get(component, items_changed) && !m.get(component, feed_changed), + "Waiting for the skater's items to finish updating."); + (void)feed(m, component); // all four can be read, so they can be written + const auto put = [](std::uintptr_t address, const auto& value) { + SIZE_T done{}; + SkaterItems::check(WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(address), &value, sizeof(value), &done) && + done == sizeof(value), + "Cannot update the skater's item component."); + }; + for (std::size_t i = 0; i < feed_words.size(); ++i) put(component + feed_words[i], before.words[i]); + put(component + feed_changed, std::uint8_t{1}); +} +} diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 355074c..739ae79 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -124,6 +124,22 @@ int main() { model.hall_of_meat = {true, true}; activate(state, model, cb, "hall-of-meat", ""); check(meat_row().title == "Hall of Meat: On" && capture.command == "hallofmeat 0", "and off again"); + // Road Rash's switches sit there too; its blood and its healing wait for it to be on. + const auto rash_row = [&](std::string_view id) { + const auto page = render(state, model, cb, player_section); + return *find(page.main, id); + }; + check(rash_row("road-rash").title == "Road Rash: Off" && rash_row("road-rash").command.empty(), "Road Rash waits until it started"); + model.road_rash = {true, false, true}; + check(rash_row("road-rash-blood").command.empty() && rash_row("road-rash-heal").command.empty(), "Its blood and healing wait for it"); + activate(state, model, cb, "road-rash", ""); + check(rash_row("road-rash").command == "road-rash" && capture.command == "roadrash 1", "Road Rash turns on"); + model.road_rash = {true, true, true}; + check(rash_row("road-rash").title == "Road Rash: On" && rash_row("road-rash-blood").title == "Road Rash blood: On", "and shows it"); + activate(state, model, cb, "road-rash-blood", ""); + check(capture.command == "roadrashblood 0", "The blood switches off"); + activate(state, model, cb, "road-rash-heal", ""); + check(capture.command == "roadrash heal", "and the skater heals"); model.multiplayer.local_name = "steam_name"; model.player_card = {true, "Card Name", {}}; const auto card_page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index e9fa7f9..b481381 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -201,6 +201,12 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks cb.queue_offline_feature(cb.user, request, result.data(), result.size())); } else if (command == "hall-of-meat") { console(m.hall_of_meat.enabled ? "hallofmeat 0" : "hallofmeat 1", m.hall_of_meat.available); + } else if (command == "road-rash") { + console(m.road_rash.enabled ? "roadrash 0" : "roadrash 1", m.road_rash.available); + } else if (command == "road-rash-blood") { + console(m.road_rash.blood ? "roadrashblood 0" : "roadrashblood 1", m.road_rash.available && m.road_rash.enabled); + } else if (command == "road-rash-heal") { + console("roadrash heal", m.road_rash.available && m.road_rash.enabled); } else if (command == "board-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -296,6 +302,11 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 toggle(p.main, "no-bail", "No bail", m.debug.no_bail, (m.debug.no_bail_available || m.debug.no_bail) && cb.queue_debug); toggle(p.main, "freecam", "Freecam", m.debug.free_camera, m.debug.available && m.debug.camera_available && cb.queue_debug); toggle(p.main, "hall-of-meat", "Hall of Meat", m.hall_of_meat.enabled, m.hall_of_meat.available && cb.queue_console_command); + toggle(p.main, "road-rash", "Road Rash", m.road_rash.enabled, m.road_rash.available && cb.queue_console_command); + toggle(p.main, "road-rash-blood", "Road Rash blood", m.road_rash.blood, + m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); + button(p.main, "road-rash-heal", "Heal Road Rash", "road-rash-heal", + m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); toggle(p.main, "board-wear", "Board wear", m.offline.board_wear.effective, m.offline.board_wear.available && cb.queue_offline_feature); button(p.main, "board-wear-reset", "Reset board wear", "board-wear-reset", diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index 7bddbe2..dfcd670 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1019,6 +1019,9 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip}); if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail}); if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat"); + if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash"); + if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood"); + if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal"); const auto &wear = m.offline.board_wear; if (list.setting("Board wear", on_off(wear.effective), wear.available && cb.queue_offline_feature)) act("board-wear"); if (list.setting("Reset board wear", {}, wear.available && wear.effective && console)) act("board-wear-reset"); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 6bc69c4..60c56bc 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -209,6 +209,12 @@ struct HallOfMeatModel { bool available = false, enabled = false; }; +// Road Rash's switches (Extension/RoadRash/road_rash.h): started, switched on, and whether its +// worst wounds bleed. +struct RoadRashModel { + bool available = false, enabled = false, blood = false; +}; + struct Model { std::string state = "Waiting for native state"; std::string detail; @@ -228,6 +234,7 @@ struct Model { ProgressionModel progression; PlayerCardModel player_card; HallOfMeatModel hall_of_meat; + RoadRashModel road_rash; ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 5609442..d13898d 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -164,6 +164,15 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c : "Your bails show the bones they hurt and score a Meat card. A break slows the game in single player."; if (toggle_row(menu, "Hall of Meat", meat_help, hall_of_meat, model.hall_of_meat.available && callbacks.queue_console_command)) send_console(menu, callbacks, hall_of_meat ? "hallofmeat 1" : "hallofmeat 0"); + bool road_rash = model.road_rash.enabled; + const bool rash_console = model.road_rash.available && callbacks.queue_console_command; + if (toggle_row(menu, "Road Rash", "Bails leave marks that build up: dust, scrapes, bruises, wounds. Needs the Road Rash item pack.", + road_rash, rash_console)) + send_console(menu, callbacks, road_rash ? "roadrash 1" : "roadrash 0"); + bool rash_blood = model.road_rash.blood; + if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood, + rash_console && model.road_rash.enabled)) + send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0"); end_card(); begin_card(menu, "boosts", "BOOSTS", "Buttons are set in Settings > Controls"); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index e00fa37..ffc860a 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -7,6 +7,7 @@ #include "Extension/Settings/job_spin.h" #include "Engine/Game/World/client_state.h" #include "Extension/HallOfMeat/hall_of_meat.h" +#include "Extension/RoadRash/road_rash.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -723,6 +724,7 @@ void update_model(std::uintptr_t client, TickState& frame) { } if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. dingosdk::hall_of_meat::on_client_tick(); + dingosdk::road_rash::on_client_tick(client); if (!has_request && now < r.next_model && state == r.previous_state) return; r.next_model = now + 500; DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)"); @@ -860,6 +862,7 @@ void update_model(std::uintptr_t client, TickState& frame) { model.progression = dingosdk::local_profile_progression(); model.player_card = dingosdk::local_profile_player_card(); model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()}; + model.road_rash = {dingosdk::road_rash::available(), dingosdk::road_rash::enabled(), dingosdk::road_rash::blood()}; model.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 34a0fca..2157cc6 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -11,6 +11,7 @@ #include "Engine/Vfs/mod_catalog.h" #include "Extension/Boot/offline_boot.h" #include "Extension/HallOfMeat/hall_of_meat.h" +#include "Extension/RoadRash/road_rash.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -375,6 +376,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { (void)dingosdk::start_no_bail(r.base); const bool meat = dingosdk::hall_of_meat::start(r.base); record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); + const bool rash = dingosdk::road_rash::start(r.base); + record("{\"event\":\"road_rash_initialized\",\"active\":" + std::string(rash ? "true" : "false") + "}"); const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base); record("{\"event\":\"noclip_velocity_initialized\",\"active\":" + std::string(noclip_velocity ? "true" : "false") + "}"); const bool loading_screens = dingosdk::loading_screen::start(r.base); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 31b4081..ef3a885 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -124,6 +124,8 @@ add_library(dingosdk_runtime SHARED Extension/HallOfMeat/hall_of_meat_skater.cpp Extension/HallOfMeat/hall_of_meat_skeleton.cpp Extension/HallOfMeat/hall_of_meat_slow_motion.cpp + Extension/RoadRash/road_rash.cpp + Extension/RoadRash/road_rash_commands.cpp Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp diff --git a/contrib/road-rash.md b/contrib/road-rash.md new file mode 100644 index 0000000..60b13cd --- /dev/null +++ b/contrib/road-rash.md @@ -0,0 +1,95 @@ +# Road Rash + +Bails leave marks on the local skater that build up over a session: road dust and a graze first, +then scrapes and bruises, then raw wounds, a bruised face and (if the player leaves it on) blood. +Built into ReSkate; the pictures themselves come from a content mod. + +Code: [road_rash.cpp](../Extension/RoadRash/road_rash.cpp) (client thread), +[road_rash_model.h](../Extension/RoadRash/road_rash_model.h) (the rules on their own, with +[tests](../Extension/RoadRash/Test/road_rash_tests.cpp)), +[skater_items.h](../Extension/RoadRash/skater_items.h) (reading and changing the skater's recipe), +[road_rash_commands.cpp](../Extension/RoadRash/road_rash_commands.cpp) (console). + +## What the player gets + +- After 10 bails the leg of the side that took more of them shows the first marks, its arm one bail + later; at 14 they are bigger and the other side starts; at 20 they are at their worst and the face + is bruised, at 24 the face is worse too. A mark never gets smaller. +- `roadrash 0|1`, `roadrashblood 0|1` (the worst wounds bleed, or are dry and scabbed), + `roadrashtattoos 0|1` (wounds may take the place of the player's own tattoos and moles), + `roadrash heal`, and `roadrash bails ` to look at a stage. The first two and the healing + are in the menus beside Hall of Meat (Mod Options > Player). The choices are saved with the profile. +- Without the item pack installed nothing shows and nothing is changed. + +## How it works + +- **A bail** is the physics state selector choosing Wipeout (`watched_physics_state`, no_bail.h). +- **Its side** comes from the ragdoll's contacts, read with Hall of Meat's reader + (`hall_of_meat::read_step`) on every client tick while the body is a ragdoll: the impact and the + slide of each arm and leg body that touches something other than the skater's own board are summed + per side. The side with three fifths of it or more took the fall; otherwise it counts half for + each. Read outside the physics step a contact can be half written; the sums are only compared. +- **The marks** are cosmetic items: tattoo items for the limbs (the skater has one tattoo slot per + arm and leg) and moles items for the face. Road Rash puts them into the local skater's recipe + with the game's own recipe copy, the call the multiplayer code makes for peers, after the same + validation. The game rebuilds the skater in about one frame. +- **Where a limb's decal sits** is the slot's five parameters (x, y, size, turn, unused). The + numbers for knee and elbow are in road_rash_model.h: x and y are a position in the limb's + rectangle of the body mesh's second UV set, the right limb mirrors x. +- **Nothing is saved** to the outfit. The game builds the skater from the profile at every level + change; the marks are put back from the session's count. A slot that holds the player's own + tattoo or moles is left alone unless `roadrashtattoos` is on, and what was covered comes back + with healing or switching off. +- **The outfit menus.** The game feeds a skater's items either from the player's outfit or from a + recipe of the skater's own, never both. After the recipe copy the local skater no longer gets + what the player changes in the menus: the new top shows on the menu's skater and is saved, and + the skater in the world keeps the old one. So Road Rash gives the skater back to the game while + a game menu is up (`sample_game_ui_state`, game_ui_state.h), and also with nothing to show, on + healing, when a switch changes and when switched off. Giving back (`hand_back`, skater_items.h) + writes the four words of the item component the copy had changed back to what they were and + sets the component's "feed changed" byte; at its next update the game puts the outfit's own + items back and feeds the skater again. The marks go back on, over the outfit as it is by then, a + second after the menu has gone. A skater whose four words do not read as "follows its outfit" + before the first marks never gets a recipe. +- **Respawns.** After a bail the game hands over what looks like a new skater and is the same one, + items and all. So whose recipe a skater wears is read from the skater at every look (the four + words, and whether Road Rash items are in the recipe), not remembered from before. +- **Hashes, no names.** The recipe handed to the copy names no item, like the game's own for the + local skater: the game changes an item of that skater where it lies (hash and parameters only), + and a name left beside it would no longer match. That made every later read of the skater's + recipe fail, the multiplayer code's too. +- **Parameters come back rounded.** The game works a slot's parameters out again when it updates + the skater; they are compared with a tolerance, not bit for bit. +- Off, it does nothing. On and without marks, a tick costs one physics-state read. + +## The item pack + +A content mod (no code) with items named `RoadRash__[_][_Dry|_Blood]`: + +| Set | Kind of item | Levels | +| --- | --- | --- | +| `Leg`, `LegR` (the left leg's pictures mirrored, for the right leg) | tattoo | 1, 2, 3_Dry, 3_Blood | +| `Arm` | tattoo | 1, 2, 3_Dry, 3_Blood | +| `Face` | moles | 1, 2_Dry, 2_Blood | + +A design letter (`B_`, `C_`) after the set names a second and third look; each limb draws one per +session. A missing design falls back to the first, a missing `LegR` to `Leg`, a missing level to +the one below. Any pack that follows the names works. + +## Seen working in game + +Offline, on main at b7936a3 with a 45 item pack: every stage through `roadrash bails`, the blood +switch, healing, switching off and on, and real bails counted with their side read from the +contacts (falls on the left and on the right both came out as such), the marks growing in place +through respawns. With marks on: a top changed in the apparel menu and a switch to another skater +preset both show on the skater in the world, with the marks back over them; no wound item ends up +in a saved outfit. A female and a male body. + +## Not tried yet + +- Multiplayer: what peers see, with and without the pack; dedicated servers. +- The online route: all of the above ran on the offline profile. +- A game menu that keeps the skater in view (the marks are off while any game menu is up), and + anything outside the menus that changes the outfit while marks are on. +- A level change in the middle of a bail. +- No marks for the torso or the neck: the chest tattoo slot is not measured.