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.