diff --git a/CMakeLists.txt b/CMakeLists.txt index e427d438..7553abbf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -377,20 +377,14 @@ if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS) target_include_directories(dingosdk_replay_keyframes_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME replay_keyframes COMMAND dingosdk_replay_keyframes_tests) endif() -option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) -if(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS) +option(DINGOSDK_BUILD_XRAY_TESTS "Build the skater x-ray's skeleton tests" OFF) +if(DINGOSDK_BUILD_XRAY_TESTS) enable_testing() - # The bail: its phases, what each hit did, the Meat it scores and the card that shows it. - add_executable(dingosdk_hall_of_meat_tests Extension/HallOfMeat/Test/hall_of_meat_tests.cpp - Extension/HallOfMeat/hall_of_meat_model.cpp Extension/HallOfMeat/hall_of_meat_card.cpp) - target_include_directories(dingosdk_hall_of_meat_tests PRIVATE "${PROJECT_SOURCE_DIR}") - add_test(NAME hall_of_meat COMMAND dingosdk_hall_of_meat_tests) # skate.'s own skeleton mesh, read from the game's data and posed (with a Skate folder). - add_executable(dingosdk_hall_of_meat_skeleton_tests Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp - Extension/HallOfMeat/hall_of_meat_skeleton.cpp) - target_include_directories(dingosdk_hall_of_meat_skeleton_tests PRIVATE "${PROJECT_SOURCE_DIR}") - target_link_libraries(dingosdk_hall_of_meat_skeleton_tests PRIVATE dingosdk_game_archives dingosdk_logging) - add_test(NAME hall_of_meat_skeleton COMMAND dingosdk_hall_of_meat_skeleton_tests "${DINGOSDK_TEST_GAME_ROOT}") + add_executable(dingosdk_xray_skeleton_tests Extension/Skater/Test/xray_skeleton_tests.cpp Extension/Skater/xray_skeleton.cpp) + target_include_directories(dingosdk_xray_skeleton_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_xray_skeleton_tests PRIVATE dingosdk_game_archives dingosdk_logging) + add_test(NAME xray_skeleton COMMAND dingosdk_xray_skeleton_tests "${DINGOSDK_TEST_GAME_ROOT}") endif() option(DINGOSDK_BUILD_PLUGIN_TESTS "Build plugin host regression tests" OFF) if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32) diff --git a/Engine/Game/Build/20260929/skater_render.h b/Engine/Game/Build/20260929/skater_render.h index 349ade1c..04622d48 100644 --- a/Engine/Game/Build/20260929/skater_render.h +++ b/Engine/Game/Build/20260929/skater_render.h @@ -7,7 +7,7 @@ namespace dingosdk::game::build::v20260929::skater_render { // Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. // The local skater as the renderer draws it: the skinning matrices of its mesh and the camera of -// the same picture (Extension/HallOfMeat/hall_of_meat_render.h). Read 2026-10-06; the draw packet, +// the same picture (Extension/Skater/xray_render.h). Read 2026-10-06; the draw packet, // the render view and the handle chain were first used by gBGYo's ReSkate fork // (github.com/gBGYo/ReSkate). // diff --git a/Engine/Game/Build/20260929/skater_skeleton.h b/Engine/Game/Build/20260929/skater_skeleton.h index 84104973..97433846 100644 --- a/Engine/Game/Build/20260929/skater_skeleton.h +++ b/Engine/Game/Build/20260929/skater_skeleton.h @@ -5,7 +5,7 @@ namespace dingosdk::game::build::v20260929::skater_skeleton { // Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. // The skater's skeleton as skate. itself has it, read from the installed game's data -// (Extension/HallOfMeat/hall_of_meat_skeleton.h): the player's render skeleton, and the Dem Bones +// (Extension/Skater/xray_skeleton.h): the player's render skeleton, and the Dem Bones // costume, a full-body skeleton outfit skinned to it. Both were first used by gBGYo's ReSkate // fork (github.com/gBGYo/ReSkate); read on 2026-10-06. // diff --git a/Engine/Game/Build/20260929/skater_state.h b/Engine/Game/Build/20260929/skater_state.h index 3ff12d22..b67509ec 100644 --- a/Engine/Game/Build/20260929/skater_state.h +++ b/Engine/Game/Build/20260929/skater_state.h @@ -5,7 +5,7 @@ namespace dingosdk::game::build::v20260929::skater_state { // Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. -// The skater's live state, read-only (Extension/HallOfMeat/hall_of_meat_skater.h). +// The skater's live state, read-only (Extension/Skater/skater_physics.h). // The physics state the core's selector chose this step (no_bail.h: choose_physics_state; its // offboard_physics_state is the one on foot and through a whole bail). diff --git a/Engine/Game/Rendering/draw_packet.h b/Engine/Game/Rendering/draw_packet.h index dd063b2b..58583cb7 100644 --- a/Engine/Game/Rendering/draw_packet.h +++ b/Engine/Game/Rendering/draw_packet.h @@ -6,7 +6,7 @@ // A skinned mesh's draw packet as the renderer hands it out (Engine/Game/Build/20260929/ // skater_render.h): its bones' skinning matrices, packed, and how to place them in the world. -// Plain data and math; Extension/HallOfMeat/hall_of_meat_render.h reads it. +// Plain data and math; Extension/Skater/xray_render.h reads it. namespace dingosdk::draw_packet { using game::LinearTransform; diff --git a/Engine/Game/Skater/skater_body.h b/Engine/Game/Skater/skater_body.h index e0e363e2..720e9188 100644 --- a/Engine/Game/Skater/skater_body.h +++ b/Engine/Game/Skater/skater_body.h @@ -6,7 +6,7 @@ // The skater's body as the physics simulates it (Engine/Game/Build/20260929/skater_body.h): // 24 rigid bodies, the ragdoll, and the contacts of each physics step. Plain data; -// Extension/HallOfMeat/hall_of_meat_skater.h reads it. +// Extension/Skater/skater_physics.h reads it. // // The bodies by the physics bone id the contact processing reports. The ids follow the // order of the game's physics bone name map (Engine/Game/Build/20260929/skater_body.h). The diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index dc8a999e..a9b60f17 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -228,9 +228,7 @@ const Commands &game_commands() { register_perf_commands(*result); register_item_commands(*result); register_trainer_commands(*result); - register_hall_of_meat_commands(*result); register_breakable_deck_commands(*result); - register_grind_exit_commands(*result); register_replay_keyframes_commands(*result); register_plugin_commands(*result); return result; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index c0ed9264..54035a39 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -26,9 +26,7 @@ void register_perf_commands(Commands &); void start_memory_trace_from_environment() noexcept; void register_item_commands(Commands &); void register_trainer_commands(Commands &); -void register_hall_of_meat_commands(Commands &); void register_breakable_deck_commands(Commands &); -void register_grind_exit_commands(Commands &); void register_replay_keyframes_commands(Commands &); void register_plugin_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. diff --git a/Extension/HallOfMeat/README.md b/Extension/HallOfMeat/README.md deleted file mode 100644 index 214e276e..00000000 --- a/Extension/HallOfMeat/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Hall of Meat - -skate. 3's Hall of Meat for skate.: when you bail, the bones you hurt show through your skater, -yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the bottom -left corner. It ships no game data: the skeleton (the Dem Bones costume's mesh), the card's icons, the -THRASHER wordmark and the brush strokes are read from the installed game. - -## What it does - -- **The skeleton.** From the first hurt bone until you are back on your feet, then it fades out. A fall - that hurts nothing shows nothing. A fresh hit flashes the bone it hit. -- **The card.** It takes the place of skate.'s own HUD in that corner (the d-pad and the score HUD come - back after). Time, hits (head hits count double, hits from vehicles half again), broken bones, road - rash, airtime, fall, top speed and rotations, each with its points, then the Meat. Small stats join - the card once they are worth showing. Each map keeps its best Meat (**NEW BEST** / **BEST 12,345**). -- **A break.** The screen's edges pulse red and, in single player only, the game slows down for a - moment. The slow motion never runs in a multiplayer session. It is the same at every break, but - never faster than the trainer's game speed (PRACTICE), which it gives back after. - -Off until you switch it on: skate.'s menu, **Mod Options > Player > Hall of Meat**, or the console (`~`) -with `hallofmeat 0|1`. The choice and each map's best are saved with the profile. - -With No Bail on, a wipeout No Bail stops is not a bail, so nothing shows. - -## How it works - -| Part | Files | -|---|---| -| Start, client tick, the switch and each map's best | `hall_of_meat.h`, `hall_of_meat.cpp` | -| Each physics step of the local skater, handed on by No Bail's skeleton response hook (`Extension/Skater/no_bail.h`) while the feature is on: what every ragdoll body hit and how hard, and whether it is a ragdoll | `hall_of_meat_skater.*` | -| The bail's phases (riding, bailing, down, getting up) and its Meat, on the game's clock; no game access | `hall_of_meat_model.*` | -| The card's rows | `hall_of_meat_card.*` | -| skate.'s skeleton mesh, read from the game's data and posed with the renderer's skinning matrices | `hall_of_meat_skeleton.*`, `hall_of_meat_render.*` | -| Hiding skate.'s bottom left HUD while the card shows | `hall_of_meat_hud.*` | -| The slow motion: SimulationTime's rate, frame cap and time scale as a session override, only where slower than the speed it found and writing that back after, the local skater's step along with them | `hall_of_meat_slow_motion.*` | -| Drawing, on the overlay's background draw list | `hall_of_meat_overlay.*` | -| The `hallofmeat` console switch | `hall_of_meat_commands.cpp` | - -Every address, offset and fingerprint is in `Engine/Game/Build/20260929/` (`skater_body.h`, -`skater_state.h`, `skater_render.h`, `skater_skeleton.h`, `hud_corner.h`, `ui_model.h`, -`ui_textures.h`), and each is checked before use. Without a matching game build the feature stays -unavailable and the menu's switch is greyed out. - -Switched off, it costs the game nothing while it plays: the renderer's two hooks and the UI model's -write hook are prepared at startup but only switched on with the feature (and off again once the HUD -corner and the game's speed are given back), and No Bail hands no physics step on. The skeleton mesh -is read the first time it is switched on. Only the images are read at startup, in the background: -the overlay builds its atlas once, when its graphics start. - -A finished bail logs what it scored at `--log-level=debug`. - -## Tests - -```bash -cmake --preset vs2022-x64 -DDINGOSDK_BUILD_HALL_OF_MEAT_TESTS=ON -DDINGOSDK_TEST_GAME_ROOT= -cmake --build --preset release --parallel 4 -ctest --test-dir build/vs2022-x64 -C Release -R hall_of_meat -``` - -`hall_of_meat` covers the bail's phases, the Meat and the card. `hall_of_meat_skeleton` reads and poses -the skeleton mesh from a real Skate folder. - -The skater's render hooks, its skeleton and its body maps were first found by -[gBGYo's ReSkate fork](https://github.com/gBGYo/ReSkate). diff --git a/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp b/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp deleted file mode 100644 index 444b2b8a..00000000 --- a/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp +++ /dev/null @@ -1,663 +0,0 @@ -// The Hall of Meat model, with made-up physics steps, and its score card. -#include "Extension/HallOfMeat/hall_of_meat_card.h" -#include "Extension/HallOfMeat/hall_of_meat_model.h" -#include "Extension/HallOfMeat/hall_of_meat_slow_motion.h" -#include -#include -#include -#include -#include -#include -#include - -using namespace dingosdk; -using namespace dingosdk::hall_of_meat; -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; } -constexpr std::uint64_t t0 = 100000; // GetTickCount64() is never near 0 - -// The game stepping its physics at `speed` (1 full speed, break_game_speed in slow motion): each -// step as long in the game's time as the real time since the one before, times the speed. -struct Game : Tracker { - float speed = 1.0f; - std::uint64_t last{}; - bool step(std::uint64_t now, Step step, Summary* ended = nullptr) noexcept { - step.seconds = last && now > last ? static_cast(now - last) / 1000.0f * speed : 0.0f; - last = now; - return Tracker::step(now, step, ended); - } -}; - -// A step of a skater in a ragdoll that touches nothing: a bail lying still. -Step lying() { - Step step; - step.ragdoll = true; - return step; -} -// A step of a skater standing, or riding: no ragdoll. -Step standing_up() { - Step step; - step.ragdoll = false; - return step; -} -// A ragdoll step in which `bone` hits the ground at `speed`. -Step hit(Bone bone, float speed, bool wipeout = false) { - auto step = lying(); - step.wipeout = wipeout; - auto& contact = step.body.bodies[skater_body::index(bone)]; - contact.touching = true; - contact.impact = speed; - contact.hit.world = true; - return step; -} -// The same while riding: no ragdoll yet. -Step riding_hit(Bone bone, float speed) { - auto step = hit(bone, speed); - step.ragdoll = false; - return step; -} -Step wipeout() { - auto step = lying(); - step.wipeout = true; - return step; -} -// A step in the air, hitting nothing: in a ragdoll, or on the board. -Step flying(bool ragdoll = true) { - Step step; - step.airborne = true; - step.ragdoll = ragdoll; - return step; -} -// A ragdoll step in which `bone` slides along the ground at `speed`, hitting nothing hard. -Step slide(Bone bone, float speed, bool wipeout = false) { - auto step = lying(); - step.wipeout = wipeout; - auto& contact = step.body.bodies[skater_body::index(bone)]; - contact.touching = true; - contact.slide = {speed, 0, 0}; - contact.hit.world = true; - return step; -} -// A ragdoll step at `speed`, touching nothing. -Step moving(float speed) { - auto step = lying(); - step.velocity = game::Vec3{speed, 0, 0}; - return step; -} -float seconds(std::uint64_t ms) { return static_cast(ms) / 1000.0f; } -// Steps `step` from `now` on, every 16 ms, for `ms`; returns the time after the last. -std::uint64_t keep(Game& tracker, std::uint64_t now, std::uint64_t ms, const Step& step) { - for (const auto until = now + ms; now < until; now += 16) tracker.step(now, step); - return now; -} - -void impacts_count_only_during_a_bail() { - Game tracker; - check(!tracker.step(t0, riding_hit(Bone::neck1, 9.0f)), "a step without a bail ends nothing"); - check(tracker.view(t0).phase == Phase::riding, "nothing shows before a bail"); - const auto later = t0 + wipeout_after_impact_ms + 16; - tracker.step(later, wipeout()); - check(tracker.phase(later) == Phase::bailing, "a wipeout starts a bail"); - check(tracker.view(later).injuries[skater_body::index(Bone::neck1)] == Injury::none, - "an impact long before the bail is not counted"); -} - -void the_hit_that_causes_the_wipeout_counts() { - // A drop from height: the thigh hits, the wipeout comes a step later. - Game tracker; - tracker.step(t0, riding_hit(Bone::right_upleg, 35.0f)); - tracker.step(t0 + 16, riding_hit(Bone::right_upleg, 12.0f)); - tracker.step(t0 + 32, wipeout()); - const auto view = tracker.view(t0 + 32); - check(view.phase == Phase::bailing && view.injuries[skater_body::index(Bone::right_upleg)] == Injury::broken, - "the hit before the wipeout breaks"); - check(view.tally.impacts == 1 && view.tally.hit_points == hit_points(35.0f), "at its hardest, once"); -} - -void nothing_shows_until_a_bone_is_hurt() { - Game tracker; - tracker.step(t0, wipeout()); - tracker.step(t0 + 16, hit(Bone::spine, hit_speed - 0.5f)); - check(tracker.phase(t0 + 16) == Phase::bailing && tracker.view(t0 + 16).phase == Phase::riding, - "a bail that hurts nothing shows nothing"); - tracker.step(t0 + 32, hit(Bone::spine, hit_speed)); - const auto view = tracker.view(t0 + 32); - check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && view.tally.impacts == 1, - "the first bruise brings the skeleton and the card"); - Game unhurt; - unhurt.step(t0, hit(Bone::spine, hit_speed - 0.5f, true)); - Summary summary; - check(unhurt.step(t0 + 16, standing_up(), &summary) && !summary.shown, "it ends unshown"); - check(unhurt.view(t0 + 32).phase == Phase::riding, "nor does it get a card"); -} - -void hard_hits_break_and_light_ones_bruise() { - Game tracker; - tracker.step(t0, hit(Bone::left_forearm, hit_speed + 0.1f, true)); - tracker.step(t0 + 16, hit(Bone::neck1, broken_speed + 0.1f)); - tracker.step(t0 + 32, hit(Bone::spine, hit_speed - 0.1f)); - tracker.step(t0 + 48, hit(Bone::left_forearm, 1.0f)); // a later, softer hit keeps the worst - const auto view = tracker.view(t0 + 48); - check(view.injuries[skater_body::index(Bone::left_forearm)] == Injury::hit, "a light hit bruises"); - check(view.injuries[skater_body::index(Bone::neck1)] == Injury::broken, "a hard hit breaks"); - check(view.injuries[skater_body::index(Bone::spine)] == Injury::none, "a graze below the threshold leaves no mark"); - check(view.flashes[skater_body::index(Bone::neck1)] > 0.9f, "a fresh hit flashes"); - check(tracker.view(t0 + 16 + flash_ms).flashes[skater_body::index(Bone::neck1)] == 0, "the flash fades"); -} - -void a_flump_starts_no_bail() { - // Rolling along the ground and jumping off a height while rolling: a ragdoll slamming the ground, - // which the game never wipes out. - Game tracker; - auto now = keep(tracker, t0, 1500, flying()); - for (int i = 0; i < 60; ++i, now += 16) { - auto step = hit(i % 2 ? Bone::left_arm : Bone::spine, broken_speed + 5.0f); - step.velocity = game::Vec3{8, -6, 0}; - tracker.step(now, step); - } - check(tracker.view(now).phase == Phase::riding && tracker.view(now).tally.score == 0, "nothing shows, nothing counts"); - check(!tracker.step(now, standing_up()), "and no bail ends"); -} - -void a_break_pulses_the_screen_edge() { - Game tracker; - tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true)); - check(tracker.view(t0 + 16).break_pulse == 0.0f, "a bruise does not pulse"); - tracker.step(t0 + 16, hit(Bone::left_forearm, broken_speed + 1.0f)); - const auto broke = t0 + 16; - check(near(tracker.view(broke + break_rise_ms / 2).break_pulse, 0.5f), "a break pulses up"); - check(near(tracker.view(broke + break_rise_ms).break_pulse, 1.0f), "to its full strength"); - check(near(tracker.view(broke + break_rise_ms + break_hold_ms - 16).break_pulse, 1.0f), "holds it"); - check(near(tracker.view(broke + break_rise_ms + break_hold_ms + break_fall_ms / 2).break_pulse, 0.5f), "and fades"); - check(tracker.view(broke + break_effect_ms).break_pulse == 0.0f, "then it is gone"); - tracker.step(broke + break_effect_ms, hit(Bone::left_forearm, broken_speed + 5.0f)); - check(tracker.view(broke + break_effect_ms + break_rise_ms).break_pulse == 0.0f, "a broken bone hit again does not pulse"); - tracker.step(broke + break_effect_ms + 16, hit(Bone::right_leg, broken_speed + 1.0f)); - check(near(tracker.view(broke + break_effect_ms + 16 + break_rise_ms).break_pulse, 1.0f), "the next break pulses again"); -} - -void a_break_slows_the_game_with_its_red_edge() { - Game tracker; - check(tracker.game_speed(t0) == 1.0f, "full speed without a bail"); - tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true)); - check(tracker.game_speed(t0 + 16) == 1.0f, "a bruise keeps the speed"); - tracker.step(t0 + 16, hit(Bone::left_forearm, broken_speed + 1.0f)); - const auto broke = t0 + 16; - check(tracker.game_speed(broke) == 1.0f && near(tracker.game_speed(broke + break_rise_ms), break_game_speed), - "a break slows the game down"); - check(near(tracker.game_speed(broke + break_rise_ms + break_hold_ms - 16), break_game_speed), "holds it slow"); - check(near(tracker.game_speed(broke + break_rise_ms + break_hold_ms + break_fall_ms / 2), (1.0f + break_game_speed) / 2), - "then lets it go"); - check(tracker.game_speed(broke + break_effect_ms) == 1.0f, "back to full speed as the red edge is gone"); - for (auto at = broke; at < broke + break_effect_ms; at += 10) - check(near(tracker.game_speed(at), 1.0f - (1.0f - break_game_speed) * tracker.view(at).break_pulse), - "the slow motion and the red edge are one"); - // A further break while it lets go slows the game again, with no jump on the way. - const auto again = broke + break_rise_ms + break_hold_ms + break_fall_ms / 2; - tracker.step(again, hit(Bone::right_leg, broken_speed + 1.0f)); - float previous = tracker.game_speed(again - 1), steepest = 0; - for (auto at = again; at < again + break_rise_ms + 16; ++at) { - const float speed = tracker.game_speed(at); - steepest = std::max(steepest, std::abs(speed - previous)); - previous = speed; - } - check(steepest < 0.02f, "the speed never jumps"); - check(near(tracker.game_speed(again + break_rise_ms), break_game_speed), "the further break slows it down again"); - tracker.lose(again + 16); - check(tracker.game_speed(again + 32) == 1.0f, "a skater gone takes the slow motion with them"); -} - -void a_break_just_before_the_bail_counts_from_the_bail() { - // The wipeout comes a few steps after the impact that broke the bone: the slow motion and the - // red edge run from the step that saw the break, not from the impact. - Game tracker; - tracker.step(t0, riding_hit(Bone::right_upleg, broken_speed + 5.0f)); - const auto wiped_out = t0 + wipeout_after_impact_ms - 16; - tracker.step(wiped_out, wipeout()); - check(near(tracker.game_speed(wiped_out + break_rise_ms), break_game_speed), "the slow motion at its full strength"); - check(near(tracker.view(wiped_out + break_rise_ms).break_pulse, 1.0f), "the red edge is at its full strength"); -} - -void the_board_and_bad_values_are_ignored() { - Game tracker; - auto step = wipeout(); - step.body.bodies[skater_body::index(Bone::board_root)].impact = 20.0f; - step.body.bodies[skater_body::index(Bone::hips)].impact = std::nanf(""); - step.body.bodies[skater_body::index(Bone::spine1)].impact = -8.0f; - tracker.step(t0, step); - for (const auto injury : tracker.view(t0).injuries) check(injury == Injury::none, "board, NaN and negative peaks hurt nothing"); -} - -void a_bail_lasts_until_the_skater_gets_up() { - Game tracker; - tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true)); - // Rolling, then lying however long: the bail goes on, its card with it. - auto now = keep(tracker, t0 + 16, 3000, hit(Bone::spine2, 2.0f)); - now = keep(tracker, now, 6000, lying()); - tracker.step(now, hit(Bone::neck1, hit_speed + 1.0f)); // a car finds the body on the ground - now = keep(tracker, now + 16, 1000, lying()); - const auto down = tracker.view(now); - check(down.phase == Phase::bailing && near(down.alpha, 1.0f) && down.tally.impacts == 2, "the bail and its card go on"); - Summary summary; - check(tracker.step(now, standing_up(), &summary), "standing up ends the bail"); - check(near(summary.tally.seconds, seconds(now - t0)) && near(summary.peaks[skater_body::index(Bone::hips)], hit_speed + 1.0f) && summary.shown, - "the summary holds the duration and the worst hits"); - const auto lingering = tracker.view(now + linger_ms - 16); - check(lingering.phase == Phase::getting_up && near(lingering.alpha, 1.0f), "the skeleton and the card stay a while"); - const auto fading = tracker.view(now + linger_ms + fade_ms / 2); - check(fading.phase == Phase::getting_up && near(fading.alpha, 0.5f), "then fade out together"); - check(fading.tally.impacts == 2, "with the bail's tally"); - check(tracker.view(now + linger_ms + fade_ms).phase == Phase::riding, "then they are gone"); - // The next wipeout starts afresh. - const auto later = now + linger_ms + fade_ms + 16; - tracker.step(later, wipeout()); - check(tracker.view(later).injuries[skater_body::index(Bone::hips)] == Injury::none, "a new bail starts clean"); -} - -void a_rest_ends_the_counting() { - Game tracker; - tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true)); - auto now = keep(tracker, t0 + 16, 1000, moving(4.0f)); - const auto went_still = now; - now = keep(tracker, now, rest_ms - 16, moving(0.2f)); - check(tracker.phase(now - 16) == Phase::bailing, "not resting yet"); - now = keep(tracker, now, 32, moving(0.1f)); - check(tracker.phase(now) == Phase::down, "a still body rests"); - check(near(tracker.view(now).tally.seconds, seconds(went_still - t0)), "its time stopped when it went still"); - auto twitch = hit(Bone::left_hand, hit_speed + 1.0f); - twitch.velocity = game::Vec3{0.3f, 0, 0}; // a bone twitches, the body barely moves - tracker.step(now, twitch); - check(tracker.phase(now) == Phase::down && tracker.view(now).tally.impacts == 1, "a twitch at rest scores nothing"); - const auto rested = tracker.view(now).tally; - const auto later = now + 3000; - check(near(tracker.view(later).tally.seconds, seconds(went_still - t0)), "however long it lies"); - // Getting up starts inside the ragdoll and moves the body fast again: nothing more counts. - auto getting_up = hit(Bone::left_foot, hit_speed + 1.0f); - getting_up.velocity = game::Vec3{0, 3.0f, 2.0f}; - now = keep(tracker, later, 600, getting_up); - const auto final = tracker.view(now).tally; - check(tracker.phase(now) == Phase::down && final.score == rested.score && final.impacts == rested.impacts && - near(final.seconds, rested.seconds) && near(final.top_speed, rested.top_speed), - "getting up adds no time and no points"); - Summary summary; - tracker.step(now, standing_up(), &summary); - check(summary.tally.score == rested.score && near(summary.tally.seconds, seconds(went_still - t0)), - "the bail ends as it was when the body came to rest"); - check(tracker.view(now + linger_ms + fade_ms / 2).tally.score == rested.score, "and fades out so"); - Game unknown; - unknown.step(t0, hit(Bone::hips, hit_speed + 1.0f, true)); - check(unknown.phase(keep(unknown, t0 + 16, 2000, lying())) == Phase::bailing, "an unknown speed never rests"); -} - -void a_fall_from_height_bails_at_its_wipeout() { - // Thrown off the board from height: the ragdoll begins in the air, the wipeout comes with the impact. - Game tracker; - auto now = keep(tracker, t0, 500, flying(false)); - now = keep(tracker, now, 2000, flying()); - check(tracker.phase(now) == Phase::riding, "a ragdoll in the air is no bail yet"); - tracker.step(now, hit(Bone::neck1, 20.0f, true)); - check(tracker.view(now).phase == Phase::bailing && tracker.view(now).tally.impacts == 1, "the wipeout starts it with the impact"); - check(std::abs(tracker.view(now).tally.airtime - 2.5f) < 0.02f, "and the whole flight's airtime"); - now = keep(tracker, now + 16, 1000, lying()); - Summary summary; - check(tracker.step(now, standing_up(), &summary) && summary.tally.impacts == 1, "and ends when the skater stands up"); -} - -void a_stumble_without_a_ragdoll_ends() { - Game tracker; - auto stumble = wipeout(); - stumble.ragdoll = false; - tracker.step(t0, stumble); - const auto now = keep(tracker, t0 + 16, ragdoll_wait_ms - 32, standing_up()); - check(tracker.phase(now) == Phase::bailing, "the ragdoll is given a moment to begin"); - check(tracker.step(t0 + ragdoll_wait_ms, standing_up()), "then the bail ends"); -} - -void an_unknown_skater_state_ends_nothing() { - Game tracker; - tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true)); - const auto now = keep(tracker, t0 + 16, 2000, Step{}); // the skater state could not be read - check(tracker.phase(now) == Phase::bailing, "the bail goes on"); - check(tracker.step(now, standing_up()), "until the skater is seen standing"); - Game riding; - riding.step(t0, Step{}); - check(riding.phase(t0) == Phase::riding, "nor does it start one"); -} - -void a_lost_skater_ends_the_bail() { - Game tracker; - check(!tracker.lose(t0), "no bail, nothing to end"); - tracker.step(t0, hit(Bone::hips, hit_speed + 1.0f, true)); - tracker.step(keep(tracker, t0 + 16, 480, lying()) + 4, lying()); // the last step at t0 + 500 - Summary summary; - check(tracker.lose(t0 + 500, &summary) && near(summary.tally.seconds, 0.5f) && summary.shown, "a respawn ends the bail"); - check(tracker.view(t0 + 500).phase == Phase::getting_up, "and it fades out"); - check(!tracker.lose(t0 + 516), "once"); - // A teleport in mid-air: the flight before it is not the next bail's. - Game teleported; - const auto now = keep(teleported, t0, 1000, flying(false)); - teleported.lose(now); - teleported.step(now + 16, hit(Bone::hips, hit_speed + 1.0f, true)); - check(teleported.view(now + 16).tally.airtime == 0, "a flight before a teleport is forgotten"); -} - -void a_reading_clock_behind_the_physics_one_is_harmless() { - Game tracker; - tracker.step(t0, hit(Bone::neck, hit_speed + 0.5f, true)); - const auto view = tracker.view(t0 - 5); - check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && near(view.flashes[skater_body::index(Bone::neck)], 1.0f), - "a view a moment before the step still shows it"); -} - -void one_contact_is_one_impact() { - Game tracker; - constexpr float light = hit_speed + 0.5f, harder = broken_speed - 1.0f; - tracker.step(t0, hit(Bone::spine, light, true)); - tracker.step(t0 + 16, hit(Bone::spine, harder)); // the same contact, harder - tracker.step(t0 + 32, hit(Bone::spine, 2.0f)); // easing off: no hit - auto tally = tracker.view(t0 + 32).tally; - check(tally.impacts == 1 && tally.damage == hit_points(harder), "a contact counts once, as its hardest step"); - tracker.step(t0 + 32 + impact_gap_ms + 16, hit(Bone::spine, light)); // hitting the ground again - tally = tracker.view(t0 + 32 + impact_gap_ms + 16).tally; - check(tally.impacts == 2 && tally.damage == hit_points(harder) + hit_points(light), "a new contact is a new impact"); - check(hit_points(hit_speed - 0.1f) == 0 && hit_points(hit_speed) == 100, "only hits score, the lightest 100"); - check(hit_points(30.0f) == 887, "a slam scores more than its speed, less than its energy"); -} - -void the_meat_adds_up() { - Game tracker; - constexpr float breaks = broken_speed + 1.0f, bruises = hit_speed + 1.0f; - tracker.step(t0, hit(Bone::neck1, breaks, true)); // the head breaks - tracker.step(t0 + 16, hit(Bone::left_hand, bruises)); // a hand is bruised - const auto tally = tracker.view(t0 + 16).tally; - check(tally.broken == 1 && tally.impacts == 2, "one bone broken, two hits"); - check(tally.hit_points == hit_points(breaks) + hit_points(bruises) && tally.head_bonus == hit_points(breaks), - "a hit to the head counts double"); - check(tally.score == 2 * hit_points(breaks) + hit_points(bruises) + points_per_break + tally.time_points, - "the Meat is the hits, the breaks and the time"); -} - -void a_vehicle_adds_half_again() { - Game tracker; - auto car = hit(Bone::spine, 10.0f, true); - car.body.bodies[skater_body::index(Bone::spine)].hit.vehicle = true; - tracker.step(t0, car); - tracker.step(t0 + 16, hit(Bone::spine, 12.0f)); // the same contact, harder: still the car's - const auto tally = tracker.view(t0 + 16).tally; - check(tally.impacts == 1 && tally.vehicle_bonus == static_cast(hit_points(12.0f) * 0.5f + 0.5f), "half again"); - check(impact_points({skater_body::index(Bone::neck1), 10.0f, true}) == - hit_points(10.0f) * 2 + static_cast(hit_points(10.0f) * 0.5f + 0.5f), - "the head and a vehicle together"); -} - -void sliding_along_the_ground_is_road_rash() { - Game tracker; - tracker.step(t0, slide(Bone::hips, 5.0f, true)); - auto now = t0; - for (int i = 0; i < 50; ++i) tracker.step(now += 20, slide(Bone::hips, 5.0f)); // a second at 5 m/s - const auto view = tracker.view(now); - check(view.phase == Phase::bailing, "a sliding bail goes on"); - check(std::abs(view.tally.scraped - 5.0f) < 1e-3f && view.tally.scrape_points == static_cast(5.0f * points_per_scraped_metre + 0.5f), "5 m of road rash"); - check(view.injuries[skater_body::index(Bone::hips)] == Injury::hit, "it bruises and shows"); - check(view.tally.score == view.tally.scrape_points + view.tally.time_points && view.tally.impacts == 0, "it scores without a hit"); - - // The whole body slides as one: the road rash is how far it went, not the bodies' sum. - Game body; - auto all = slide(Bone::hips, 4.0f, true); - for (const auto bone : {Bone::spine, Bone::spine1, Bone::left_upleg}) - all.body.bodies[skater_body::index(bone)] = all.body.bodies[skater_body::index(Bone::hips)]; - all.body.bodies[skater_body::index(Bone::left_hand)] = slide(Bone::left_hand, 8.0f).body.bodies[skater_body::index(Bone::left_hand)]; - body.step(t0, all); - all.wipeout = false; - for (now = t0; now < t0 + 1000;) body.step(now += 20, all); - const auto tally = body.view(now).tally; - check(std::abs(tally.scraped - 4.8f) < 1e-3f, "the average of the scraping bodies' slides, over a second"); - - Game board; - auto on_board = slide(Bone::spine, 5.0f, true); - auto& contact = on_board.body.bodies[skater_body::index(Bone::spine)]; - contact.hit = {}; - contact.hit.board = true; - board.step(t0, on_board); - board.step(t0 + 1000, on_board); - board.step(t0 + 1020, slide(Bone::left_foot, 5.0f)); - board.step(t0 + 1040, slide(Bone::spine, scrape_speed - 0.1f)); - check(board.view(t0 + 1040).tally.scraped == 0.0f, "the board, the feet and a slow slip do not scrape"); -} - -void the_view_follows_the_bail() { - Game tracker; - check(tracker.phase(t0) == Phase::riding, "riding"); - tracker.step(t0, hit(Bone::left_hand, hit_speed + 1.0f, true)); - tracker.step(keep(tracker, t0 + 16, 480, lying()) + 4, lying()); // the last step at t0 + 500 - auto view = tracker.view(t0 + 500); - check(view.phase == Phase::bailing && near(view.tally.seconds, 0.5f), "bailing"); - check(view.injuries[skater_body::index(Bone::left_hand)] == Injury::hit && view.tally.impacts == 1, "the hand's hit"); - const auto now = keep(tracker, t0 + 516, 1000, lying()); - tracker.step(now, standing_up()); - view = tracker.view(now + 100); - check(view.phase == Phase::getting_up && near(view.tally.seconds, seconds(now - t0)), "getting up, with how long the bail lasted"); - check(tracker.phase(now + linger_ms + fade_ms) == Phase::riding, "then riding again"); -} - -void airtime_is_the_bails_time_in_the_air() { - // A fall from height on the board: 4 s in the air, the wipeout a step after the impact. - Game tracker; - std::uint64_t now = t0; - for (; now <= t0 + 4000; now += 16) tracker.step(now, flying(false)); - tracker.step(now, standing_up()); // the touch-down - tracker.step(now + 16, hit(Bone::hips, hit_speed, true)); // the impact's wipeout - tracker.step(now + 32, flying()); // the ragdoll bounces: +16 ms - tracker.step(now + 48, lying()); // and lies - check(std::abs(tracker.view(now + 48).tally.airtime - (4000 + 16) / 1000.0f) < 1e-4f, - "the flight before the wipeout and the bail's own flights count"); - const auto air = tracker.view(now + 48).tally; - check(air.airtime_points == static_cast(air.airtime * points_per_air_second + 0.5f), "airtime scores by the second"); - - // A clean landing, then a bail later on the ground: that flight is not the bail's. - Game later; - for (now = t0; now <= t0 + 1000; now += 16) later.step(now, flying(false)); - later.step(now, standing_up()); - later.step(now + wipeout_after_impact_ms + 16, hit(Bone::hips, hit_speed, true)); - check(later.view(now + wipeout_after_impact_ms + 16).tally.airtime == 0, "a bail on the ground has no airtime"); -} - -void a_bail_lasts_through_its_flights() { - // The first impact throws the body off a ledge: seconds in the air, then the second one. - Game tracker; - auto now = keep(tracker, t0, 1000, flying(false)); - tracker.step(now, hit(Bone::hips, broken_speed + 1.0f, true)); - const auto thrown = now; - now = keep(tracker, now + 16, 3000, flying()); - check(tracker.view(now).phase == Phase::bailing, "a body in the air is still bailing"); - tracker.step(now, hit(Bone::neck1, broken_speed + 1.0f, true)); // the second impact, wiping out again - const auto tally = tracker.view(now).tally; - check(tracker.view(now).phase == Phase::bailing && tally.impacts == 2 && tally.broken == 2, "the same bail, both impacts"); - check(std::abs(tally.airtime - static_cast(1000 + now - thrown - 16) / 1000.0f) < 0.02f, - "the flight before the bail and the one inside it add up"); - Summary summary; - tracker.step(now + 16, standing_up(), &summary); - check(summary.tally.impacts == 2 && std::abs(summary.tally.airtime - tally.airtime) < 1e-4f, - "the airtime holds until the bail is over"); -} - -void the_slow_motion_keeps_the_steps() { - check(slow_motion_rate(60, 1.0f) == 60, "full speed keeps the rate"); - check(slow_motion_rate(60, break_game_speed) == 200, "a break's slow motion steps more often: 200 a second of game time is 60 a real one"); - check(slow_motion_rate(60, 0.5f) == 120, "half speed doubles it"); - check(slow_motion_rate(60, 2.0f) == 60, "never faster than the game's own"); - check(slow_motion_rate(60, 0.0f) == highest_simulation_rate && slow_motion_rate(60, -1.0f) == highest_simulation_rate, - "a stop is the slowest it can follow"); - check(slow_motion_rate(60, std::numeric_limits::quiet_NaN()) == 60, "a broken speed is full speed"); - // Every break runs the same speeds, but never faster than the game ran (the trainer's game speed). - check(slow_motion_time_scale(1.0f, break_game_speed) == break_game_speed, "at the game's own speed, the break's"); - check(slow_motion_time_scale(0.5f, break_game_speed) == break_game_speed, "with the trainer at half speed, the same"); - check(!slow_motion_time_scale(0.5f, 0.7f), "but not where the curve is faster than the trainer"); - check(!slow_motion_time_scale(0.1f, break_game_speed), "a trainer slower than the break is never sped up"); - check(!slow_motion_time_scale(0.0f, break_game_speed), "a paused game stays paused"); - check(!slow_motion_time_scale(1.0f, 1.0f) && !slow_motion_time_scale(1.0f, 2.0f), "full speed is none"); - check(slow_motion_time_scale(1.0f, 0.0f) == slowest_speed, "no slower than the steps can follow"); - check(!slow_motion_time_scale(1.0f, std::numeric_limits::quiet_NaN()), "a broken speed is none"); -} - -void the_card_shows_the_bail() { - check(grouped(0) == "0" && grouped(999) == "999" && grouped(1000) == "1,000" && grouped(1234567) == "1,234,567", - "thousands grouped"); - check(grouped(-1234) == "-1,234", "a negative number too"); - Game tracker; - check(card(tracker.view(t0), {}).opacity == 0 && card(tracker.view(t0), {}).rows.empty(), "no card while riding"); - tracker.step(t0, hit(Bone::neck1, broken_speed + 1.0f, true)); // the head breaks - tracker.step(t0 + 16, hit(Bone::left_hand, hit_speed + 1.0f)); - tracker.step(keep(tracker, t0 + 32, 1456, lying()) + 12, lying()); // the last step at t0 + 1500 - const auto view = tracker.view(t0 + 1500); - auto shown = card(view, standing(0, view.tally.score)); - check(near(shown.opacity, 1.0f) && shown.total == view.tally.score, "the Meat, every stat counted"); - check(shown.rows.size() == 1 && shown.rows[0].stat == Stat::time && shown.rows[0].value == "1.5 s" && - shown.rows[0].points == static_cast(1.5f * points_per_second + 0.5f), - "a small bail shows its time alone"); - check(shown.badge == "NEW BEST" && shown.highlight, "the map's first bail sets its best"); - shown = card(view, standing(99999, view.tally.score)); - check(shown.badge == "BEST 99,999" && !shown.highlight, "a smaller one shows the best"); - check(card(view, {}).badge.empty(), "an unknown best shows none"); - tracker.step(t0 + 1500, standing_up()); - check(near(card(tracker.view(t0 + 1500 + linger_ms / 2), {}).opacity, 1.0f), "the card stays once the skater gets up"); - check(near(card(tracker.view(t0 + 1500 + linger_ms + fade_ms / 2), {}).opacity, 0.5f), "then fades"); - - // A big one: off a roof for 3.5 s, five bones broken at 20 m/s, a slide of 5 m. - Game big; - auto falling = flying(false); - falling.velocity = game::Vec3{0, -10, 0}; - auto now = keep(big, t0, 3500, falling); - for (const auto bone : {Bone::hips, Bone::spine, Bone::left_leg, Bone::right_leg, Bone::left_arm}) { - auto impact = hit(bone, broken_speed + 1.0f, true); - impact.velocity = game::Vec3{20, 0, 0}; - big.step(now += 16, impact); - } - auto sliding = slide(Bone::spine1, 5.0f); - sliding.velocity = game::Vec3{5, 0, 0}; - sliding.spin = 3.0f * radians_per_rotation; // tumbling over three times a second - now = keep(big, now + 16, 1000, sliding); - shown = card(big.view(now), {}); - std::vector stats; - for (const auto& row : shown.rows) stats.push_back(row.stat); - check(stats == std::vector{Stat::time, Stat::hits, Stat::broken, Stat::road_rash, Stat::airtime, Stat::fall, Stat::speed, - Stat::rotations}, - "a big bail shows every stat, in its order"); - check(shown.rows[2].value == "5 broken" && shown.rows[2].points == 5 * points_per_break, "the broken bones"); - check(shown.rows[6].value == "44.7 MPH" && shown.rows[6].points == static_cast(20.0f * points_per_speed + 0.5f), - "the top speed, in miles per hour"); - check(shown.rows[7].value == "3.0 rotations", "the rotations"); -} - -void the_fall_and_the_top_speed_score() { - // Off a roof on the board: falling at 10 m/s for 2 s, then the impact's wipeout at 20 m/s. - Game tracker; - auto falling = flying(false); - falling.velocity = game::Vec3{3, -10, 0}; - auto now = keep(tracker, t0, 2000, falling); - auto impact = hit(Bone::hips, hit_speed + 2.0f, true); - impact.velocity = game::Vec3{12, -16, 0}; - tracker.step(now, impact); - auto rolling = lying(); - rolling.velocity = game::Vec3{0, 2, 4}; // rolling on, bouncing up: it goes no further down - now = keep(tracker, now + 16, 500, rolling); - const auto t = tracker.view(now).tally; - check(std::abs(t.fallen - 19.84f) < 0.01f, "the flight's fall is the bail's"); - check(t.fall_points == static_cast(t.fallen * points_per_metre_fallen + 0.5f), "a metre fallen scores"); - check(near(t.top_speed, 20.0f) && t.speed_points == static_cast(20.0f * points_per_speed + 0.5f), "the top speed scores"); - check(t.score == t.damage + t.broken * points_per_break + t.time_points + t.airtime_points + t.fall_points + - t.speed_points + t.rotation_points, - "the Meat is every stat's points"); -} - -void the_body_turning_over_scores() { - Game tracker; - auto spinning = hit(Bone::hips, hit_speed + 1.0f, true); - spinning.spin = -1.0f; // not a speed: nothing - tracker.step(t0, spinning); - auto tumbling = moving(4.0f); - tumbling.spin = 2.0f * radians_per_rotation; // twice a second, any way round - auto now = keep(tracker, t0 + 16, 1504, tumbling); - auto t = tracker.view(now).tally; - check(std::abs(t.rotations - 3.0f) < 0.01f, "three rotations in a second and a half"); - check(t.rotation_points == static_cast(t.rotations * points_per_rotation + 0.5f), "a rotation scores"); - auto lying_turning = moving(0.1f); - lying_turning.spin = radians_per_rotation; // lying still: the body comes to rest, whatever turns - now = keep(tracker, now, rest_ms + 32, lying_turning); - check(tracker.phase(now) == Phase::down, "the body rests"); - const auto rested = tracker.view(now).tally.rotations; - now = keep(tracker, now, 1000, tumbling); // getting up - check(near(tracker.view(now).tally.rotations, rested), "after the rest nothing turns any more"); -} - -void a_bail_runs_on_the_games_clock() { - // A break slows the game down: its physics steps as often each real second as before, each the - // shorter. In play on 2026-10-08 the ragdoll came later than ragdoll_wait_ms in real time, so the - // bail ended in mid-air and the next impact began another: the bones hurt before were gone. - Game slow; - slow.speed = break_game_speed; - auto thrown = hit(Bone::left_leg, hit_speed + 1.0f, true); - thrown.ragdoll = false; // the bail's first part: off the board, the ragdoll still to come - slow.step(t0, thrown); - auto falling = flying(false); - falling.velocity = game::Vec3{0, -10, 0}; - auto now = keep(slow, t0 + 16, 1000, falling); // a real second, under a third of the game's - check(slow.phase(now) == Phase::bailing, "the ragdoll still has the game's moment to begin"); - falling.ragdoll = true; - now = keep(slow, now, 1000, falling); - slow.step(now, hit(Bone::neck1, broken_speed + 1.0f, true)); // a further impact, wiping out again - const auto view = slow.view(now); - check(view.phase == Phase::bailing && near(view.alpha, 1.0f) && view.tally.impacts == 2 && - view.injuries[skater_body::index(Bone::left_leg)] == Injury::hit, - "one bail, the skeleton whole: the leg bruised before is still bruised"); - check(std::abs(view.tally.airtime - 0.6f) < 0.01f && std::abs(view.tally.fallen - 6.0f) < 0.1f, - "the airtime and the fall are the game's: two real seconds at 0.3"); -} - -void a_bail_sets_a_best_only_by_beating_it() { - check(standing(0, 300).new_best && standing(0, 300).best == 300, "the first bail on a map sets its best"); - check(!standing(500, 300).new_best && standing(500, 300).best == 500, "a smaller bail keeps it"); - check(!standing(500, 500).new_best, "matching it does not beat it"); -} -} - -int main() { - try { - impacts_count_only_during_a_bail(); - the_hit_that_causes_the_wipeout_counts(); - nothing_shows_until_a_bone_is_hurt(); - hard_hits_break_and_light_ones_bruise(); - a_break_pulses_the_screen_edge(); - a_break_slows_the_game_with_its_red_edge(); - a_break_just_before_the_bail_counts_from_the_bail(); - a_flump_starts_no_bail(); - the_board_and_bad_values_are_ignored(); - a_bail_lasts_until_the_skater_gets_up(); - a_rest_ends_the_counting(); - a_fall_from_height_bails_at_its_wipeout(); - a_stumble_without_a_ragdoll_ends(); - an_unknown_skater_state_ends_nothing(); - a_lost_skater_ends_the_bail(); - a_reading_clock_behind_the_physics_one_is_harmless(); - one_contact_is_one_impact(); - the_meat_adds_up(); - a_vehicle_adds_half_again(); - sliding_along_the_ground_is_road_rash(); - the_view_follows_the_bail(); - airtime_is_the_bails_time_in_the_air(); - a_bail_lasts_through_its_flights(); - the_fall_and_the_top_speed_score(); - the_body_turning_over_scores(); - a_bail_runs_on_the_games_clock(); - a_bail_sets_a_best_only_by_beating_it(); - the_card_shows_the_bail(); - the_slow_motion_keeps_the_steps(); - } catch (const std::exception& error) { - std::cerr << "FAILED: " << error.what() << '\n'; - return 1; - } - std::cout << "Hall of Meat tests passed.\n"; - return 0; -} diff --git a/Extension/HallOfMeat/hall_of_meat.cpp b/Extension/HallOfMeat/hall_of_meat.cpp deleted file mode 100644 index 1d2a913b..00000000 --- a/Extension/HallOfMeat/hall_of_meat.cpp +++ /dev/null @@ -1,235 +0,0 @@ -#include "hall_of_meat.h" -#include "Engine/Core/Hooks/hooks.h" -#include "hall_of_meat_hud.h" -#include "hall_of_meat_overlay.h" -#include "hall_of_meat_render.h" -#include "hall_of_meat_skater.h" -#include "hall_of_meat_skeleton.h" -#include "hall_of_meat_slow_motion.h" -#include "Engine/Core/Log/logging.h" -#include "Extension/Profile/local_profile_runtime.h" -#include "Extension/Skater/no_bail.h" -#include -#include -#include -#include -#include -#include -#include - -namespace dingosdk::hall_of_meat { -namespace { -constexpr const char* preference = "HallOfMeat"; -constexpr std::string_view best_prefix = "HallOfMeat.Best."; // + the level, lower case - -struct State { - std::atomic ready{}, on{}; // the switch: started, and switched on - SRWLOCK lock = SRWLOCK_INIT; // guards the rest; never held across game reads - Tracker tracker; - bool summary_pending{}; - Summary summary; - // The current map's best Meat (known once the client tick named the map), where the last - // finished bail that showed stands against it, and whether a new best is still to be saved. - bool best_known{}, best_unsaved{}; - int best{}; - Standing standing; - std::string level; // client thread only - SlowMotion slow_motion; // client thread only: after a break - bool active{}; // client thread only: the hooks on, as the switch is or until what it changed is given back -}; -State& state() { static auto* value = new State; return *value; } - -// A bail is over (under the lock): it is logged on the client tick, and stands against the map's best. -void finished(State& s, const Summary& ended) noexcept { - s.summary = ended; - s.summary_pending = true; - s.standing = ended.shown && s.best_known ? standing(s.best, ended.tally.score) : Standing{}; - if (s.standing.new_best) { - s.best = s.standing.best; - s.best_unsaved = true; - } -} - -// Physics thread, every step of the local skater (no_bail.h): what each body touched in the step -// and whether it is a ragdoll, and so where the bail is and what it did. -void observe_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { - if (!enabled()) return; - auto& s = state(); - const auto now = GetTickCount64(); - const auto step = read_step(skater, seconds, wipeout); - Summary ended; - AcquireSRWLockExclusive(&s.lock); - if (s.tracker.step(now, step, &ended)) finished(s, ended); - ReleaseSRWLockExclusive(&s.lock); -} - -// One debug line per bail: what it scored and why. -void log_bail(const Summary& summary) { - std::vector order; - for (std::size_t index = 1; index < skater_body::count; ++index) - if (summary.peaks[index] > 0) order.push_back(index); - std::sort(order.begin(), order.end(), [&](auto a, auto b) { return summary.peaks[a] > summary.peaks[b]; }); - std::string hits; - for (const auto index : order) { - const float peak = summary.peaks[index]; - if (peak < hit_speed && !hits.empty()) break; - hits += std::format("{}{} {:.1f} m/s{}", hits.empty() ? "" : ", ", skater_body::names[index], peak, - peak >= broken_speed ? " (broken)" : peak >= hit_speed ? " (hit)" : " (hardest, not hurt)"); - } - std::string scrapes; - for (std::size_t index = 1; index < skater_body::count; ++index) - if (summary.scraped[index] >= 0.05f) - scrapes += std::format("{}{} {:.1f} m", scrapes.empty() ? "" : ", ", skater_body::names[index], summary.scraped[index]); - const auto& tally = summary.tally; - logging::log(logging::Level::debug, logging::Channel::skater, - "Hall of Meat: bail over, {} Meat ({} damage: {} from {} impacts, head +{}, vehicle +{}, road rash {:.1f} m +{}; " - "{} broken +{}; {:.1f} s +{}; {:.1f} s airtime +{}; fell {:.1f} m +{}; top speed {:.1f} m/s +{}; {:.1f} rotations +{}). " - "Hits: {}. Road rash: {}", - tally.score, tally.damage, tally.hit_points, tally.impacts, tally.head_bonus, tally.vehicle_bonus, - static_cast(tally.scraped), tally.scrape_points, tally.broken, tally.broken * points_per_break, - static_cast(tally.seconds), tally.time_points, static_cast(tally.airtime), tally.airtime_points, - static_cast(tally.fallen), tally.fall_points, static_cast(tally.top_speed), tally.speed_points, - static_cast(tally.rotations), tally.rotation_points, - hits.empty() ? std::string("no body contact") : hits, scrapes.empty() ? std::string("none") : scrapes); -} - -std::string best_key(std::string_view level) { - std::string key(best_prefix); - for (const char c : level) key += c >= 'A' && c <= 'Z' ? static_cast(c - 'A' + 'a') : c; - return key; -} -int saved_best(std::string_view level) noexcept { - const auto value = profile_runtime::local_value(best_key(level)); - if (!value || !value->is_number()) return 0; - const auto best = value->get(); - return std::isfinite(best) && best > 0 && best < 1e9 ? static_cast(best) : 0; -} -} - -bool start(std::uintptr_t base) noexcept { - auto& s = state(); - if (s.ready.load(std::memory_order_acquire)) return true; - if (!start_skater(base)) return false; // logged - // The hooks are prepared here and switched on and off with the feature (on_client_tick). - (void)start_render(base); // without it no skeleton shows: logged - start_hud(base); - const bool on = profile_runtime::local_preference(preference).value_or(false); - // The overlay builds its atlas once, when its graphics start, so the images are read now. - overlay::prepare_hall_of_meat_images(); - s.on.store(on, std::memory_order_release); - if (on) set_no_bail_step_observer(&observe_step); - s.ready.store(true, std::memory_order_release); - logging::log(logging::Level::info, logging::Channel::skater, "Hall of Meat ready ({}).", on ? "on" : "off"); - return true; -} - -void on_client_tick() noexcept { - auto& s = state(); - if (!s.ready.load(std::memory_order_acquire)) return; - const bool on = enabled(); - if (!on && !s.active) return; // switched off: nothing hooked, nothing to give back - if (on && !s.active) { - prepare_skeleton(); // once, in the background - { - HookBatch batch; // (one pause of the game's threads for both) - (void)hook_render(true); - (void)hook_hud(true); - } - s.active = true; - } - try { - NoBailSkater skater; - const bool gone = !no_bail_skater(skater); - const auto now = GetTickCount64(); - Summary summary, ended; - bool pending{}, unsaved{}, card{}; - int best{}; - float speed{}; - AcquireSRWLockExclusive(&s.lock); - if (gone && s.tracker.lose(now, &ended)) finished(s, ended); - card = s.tracker.view(now).phase != Phase::riding; - speed = s.tracker.game_speed(now); - std::swap(pending, s.summary_pending); - if (pending) summary = s.summary; - std::swap(unsaved, s.best_unsaved); - best = s.best; - ReleaseSRWLockExclusive(&s.lock); - // The renderer's picture of the skater is only read while a bail shows. - follow_render(card); - // The card takes the place of skate.'s bottom left HUD while it shows: the bail is what counts. - const bool corner_back = hide_hud(card); - // A break slows the game down (in single player only: hall_of_meat_slow_motion.h). - (void)s.slow_motion.set(speed); - if (pending && logging::enabled(logging::Level::debug)) log_bail(summary); - if (unsaved && !s.level.empty()) profile_runtime::set_local_values({{best_key(s.level), static_cast(best)}}); - // Switched off (set_enabled ended the bail): the hooks go once the corner and the game's speed are given back. - if (!on && corner_back && s.slow_motion.idle() && hook_hud(false) && hook_render(false)) s.active = false; - } catch (...) { /* A lost log line or best is never worth the client tick. */ } -} - -void set_level(std::string_view level) noexcept { - auto& s = state(); - if (!s.ready.load(std::memory_order_acquire) || level == s.level) return; - try { - s.level = level; - const int best = level.empty() ? 0 : saved_best(level); - AcquireSRWLockExclusive(&s.lock); - s.best_known = !level.empty(); - s.best = best; - s.best_unsaved = false; - s.standing = {}; - ReleaseSRWLockExclusive(&s.lock); - } catch (...) { /* The map's best stays unknown: no card shows one. */ } -} - -bool available() noexcept { return state().ready.load(std::memory_order_acquire); } -bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); } - -void set_enabled(bool enabled) noexcept { - auto& s = state(); - s.on.store(enabled, std::memory_order_release); - // Switched off, No Bail's hook hands no step on: the physics step pays nothing for it. - if (s.ready.load(std::memory_order_acquire)) set_no_bail_step_observer(enabled ? &observe_step : nullptr); - if (!enabled) { - AcquireSRWLockExclusive(&s.lock); - s.tracker.reset(); - s.summary_pending = false; - ReleaseSRWLockExclusive(&s.lock); - } - profile_runtime::set_local_preference(preference, enabled); -} - -Frame frame() { - auto& s = state(); - if (!enabled()) return {}; - const auto now = GetTickCount64(); - View view; - Standing against; // the card's: the bail against the map's best before it - AcquireSRWLockExclusive(&s.lock); - view = s.tracker.view(now); - if (view.phase == Phase::bailing || view.phase == Phase::down) - against = s.best_known ? standing(s.best, view.tally.score) : Standing{}; - else if (view.phase == Phase::getting_up) against = s.standing; // the best already counts this bail - ReleaseSRWLockExclusive(&s.lock); - if (view.phase == Phase::riding) return {}; - - Frame result; - result.card = card(view, against); - result.break_pulse = view.break_pulse; - // The skeleton as the renderer drew the skater in the latest picture, seen by its camera. - auto mesh = skeleton_mesh(); - Picture picture; - PosedSkeleton posed; - if (!mesh || !latest_picture(picture) || !pose_skeleton(*mesh, picture.skin, posed)) return result; - auto& skeleton = result.skeleton; - skeleton.camera = picture.camera; - skeleton.vertical_fov = picture.vertical_fov; - skeleton.positions = std::move(posed.positions); - skeleton.normals = std::move(posed.normals); - skeleton.mesh = std::move(mesh); - skeleton.alpha = view.alpha; - skeleton.injuries = view.injuries; - skeleton.flashes = view.flashes; - return result; -} -} diff --git a/Extension/HallOfMeat/hall_of_meat.h b/Extension/HallOfMeat/hall_of_meat.h deleted file mode 100644 index 71a4c64c..00000000 --- a/Extension/HallOfMeat/hall_of_meat.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include "hall_of_meat_card.h" -#include "hall_of_meat_model.h" -#include "hall_of_meat_skeleton.h" -#include -#include -#include -#include -#include - -// Hall of Meat, as in skate. 3: when the local skater bails, the bones it hurt show over the -// world, yellow where a hit bruised one and red where one broke it, with the bail's card, its -// time and Meat counting until the body comes to rest; both stay a second after the skater gets -// up, then fade out. A bone breaking flashes the screen's edges red and, in single player, slows -// the game down a moment (hall_of_meat_slow_motion.h). -// -// Each physics step of the local skater (hall_of_meat_skater.h) feeds the bail -// (hall_of_meat_model.h): what each body hit, and whether it is a ragdoll. The skeleton is skate.'s -// own skeleton mesh (hall_of_meat_skeleton.h) posed as the renderer draws the skater -// (hall_of_meat_render.h); the card (hall_of_meat_card.h) takes the place of skate.'s bottom left HUD -// while it shows (hall_of_meat_hud.h). The overlay draws both (hall_of_meat_overlay.h). Each map's best -// Meat is saved with the profile. -namespace dingosdk::hall_of_meat { -// Requires the validated build, No Bail started, and the local profile loaded: the switch starts -// from the saved choice, off until a player switches it on. -bool start(std::uintptr_t image_base) noexcept; -// Client thread, every tick: switches the renderer's and the UI model's hooks with the feature (off, -// none of them is called); a bail ends when the local skater is gone (a respawn, a teleport); -// logs each finished bail, saves a new best, hides skate.'s HUD corner while the card shows and -// runs the slow motion. -void on_client_tick() noexcept; -// Client thread, with the level being played (empty without one): loads that map's best when -// the map changes. -void set_level(std::string_view level) noexcept; -// Applies at once and saves the choice with the profile. -void set_enabled(bool enabled) noexcept; -// The switch, any thread: whether it started, and whether it is on. The menu reads it through the -// overlay's model (overlay::HallOfMeatModel), the console directly. -bool available() noexcept; -bool enabled() noexcept; - -// skate.'s skeleton mesh posed as the renderer drew the skater, in world space, with that picture's -// camera, and how each body was hurt. -struct Skeleton { - std::array camera{}; // world matrix: right, up, back, position rows - float vertical_fov{}; // degrees - std::vector positions, normals; // one per vertex - std::shared_ptr mesh; // its triangles, and each vertex's body - float alpha{}; // fades the whole skeleton out - std::array injuries{}; - std::array flashes{}; // 1 the moment a body is hit, falling to 0 -}; -// What the overlay draws now; nothing (no skeleton, no card, no pulse) while riding. -struct Frame { - Skeleton skeleton; // no positions while the renderer's picture is not there - Card card; - float break_pulse{}; // the screen's red edge when a bone breaks, 0 to 1 -}; -// Render thread, every presented frame. -Frame frame(); -} diff --git a/Extension/HallOfMeat/hall_of_meat_card.cpp b/Extension/HallOfMeat/hall_of_meat_card.cpp deleted file mode 100644 index c53a1909..00000000 --- a/Extension/HallOfMeat/hall_of_meat_card.cpp +++ /dev/null @@ -1,44 +0,0 @@ -#include "hall_of_meat_card.h" -#include - -namespace dingosdk::hall_of_meat { -namespace { -std::string hits(int value) { return grouped(value) + (value == 1 ? " hit" : " hits"); } -} - -Card card(const View& view, const Standing& against) { - if (view.phase == Phase::riding) return {}; - const auto& t = view.tally; - Card result; - result.opacity = view.alpha; - const float road_rash = t.scraped * feet_per_metre, fallen = t.fallen * feet_per_metre, - speed = t.top_speed * mph_per_metre_per_second; - const auto row = [&](bool shown, Stat stat, std::string value, int points) { - if (shown) result.rows.push_back({stat, std::move(value), points}); - }; - row(true, Stat::time, std::format("{:.1f} s", t.seconds), t.time_points); - row(t.impacts >= hits_shown, Stat::hits, hits(t.impacts), t.hit_points + t.head_bonus + t.vehicle_bonus); - row(t.broken >= broken_shown, Stat::broken, grouped(t.broken) + " broken", t.broken * points_per_break); - row(road_rash >= road_rash_shown_feet, Stat::road_rash, std::format("{:.1f} ft", road_rash), t.scrape_points); - row(t.airtime >= airtime_shown_seconds, Stat::airtime, std::format("{:.1f} s", t.airtime), t.airtime_points); - row(fallen >= fall_shown_feet, Stat::fall, std::format("{:.1f} ft", fallen), t.fall_points); - row(speed >= speed_shown_mph, Stat::speed, std::format("{:.1f} MPH", speed), t.speed_points); - row(t.rotations >= rotations_shown, Stat::rotations, std::format("{:.1f} rotations", t.rotations), t.rotation_points); - result.total = t.score; - if (against.new_best) { - result.badge = "NEW BEST"; - result.highlight = true; - } else if (against.best > 0) { - result.badge = "BEST " + grouped(against.best); - } - return result; -} - -std::string grouped(long long value) { - const bool negative = value < 0; - const auto magnitude = negative ? 0ull - static_cast(value) : static_cast(value); - auto digits = std::to_string(magnitude); - for (auto at = static_cast(digits.size()) - 3; at > 0; at -= 3) digits.insert(static_cast(at), ","); - return negative ? "-" + digits : digits; -} -} diff --git a/Extension/HallOfMeat/hall_of_meat_card.h b/Extension/HallOfMeat/hall_of_meat_card.h deleted file mode 100644 index 2f6dc5c7..00000000 --- a/Extension/HallOfMeat/hall_of_meat_card.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once -#include "hall_of_meat_model.h" -#include -#include -#include - -// Hall of Meat's score card, as in skate. 3: a row per stat, with skate.'s own icon for it -// (hall_of_meat_overlay.cpp draws it), then the Meat. No game access. -namespace dingosdk::hall_of_meat { -// When a stat comes onto the card: once the bail has done something worth showing, so a small -// bail's card stays short and a big one grows as it goes. The time shows from the start; the -// Meat counts every stat whether shown or not. -inline constexpr int hits_shown = 5, broken_shown = 5; -inline constexpr float road_rash_shown_feet = 15.0f, airtime_shown_seconds = 3.0f, fall_shown_feet = 100.0f, - speed_shown_mph = 20.0f, rotations_shown = 2.0f; -// skate. shows lengths in feet and speeds in miles per hour. -inline constexpr float feet_per_metre = 3.28084f; -inline constexpr float mph_per_metre_per_second = 2.23694f; - -// Each stat's row, by the icon it shows, in the card's order. -enum class Stat : std::uint8_t { time, hits, broken, road_rash, airtime, fall, speed, rotations }; -struct CardRow { - Stat stat{}; - std::string value; // what was measured, as shown: "6.2 s" - int points{}; -}; -struct Card { - float opacity{}; // 0: no card - std::vector rows; - int total{}; // the Meat - std::string badge; // beside the title: "NEW BEST" or "BEST 12,345"; empty for none - bool highlight{}; // the badge marks a record -}; -// The card for what the overlay draws now, against the map's best before the bail. No card while -// riding. -Card card(const View& view, const Standing& against); -// 12,345 and -1,234, as skate. shows its numbers. -std::string grouped(long long value); -} diff --git a/Extension/HallOfMeat/hall_of_meat_commands.cpp b/Extension/HallOfMeat/hall_of_meat_commands.cpp deleted file mode 100644 index 91e71d79..00000000 --- a/Extension/HallOfMeat/hall_of_meat_commands.cpp +++ /dev/null @@ -1,15 +0,0 @@ -#include "hall_of_meat.h" -#include "Extension/Console/commands.h" - -// The `hallofmeat` console switch; the menu's (Mod Options > Player) sends it too. -namespace dingosdk::console { -void register_hall_of_meat_commands(Commands ®istry) { - auto meat = variable("hallofmeat", "Show the bones a bail hurt, bruised yellow and broken red, and its Meat score", - Group::gameplay, argument("0|1", Type::boolean)); - meat.inspect = [](const Model &) { - return boolean_state(hall_of_meat::available(), hall_of_meat::enabled(), "Hall of Meat is unavailable for this game build."); - }; - meat.run = [](const Model &, const Values &args, const Output &) { hall_of_meat::set_enabled(std::get(args[0])); }; - registry.add(std::move(meat)); -} -} // namespace dingosdk::console diff --git a/Extension/HallOfMeat/hall_of_meat_model.cpp b/Extension/HallOfMeat/hall_of_meat_model.cpp deleted file mode 100644 index 0f1d8a18..00000000 --- a/Extension/HallOfMeat/hall_of_meat_model.cpp +++ /dev/null @@ -1,253 +0,0 @@ -#include "hall_of_meat_model.h" -#include -#include - -namespace dingosdk::hall_of_meat { -namespace { -// The physics thread steps and the render thread draws, each reading the real clock itself: -// a moment ago may be a moment ahead. -std::uint64_t elapsed(std::uint64_t now, std::uint64_t since) noexcept { return now > since ? now - since : 0; } -// A step's length on the game's clock; nothing for one the game did not report sensibly. -double length_ms(float seconds) noexcept { return std::isfinite(seconds) && seconds > 0 ? seconds * 1000.0 : 0.0; } -float speed_of(float value) noexcept { return std::isfinite(value) && value > 0 ? value : 0.0f; } -bool is_foot(std::size_t bone) noexcept { return skater_body::foot(static_cast(bone)); } -// Sliding along anything but the board scrapes: the feet ride on it all the time. -bool scrapes(const skater_body::HitKinds& hit) noexcept { - return hit.world || hit.vehicle || hit.kind_5 || hit.kind_11; -} -int points(float amount, float per_unit) noexcept { return static_cast(amount * per_unit + 0.5f); } -} - -bool Tracker::step(std::uint64_t now, const Step& step, Summary* ended) noexcept { - const double step_ms = length_ms(step.seconds); - played_ += step_ms; - const double flown = step.airborne ? step_ms : 0.0; - if (step.airborne) landed_.reset(); - else if (!landed_) landed_ = played_; - const bool ragdoll = step.ragdoll.value_or(false); - // How far the skater went down in the step. - const float descent = step.velocity ? std::max(0.0f, -(*step.velocity)[1]) * static_cast(step_ms / 1000.0) : 0.0f; - bool began{}; - if (phase_ == Phase::riding) { - if (step.airborne) { - flight_ms_ += flown; - flight_fallen_ += descent; - } else if (played_ - *landed_ > wipeout_after_impact_ms) { // landed, and stayed up - flight_ms_ = 0; - flight_fallen_ = 0; - } - // Each body's hardest recent hit, for a bail that starts a few steps after it. - for (std::size_t index = 1; index < skater_body::count; ++index) { // 0 is the board - const auto& contact = step.body.bodies[index]; - const float speed = speed_of(contact.impact); - auto& lead = bones_[index].lead; - if (speed <= 0 || (lead.at && played_ - *lead.at <= wipeout_after_impact_ms && speed <= lead.speed)) continue; - lead = {speed, contact.hit.vehicle, played_}; - } - if (!step.wipeout) return false; - begin(now); // the flight it came from holds this step's airtime and fall - began = true; - } - ragdolled_ = ragdolled_ || ragdoll; - const auto speed = step.velocity ? std::optional(game::length(*step.velocity)) : std::nullopt; - rest(speed); - if (phase_ == Phase::bailing) { - if (!began) { - airtime_ms_ += flown; - fallen_ += descent; - } - if (speed) top_speed_ = std::max(top_speed_, *speed); - if (step.spin) turned_ += speed_of(*step.spin) * static_cast(step_ms / 1000.0); - count(now, step, step_ms); - } - // The skater stood up, or never went down. - const bool stood_up = ragdolled_ ? step.ragdoll.has_value() && !*step.ragdoll : played_ - started_ >= ragdoll_wait_ms; - if (breaks_ != seen_breaks_) { // a break this step: its effect runs from now - seen_breaks_ = breaks_; - if (breaks_seen_ < breaks_seen_at_.size()) breaks_seen_at_[breaks_seen_++] = now; - } - return stood_up && end(now, ended); -} - -// Whether the body has come to rest, by how fast it moves: then the bail's time and points are final. -void Tracker::rest(std::optional speed) noexcept { - if (phase_ != Phase::bailing || !speed) return; - if (*speed >= still_speed) { - still_since_.reset(); - } else if (!still_since_) { - still_since_ = played_; - } else if (played_ - *still_since_ >= rest_ms) { - phase_ = Phase::down; - stopped_at_ = still_since_; // it has rested since it went still - } -} - -// What each body hit and how far it slid in one step of the bail. -void Tracker::count(std::uint64_t now, const Step& step, double step_ms) noexcept { - const float step_seconds = static_cast(step_ms / 1000.0); - float sliding{}; // the scraping bodies' slides, summed - int scraping{}; - for (std::size_t index = 1; index < skater_body::count; ++index) { - const auto& contact = step.body.bodies[index]; - const float speed = speed_of(contact.impact); - if (speed > 0) hit(index, speed, contact.hit.vehicle, played_, now); - const float slide = game::length(contact.slide); - if (!contact.touching || is_foot(index) || !scrapes(contact.hit) || !std::isfinite(slide) || slide < scrape_speed) - continue; - auto& bone = bones_[index]; - bone.scraped += slide * step_seconds; - if (bone.scraped >= bruising_scrape) hurt_ = true; - sliding += slide; - ++scraping; - } - if (scraping) scraped_ += sliding / static_cast(scraping) * step_seconds; -} - -bool Tracker::lose(std::uint64_t now, Summary* ended) noexcept { - // Nothing from before the skater went carries over to the one that comes back. - flight_ms_ = 0; - landed_.reset(); - breaks_ = seen_breaks_ = 0; - breaks_seen_ = 0; - flight_fallen_ = 0; - for (auto& bone : bones_) bone.lead = {}; - return phase_ != Phase::riding && end(now, ended); -} - -// A bail from this step (`now` in real time), with the flight it came from and the hits just before it. -void Tracker::begin(std::uint64_t now) noexcept { - const auto played = played_; - const auto flight = flight_ms_; - const auto flight_fallen = flight_fallen_; - const auto before = bones_; - *this = {}; - played_ = played; - phase_ = Phase::bailing; - started_ = played_; - airtime_ms_ = flight; // this step's share is in it - fallen_ = flight_fallen; - for (std::size_t index = 1; index < skater_body::count; ++index) { - const auto& lead = before[index].lead; - if (lead.at && played_ - *lead.at <= wipeout_after_impact_ms) hit(index, lead.speed, lead.vehicle, *lead.at, now); - } -} - -// A hit of one bone at `at` on the game's clock, shown from `now`: a new impact, or the harder step -// of the contact it belongs to. -void Tracker::hit(std::size_t index, float speed, bool vehicle, double at, std::uint64_t now) noexcept { - auto& bone = bones_[index]; - if (bone.peak < broken_speed && speed >= broken_speed) ++breaks_; - bone.peak = std::max(bone.peak, speed); - if (speed < hit_speed) return; - hurt_ = true; - if (bone.hit_at && at - *bone.hit_at <= impact_gap_ms) { - auto& impact = impacts_[bone.impact]; - impact.speed = std::max(impact.speed, speed); - impact.vehicle = impact.vehicle || vehicle; - } else if (impact_count_ < max_impacts) { - bone.impact = impact_count_; - impacts_[impact_count_++] = {index, speed, vehicle}; - } - bone.hit_at = at; - bone.flashed_at = now; -} - -bool Tracker::end(std::uint64_t now, Summary* ended) noexcept { - phase_ = Phase::riding; - ended_ = now; - if (!stopped_at_) stopped_at_ = played_; - if (ended) { - ended->shown = hurt_; - for (std::size_t index = 0; index < skater_body::count; ++index) { - ended->peaks[index] = bones_[index].peak; - ended->scraped[index] = bones_[index].scraped; - } - ended->tally = tally(); - } - return true; -} - -// The bail's time, on the game's clock: until the body came to rest, or the bail ended, or the latest step. -double Tracker::bail_ms() const noexcept { - return std::max(0.0, (stopped_at_ ? *stopped_at_ : played_) - started_); -} - -Phase Tracker::phase(std::uint64_t now) const noexcept { - if (phase_ != Phase::riding) return phase_; - return ended_ && elapsed(now, ended_) < linger_ms + fade_ms ? Phase::getting_up : Phase::riding; -} - -Injury Tracker::injury_of(const BoneState& bone) const noexcept { - const auto by_hits = injury(bone.peak); - return by_hits == Injury::none && bone.scraped >= bruising_scrape ? Injury::hit : by_hits; -} - -Tally Tracker::tally() const noexcept { - Tally result; - for (std::size_t index = 0; index < impact_count_; ++index) { - const auto& impact = impacts_[index]; - result.hit_points += hit_points(impact.speed); - result.head_bonus += head_bonus(impact); - result.vehicle_bonus += vehicle_bonus(impact); - } - result.impacts = static_cast(impact_count_); - for (std::size_t index = 1; index < skater_body::count; ++index) // 0 is the board - if (injury(bones_[index].peak) == Injury::broken) ++result.broken; - result.scraped = scraped_; - result.scrape_points = points(result.scraped, points_per_scraped_metre); - result.damage = result.hit_points + result.head_bonus + result.vehicle_bonus + result.scrape_points; - result.seconds = static_cast(bail_ms() / 1000.0); - result.time_points = points(result.seconds, points_per_second); - result.airtime = static_cast(airtime_ms_ / 1000.0); - result.airtime_points = points(result.airtime, points_per_air_second); - result.fallen = fallen_; - result.fall_points = points(result.fallen, points_per_metre_fallen); - result.top_speed = top_speed_; - result.speed_points = points(result.top_speed, points_per_speed); - result.rotations = turned_ / radians_per_rotation; - result.rotation_points = points(result.rotations, points_per_rotation); - result.score = result.damage + result.broken * points_per_break + result.time_points + result.airtime_points + - result.fall_points + result.speed_points + result.rotation_points; - return result; -} - -View Tracker::view(std::uint64_t now) const noexcept { - View view; - const auto phase = this->phase(now); - if (phase == Phase::riding || !hurt_) return view; - view.phase = phase; - const auto since_end = phase == Phase::getting_up ? elapsed(now, ended_) : 0; - view.alpha = since_end > linger_ms ? 1.0f - static_cast(since_end - linger_ms) / static_cast(fade_ms) : 1.0f; - view.tally = tally(); - for (std::size_t index = 0; index < skater_body::count; ++index) { - const auto& bone = bones_[index]; - view.injuries[index] = injury_of(bone); - const auto since_hit = elapsed(now, bone.flashed_at); - if (bone.flashed_at && since_hit < flash_ms) - view.flashes[index] = 1.0f - static_cast(since_hit) / static_cast(flash_ms); - } - view.break_pulse = break_effect(now); - return view; -} - -float Tracker::break_effect(std::uint64_t now) const noexcept { - const auto smooth = [](std::uint64_t part, std::uint64_t whole) { - const float t = static_cast(part) / static_cast(whole); - return t * t * (3.0f - 2.0f * t); - }; - float strongest = 0.0f; - for (std::size_t index = 0; index < breaks_seen_; ++index) { - const auto since = elapsed(now, breaks_seen_at_[index]); - if (since >= break_effect_ms) continue; - const float effect = since < break_rise_ms ? smooth(since, break_rise_ms) - : since < break_rise_ms + break_hold_ms ? 1.0f - : 1.0f - smooth(since - break_rise_ms - break_hold_ms, break_fall_ms); - strongest = std::max(strongest, effect); - } - return strongest; -} - -float Tracker::game_speed(std::uint64_t now) const noexcept { - return 1.0f - (1.0f - break_game_speed) * break_effect(now); -} -} diff --git a/Extension/HallOfMeat/hall_of_meat_model.h b/Extension/HallOfMeat/hall_of_meat_model.h deleted file mode 100644 index 12423caf..00000000 --- a/Extension/HallOfMeat/hall_of_meat_model.h +++ /dev/null @@ -1,268 +0,0 @@ -#pragma once -#include "Engine/Game/Skater/skater_body.h" -#include -#include -#include -#include -#include - -// Hall of Meat, the model: what a bail did to the skater, and what it scores. Fed one -// physics step at a time (hall_of_meat_skater.h) with what each body touched -// (Engine/Game/Skater/skater_body.h), how the skater moves and whether the game simulates the -// body as a ragdoll; read by the overlay. No game access, no locking: the caller owns both. -// -// A bail follows the skater, not a clock: -// riding → bailing the game's wipeout, and nothing else: a fall from height ragdolls in the -// air before the wipeout of its impact (its flight and its hits of just -// before carry into the bail), and skate.'s flumps (a dive, a roll along -// the ground, from any height) are ragdolls the game never wipes out -// bailing → down the body has come to rest: the bail's time and points are final -// bailing, down → getting up the ragdoll ends (the skater stands up), or the skater is gone -// (a respawn, a teleport); the bail is over, stays and fades out -// Measured in play: the ragdoll begins with the wipeout or in the flight before it, and -// ends exactly once, when the skater stands up on foot; skate. may put them back on the board -// a moment later. The bail shows only once it has hurt a bone: a fall that leaves the skater -// unbruised shows nothing. -// -// Two clocks. The bail runs on the game's: its steps' own lengths added up, so in slow motion -// (a break: hall_of_meat.h) the moment the ragdoll has to begin, the rest, the impacts and every -// stat still measure what the body did. What the player sees runs on the real one: a hit's -// flash, a break's effect, the linger and the fade after the bail. -namespace dingosdk::hall_of_meat { -using skater_body::Bone; - -// One physics step of the local skater. -struct Step { - float seconds{}; // how long the step simulated, in the game's time - bool wipeout{}; // the step asks for a wipeout - bool airborne{}; // the skater is in the air, on the board or off it - // The body is a ragdoll; empty when the skater state could not be read: such a step neither - // starts nor ends a bail by it. - std::optional ragdoll; - // How the skater moves (with the board while on it, else as its pelvis), metres per second; - // empty when unknown: such a step neither rests nor stirs the body, and adds nothing to its - // fall or its speed. - std::optional velocity; - // How fast the pelvis turns, any way round, radians per second; empty when unknown: such a - // step adds nothing to its rotations. - std::optional spin; - skater_body::Contacts body; // what each body touched in the step -}; - -enum class Injury : std::uint8_t { none, hit, broken }; - -// Impact speeds along the contact normal, as measured in play: a fall on flat ground hits the -// bones it lands on at 3 to 9 m/s and grazes the rest at 2 to 3, a hard landing hits the legs at -// 8 to 10, a drop from height everything at 13 to 35. So a hard fall on flat ground bruises, and -// only a drop from height breaks bones. -inline constexpr float hit_speed = 7.0f; -inline constexpr float broken_speed = 12.0f; -// A wipeout whose ragdoll does not begin within this long was a stumble: its bail ends. -inline constexpr std::uint64_t ragdoll_wait_ms = 500; -// The body has come to rest once it moves slower than still_speed for rest_ms: from then on -// nothing counts. Measured in play: a body lying moves at 0.0 to 0.3 m/s (a skater standing -// 0.0 to 0.1), a tumbling or sliding one at 2 and more. A rest is final: getting up starts inside -// the ragdoll (its animated part), moving the body fast again before the ragdoll ends. -inline constexpr float still_speed = 0.5f; -inline constexpr std::uint64_t rest_ms = 750; -// Once the skater gets up, the skeleton and the card stay linger_ms, then fade out over fade_ms. -inline constexpr std::uint64_t linger_ms = 1000; -inline constexpr std::uint64_t fade_ms = 800; -inline constexpr std::uint64_t flash_ms = 350; // a fresh hit flashes this long -// A bone breaking hits the whole screen, as in skate. 3's Hall of Meat: its edges pulse red and the -// game slows down to break_game_speed (single player only: hall_of_meat.h), together, by one -// effect: easing in over break_rise_ms, held break_hold_ms, easing out over break_fall_ms; in real -// time, from the step that saw the break (a bail takes over the breaks of just before it, which -// happened earlier). Each break has its own and the strongest counts, so a further break hits -// again without the screen or the game's speed jumping. -inline constexpr std::uint64_t break_rise_ms = 150; -inline constexpr std::uint64_t break_hold_ms = 1000; -inline constexpr std::uint64_t break_fall_ms = 1000; -inline constexpr std::uint64_t break_effect_ms = break_rise_ms + break_hold_ms + break_fall_ms; -inline constexpr float break_game_speed = 0.3f; -// A bone's contact lasts several physics steps: hits of one bone closer together than -// this are one impact, as hard as its hardest step. -inline constexpr std::uint64_t impact_gap_ms = 200; -inline constexpr std::size_t max_impacts = 64; -// The game reports the wipeout of an impact a few physics steps after it (0 to 2 steps in -// play; a drop from height hit the right thigh at 35 m/s a step before its wipeout): a bail takes the flight and the hits of this long before it starts. -inline constexpr std::uint64_t wipeout_after_impact_ms = 250; - -// The Meat a bail scores: every hit by how hard it was, and each bone broken a bonus once. -// A hit's points grow faster than its speed but slower than its energy: the lightest hit -// (hit_speed) scores 100, a 30 m/s slam about 900 (not the 18 bruises its energy would make it). -// A break scores what ten of the lightest hits do, so a heavy bail's hits and breaks do not -// outweigh the rest of the card. -inline constexpr float hit_reference_speed = hit_speed; -inline constexpr float points_per_reference_hit = 100.0f; -inline constexpr float hit_points_exponent = 1.5f; -inline constexpr int points_per_break = 1000; -// A hit to the head (the upper neck it rides on, Bone::neck1) counts double; one from a -// vehicle half again. Both together count 2.5 times. -inline constexpr float head_multiplier = 2.0f; -inline constexpr float vehicle_multiplier = 1.5f; -// Road rash: a body sliding along the ground or a wall, not the board, at least this fast -// (metres per second along the surface) scrapes. A long slide moves the body at 3 to 10 -// m/s for seconds without a single hit. The skater's road rash is how far it slid: in -// each step, how fast its scraping bodies slid on average, not their sum (a dozen bodies on the -// ground at once would make one metre a dozen). A metre scores what four of the lightest hits do. -inline constexpr float scrape_speed = 1.5f; -inline constexpr float points_per_scraped_metre = 400.0f; -inline constexpr float bruising_scrape = 1.0f; // metres one bone slid: it shows as bruised -// The rest of the bail scores as skate. 3's Hall of Meat did, each stat its own points: its time -// (falling and sliding, not resting), its airtime, how far the body fell (every metre it went down, -// from the flight the bail came from on), its top speed and how often it turned over. Its example card -// scored a 6.2 s bail 5,416, 3.4 s of air 10,816, a 50 m fall 13,251 and 25 MPH 1,590. A fall -// scores half of that here: at the card's rate it outweighed everything else in play. -inline constexpr float points_per_second = 600.0f; -inline constexpr float points_per_air_second = 2000.0f; -inline constexpr float points_per_metre_fallen = 125.0f; -inline constexpr float points_per_speed = 130.0f; // per metre per second -// Rotations: how far the body turned, any way round, while it fell and slid. Measured in -// play: a skater turning on the spot turns the pelvis at 30 to 300 degrees a second; a body -// tumbling down stairs turns over several times a second. A rotation scores what a second's bail does. -inline constexpr float points_per_rotation = 600.0f; -inline constexpr float radians_per_rotation = 6.2831853f; - -constexpr Injury injury(float peak) noexcept { - return peak >= broken_speed ? Injury::broken : peak >= hit_speed ? Injury::hit : Injury::none; -} -// A hit's points by its speed alone. -inline int hit_points(float speed) noexcept { - if (!(speed >= hit_speed)) return 0; - return static_cast( - points_per_reference_hit * std::pow(speed / hit_reference_speed, hit_points_exponent) + 0.5f); -} - -struct Impact { - std::size_t bone{}; - float speed{}; // the hardest step of the contact - bool vehicle{}; // a vehicle took part in it -}; -constexpr bool head(std::size_t bone) noexcept { return bone == skater_body::index(Bone::neck1); } -// What an impact adds on top of its hit points, for the head and for a vehicle. -inline int head_bonus(const Impact& impact) noexcept { - return head(impact.bone) ? static_cast(hit_points(impact.speed) * (head_multiplier - 1.0f) + 0.5f) : 0; -} -inline int vehicle_bonus(const Impact& impact) noexcept { - return impact.vehicle ? static_cast(hit_points(impact.speed) * (vehicle_multiplier - 1.0f) + 0.5f) : 0; -} -inline int impact_points(const Impact& impact) noexcept { - return hit_points(impact.speed) + head_bonus(impact) + vehicle_bonus(impact); -} - -// A bail's numbers, so far or in the end. -struct Tally { - int impacts{}; // hits of at least hit_speed - int broken{}; // bones - int hit_points{}; // the hits by their speed - int head_bonus{}; // what hits to the head added - int vehicle_bonus{}; // what hits from vehicles added - float scraped{}; // metres the skater slid along surfaces: the road rash - int scrape_points{}; - int damage{}; // the hits, their bonuses and the road rash - float seconds{}; // falling and sliding, not resting - int time_points{}; - float airtime{}; // seconds in the air - int airtime_points{}; - float fallen{}; // metres the body went down - int fall_points{}; - float top_speed{}; // metres per second - int speed_points{}; - float rotations{}; // turns of the body, any way round - int rotation_points{}; - int score{}; // the damage, the breaks, the time, the airtime, the fall, the speed and the rotations: the Meat -}; -// A bail's score against the best on the same map before it. -struct Standing { - int best{}; // the best, this bail included - bool new_best{}; // this bail set it -}; -constexpr Standing standing(int best, int score) noexcept { - return score > best ? Standing{score, true} : Standing{best, false}; -} - -enum class Phase : std::uint8_t { riding, bailing, down, getting_up }; -// What the overlay draws at one moment: riding (nothing) until the bail hurts a bone. -struct View { - Phase phase{}; - float alpha{}; // the skeleton's and the card's: 1 through the bail, fading to 0 as the skater gets up - std::array injuries{}; - std::array flashes{}; // 1 at a fresh hit, falling to 0 - float break_pulse{}; // the red edge after a bone breaks: rising to 1, falling to 0 - Tally tally; -}; -// A finished bail, for the log and the map's best. -struct Summary { - bool shown{}; // it hurt a bone, so it showed (and counts for a best) - std::array peaks{}; // each body's hardest hit - std::array scraped{}; // and how far it slid, each on its own - Tally tally; -}; - -class Tracker { -public: - // One physics step, at `now` in real time (milliseconds). True when this step ended a bail; - // `ended` then receives it. - bool step(std::uint64_t now, const Step& step, Summary* ended = nullptr) noexcept; - // The skater is gone (a respawn, a teleport, a map change): a bail ends now. True when one did. - bool lose(std::uint64_t now, Summary* ended = nullptr) noexcept; - View view(std::uint64_t now) const noexcept; - // Where the bail is at `now`, whether it hurt a bone (and so shows) or not. - Phase phase(std::uint64_t now) const noexcept; - // How fast the game is to run now: down to break_game_speed as a break hits, else 1. - float game_speed(std::uint64_t now) const noexcept; - void reset() noexcept { *this = {}; } - -private: - // Times on the game's clock (played_) are milliseconds of its time, as doubles; real times are - // GetTickCount64()'s milliseconds, never 0. - // - // A bone's hardest hit of the last wipeout_after_impact_ms while riding, which a bail takes. - struct Lead { - float speed{}; - bool vehicle{}; - std::optional at; // the game's clock - }; - struct BoneState { - float peak{}; // the hardest hit this bail - std::optional hit_at; // its last step with a hit, on the game's clock - std::uint64_t flashed_at{}; // and when that hit showed, in real time; 0 never - std::size_t impact{}; // which impact that step belonged to - float scraped{}; // metres it slid - Lead lead; - }; - void begin(std::uint64_t now) noexcept; - void rest(std::optional speed) noexcept; - void count(std::uint64_t now, const Step& step, double step_ms) noexcept; - void hit(std::size_t bone, float speed, bool vehicle, double at, std::uint64_t now) noexcept; - bool end(std::uint64_t now, Summary* ended) noexcept; - double bail_ms() const noexcept; - Tally tally() const noexcept; - Injury injury_of(const BoneState& bone) const noexcept; - - Phase phase_{}; // riding, bailing or down; getting_up is the linger and the fade after a bail's end - bool ragdolled_{}; // the bail's ragdoll has begun - bool hurt_{}; // a bone was hit at least at hit_speed, or scraped bruised, this bail - unsigned breaks_{}, seen_breaks_{}; // bones broken this bail, and how many of them the steps saw - std::array breaks_seen_at_{}; // the steps that saw breaks, oldest first - std::size_t breaks_seen_{}; // how many of them - float break_effect(std::uint64_t now) const noexcept; // 0 to 1 - double played_{}; // the game's clock: every step's length added up - double started_{}; // the game's clock at the bail's wipeout - std::uint64_t ended_{}; // real time: when the bail ended, for its linger and fade; 0 none yet - std::optional still_since_; // bailing: when the body last went slower than still_speed - std::optional stopped_at_; // when the bail's time stopped: the body came to rest, or the bail ended - double flight_ms_{}; // while riding: the last flight, which a bail takes over - float flight_fallen_{}; // and how far it went down - std::optional landed_; // when it touched down; empty while in the air - double airtime_ms_{}; - float fallen_{}; // metres the body went down - float top_speed_{}; // metres per second - float turned_{}; // radians the body turned, any way round - float scraped_{}; // metres the skater slid: the road rash - std::array bones_{}; - std::array impacts_{}; - std::size_t impact_count_{}; -}; -} diff --git a/Extension/HallOfMeat/hall_of_meat_overlay.cpp b/Extension/HallOfMeat/hall_of_meat_overlay.cpp deleted file mode 100644 index 3564fb37..00000000 --- a/Extension/HallOfMeat/hall_of_meat_overlay.cpp +++ /dev/null @@ -1,477 +0,0 @@ -#include "hall_of_meat_overlay.h" -#include "hall_of_meat.h" -#include "Engine/Core/Log/logging.h" -#include "Engine/Game/Build/addresses.h" -#include "Engine/Game/Build/20260929/ui_textures.h" -#include "Engine/Vfs/game_textures.h" -#include "Extension/UI/Overlay/overlay_internal.h" -#include "Extension/UI/skate_theme.h" -#include -#include -#include -#include -#include - -namespace dingosdk::overlay { -namespace { -namespace ui = addr::ui_textures; -namespace theme = dingosdk::skate_theme; -using hall_of_meat::Stat; - -// The images: an icon per stat (in Stat's order), then the logo and the card's shapes. -enum Picture : std::size_t { stat_icons, logo = stat_icons + 8, row_bar, panel, scratches, underline, picture_count }; -struct Source { - const ui::Texture* texture; - std::uint32_t side; // fitted into a side x side square, its aspect kept - bool brightness{}; // white with the texture's brightness as alpha (light marks on black), else its own alpha -}; -constexpr std::array sources{{ - {&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64}, - {&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64}, - {&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true}, - {&ui::streak, 512}, -}}; -using Images = std::array, picture_count>; - -struct Reads { - std::mutex mutex; - bool started{}; - std::future pending; - Images decoded; // kept: every atlas the overlay builds (one per swapchain) needs them -}; -Reads& reads() { - static auto* state = new Reads; - return *state; -} - -// The picture's part of the texture, white: with its own alpha, or its brightness as alpha. -frostbite::Image cut(const frostbite::Image& image, const ui::Region& region, bool brightness) { - const auto at = [](float fraction, std::uint32_t size) { return static_cast(fraction * static_cast(size) + 0.5f); }; - if (!image.width || !image.height || image.rgba.size() != std::size_t{image.width} * image.height * 4) - throw std::runtime_error("the texture is empty"); - const auto x0 = std::min(at(region.left, image.width), image.width - 1), y0 = std::min(at(region.top, image.height), image.height - 1); - const auto x1 = std::clamp(at(region.right, image.width), x0 + 1, image.width); - const auto y1 = std::clamp(at(region.bottom, image.height), y0 + 1, image.height); - frostbite::Image result{x1 - x0, y1 - y0, std::vector(std::size_t{x1 - x0} * (y1 - y0) * 4)}; - for (std::uint32_t y = y0; y < y1; ++y) - std::memcpy(result.rgba.data() + std::size_t{y - y0} * result.width * 4, - image.rgba.data() + (std::size_t{y} * image.width + x0) * 4, std::size_t{result.width} * 4); - for (std::size_t i = 0; i < result.rgba.size(); i += 4) { - auto* p = result.rgba.data() + i; - if (brightness) p[3] = std::max({p[0], p[1], p[2]}); - p[0] = p[1] = p[2] = 255; - } - return result; -} - -Images decode() { - Images result; - try { - vfs::GameTextures textures(hall_of_meat::game_folder()); - for (std::size_t index = 0; index < picture_count; ++index) { - const auto& source = sources[index]; - try { - result[index] = cut(textures.read(source.texture->toc, source.texture->bundle, source.texture->name, source.side), - source.texture->region, source.brightness); - } catch (const std::exception& error) { - logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat image {} is unavailable: {}.", - source.texture->name, error.what()); - } - } - } catch (const std::exception& error) { - logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat's images are unavailable: {}.", error.what()); - } - return result; -} - -// The images in the atlas being built or drawn with: render thread only. -struct Loaded { - ImFontAtlas* atlas{}; - bool filled{}; - std::array rects{}; // each one's custom rect in the atlas; -1 none -}; -Loaded& loaded() { - static Loaded state; - return state; -} -// A picture in the atlas the overlay draws with: where, and its texture coordinates. -struct Found { - const ImFontAtlasCustomRect* rect{}; - ImVec2 uv0, uv1; -}; -bool find(Picture picture, Found& found) { - const auto& l = loaded(); - if (!l.filled || !l.atlas || ImGui::GetIO().Fonts != l.atlas || !l.atlas->TexID || l.rects[picture] < 0) return false; - found.rect = l.atlas->GetCustomRectByIndex(l.rects[picture]); - if (!found.rect || !found.rect->IsPacked() || !found.rect->Width || !found.rect->Height) return false; - l.atlas->CalcCustomRectUV(found.rect, &found.uv0, &found.uv1); - return true; -} - -// Fitted into the box with its aspect kept, centred: an icon, a logo. -bool draw_image(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) { - Found found; - if (!find(picture, found)) return false; - const auto* rect = found.rect; - const float scale = std::min((max.x - min.x) / rect->Width, (max.y - min.y) / rect->Height); - const ImVec2 size(rect->Width * scale, rect->Height * scale); - const ImVec2 at(min.x + (max.x - min.x - size.x) * 0.5f, min.y + (max.y - min.y - size.y) * 0.5f); - draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + size.x, at.y + size.y), found.uv0, found.uv1, tint); - return true; -} -// Its body (ui_textures.h Texture::body) stretched onto the box, the rest of it around: a brush -// stroke's bar on the box, its splatter beyond. -bool draw_shape(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) { - Found found; - if (!find(picture, found)) return false; - const auto& body = sources[picture].texture->body; - const float width = (max.x - min.x) / (body.right - body.left), height = (max.y - min.y) / (body.bottom - body.top); - const ImVec2 at(min.x - body.left * width, min.y - body.top * height); - draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + width, at.y + height), found.uv0, found.uv1, tint); - return true; -} -// Nine-sliced onto the box: its corners (Texture::slice of the picture) kept `edge` pixels square, -// its edges stretched along them and its middle across the rest. A box smaller than two edges -// shrinks them to half of it. -bool draw_panel(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, float edge, ImU32 tint) { - Found found; - if (!find(picture, found)) return false; - const float slice = sources[picture].texture->slice; - const float edge_x = std::clamp(edge, 0.0f, (max.x - min.x) * 0.5f), edge_y = std::clamp(edge, 0.0f, (max.y - min.y) * 0.5f); - const float uv_x = slice * (found.uv1.x - found.uv0.x), uv_y = slice * (found.uv1.y - found.uv0.y); - const std::array xs{min.x, min.x + edge_x, max.x - edge_x, max.x}, ys{min.y, min.y + edge_y, max.y - edge_y, max.y}; - const std::array us{found.uv0.x, found.uv0.x + uv_x, found.uv1.x - uv_x, found.uv1.x}; - const std::array vs{found.uv0.y, found.uv0.y + uv_y, found.uv1.y - uv_y, found.uv1.y}; - for (std::size_t row = 0; row < 3; ++row) - for (std::size_t column = 0; column < 3; ++column) - draw->AddImage(loaded().atlas->TexID, ImVec2(xs[column], ys[row]), ImVec2(xs[column + 1], ys[row + 1]), - ImVec2(us[column], vs[row]), ImVec2(us[column + 1], vs[row + 1]), tint); - return true; -} - -// `colour` at `opacity` (0 to 1) of its own alpha. -ImU32 faded(ImU32 colour, float opacity) { - const auto alpha = static_cast(((colour >> IM_COL32_A_SHIFT) & 0xff) * std::clamp(opacity, 0.0f, 1.0f)); - return (colour & ~IM_COL32_A_MASK) | (alpha << IM_COL32_A_SHIFT); -} -// Text over a soft drop shadow, the shadow as see-through as the text. -void shadowed_text(ImDrawList* draw, ImFont* font, float size, ImVec2 at, ImU32 colour, const char* text) { - const float offset = std::max(1.0f, size / 16.0f); - const float opacity = static_cast((colour >> IM_COL32_A_SHIFT) & 0xff) / 255.0f; - draw->AddText(font, size, ImVec2(at.x + offset, at.y + offset), faded(IM_COL32_BLACK, opacity * 0.7f), text); - draw->AddText(font, size, at, colour, text); -} -float text_width(ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0.0f, text.c_str()).x; } - -constexpr ImU32 bruised = IM_COL32(255, 196, 36, 255), broken = IM_COL32(232, 32, 32, 255), - dark_red = IM_COL32(150, 12, 12, 255), hot = IM_COL32(255, 255, 255, 255); -ImU32 mix(ImU32 from, ImU32 to, float amount) { - const auto channel = [&](int shift) { - const float a = static_cast((from >> shift) & 0xff), b = static_cast((to >> shift) & 0xff); - return static_cast(a + (b - a) * std::clamp(amount, 0.0f, 1.0f)); - }; - return IM_COL32(channel(IM_COL32_R_SHIFT), channel(IM_COL32_G_SHIFT), channel(IM_COL32_B_SHIFT), channel(IM_COL32_A_SHIFT)); -} -// `colour` darkened to `light` (0 black, 1 itself), at `alpha`. -ImU32 lit(ImU32 colour, float light, float alpha) { - const auto channel = [&](int shift) { - return static_cast(static_cast((colour >> shift) & 0xff) * std::clamp(light, 0.0f, 1.0f)) << shift; - }; - const auto a = static_cast(255.0f * std::clamp(alpha, 0.0f, 1.0f)); - return channel(IM_COL32_R_SHIFT) | channel(IM_COL32_G_SHIFT) | channel(IM_COL32_B_SHIFT) | a << IM_COL32_A_SHIFT; -} - -// The screen at `strength` (0 to 1), as a hard hit shows it: darkened a little, the broken bones' red -// washing in from all four edges, top and bottom deeper than the sides. -void draw_break_pulse(float strength) { - constexpr float darkening = 0.3f, edge_opacity = 0.7f, top_bottom_reach = 0.3f, sides_reach = 0.18f; - strength = std::clamp(strength, 0.0f, 1.0f); - const auto display = ImGui::GetIO().DisplaySize; - if (strength <= 0 || display.x <= 0 || display.y <= 0) return; - auto* draw = ImGui::GetBackgroundDrawList(); - const ImVec2 min(0, 0), max(display.x, display.y); - draw->AddRectFilled(min, max, faded(IM_COL32_BLACK, darkening * strength)); - const auto edge = faded(broken, edge_opacity * strength), clear = faded(broken, 0); - const float vertical = display.y * top_bottom_reach * strength, horizontal = display.x * sides_reach * strength; - // Each band from its edge (the colour) inwards (clear); corners: top left, top right, bottom right, bottom left. - draw->AddRectFilledMultiColor(min, ImVec2(max.x, vertical), edge, edge, clear, clear); - draw->AddRectFilledMultiColor(ImVec2(0, max.y - vertical), max, clear, clear, edge, edge); - draw->AddRectFilledMultiColor(min, ImVec2(horizontal, max.y), edge, clear, clear, edge); - draw->AddRectFilledMultiColor(ImVec2(max.x - horizontal, 0), max, clear, edge, edge, clear); -} - -// The hurt bodies of skate.'s skeleton mesh as an x-ray over the world, projected like the nametags: -// bruised ones yellow, broken ones a slowly pulsing red, a fresh hit flashing white-hot; the rest are -// not drawn. Every triangle of a hurt body is drawn back to front, each vertex lit by how it faces -// the camera: an outline near solid and a middle fainter, so the bones read through each other and -// against the world. A triangle reaching into an unhurt body fades out. -void draw_skeleton(const hall_of_meat::Skeleton& skeleton) { - using Vec3 = std::array; - const auto dot = [](const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; - const auto& positions = skeleton.positions; - const auto count = positions.size(); - const auto* mesh = skeleton.mesh.get(); - if (!count || count > 0xffff || !mesh || mesh->parts.size() != count || skeleton.normals.size() != count || - skeleton.alpha <= 0 || !(skeleton.vertical_fov > 1 && skeleton.vertical_fov < 175)) - return; - std::array paint{}; // 0: not drawn - const double time = ImGui::GetTime(); - for (std::size_t body = 0; body < skater_body::count; ++body) { - const auto injury = skeleton.injuries[body]; - if (injury == hall_of_meat::Injury::none) continue; - const ImU32 colour = injury == hall_of_meat::Injury::broken - ? mix(dark_red, broken, 0.5f + 0.5f * static_cast(std::sin(time * 6.0))) : bruised; - paint[body] = mix(colour, hot, skeleton.flashes[body] * 0.8f); - } - constexpr float solid = 0.6f; - const auto display = ImGui::GetIO().DisplaySize; - const auto& m = skeleton.camera; - const Vec3 right{m[0], m[1], m[2]}, up{m[4], m[5], m[6]}, back{m[8], m[9], m[10]}, origin{m[12], m[13], m[14]}; - const float focal = display.y / (2.0f * std::tan(skeleton.vertical_fov * 3.14159265f / 360.0f)); - const ImVec2 centre(display.x * 0.5f, display.y * 0.5f); - // Render thread only: reused frame to frame. - static std::vector at; - static std::vector depth; - static std::vector colour; // 0 for an unhurt body's vertex - static std::vector> order; // a triangle's depth, its first index - at.resize(count); - depth.resize(count); - colour.resize(count); - for (std::size_t i = 0; i < count; ++i) { - const auto& p = positions[i]; - const Vec3 delta{p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]}; - depth[i] = -dot(delta, back); - const float z = std::max(depth[i], 0.1f); - at[i] = ImVec2(centre.x + dot(delta, right) * focal / z, centre.y - dot(delta, up) * focal / z); - const float distance = std::sqrt(dot(delta, delta)); - const float facing = distance > 1e-4f ? std::abs(dot(skeleton.normals[i], delta)) / distance : 1.0f; - const auto painted = paint[std::min(mesh->parts[i], skater_body::count - 1)]; - const float faint = 0.25f + 0.6f * (1.0f - facing); - colour[i] = painted ? lit(painted, 0.55f + 0.45f * facing, skeleton.alpha * (solid + (1.0f - solid) * faint)) : 0; - } - const auto& triangles = mesh->triangles; - order.clear(); - for (std::uint32_t t = 0; t + 2 < triangles.size(); t += 3) { - const auto a = triangles[t], b = triangles[t + 1], c = triangles[t + 2]; - if (a >= count || b >= count || c >= count || depth[a] <= 0.1f || depth[b] <= 0.1f || depth[c] <= 0.1f || - (!colour[a] && !colour[b] && !colour[c])) - continue; - order.emplace_back(depth[a] + depth[b] + depth[c], t); - } - if (order.empty()) return; - std::sort(order.begin(), order.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); // far first - auto* draw = ImGui::GetBackgroundDrawList(); - const auto uv = ImGui::GetFontTexUvWhitePixel(); - draw->PrimReserve(static_cast(order.size() * 3), static_cast(count)); - const auto first = draw->_VtxCurrentIdx; - for (std::size_t i = 0; i < count; ++i) draw->PrimWriteVtx(at[i], uv, colour[i]); - for (const auto& [unused, t] : order) - for (std::uint32_t corner = 0; corner < 3; ++corner) draw->PrimWriteIdx(static_cast(first + triangles[t + corner])); -} - -// The card from frame to frame: the total counts up from what it last showed, and a row fades in -// from when it came. -struct Motion { - float total{}; - double at{}; // ImGui time of the last frame drawn - std::array since{-1, -1, -1, -1, -1, -1, -1, -1}; // each stat's row: when it came onto the card -}; - -// The score card as skate. 3's Hall of Meat shows it, in the bottom left corner where skate.'s own HUD -// shows (hidden meanwhile: hall_of_meat_hud.h), growing upwards as rows come: a brush bar per stat -// with its icon, value and points, then a scratched panel with the THRASHER logo, the title in its -// arch, the total on a blue stroke and the badge. skate.'s colours: dark bars and panel, its blue for -// the icons, the logo and the stroke, white numbers. A shape not loaded draws a plain tile instead. -void draw_card(const hall_of_meat::Card& card, Motion& motion) { - // From the screen's bottom left corner, in 1080p pixels; as wide as the logo it carries. - constexpr float corner_left = 64.0f, corner_bottom = 96.0f, card_width = 250.0f; - constexpr double row_fade_seconds = 0.3; // a new row opens and fades in this long - const auto display = ImGui::GetIO().DisplaySize; - if (card.opacity <= 0 || display.x <= 0 || display.y <= 0) { - motion = {}; - return; - } - // The total catches up with its value in about a quarter of a second. - const double now = ImGui::GetTime(); - const float step = static_cast(std::clamp(now - motion.at, 0.0, 0.1)); - motion.at = now; - motion.total += (static_cast(card.total) - motion.total) * (1.0f - std::exp(-step * 12.0f)); - if (std::abs(static_cast(card.total) - motion.total) < 1.0f) motion.total = static_cast(card.total); - - auto& s = detail::state(); - auto* title_font = s.menu.title ? s.menu.title : ImGui::GetFont(); - auto* heading = s.menu.heading ? s.menu.heading : ImGui::GetFont(); - auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont(); - auto* draw = ImGui::GetBackgroundDrawList(); - const float k = display.y / 1080.0f, o = card.opacity; - const auto fade = [o](ImU32 colour) { return faded(colour, o); }; - const float left = corner_left * k, right = left + card_width * k, pad = 14.0f * k; - unsigned seed = 211u; - - // The panel's height, under the rows: the logo, the title (in the logo's arch), the total and the badge. - const float logo_height = 72.0f * k, title_size = 22.0f * k, total_size = 56.0f * k, badge_size = 18.0f * k; - const float edge = 16.0f * k; - const float logo_step = logo_height * ui::thrasher_wordmark_arch + 4.0f * k; - const float height = pad * 2.0f + logo_step + title_size + total_size + (card.badge.empty() ? 0.0f : badge_size + 4.0f * k); - - // A bar per stat: its icon, what was measured and what it scored. A new one opens and fades in, - // and the card grows upwards by it. Every bar is drawn before any row's content, so no bar's - // splatter covers another's numbers. - struct Line { - const hall_of_meat::CardRow* row{}; - float top{}, shown{}; - }; - const float row_height = 30.0f * k, row_gap = 10.0f * k, icon = 22.0f * k, text = 18.0f * k; - std::vector lines; - float rows_height{}; - for (const auto& row : card.rows) { - auto& came = motion.since[static_cast(row.stat)]; - if (came < 0) came = now; - const float shown = static_cast(std::clamp((now - came) / row_fade_seconds, 0.0, 1.0)); - lines.push_back({&row, rows_height, shown}); - rows_height += (row_height + row_gap) * shown * (2.0f - shown); // eases out - } - float y = display.y - corner_bottom * k - height - rows_height; - for (auto& line : lines) line.top += y; - y += rows_height; - for (const auto& line : lines) { - const ImVec2 min(left, line.top), max(right, line.top + row_height); - const auto colour = faded(theme::tile, o * line.shown * 0.92f); - if (!draw_shape(draw, row_bar, min, max, colour)) theme::rough_rect(draw, min, max, colour, ++seed, k); - } - for (const auto& line : lines) { - const auto& row = *line.row; - const float middle = line.top + row_height * 0.5f; - const auto row_fade = [&](ImU32 colour) { return faded(colour, o * line.shown); }; - float x = left + pad; - if (draw_image(draw, static_cast(stat_icons + static_cast(row.stat)), ImVec2(x, middle - icon * 0.5f), - ImVec2(x + icon, middle + icon * 0.5f), row_fade(theme::blue))) - x += icon + 10.0f * k; - shadowed_text(draw, bold, text, ImVec2(x, middle - text * 0.5f), row_fade(theme::white), row.value.c_str()); - const auto points = hall_of_meat::grouped(row.points); - shadowed_text(draw, bold, text, ImVec2(right - pad - text_width(bold, text, points), middle - text * 0.5f), - row_fade(theme::white), points.c_str()); - } - - const ImVec2 min(left, y), max(right, y + height); - const auto tile = fade(faded(theme::tile, 0.92f)); - if (draw_panel(draw, panel, min, max, edge, tile)) - draw_shape(draw, scratches, ImVec2(min.x + edge, min.y + edge), ImVec2(max.x - edge, max.y - edge), fade(faded(theme::white, 0.5f))); - else - theme::rough_rect(draw, min, max, tile, ++seed, k); - const float centre = (left + right) * 0.5f; - const auto centred = [&](ImFont* font, float size, ImU32 colour, const std::string& line) { - shadowed_text(draw, font, size, ImVec2(centre - text_width(font, size, line) * 0.5f, y), colour, line.c_str()); - y += size; - }; - y += pad; - draw_image(draw, logo, ImVec2(left + pad, y), ImVec2(right - pad, y + logo_height), fade(theme::blue)); - y += logo_step; - centred(heading, title_size, fade(theme::white), "Hall of Meat"); - const auto total = hall_of_meat::grouped(static_cast(motion.total + 0.5f)); - const float stroke = text_width(title_font, total_size, total) * 0.5f + 20.0f * k; - draw_shape(draw, underline, ImVec2(centre - stroke, y + total_size * 0.55f), ImVec2(centre + stroke, y + total_size * 0.95f), - fade(theme::blue)); - centred(title_font, total_size, fade(theme::white), total); - if (!card.badge.empty()) { - y += 4.0f * k; - centred(bold, badge_size, fade(card.highlight ? theme::good : theme::grey_text), card.badge); - } -} - -// Render thread only: this frame's, and the card's motion. -struct Shown { - hall_of_meat::Frame frame; - Motion motion; -}; -Shown& shown() { - static Shown value; - return value; -} -} // namespace - -void prepare_hall_of_meat_images() noexcept { - try { - auto& state = reads(); - std::lock_guard lock(state.mutex); - if (std::exchange(state.started, true)) return; - state.pending = std::async(std::launch::async, &decode); - } catch (...) { - logging::write(logging::Level::warning, logging::Channel::graphics, "Hall of Meat's images are unavailable: their reading could not start."); - } -} - -void reserve_hall_of_meat_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept { - auto& l = loaded(); - l = {}; - l.rects.fill(-1); - try { - auto& state = reads(); - std::lock_guard lock(state.mutex); - // A read still running stays pending for the next atlas. - if (state.pending.valid() && state.pending.wait_for(wait) == std::future_status::ready) state.decoded = state.pending.get(); - for (std::size_t index = 0; index < picture_count; ++index) { - const auto& image = state.decoded[index]; - if (!image) continue; - if (!l.atlas) { - atlas.TexDesiredWidth = std::max(atlas.TexDesiredWidth, 2048); - atlas.Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight; - l.atlas = &atlas; - } - l.rects[index] = atlas.AddCustomRectRegular(static_cast(image->width), static_cast(image->height)); - } - } catch (...) { - l = {}; - } -} - -void fill_hall_of_meat_images(ImFontAtlas& atlas) noexcept { - auto& l = loaded(); - if (l.atlas != &atlas) return; - try { - unsigned char* pixels{}; - int width{}, height{}; - atlas.GetTexDataAsRGBA32(&pixels, &width, &height); - if (!pixels || width <= 0 || height <= 0) { - l = {}; - return; - } - auto& state = reads(); - std::lock_guard lock(state.mutex); - for (std::size_t index = 0; index < picture_count; ++index) { - if (l.rects[index] < 0) continue; - const auto& image = *state.decoded[index]; - const auto* rect = atlas.GetCustomRectByIndex(l.rects[index]); - if (!rect || !rect->IsPacked() || rect->X + image.width > static_cast(width) || - rect->Y + image.height > static_cast(height)) { - l = {}; - return; - } - for (std::uint32_t y = 0; y < image.height; ++y) - std::memcpy(pixels + ((static_cast(rect->Y) + y) * width + rect->X) * 4, - image.rgba.data() + static_cast(y) * image.width * 4, static_cast(image.width) * 4); - } - atlas.TexPixelsUseColors = true; - l.filled = true; - } catch (...) { - l = {}; - } -} - -bool hall_of_meat_pending() { - auto& s = shown(); - try { - s.frame = hall_of_meat::frame(); - } catch (...) { - s.frame = {}; - } - return !s.frame.skeleton.positions.empty() || s.frame.card.opacity > 0 || s.frame.break_pulse > 0; -} - -void draw_hall_of_meat() { - auto& s = shown(); - draw_break_pulse(s.frame.break_pulse); // under the skeleton and the card - draw_skeleton(s.frame.skeleton); - draw_card(s.frame.card, s.motion); -} -} diff --git a/Extension/HallOfMeat/hall_of_meat_overlay.h b/Extension/HallOfMeat/hall_of_meat_overlay.h deleted file mode 100644 index a3923d0b..00000000 --- a/Extension/HallOfMeat/hall_of_meat_overlay.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -// The overlay's side of Hall of Meat (hall_of_meat.h): from the local skater's bail until they get -// up, the bones it hurt over the world, the bail's score card in the bottom left corner, and the -// screen's red edge when a bone breaks. Drawn with skate.'s own icons, logo and UI shapes, read from -// the installed game's data (Engine/Game/Build/20260929/ui_textures.h) and placed in the overlay's -// font atlas like the chat emotes. All of it on the background draw list, under ReSkate's own menus -// and chat, taking no input. -#include - -struct ImFontAtlas; - -namespace dingosdk::overlay { -// Startup: starts reading the images in the background, once. -void prepare_hall_of_meat_images() noexcept; -// Each atlas the overlay builds: their room reserved first, waiting up to `wait` for a read still -// running, and filled after the atlas is built. -void reserve_hall_of_meat_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept; -void fill_hall_of_meat_images(ImFontAtlas& atlas) noexcept; -// Every presented frame: whether there is something to draw (so the overlay renders with the menu -// closed), then the drawing. -bool hall_of_meat_pending(); -void draw_hall_of_meat(); -} diff --git a/Extension/HallOfMeat/hall_of_meat_skater.h b/Extension/HallOfMeat/hall_of_meat_skater.h deleted file mode 100644 index 61a04b4b..00000000 --- a/Extension/HallOfMeat/hall_of_meat_skater.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include "hall_of_meat_model.h" -#include "Extension/Skater/no_bail.h" -#include - -// The local skater as Hall of Meat reads it, every physics step No Bail hands on (no_bail.h): what -// each body touched (Engine/Game/Build/20260929/skater_body.h) and how the skater moves, on the board -// or off it and whether in a ragdoll (Engine/Game/Build/20260929/skater_state.h). Read-only, but for -// the physics step length the slow motion gives the skater. -namespace dingosdk::hall_of_meat { -// Requires the validated build and No Bail started: verifies the layout once. -bool start_skater(std::uintptr_t image_base) noexcept; -bool skater_available() noexcept; -// Physics thread, in the step: the step as hall_of_meat_model.h takes it. Exact here; read outside -// the step, the contacts may be half written. -Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept; -// The local skater's physics step length (seconds of game time): the game sets it only when it builds -// the skater's core (hall_of_meat_slow_motion.h). 0 while there is no local skater. -float step_length() noexcept; -// Game update thread: gives the local skater this physics step length. False while there is no local -// skater. -bool set_step_length(float seconds) noexcept; -} diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index b918c88a..acd2f0ad 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -64,6 +64,30 @@ struct FakeGame final : Game { } SkaterNews news; SkaterNews take_skater_news() override { return std::exchange(news, {}); } + std::vector steps; + std::vector take_physics_steps() override { return std::exchange(steps, {}); } + std::string xray(std::string_view plugin, bool on) override { + calls.push_back(std::format("xray {} {}", plugin, on ? "on" : "off")); + return {}; + } + std::string hud_corner(std::string_view plugin, bool hidden) override { + calls.push_back(std::format("corner {} {}", plugin, hidden ? "on" : "off")); + return {}; + } + std::string game_speed(std::string_view plugin, float speed) override { + calls.push_back(std::format("slow {} {:.2f}", plugin, speed)); + return {}; + } + std::string grind_exit(std::string_view plugin, std::optional wish) override { + calls.push_back(wish ? std::format("grind {} {} {} {:.2f}", plugin, wish->side, wish->stance, wish->strength) : std::format("grind {} none", plugin)); + return {}; + } + std::vector pops; + std::vector take_grind_pops(std::string_view) override { return std::exchange(pops, {}); } + std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override { + calls.push_back(std::format("hide {} {} {} {}", plugin, lt, rt, left_x)); + return {}; + } std::optional board_velocity() override { return Vec3{1, -2, 3}; } std::optional last_slam() override { return SlamNews{7.5f, 12, 100}; } std::string wipeout(float force) override { @@ -125,7 +149,8 @@ struct FakeGame final : Game { ClipRef take_recording(std::string_view) override { return std::exchange(recorded, nullptr); } std::string current_trick() override { return "kickflip"; } std::optional flight_input(std::string_view) override { return FlightInput{1, 0, 0.5f, 3, 4, true}; } - std::optional gamepad() override { return PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false}; } + std::optional pad = PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false}; + std::optional gamepad() override { return pad; } std::optional joystick() override { StickInput stick; stick.axes = {0, -1, 0.5f, 0, 0, 0}; @@ -1611,6 +1636,73 @@ void board_break_snaps_the_board_on_a_slam() { // The FPV drone as a mod (contrib/plugins/fpv-drone): its own tests of the flight maths pass, and // over the fake world (flat ground at y = 0) it flies the camera, keeps the game off the input, // plays its motor, follows the skater and tells the session where it is, within its 2 ms a tick. +// Hall of Meat as a mod (contrib/plugins/hall-of-meat): its own checks pass, and a bail fed through +// physics_step shows the x-ray, hides the HUD corner, slows the game at a break and keeps the map's best. +void hall_of_meat_scores_a_bail() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "hall-of-meat"); + auto& game = environment.fake; + Host host(environment, Side::client); + host.apply({contributed("hall-of-meat")}); + running(host, "hall-of-meat", environment); + check(host.run_console("hallofmeat test"), "hallofmeat test"); + check(environment.printed("hallofmeat: 19 passed, 0 failed") && !environment.printed("FAIL"), + "Hall of Meat's own checks pass:\n" + printed_lines(environment) + environment.all()); + check(game.called("xray hall-of-meat on") && game.called("corner hall-of-meat off"), "It gets the x-ray and the HUD corner ready\n" + environment.all()); + host.set_level("sanvan"); + // A wipeout that breaks the head, then the body lying for two seconds. + const auto step = [&](bool wipeout, std::optional ragdoll, std::vector bodies) { + game.steps.push_back({environment.now, 1.0f / 60, wipeout, false, ragdoll, Vec3{0, 0, 0}, 0.0f, std::move(bodies)}); + ticks(host, environment, 1); + }; + step(true, true, {{"Head", 20, 0, false, false, true}}); + for (int i = 0; i < 120; ++i) step(false, true, {}); + check(game.called("corner hall-of-meat on"), "The card takes the HUD corner\n" + environment.all()); + check(game.called("slow hall-of-meat 0.30"), "A break slows the game\n" + environment.all()); + const auto& shapes = environment.layers.empty() ? std::vector{} : environment.layers[0].shapes; + check(std::any_of(shapes.begin(), shapes.end(), [](const auto& s) { return s.kind == plugin_ui::Shape::Kind::skeleton && s.colours[1] != 0; }) && + std::any_of(shapes.begin(), shapes.end(), [](const auto& s) { return s.kind == plugin_ui::Shape::Kind::game_image && s.text == "thrasher"; }), + "The broken head shows on the x-ray, and the card with skate.'s wordmark"); + // Standing up ends it; it is the map's first, so its best. + step(false, false, {}); + ticks(host, environment, 200); + check(game.called("corner hall-of-meat off") && game.called("slow hall-of-meat 1.00"), "The corner and the speed are given back\n" + environment.all()); + environment.lines.clear(); + check(host.run_console("hallofmeat") && environment.printed("hallofmeat: on; this map's best ") && environment.printed("Meat (1 hits, 1 broken"), + "It says the map's best and the last bail:\n" + printed_lines(environment)); +} +// Grind exits as a mod (contrib/plugins/grind-exits): its own checks pass, and RT held in a grind asks +// for the heel side and keeps RT and the stick's lean from the game. +void grind_exits_pick_the_side() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "grind-exits"); + auto& game = environment.fake; + Host host(environment, Side::client); + host.apply({contributed("grind-exits")}); + running(host, "grind-exits", environment); + check(host.run_console("grindexit test"), "grindexit test"); + check(environment.printed("grindexit: 7 passed, 0 failed") && !environment.printed("FAIL"), + "Grind exits' own checks pass:\n" + printed_lines(environment) + environment.all()); + check(game.called("grind grind-exits none"), "It hears its pops from the start"); + check(host.run_console("grindexit stance goofy"), "grindexit stance"); + game.pad = PadInput{}; + game.state = 100; + ticks(host, environment, 2); + game.state = 400; // grinding + ticks(host, environment, 2); + game.pad->right_trigger = 1; + ticks(host, environment, 2); + check(game.called("grind grind-exits heel 2 1.00"), "RT asks for the heel side\n" + environment.all()); + check(game.called("hide grind-exits false true true"), "and keeps RT and the stick's lean from the game\n" + environment.all()); + game.pops.push_back({true, "applied", "toe_heel", -1, 1.5f, -1.5f}); + ticks(host, environment, 1); + game.pad->right_trigger = 0; + ticks(host, environment, 10); + check(game.called("grind grind-exits none") && game.called("hide grind-exits false false false"), "Let go, the game's own again"); + environment.lines.clear(); + check(host.run_console("grindexit") && environment.printed("sides set: 1. Last: right of travel (toe_heel)"), + "It counts the pop:\n" + printed_lines(environment)); +} void fpv_drone_flies_and_follows() { TestEnvironment environment; std::filesystem::remove_all(scratch / "storage" / "fpv-drone"); @@ -1765,6 +1857,8 @@ int main() { road_rash_marks_the_skater_as_it_bails(); board_break_snaps_the_board_on_a_slam(); fpv_drone_flies_and_follows(); + hall_of_meat_scores_a_bail(); + grind_exits_pick_the_side(); reskate_hostile_is_stopped_or_refused_every_time(); std::filesystem::remove_all(scratch); std::cout << "plugin tests passed\n"; diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 0e091ec8..eb54cbbb 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,13 +17,14 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, {"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"}, {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, + {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index 301759fb..9df7ae57 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -81,6 +81,15 @@ plugin_ui::Style style_of(lua_State* L, int index) { lua_pop(L, 1); if (field("shadow")) style.shadow = lua_toboolean(L, -1) != 0; lua_pop(L, 1); + if (field("font")) { + const std::string font = luaL_checkstring(L, -1); + if (font == "bold") style.font = 0; + else if (font == "body") style.font = 1; + else if (font == "heading") style.font = 2; + else if (font == "title") style.font = 3; + else luaL_error(L, "font is \"bold\", \"body\", \"heading\" or \"title\""); + } + lua_pop(L, 1); // A picture's source rectangle, {u0, v0, u1, v1} from 0 to 1: one glyph of a font atlas, a tile. if (field("uv")) { luaL_checktype(L, -1, LUA_TTABLE); @@ -184,6 +193,70 @@ struct UiApi { add(L, plugin, {plugin_ui::Shape::Kind::world_image, {point(L, 3, "the picture's place")}, height, std::move(file), style_of(L, 5)}); return 0; } + // d:game_image(name, top_left, bottom_right, style): one of the game's own pictures; style.fit + // "contain" (the default), "shape" or "panel", style.edge a panel's corner size in pixels. + static int game_image(lua_State* L) { + auto& plugin = drawing(L); + auto name = checked_text(L, 2, 40, "a game picture's name"); + if (std::find(plugin_ui::game_images.begin(), plugin_ui::game_images.end(), name) == plugin_ui::game_images.end()) { + std::string known; + for (const auto one : plugin_ui::game_images) known += (known.empty() ? "" : ", ") + std::string(one); + luaL_error(L, "the game's pictures are %s", known.c_str()); + } + plugin_ui::Shape shape{plugin_ui::Shape::Kind::game_image, {point(L, 3, "a picture's top left"), point(L, 4, "a picture's bottom right")}, 0, + std::move(name), style_of(L, 5)}; + if (lua_istable(L, 5)) { + if (lua_getfield(L, 5, "fit") != LUA_TNIL) { + const std::string fit = luaL_checkstring(L, -1); + if (fit == "contain") shape.fit = 0; + else if (fit == "shape") shape.fit = 1; + else if (fit == "panel") shape.fit = 2; + else luaL_error(L, "fit is \"contain\", \"shape\" or \"panel\""); + } + lua_pop(L, 1); + if (lua_getfield(L, 5, "edge") != LUA_TNIL) shape.edge = std::clamp(finite(L, -1, "edge"), 0.0f, 400.0f); + lua_pop(L, 1); + } + add(L, plugin, std::move(shape)); + return 0; + } + // d:gradient(top_left, bottom_right, {top_left, top_right, bottom_right, bottom_left}): a box + // shading between the colours of its corners. + static int gradient(lua_State* L) { + auto& plugin = drawing(L); + plugin_ui::Shape shape{plugin_ui::Shape::Kind::gradient, {point(L, 2, "a box's top left"), point(L, 3, "a box's bottom right")}}; + luaL_checktype(L, 4, LUA_TTABLE); + for (int i = 1; i <= 4; ++i) { + lua_rawgeti(L, 4, i); + if (lua_isnil(L, -1)) luaL_argerror(L, 4, "a gradient has four colours: top left, top right, bottom right, bottom left"); + shape.colours.push_back(colour_of(L, -1)); + lua_pop(L, 1); + } + add(L, plugin, std::move(shape)); + return 0; + } + // d:skeleton(paint, alpha): the local skater's x-ray (reskate.skater.xray has it ready), each body + // named in `paint` in its colour, the rest left out; `alpha` (0 to 1) fades it all. + static int skeleton(lua_State* L) { + auto& plugin = drawing(L); + luaL_checktype(L, 2, LUA_TTABLE); + const double alpha = luaL_optnumber(L, 3, 1.0); + if (!(alpha >= 0 && alpha <= 1)) luaL_argerror(L, 3, "alpha is 0 to 1"); + plugin_ui::Shape shape{plugin_ui::Shape::Kind::skeleton}; + shape.colours.assign(plugin_ui::body_names.size(), 0); + shape.style.colour = 0x00ffffffu | static_cast(alpha * 255.0 + 0.5) << 24; + lua_pushnil(L); + while (lua_next(L, 2)) { + if (lua_type(L, -2) != LUA_TSTRING) luaL_error(L, "the skeleton's paint is by body name"); + const std::string name = lua_tostring(L, -2); + const auto found = std::find(plugin_ui::body_names.begin() + 1, plugin_ui::body_names.end(), name); + if (found == plugin_ui::body_names.end()) luaL_error(L, "\"%s\" is not a body: the bodies are body_hit's names", name.c_str()); + shape.colours[static_cast(found - plugin_ui::body_names.begin())] = colour_of(L, -1); + lua_pop(L, 1); + } + add(L, plugin, std::move(shape)); + return 0; + } static int text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::text); } static int world_text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::world_text); } static int rect(lua_State* L) { return two_points(L, plugin_ui::Shape::Kind::rect); } @@ -497,6 +570,9 @@ void UiApi::install(Plugin& plugin, lua_State* L) { set_function(L, "world_circle", UiApi::world_circle); set_function(L, "image", UiApi::image); set_function(L, "world_image", UiApi::world_image); + set_function(L, "game_image", UiApi::game_image); + set_function(L, "gradient", UiApi::gradient); + set_function(L, "skeleton", UiApi::skeleton); set_function(L, "size", UiApi::size); set_function(L, "world_to_screen", UiApi::world_to_screen); readonly(); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 73f86692..1fc34d57 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -120,6 +120,42 @@ struct WorldApi { plugin.wipeout_at_ = now; return result(L, game(plugin).wipeout(static_cast(force))); } + // skater.xray(on): the x-ray ready (the renderer followed) while on; d:skeleton draws it. + static int xray(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "overlay", "reskate.skater.xray"); + return result(L, game(plugin).xray(plugin.id(), lua_toboolean(L, 1) != 0)); + } + // skater.grind_exit(wish): the next pop out of a grind, {side = "toe"|"heel"|"left"|"right", + // stance = "regular"|"goofy", strength = 0.1 to 1}; nil the game's own exit. + static int grind_exit(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.grind_exit"); + if (lua_isnoneornil(L, 1)) return result(L, game(plugin).grind_exit(plugin.id(), std::nullopt)); + luaL_checktype(L, 1, LUA_TTABLE); + GrindExitWish wish; + lua_getfield(L, 1, "side"); + wish.side = luaL_checkstring(L, -1); + lua_pop(L, 1); + if (wish.side != "toe" && wish.side != "heel" && wish.side != "left" && wish.side != "right") + luaL_error(L, "a grind exit's side is \"toe\", \"heel\", \"left\" or \"right\""); + lua_getfield(L, 1, "stance"); + if (!lua_isnil(L, -1)) { + const std::string stance = luaL_checkstring(L, -1); + if (stance == "regular") wish.stance = 1; + else if (stance == "goofy") wish.stance = 2; + else luaL_error(L, "a stance is \"regular\" or \"goofy\""); + } + lua_pop(L, 1); + lua_getfield(L, 1, "strength"); + if (!lua_isnil(L, -1)) { + const double strength = luaL_checknumber(L, -1); + if (!(strength >= 0.1 && strength <= 1)) luaL_error(L, "a grind exit's strength is 0.1 to 1"); + wish.strength = static_cast(strength); + } + lua_pop(L, 1); + return result(L, game(plugin).grind_exit(plugin.id(), std::move(wish))); + } // board.motion(): the board's velocity in the last physics step, or nil. static int board_motion(lua_State* L) { auto& plugin = plugin_of(L); @@ -249,6 +285,26 @@ struct WorldApi { game(plugin).hide_hud(plugin.id(), lua_toboolean(L, 1) != 0); return 0; } + // hud.corner(hidden): skate.'s bottom left HUD hidden while true; false keeps it ready to hide. + static int hud_corner(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "hud", "reskate.hud.corner"); + return result(L, game(plugin).hud_corner(plugin.id(), lua_toboolean(L, 1) != 0)); + } + // time.slow(speed): the game at `speed` (0.05 to 1; 1 or nil gives it back); single player only. + static int slow(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "game.speed", "reskate.time.slow"); + const double speed = luaL_optnumber(L, 1, 1.0); + if (!(speed >= 0.05 && speed <= 1)) luaL_argerror(L, 1, "a game speed is 0.05 to 1"); + return result(L, game(plugin).game_speed(plugin.id(), static_cast(speed))); + } + static int speed(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "game.speed", "reskate.time.speed"); + lua_pushnumber(L, game(plugin).game_speed_now()); + return 1; + } // ---- world.objects ----------------------------------------------------------------------------- static int catalog(lua_State* L) { auto& plugin = plugin_of(L); @@ -1234,6 +1290,28 @@ struct WorldApi { need(L, plugin, "input", "reskate.input.capture"); return result(L, game(plugin).capture_input(plugin.id(), lua_toboolean(L, 1) != 0)); } + // input.hide({left_trigger = true, right_trigger = true, left_x = "grinding"}): those kept from the + // game while asked; nil or {} gives them back. + static int input_hide(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.hide"); + bool lt{}, rt{}, left_x{}; + if (!lua_isnoneornil(L, 1)) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "left_trigger"); + lt = lua_toboolean(L, -1) != 0; + lua_getfield(L, 1, "right_trigger"); + rt = lua_toboolean(L, -1) != 0; + lua_getfield(L, 1, "left_x"); + if (!lua_isnil(L, -1)) { + if (lua_type(L, -1) != LUA_TSTRING || std::string_view(lua_tostring(L, -1)) != "grinding") + luaL_error(L, "left_x is hidden only while grinding: left_x = \"grinding\""); + left_x = true; + } + lua_pop(L, 3); + } + return result(L, game(plugin).hide_pad(plugin.id(), lt, rt, left_x)); + } // ---- ui.screen --------------------------------------------------------------------------------------- // screen(title, build): a full-screen screen, drawn while open() has it open; Esc closes it. static int screen(lua_State* L) { @@ -1295,12 +1373,14 @@ static void into(lua_State* L, const char* field, std::initializer_list hits; std::vector falls; }; +// One physics step of the local skater (events.physics): when (the steady clock, ms, as +// reskate.time.now), how long it simulated, and what the body did; only the bodies touching something. +struct PhysicsStep { + double at{}; + float seconds{}; + bool wipeout{}, airborne{}; + std::optional ragdoll; + std::optional velocity; + std::optional spin; + struct Body { + std::string name; // as body_hit names it + float impact{}, slide{}; + bool board{}, vehicle{}, world{}; // world: anything but the board and vehicles + }; + std::vector bodies; +}; +// What a plugin asks of the next pop out of a grind: "toe", "heel", "left" or "right" (of travel), +// the player's stance (0 not set, 1 regular, 2 goofy: what toe and heel need), and 0.1 to 1 of the +// full sideways speed. And a pop as it went (Extension/Skater/grind_exit.h). +struct GrindExitWish { + std::string side; + int stance{}; + float strength{1}; +}; +struct GrindPop { + bool applied{}; + std::string reason, basis; + int toward{}; // +1 the left of travel, -1 the right, 0 the game's own + float before{}, after{}; +}; // The last slam: its force and the game's cause number, and how long ago (ms). struct SlamNews { float force{}; @@ -268,6 +298,23 @@ public: virtual SkaterNews take_skater_news() { return {}; } virtual std::optional board_velocity() { return std::nullopt; } virtual std::optional last_slam() { return std::nullopt; } + // Every physics step since the last tick (kept once asked for: at most 512). + virtual std::vector take_physics_steps() { return {}; } + // The skater's x-ray ready while any plugin asks (Extension/Skater/xray.h); d:skeleton draws it. + virtual std::string xray(std::string_view /*plugin*/, bool) { return unavailable; } + // skate.'s bottom left HUD hidden while any plugin asks; false keeps the plugin ready to hide it. + virtual std::string hud_corner(std::string_view /*plugin*/, bool) { return unavailable; } + // The game run at `speed` (0.05 to 1; 1 drops the ask) while it is the slowest asked; never in a + // multiplayer session. And the speed it runs at for plugins now. + virtual std::string game_speed(std::string_view /*plugin*/, float) { return unavailable; } + virtual float game_speed_now() { return 1; } + // The next pop out of a grind (Extension/Skater/grind_exit.h); empty: the game's own exit. One + // plugin at a time. Its pops come back from take_grind_pops. + virtual std::string grind_exit(std::string_view /*plugin*/, std::optional) { return unavailable; } + virtual std::vector take_grind_pops(std::string_view /*plugin*/) { return {}; } + // Pad controls kept from the game while any plugin asks: the triggers, and the left stick's + // left/right while the skater grinds. + virtual std::string hide_pad(std::string_view /*plugin*/, bool /*lt*/, bool /*rt*/, bool /*left_x while grinding*/) { return unavailable; } // The skater bails as from a slam of `force` (0.5 to 3); not in a throwdown. While No Bail protects // the skater the wipeout is dropped, as any other is. virtual std::string wipeout(float /*force*/) { return unavailable; } diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 5cf88f07..5f702a4f 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -17,6 +17,10 @@ #include "Extension/Skater/skater_events.h" #include "Extension/Skater/outfit_overlay.h" #include "Extension/Skater/board_pose.h" +#include "Extension/Skater/game_speed.h" +#include "Extension/Skater/grind_exit.h" +#include "Extension/Skater/xray.h" +#include "Extension/UI/hud_corner.h" #include "plugin_camera.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Music/external_player.h" @@ -343,6 +347,58 @@ public: } return news; } + std::vector take_physics_steps() override { + skater_events::want_steps(); + std::vector out; + for (const auto& news : skater_events::take_steps()) { + const auto& s = news.step; + PhysicsStep step{news.at, s.seconds, s.wipeout, s.airborne, s.ragdoll, std::nullopt, s.spin, {}}; + if (s.velocity) step.velocity = Vec3{(*s.velocity)[0], (*s.velocity)[1], (*s.velocity)[2]}; + for (std::size_t i = 1; i < skater_body::count; ++i) { + const auto& c = s.body.bodies[i]; + if (!c.touching) continue; + const float slide = std::sqrt(c.slide[0] * c.slide[0] + c.slide[1] * c.slide[1] + c.slide[2] * c.slide[2]); + step.bodies.push_back({skater_body::names[i], c.impact, std::isfinite(slide) ? slide : 0.0f, c.hit.board, c.hit.vehicle, + c.hit.world || c.hit.kind_5 || c.hit.kind_11}); + } + out.push_back(std::move(step)); + } + return out; + } + std::string xray(std::string_view plugin, bool on) override { + if (!xray::available()) return "the x-ray is unavailable for this game build"; + xray::prepare(plugin, on); + return {}; + } + std::string hud_corner(std::string_view plugin, bool hidden) override { + hud_corner::ask(plugin, hidden); + return {}; + } + std::string game_speed(std::string_view plugin, float speed) override { + if (speed < 1.0f && multiplayer_session_active()) return "never in a multiplayer session"; + game_speed::ask(plugin, speed); + return {}; + } + float game_speed_now() override { return game_speed::current(); } + std::string grind_exit(std::string_view plugin, std::optional wish) override { + std::optional w; + if (wish) { + using Side = grind_exit::Wish::Side; + w = grind_exit::Wish{wish->side == "toe" ? Side::toe : wish->side == "heel" ? Side::heel : wish->side == "right" ? Side::right : Side::left, + wish->stance, wish->strength}; + } + return grind_exit::wish(plugin, w); + } + std::vector take_grind_pops(std::string_view plugin) override { + std::vector out; + for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after}); + return out; + } + std::string hide_pad(std::string_view plugin, bool lt, bool rt, bool left_x) override { + if (!grind_exit::available()) return "hiding pad controls is unavailable for this game build"; + grind_exit::hide(plugin, {lt, rt, left_x}); + return {}; + } std::optional board_velocity() override { skater_events::want(); return skater_events::board_velocity(); @@ -469,6 +525,10 @@ public: } std::string current_trick() override { return skater_animation::current_trick(); } void tick_animation(std::uintptr_t base, std::uintptr_t client) { + xray::tick(); + hud_corner::tick(); + game_speed::tick(); + grind_exit::tick(client); outfit_overlay::tick(); board_pose::tick(base, local_skater_component(base, client)); const bool allowed = !multiplayer::local_throwdown_active(); @@ -570,6 +630,10 @@ public: release_camera(plugin); (void)freeze(plugin, false); hide_hud(plugin, false); + xray::prepare(plugin, false); + hud_corner::release(plugin); + game_speed::ask(plugin, 1.0f); + grind_exit::release(plugin); plugin_feed::release_tags(std::string(plugin)); if (std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; })) publish_binds(); } diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index dd670ce6..f9b74f3a 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -287,6 +287,42 @@ void Host::follow_game() { each([&](Plugin& plugin, const std::string&) { plugin.game_event("bail_end", "events.skater", data); }); } } + if (wants("events.physics")) + for (const auto& step : game.take_physics_steps()) { + auto data = Json::object(); + data["at"] = step.at; + data["seconds"] = step.seconds; + data["wipeout"] = step.wipeout; + data["airborne"] = step.airborne; + if (step.ragdoll) data["ragdoll"] = *step.ragdoll; + if (step.velocity) data["velocity"] = Json::array({(*step.velocity)[0], (*step.velocity)[1], (*step.velocity)[2]}); + if (step.spin) data["spin"] = *step.spin; + auto bodies = Json::object(); + for (const auto& b : step.bodies) { + auto body = Json::object(); + body["impact"] = b.impact; + body["slide"] = b.slide; + body["board"] = b.board; + body["vehicle"] = b.vehicle; + body["world"] = b.world; + bodies[b.name] = std::move(body); + } + data["bodies"] = std::move(bodies); + each([&](Plugin& plugin, const std::string&) { plugin.game_event("physics_step", "events.physics", data); }); + } + if (wants("skater.control")) + each([&](Plugin& plugin, const std::string& id) { + for (const auto& pop : game.take_grind_pops(id)) { + auto data = Json::object(); + data["applied"] = pop.applied; + data["reason"] = pop.reason; + if (!pop.basis.empty()) data["basis"] = pop.basis; + data["side"] = pop.toward > 0 ? "left" : pop.toward < 0 ? "right" : ""; + data["before"] = pop.before; + data["after"] = pop.after; + plugin.game_event("grind_exit", "events.skater", data); + } + }); if (wants("chat.read")) { auto lines = game.chat_since(chat_seen_); // Lines from before the first plugin listened are not news. diff --git a/Extension/Plugins/plugin_manifest.cpp b/Extension/Plugins/plugin_manifest.cpp index e3c7edb8..a0df6762 100644 --- a/Extension/Plugins/plugin_manifest.cpp +++ b/Extension/Plugins/plugin_manifest.cpp @@ -7,12 +7,13 @@ namespace dingosdk::plugins { namespace { -constexpr std::array permissions{ +constexpr std::array permissions{ "console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera", "world.raycast", "world.objects", "world.environment", "world.ai", "travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read", - "chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read"}; + "chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read", + "events.physics", "game.speed"}; } bool known_permission(std::string_view name) noexcept { diff --git a/Extension/Plugins/plugin_ui.h b/Extension/Plugins/plugin_ui.h index 683e4779..d00ba18f 100644 --- a/Extension/Plugins/plugin_ui.h +++ b/Extension/Plugins/plugin_ui.h @@ -8,6 +8,7 @@ #include #include #include +#include #include // What plugins show, handed from the client thread (where plugins run) to the render thread (the @@ -26,17 +27,36 @@ struct Style { int align{}; // text: 0 left, 1 centre, 2 right of its point bool shadow = true; std::array uv{0, 0, 1, 1}; // pictures: the part of the picture drawn (u0, v0, u1, v1) + int font{}; // text: 0 bold, 1 body, 2 heading, 3 title (the menus' fonts) }; +// The skater's bodies by the ragdoll's order (Engine/Game/Skater/skater_body.h's names): d:skeleton's paint. +inline constexpr std::array body_names{ + "Board", "Head", "Neck", "Left hand", "Left forearm", "Left upper arm", "Left collarbone", + "Right hand", "Right forearm", "Right upper arm", "Right collarbone", "Upper chest", "Chest", + "Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh", + "Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis"}; +// The game's own pictures a plugin draws by name (game_image; Extension/UI/Overlay/game_images.h). +inline constexpr std::array game_images{ + "stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll", + "thrasher", "brush_bar", "rough_tile", "scratches", "streak"}; // Screen shapes are in pixels from the top left; world shapes in world metres, projected through // the camera as the frame is drawn, and left out behind it. struct Shape { // image: `text` is the picture's file, points[0] and [1] its corners; world_image: centred on - // points[0], `radius` metres tall. - enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle, image, world_image } kind{Kind::text}; + // points[0], `radius` metres tall. game_image: `text` names one of game_images, `fit` 0 contain, + // 1 shape, 2 panel (its corners `edge` pixels). gradient: points[0] and [1] the corners, `colours` + // the top left, top right, bottom right and bottom left. skeleton: the local skater's x-ray, a + // colour for each body in `colours` (body_names' order; 0 leaves it out), the style's alpha + // fading the whole. + enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle, image, world_image, game_image, gradient, skeleton } kind{ + Kind::text}; std::vector points; float radius{}; std::string text; Style style; + std::vector colours; + int fit{}; + float edge{}; }; struct Layer { std::string plugin; diff --git a/Extension/Skater/GRIND_EXITS.md b/Extension/Skater/GRIND_EXITS.md deleted file mode 100644 index eb64e347..00000000 --- a/Extension/Skater/GRIND_EXITS.md +++ /dev/null @@ -1,81 +0,0 @@ -# Grind exit triggers - -Pick the side you pop out of a grind to with **LT / RT**, independently of the trick and of the -body's rotation. In skate.'s own controls both come from the left stick at the same moment, so a -frontside 180 always pops out toward one side and a backside 180 toward the other. With this on, any -trick and any rotation can go out either side: a frontside 180 out to your toe side, a backside 180 -out to your heel side, a kickflip out to whichever side you pick. - -## Using it - -Off until you switch it on: skate.'s menu, **Mod Options > Player > Grind exit triggers** (or the -ReSkate menu's skater page, or the console (`~`) with `grindexit exits on|off`). The choices are kept. - -- **While grinding, press LT or RT** (or up to 1 s before the grind connects, - for example as you ollie up to the rail), then do any trick - out of the grind with the right stick as usual. Keep the trigger held until you land, then let go. -- **Input Mode** (`grindexit sides toeheel|travel`): - - **Toe / Heel**: LT pops out to your toe side, RT to your heel side, riding normal, fakie or - switch. Needs **your stance** (`grindexit stance goofy|regular`). On slides (boardslide, - lipslide), where toes and heels point along the rail, LT / RT are left / right of travel. - - **Left / Right**: LT pops out to the left of your direction of travel, RT to the right. -- **No trigger, or both**: the game's own exit. -- **Grabs**: a trigger used to pick the side is hidden from the game until you let go of it, so - holding it through the pop does not grab. Let go and press it again in the air to grab - (`grindexit guard on|off`). -- **Spins**: push the left stick for your spin as you always do. While the trigger is held in the - grind, the stick's left / right reaches the game at the pop instead of before it (it cannot - lean-balance the grind meanwhile), so the game's own lean exit cannot drag the board toward the - spin and into the rail (`grindexit leanguard on|off`). -- **Pop-out strength** (`grindexit strength 10-100`, kept): how far to the side a trigger exit - pops, as a share of the full exit (100%, the default, is the game's own sideways speed). Lower it - for rails next to a wall. Very low values leave little clearance on exits over the rail. -- **Multiplayer**: works in any session or server, whatever the host's settings, throwdowns - included. It adds no speed or height (the sideways speed is the game's own); it only changes which - input picks the exit side. Server checks do not see it, since it changes no game data. - -What it does not do: it does not make an exit easier than the game's physics allow. Popping out of a -smith or feeble over the rail, away from the side the board hangs on, still needs a good pop, as it -would on a real rail. - -## How it works - -At the pop the game calls its jump-trajectory setter with the launch velocity; the trainer already -hooks that call (`Extension/Trainer/trainer_jump.cpp`). For a pop out of a grind (its call site, -measured: `Engine/Game/Build/20260929/grind_exit.h`) only the sideways part of that velocity is -replaced, across the rail: the game's own sideways speed when it had one, mirrored to the chosen -side, else the game's usual 1.8 m/s. The speed along the rail, the pop's height, the trick, the flip -and the rotation are the game's. - -| Part | Files | -|---|---| -| The switch, triggers, lean guard, toe / heel and the launch change | `grind_exit_control.*` | -| Console verbs | `grind_exit_commands.cpp` | -| Each physics step of the local skater, from No Bail's skeleton response hook | `no_bail.h` (`add_no_bail_step_listener`) | -| The launch hook | `Extension/Trainer/trainer_jump.*` (`set_trajectory_modifier`) | -| The game's view of the pad (triggers, left stick) | `Extension/UI/Overlay/input_capture.cpp` (`DingoSDKOverlaySetGamepadFilter`) | -| Measured addresses and behaviour | `Engine/Game/Build/20260929/grind_exit.h` | -| The menus | `skate_menu_skater.cpp`, `hub_page.cpp`, `native_tools_view.h` | - -**Toe / heel.** The game keeps which foot leads in a flag (context + 0x13c4, bit 5), found by -recording the skater's state while riding regular and switch: it changed exactly at each change of -stance and never during grinds, pops or 180s. With the player's stance it gives the lead foot; the -toes face the skater root's right axis when the right foot leads (measured), the other way when the -left foot does. It was measured with a goofy skater, for whom "switch" and "left foot leads" are the -same thing; a regular skater's recording would confirm which of the two the flag means. - -**How it was found.** Everything above was measured with a logging-only recorder (every physics step -around grinds, the pad and the launch calls, to CSV, plus snapshots of the skater's state memory). It -is not part of this change; it can be offered separately for re-measuring after a game update. - -With the log level set to debug (`loglevel debug`), every pop out of a grind is written to -`ReSkate.log` with what was decided and why. - -## Measured - -Tested on build 25414733 with a goofy skater over four sessions: 48 pops out of grinds with the game's -own controls to learn its exits, then 144 pops whose side the triggers set. Forward, fakie and switch -50-50s, ollies, kick- and heelflips, shove-its and frontside / backside 180s went out to the chosen -side with the trick and the rotation unchanged. Frontside 180 out to the toe side, which the game's -controls cannot make, clipped the rail until the lean guard was added; with it, all 4 tries landed. -Heel-side exits from a fakie smith (over the rail) got out about 6 times in 16. diff --git a/Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp b/Extension/Skater/Test/xray_skeleton_tests.cpp similarity index 93% rename from Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp rename to Extension/Skater/Test/xray_skeleton_tests.cpp index f48c695a..2f7f80dc 100644 --- a/Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp +++ b/Extension/Skater/Test/xray_skeleton_tests.cpp @@ -1,6 +1,6 @@ -// Hall of Meat's skeleton: a made-up skeleton and mesh bound and skinned, and, given an installed -// game (dingosdk_hall_of_meat_skeleton_tests ), the game's own read and posed. -#include "Extension/HallOfMeat/hall_of_meat_skeleton.h" +// The x-ray skeleton: a made-up skeleton and mesh bound and skinned, and, given an installed +// game (dingosdk_xray_skeleton_tests ), the game's own read and posed. +#include "Extension/Skater/xray_skeleton.h" #include #include #include @@ -8,7 +8,7 @@ #include using namespace dingosdk; -using namespace dingosdk::hall_of_meat; +using namespace dingosdk::xray; using skater_body::Bone; namespace { void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } @@ -104,6 +104,6 @@ int main(int argc, char** argv) { std::cerr << "FAILED: " << error.what() << '\n'; return 1; } - std::cout << "Hall of Meat skeleton tests passed.\n"; + std::cout << "X-ray skeleton tests passed.\n"; return 0; } diff --git a/Extension/HallOfMeat/hall_of_meat_slow_motion.cpp b/Extension/Skater/game_speed.cpp similarity index 77% rename from Extension/HallOfMeat/hall_of_meat_slow_motion.cpp rename to Extension/Skater/game_speed.cpp index ae30079f..32db3f0d 100644 --- a/Extension/HallOfMeat/hall_of_meat_slow_motion.cpp +++ b/Extension/Skater/game_speed.cpp @@ -1,13 +1,16 @@ -#include "hall_of_meat_slow_motion.h" -#include "hall_of_meat_skater.h" +#include "game_speed.h" +#include "skater_physics.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "Engine/Game/Settings/simulation_time.h" #include "Extension/Settings/named_settings.h" #include +#include #include -namespace dingosdk::hall_of_meat { +namespace dingosdk::game_speed { namespace { +using skater_physics::set_step_length; +using skater_physics::step_length; constexpr const char* time_scale = "SimulationTime.TimeScale"; constexpr const char* sim_rate = "SimulationTime.ForceSimRate"; constexpr const char* max_sim_fps = "SimulationTime.MaxSimFps"; @@ -98,3 +101,42 @@ void SlowMotion::release() { if (!skater_changed_) base_rate_.reset(); } } + +namespace dingosdk::game_speed { +namespace { +struct Asks { + std::map> by_owner; + SlowMotion motion; + bool running{}; +}; +Asks& asks() { + static auto* value = new Asks; // the game's settings may still be held at exit + return *value; +} +} +void ask(std::string_view owner, float speed) { + auto& a = asks(); + if (!(speed < 1.0f) || !std::isfinite(speed)) { + if (const auto found = a.by_owner.find(owner); found != a.by_owner.end()) a.by_owner.erase(found); + return; + } + a.by_owner.insert_or_assign(std::string(owner), std::max(speed, slowest_speed)); +} +void tick() noexcept { + auto& a = asks(); + try { + float slowest = 1.0f; + for (const auto& [owner, speed] : a.by_owner) slowest = std::min(slowest, speed); + if (slowest < 1.0f || !a.motion.idle()) a.running = a.motion.set(slowest); + } catch (...) { + a.running = false; + } +} +float current() noexcept { + auto& a = asks(); + if (!a.running) return 1.0f; + float slowest = 1.0f; + for (const auto& [owner, speed] : a.by_owner) slowest = std::min(slowest, speed); + return slowest; +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_slow_motion.h b/Extension/Skater/game_speed.h similarity index 88% rename from Extension/HallOfMeat/hall_of_meat_slow_motion.h rename to Extension/Skater/game_speed.h index 7c61d0ff..b7559ca1 100644 --- a/Extension/HallOfMeat/hall_of_meat_slow_motion.h +++ b/Extension/Skater/game_speed.h @@ -5,8 +5,9 @@ #include #include #include +#include -// The slow motion after a break: the game run below its own speed for a while, smoothly. skate. +// Plugins' slow motion (reskate.time.slow): the game run below its own speed for a while, smoothly. skate. // steps its simulation at a fixed rate on a clock its time scale slows // (Engine/Game/Settings/simulation_time.h): at 0.3 alone only 18 steps come each real second, and the // camera, moving every frame, follows a skater that jumps 18 times a second. So the rate goes up as @@ -28,7 +29,7 @@ // // Never during a multiplayer session: the local simulation must keep the others' pace, so a session // that starts while it runs ends it. Other skaters (AI skaters) are not followed. Game update thread only. -namespace dingosdk::hall_of_meat { +namespace dingosdk::game_speed { inline constexpr float slowest_speed = 0.05f; // what the steps a second can follow inline constexpr std::uint32_t highest_simulation_rate = 1200; // steps a second of game time, at most @@ -71,4 +72,11 @@ private: std::optional base_rate_; // the game's own simulation rate, while it matters bool skater_changed_{}; // the local skater's step is ours, to be given back }; + +// Plugins' asks: the slowest one runs. `speed` 1 or more drops the owner's ask. Client thread. +void ask(std::string_view owner, float speed); +// Client thread, every tick: the slow motion at the slowest ask, or the game's own speed back. +void tick() noexcept; +// The speed the game runs at for plugins now: the slowest ask while it holds, else 1. +float current() noexcept; } diff --git a/Extension/Skater/grind_exit.cpp b/Extension/Skater/grind_exit.cpp new file mode 100644 index 00000000..78ccce27 --- /dev/null +++ b/Extension/Skater/grind_exit.cpp @@ -0,0 +1,302 @@ +#include "grind_exit.h" +#include "client_source_spawn_internal.h" +#include "no_bail.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/grind_exit.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include "Extension/Trainer/trainer_jump.h" +#include "Extension/UI/Overlay/overlay.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::grind_exit { +namespace { +namespace sstate = addr::skater_state; +namespace gx = addr::grind_exit; + +constexpr double fresh_seconds = 0.1; // a grind step this recent means "grinding now" +constexpr double axes_fresh_seconds = 0.25; // the skater's facing, sampled on the client tick +constexpr std::size_t rail_steps = 6; // grind steps the rail's direction is averaged over +constexpr float min_rail_speed = 0.5f, min_kept_side_speed = 1.0f; +constexpr float min_toe_across = 0.35f; // below this the toe points along the rail (slides): left/right of travel instead +constexpr std::size_t most_pops = 32; + +struct Control { + std::atomic base{}; + std::atomic started{}, listening{}, modifying{}, hooked{}; + // Physics thread -> the launch hook and the pad filter. + std::atomic state{}; + std::atomic step_qpc{}; + std::atomic rail_x{}, rail_z{}, rail_speed{}; + std::atomic switch_stance{}; + // Client tick -> the launch hook: the skater root's right axis, flattened. + std::atomic right_x{}, right_z{}; + std::atomic axes_qpc{}; + std::uint64_t next_axes_try{}; + // Physics thread only. + std::array, rail_steps> rail{}; + std::size_t rail_head{}, rail_count{}; + // The plugin's wish (client thread -> the launch hook), and the pad's hidden controls. + std::mutex mutex; + std::string wisher; + std::optional wanted; + std::map> hidden; + std::atomic hide_lt{}, hide_rt{}, hide_left_x{}; + std::vector pops; // under mutex + std::atomic side_speed{gx::measured_side_speed}; +}; +Control& control() { + static auto* value = new Control; // process lifetime: hooks may call in until exit + return *value; +} +std::int64_t qpc_now() noexcept { + LARGE_INTEGER value{}; + QueryPerformanceCounter(&value); + return value.QuadPart; +} +double seconds(std::int64_t ticks) noexcept { + static const double frequency = [] { + LARGE_INTEGER value{}; + QueryPerformanceFrequency(&value); + return static_cast(value.QuadPart); + }(); + return static_cast(ticks) / frequency; +} +bool grind_state(std::uint32_t state) noexcept { return state >= gx::grind_states_first && state < gx::grind_states_end; } +bool grinding_now(const Control& c, std::int64_t now) noexcept { + return grind_state(c.state.load(std::memory_order_acquire)) && seconds(now - c.step_qpc.load(std::memory_order_acquire)) < fresh_seconds; +} + +// ---- physics side -------------------------------------------------------------------------- +// The board's root body velocity (as skater_events.cpp reads it). +bool board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { + std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; + std::uint32_t count{}; + if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || !memory::peek(holder + sstate::board_physics_offset, physics) || + !memory::peek(physics, type) || type != skater.base + sstate::board_physics_vtable || + !memory::peek(physics + sstate::physics_bodies_offset, bodies) || !memory::peek(bodies, count) || count != sstate::board_body_count) + return false; + const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; + if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity)) + return false; + return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); +} +// Physics thread, every step of the local skater. +void on_step(const NoBailSkater& skater, float, bool) noexcept { + auto& c = control(); + std::uint32_t state{}; + if (!memory::peek(skater.context + sstate::physics_state_offset, state)) return; + const auto now = qpc_now(); + std::uint8_t flags{}; + if (memory::peek(skater.context + gx::stance_flags_offset, flags)) + c.switch_stance.store((flags & gx::stance_switch_mask) != 0, std::memory_order_relaxed); + if (grind_state(state)) { + std::array v{}; + if (board_velocity(skater, v)) { + c.rail[c.rail_head] = {v[0], v[2]}; + c.rail_head = (c.rail_head + 1) % rail_steps; + c.rail_count = std::min(c.rail_count + 1, rail_steps); + float x{}, z{}; + for (std::size_t i = 0; i < c.rail_count; ++i) { + x += c.rail[i][0]; + z += c.rail[i][1]; + } + x /= static_cast(c.rail_count); + z /= static_cast(c.rail_count); + const float speed = std::hypot(x, z); + if (speed > 0.0f) { + c.rail_x.store(x / speed, std::memory_order_relaxed); + c.rail_z.store(z / speed, std::memory_order_relaxed); + } + c.rail_speed.store(speed, std::memory_order_relaxed); + } + } else { + c.rail_count = c.rail_head = 0; + } + c.step_qpc.store(now, std::memory_order_release); + c.state.store(state, std::memory_order_release); +} + +// ---- input side ---------------------------------------------------------------------------- +// Any thread the game polls its pad from. +void filter_pad(unsigned, overlay::GamepadValues& pad) noexcept { + auto& c = control(); + if (c.hide_lt.load(std::memory_order_acquire)) pad.left_trigger = 0; + if (c.hide_rt.load(std::memory_order_acquire)) pad.right_trigger = 0; + if (c.hide_left_x.load(std::memory_order_acquire) && grinding_now(c, qpc_now())) pad.left_x = 0; +} +void publish_hidden(Control& c) { + Hidden all; + for (const auto& [owner, h] : c.hidden) { + all.left_trigger = all.left_trigger || h.left_trigger; + all.right_trigger = all.right_trigger || h.right_trigger; + all.left_x_grinding = all.left_x_grinding || h.left_x_grinding; + } + c.hide_lt.store(all.left_trigger, std::memory_order_release); + c.hide_rt.store(all.right_trigger, std::memory_order_release); + c.hide_left_x.store(all.left_x_grinding, std::memory_order_release); + DingoSDKOverlaySetGamepadFilter(c.hidden.empty() ? nullptr : &filter_pad); +} + +// ---- the launch ---------------------------------------------------------------------------- +// Which way across the rail (+1 the left of travel, n = (tz, -tx); -1 the right) the wish points. +int toward_side(Control& c, const Wish& w, float nx, float nz, std::int64_t now, Pop& pop) noexcept { + using Side = Wish::Side; + const int fallback = w.side == Side::toe || w.side == Side::left ? 1 : -1; + if (w.side == Side::left || w.side == Side::right) { pop.basis = "travel"; return fallback; } + if (w.stance != 1 && w.stance != 2) { pop.basis = "no_stance"; return fallback; } + if (seconds(now - c.axes_qpc.load(std::memory_order_acquire)) > axes_fresh_seconds) { pop.basis = "no_facing"; return fallback; } + float rx = c.right_x.load(std::memory_order_relaxed), rz = c.right_z.load(std::memory_order_relaxed); + const float length = std::hypot(rx, rz); + if (length < 0.2f) { pop.basis = "no_facing"; return fallback; } + rx /= length; + rz /= length; + // The toes point to the root's right axis when the right foot leads (goofy riding normal or + // fakie: measured), to the other side when the left foot does (regular, or goofy switch). + const bool right_foot_leads = (w.stance == 2) != c.switch_stance.load(std::memory_order_relaxed); + const float toe_x = right_foot_leads ? rx : -rx, toe_z = right_foot_leads ? rz : -rz; + const float across = toe_x * nx + toe_z * nz; + if (std::abs(across) < min_toe_across) { pop.basis = "slide"; return fallback; } + pop.basis = "toe_heel"; + const int toe = across > 0 ? 1 : -1; + return w.side == Side::toe ? toe : -toe; +} +void record(Control& c, Pop pop) { + std::lock_guard lock(c.mutex); + if (c.pops.size() < most_pops) c.pops.push_back(std::move(pop)); +} +// Physics thread: every call of the jump-trajectory setter. +void modify_launch(std::uintptr_t caller_rva, std::uintptr_t, float (&v)[3]) noexcept { + auto& c = control(); + if (caller_rva != gx::grind_pop_call) return; + const auto now = qpc_now(); + if (!grinding_now(c, now)) return; // no complies and pops from a manual launch from the same call + try { + std::optional wish; + { + std::lock_guard lock(c.mutex); + if (c.wisher.empty()) return; + wish = c.wanted; + } + Pop pop; + const float tx = c.rail_x.load(std::memory_order_relaxed), tz = c.rail_z.load(std::memory_order_relaxed); + const float speed = c.rail_speed.load(std::memory_order_relaxed); + const float nx = tz, nz = -tx; + const float along = v[0] * tx + v[2] * tz; + pop.before = pop.after = v[0] * nx + v[2] * nz; + if (!wish) { pop.reason = "none"; return record(c, std::move(pop)); } + if (speed < min_rail_speed) { pop.reason = "slow"; return record(c, std::move(pop)); } + // The launch must be this skater's: mostly along its rail, at about its speed (measured 0.93x). + const float horizontal = std::hypot(v[0], v[2]); + if (!(along > 0.5f * speed && along < 1.6f * speed && horizontal < 2.0f * speed + 3.0f)) { + pop.reason = "not_ours"; + return record(c, std::move(pop)); + } + pop.toward = toward_side(c, *wish, nx, nz, now, pop); + const float side = static_cast(pop.toward); + // Strength scales the sideways speed; at full strength the game's own exit toward the chosen + // side is kept as it is. + const float strength = std::clamp(wish->strength, 0.1f, 1.0f); + if (strength >= 1.0f && pop.before * side >= min_kept_side_speed) { pop.reason = "kept"; return record(c, std::move(pop)); } + const float full = std::abs(pop.before) >= min_kept_side_speed ? std::abs(pop.before) : c.side_speed.load(std::memory_order_relaxed); + const float magnitude = full * strength; + const float change = side * magnitude - pop.before; + v[0] += change * nx; + v[2] += change * nz; + pop.after = side * magnitude; + pop.applied = true; + pop.reason = "applied"; + record(c, std::move(pop)); + } catch (...) { + } +} + +// The step listener and the launch hook, from the first use on. +void listen(Control& c) { + if (!c.listening.load(std::memory_order_acquire) && add_no_bail_step_listener(&on_step)) c.listening.store(true, std::memory_order_release); + if (!c.modifying.exchange(true)) trainer::set_trajectory_modifier(&modify_launch); +} +} // namespace + +bool start(std::uintptr_t image_base) noexcept { + auto& c = control(); + if (c.started.load(std::memory_order_acquire)) return true; + if (!no_bail_available()) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Grind exits for plugins are unavailable: they follow the skater's physics steps through No Bail's hooks, which did not start."); + return false; + } + c.base.store(image_base, std::memory_order_release); + c.started.store(true, std::memory_order_release); + return true; +} +bool available() noexcept { return control().started.load(std::memory_order_acquire); } + +std::string wish(std::string_view owner, std::optional w) { + auto& c = control(); + if (!c.started.load(std::memory_order_acquire)) return "grind exits are unavailable for this game build"; + std::lock_guard lock(c.mutex); + if (!c.wisher.empty() && c.wisher != owner) return c.wisher + " picks the grind exits"; + c.wisher = owner; + c.wanted = w; + listen(c); + return {}; +} +void hide(std::string_view owner, Hidden hidden) { + auto& c = control(); + if (!c.started.load(std::memory_order_acquire)) return; + std::lock_guard lock(c.mutex); + if (hidden.left_trigger || hidden.right_trigger || hidden.left_x_grinding) c.hidden.insert_or_assign(std::string(owner), hidden); + else if (const auto found = c.hidden.find(owner); found != c.hidden.end()) c.hidden.erase(found); + if (hidden.left_x_grinding) listen(c); + publish_hidden(c); +} +void release(std::string_view owner) { + auto& c = control(); + std::lock_guard lock(c.mutex); + if (c.wisher == owner) { + c.wisher.clear(); + c.wanted.reset(); + c.pops.clear(); + } + if (const auto found = c.hidden.find(owner); found != c.hidden.end()) { + c.hidden.erase(found); + publish_hidden(c); + } +} +std::vector take_pops(std::string_view owner) { + auto& c = control(); + std::lock_guard lock(c.mutex); + if (c.wisher != owner) return {}; + return std::exchange(c.pops, {}); +} +bool grinding() noexcept { return grinding_now(control(), qpc_now()); } + +void tick(std::uintptr_t client) noexcept { + auto& c = control(); + if (!c.started.load(std::memory_order_acquire) || !c.modifying.load(std::memory_order_acquire)) return; + const auto base = c.base.load(std::memory_order_acquire); + if (!c.hooked.load(std::memory_order_acquire) && trainer::start_trick_heights(base)) c.hooked.store(true, std::memory_order_release); + // The skater's facing (rows: right, up, forward, position), for toe and heel. + const auto now = GetTickCount64(); + if (now < c.next_axes_try) return; + try { + overlay::DebugModel skater; + const auto matrix = client_source::detail::debug_skater(base, client, skater); + if (skater.skater_position_valid && std::isfinite(matrix[0]) && std::isfinite(matrix[2])) { + c.right_x.store(matrix[0], std::memory_order_relaxed); + c.right_z.store(matrix[2], std::memory_order_relaxed); + c.axes_qpc.store(qpc_now(), std::memory_order_release); + } + } catch (...) { + c.next_axes_try = now + 250; // no skater: menus, loading, a respawn + } +} +} diff --git a/Extension/Skater/grind_exit.h b/Extension/Skater/grind_exit.h new file mode 100644 index 00000000..cb16fa19 --- /dev/null +++ b/Extension/Skater/grind_exit.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include +#include +#include + +// Which side the local skater pops out of a grind to, as a plugin asks (reskate.skater.grind_exit; +// the grind-exits mod picks it with LT / RT), and pad controls kept from the game meanwhile +// (reskate.input.hide). What decides the side, when and why, is the plugin's; this is what has to +// happen in the game's own threads. +// +// At the pop the game calls its jump-trajectory setter with the launch velocity; the trainer hooks +// that call (Extension/Trainer/trainer_jump.cpp). For a pop out of a grind (its call site, measured: +// Engine/Game/Build/20260929/grind_exit.h) only the sideways part of the launch is replaced, across +// the rail: the game's own sideways speed when it had one, mirrored to the chosen side, else +// `side_speed` (1.8 m/s, the game's own), scaled by the wish's strength. Along the rail, the pop's +// height, the trick, the flip and the body's rotation stay the game's. +// +// Toe and heel come from which foot leads (the game's stance flag, grind_exit.h, and the player's +// stance as the wish gives it) and the skater root's right axis: the toes face it when the right foot +// leads (measured). On slides, where toe and heel point along the rail, and without a stance, toe is +// the left of travel and heel the right. +// +// The pad: a hidden trigger reads as let go; the left stick's left/right, hidden "while grinding", +// reads as centred only while the skater grinds, so it reaches the game again at the pop. +namespace dingosdk::grind_exit { +// Requires the validated build and No Bail started. +bool start(std::uintptr_t image_base) noexcept; +bool available() noexcept; + +struct Wish { + enum class Side : std::uint8_t { toe, heel, left, right } side{Side::left}; + int stance{}; // 0 not set, 1 regular, 2 goofy: what toe and heel need + float strength{1.0f}; // 0.1 to 1 of the full sideways speed +}; +// Client thread: what the next pop out of a grind does (empty: the game's own exit). One plugin at a +// time: empty when taken, else why not. +std::string wish(std::string_view owner, std::optional wish); +struct Hidden { + bool left_trigger{}, right_trigger{}, left_x_grinding{}; +}; +void hide(std::string_view owner, Hidden hidden); +// Whatever the plugin held. +void release(std::string_view owner); + +// A pop out of a grind, as it went: whether the side was set, and why (or why not). +struct Pop { + bool applied{}; + std::string reason; // "applied", "kept", "none", "slow", "not_ours" + std::string basis; // "toe_heel", "slide", "no_stance", "no_facing", "travel" + int toward{}; // +1 the left of travel, -1 the right, 0 the game's own + float before{}, after{}; // the sideways speed, m/s (+ the left) +}; +// Client thread: the pops since the last call, for the plugin that wished. +std::vector take_pops(std::string_view owner); +// The skater is grinding now (as the physics steps last said). +bool grinding() noexcept; +// Client thread, every tick, with the local client: the launch hook made ready while used, the +// skater's facing sampled. +void tick(std::uintptr_t client) noexcept; +} diff --git a/Extension/Skater/grind_exit_commands.cpp b/Extension/Skater/grind_exit_commands.cpp deleted file mode 100644 index f5a6620c..00000000 --- a/Extension/Skater/grind_exit_commands.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include "grind_exit_control.h" -#include "Extension/Console/commands.h" - -// The grind exit triggers' console verbs (grind_exit_control.h). -namespace dingosdk::console { -void register_grind_exit_commands(Commands ®istry) { - struct Verb { - const char *name; - const char *description; - const char *usage; - }; - const Verb verbs[]{ - {"exits", "Grind exit triggers: LT/RT pressed while grinding pick the side you pop out to (kept)", "on|off|status"}, - {"stance", "Grind exits: your skater's stance, for toe/heel sides (kept)", "goofy|regular"}, - {"sides", "Grind exits: LT toe / RT heel, or LT left / RT right of travel (kept)", "toeheel|travel"}, - {"strength", "Grind exits: how far to the side a trigger exit pops, percent of the full exit (kept; 100 is the game's own)", "<10-100>"}, - {"guard", "Grind exits: hide a trigger used to pick the side from the game until released (no accidental grab)", "on|off"}, - {"leanguard", "Grind exits: the left stick's left/right reaches the game at the pop while a trigger picks the side", "on|off"}, - {"prewindow", "Grind exits: seconds a trigger may be pressed before the grind connects and still count (1)", ""}, - {"sidespeed", "Grind exits: sideways speed of a chosen exit when the game had none (m/s, game's own 1.8)", ""}, - }; - for (const auto &verb : verbs) { - std::vector arguments; - if (verb.usage) { - auto rest = argument(verb.usage, Type::text, true); - rest.rest = true; - arguments.push_back(std::move(rest)); - } - auto entry = action(std::string("grindexit ") + verb.name, verb.description, Group::gameplay, std::move(arguments)); - entry.run = [name = std::string(verb.name)](const Model &, const Values &values, const Output &out) { - std::vector words{name}; - if (!values.empty()) - if (const auto *text = std::get_if(&values[0])) { - std::size_t start = 0; - while (start < text->size()) { - auto end = text->find(' ', start); - if (end == std::string::npos) end = text->size(); - if (end > start) words.push_back(text->substr(start, end - start)); - start = end + 1; - } - } - out(grind_exit_control::command(words)); - }; - registry.add(std::move(entry)); - } -} -} // namespace dingosdk::console diff --git a/Extension/Skater/grind_exit_control.cpp b/Extension/Skater/grind_exit_control.cpp deleted file mode 100644 index 33bafd5b..00000000 --- a/Extension/Skater/grind_exit_control.cpp +++ /dev/null @@ -1,537 +0,0 @@ -#include "grind_exit_control.h" -#include "client_source_spawn_internal.h" -#include "no_bail.h" -#include "Engine/Core/Log/logging.h" -#include "Engine/Core/Platform/memory.h" -#include "Engine/Game/Build/addresses.h" -#include "Engine/Game/Build/20260929/grind_exit.h" -#include "Engine/Game/Build/20260929/skater_state.h" -#include "Extension/Trainer/trainer_jump.h" -#include "Extension/UI/Overlay/overlay.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace dingosdk::grind_exit_control { -namespace { -namespace sstate = addr::skater_state; -namespace gx = addr::grind_exit; - -constexpr std::uint8_t press_level = 102, release_level = 51; // of 255: about 40 % in, 20 % out -constexpr double fresh_seconds = 0.1; // a grind step this recent means "grinding now" -constexpr double release_grace_seconds = 0.1; // a trigger let go this shortly before the pop still counts -constexpr double axes_fresh_seconds = 0.25; // the skater's facing, sampled on the client tick -constexpr std::size_t rail_steps = 6; // grind steps the rail's direction is averaged over -constexpr float min_rail_speed = 0.5f, min_kept_side_speed = 1.0f; -constexpr int min_strength = 10; // pop-out strength floor, percent: below it a trigger barely picks a side -constexpr float min_toe_across = 0.35f; // below this the toe points along the rail (slides): left/right of travel instead - -enum class Stance : int { unset, regular, goofy }; -enum class Sides : int { toeheel, travel }; -enum class Basis : std::uint8_t { travel, toeheel, slide, no_stance, no_facing }; -enum class Reason : std::uint8_t { applied, kept, no_trigger, both_triggers, slow, not_ours }; -const char* reason_text(Reason r) { - switch (r) { - case Reason::applied: return "side set"; - case Reason::kept: return "game already chose that side"; - case Reason::no_trigger: return "no trigger: the game's own exit"; - case Reason::both_triggers: return "both triggers: the game's own exit"; - case Reason::slow: return "skipped: too slow to tell the rail's direction"; - case Reason::not_ours: return "skipped: the launch did not match the local skater's grind"; - } - return ""; -} -const char* basis_text(Basis b) { - switch (b) { - case Basis::travel: return "left/right of travel"; - case Basis::toeheel: return "toe/heel"; - case Basis::slide: return "toe/heel along the rail (slide): left/right of travel"; - case Basis::no_stance: return "stance not set: left/right of travel"; - case Basis::no_facing: return "facing unknown: left/right of travel"; - } - return ""; -} -struct Decision { - std::int64_t qpc{}; - Reason reason{}; - Basis basis{}; - int trigger{}; // +1 LT, -1 RT, 0 none - int toward{}; // +1 the tester's left of travel, -1 right - bool switch_stance{}, lean_blocked{}; - std::uint32_t state{}; - float along{}, before_side{}, after_side{}, up{}, rail_speed{}, toe_across{}; -}; -struct Trigger { - bool pressed{}, claimed{}; - std::int64_t pressed_at{}, released{}; -}; - -struct Control { - std::atomic base{}; - std::atomic started{}, enabled{}, guard{true}, lean_guard{true}, hooked{}; - std::atomic side_speed{gx::measured_side_speed}, prewindow{1.0f}; - std::atomic strength{100}; // pop-out strength, percent of the sideways speed (10-100) - std::atomic stance{static_cast(Stance::unset)}, sides{static_cast(Sides::toeheel)}; - std::atomic wanted{}; // the player's on/off choice, kept; `enabled` is whether it is installed now - // Physics thread -> the launch hook and the pad filter. - std::atomic state{}; - std::atomic step_qpc{}, grind_start{}; - std::atomic rail_x{}, rail_z{}, rail_speed{}; - std::atomic switch_stance{}; - // Client tick -> the launch hook: the skater root's right axis, flattened. - std::atomic right_x{}, right_z{}; - std::atomic axes_qpc{}; - std::uint64_t next_axes_try{}; - // Physics thread only. - std::array, rail_steps> rail{}; - std::size_t rail_head{}, rail_count{}; - std::uint32_t last_state{}; - // The game's input thread(s) and the launch hook. - SRWLOCK trigger_lock = SRWLOCK_INIT; - std::array, 4> triggers{}; // [XInput slot][0 LT, 1 RT] - std::atomic lean_masked{}; // last time the lean guard hid a pushed stick - // Physics thread -> client tick. - SRWLOCK decision_lock = SRWLOCK_INIT; - std::array decisions{}; - std::size_t decision_count{}; - std::atomic pops{}, applied{}; - std::filesystem::path settings_path; -}; -Control& control() { - static auto* value = new Control; // process lifetime: hooks may call in until exit - return *value; -} -std::int64_t qpc_now() noexcept { - LARGE_INTEGER value{}; - QueryPerformanceCounter(&value); - return value.QuadPart; -} -double seconds(std::int64_t ticks) noexcept { - static const double frequency = [] { - LARGE_INTEGER value{}; - QueryPerformanceFrequency(&value); - return static_cast(value.QuadPart); - }(); - return static_cast(ticks) / frequency; -} -std::int64_t ticks(double s) noexcept { - static const double frequency = [] { - LARGE_INTEGER value{}; - QueryPerformanceFrequency(&value); - return static_cast(value.QuadPart); - }(); - return static_cast(s * frequency); -} -bool grind_state(std::uint32_t state) noexcept { return state >= gx::grind_states_first && state < gx::grind_states_end; } -bool grinding_now(const Control& c, std::int64_t now) noexcept { - return grind_state(c.state.load(std::memory_order_acquire)) && - seconds(now - c.step_qpc.load(std::memory_order_acquire)) < fresh_seconds; -} - -// ---- settings ------------------------------------------------------------------------------ -std::filesystem::path settings_file() { - PWSTR raw{}; - std::filesystem::path result; - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw) - result = std::filesystem::path(raw) / L"ReSkate" / L"grindexit" / L"control.txt"; - if (raw) CoTaskMemFree(raw); - return result; -} -void load_settings(Control& c) { - if (c.settings_path.empty()) return; - std::ifstream in(c.settings_path); - std::string line; - while (std::getline(in, line)) { - if (line == "stance=goofy") c.stance.store(static_cast(Stance::goofy)); - else if (line == "stance=regular") c.stance.store(static_cast(Stance::regular)); - else if (line == "sides=travel") c.sides.store(static_cast(Sides::travel)); - else if (line == "sides=toeheel") c.sides.store(static_cast(Sides::toeheel)); - else if (line == "enabled=1") c.wanted.store(true); - else if (line == "enabled=0") c.wanted.store(false); - else if (line.starts_with("strength=")) { - int value{}; - const auto parsed = std::from_chars(line.data() + 9, line.data() + line.size(), value); - if (parsed.ec == std::errc{} && value >= min_strength && value <= 100) c.strength.store(value); - } - } -} -void save_settings(Control& c) { - if (c.settings_path.empty()) return; - std::error_code error; - std::filesystem::create_directories(c.settings_path.parent_path(), error); - std::ofstream out(c.settings_path, std::ios::trunc); - const auto stance = static_cast(c.stance.load()); - if (stance != Stance::unset) out << "stance=" << (stance == Stance::goofy ? "goofy" : "regular") << "\n"; - out << "sides=" << (static_cast(c.sides.load()) == Sides::travel ? "travel" : "toeheel") << "\n"; - out << "enabled=" << (c.wanted.load() ? 1 : 0) << "\n"; - out << "strength=" << c.strength.load() << "\n"; -} - -// ---- physics side -------------------------------------------------------------------------- -// The board's root body velocity (the same checks as Hall of Meat's read). -bool board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { - std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; - std::uint32_t count{}; - if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || - !memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) || - type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) || - !memory::peek(bodies, count) || count != sstate::board_body_count) - return false; - const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; - if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || - !memory::peek(body + sstate::body_velocity_offset, velocity)) - return false; - return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); -} -// Physics thread, every step of the local skater. -void on_step(const NoBailSkater& skater, float, bool) noexcept { - auto& c = control(); - std::uint32_t state{}; - if (!memory::peek(skater.context + sstate::physics_state_offset, state)) return; - const auto now = qpc_now(); - std::uint8_t flags{}; - if (memory::peek(skater.context + gx::stance_flags_offset, flags)) - c.switch_stance.store((flags & gx::stance_switch_mask) != 0, std::memory_order_relaxed); - if (grind_state(state)) { - if (!grind_state(c.last_state)) c.grind_start.store(now, std::memory_order_release); - std::array v{}; - if (board_velocity(skater, v)) { - c.rail[c.rail_head] = {v[0], v[2]}; - c.rail_head = (c.rail_head + 1) % rail_steps; - c.rail_count = std::min(c.rail_count + 1, rail_steps); - float x{}, z{}; - for (std::size_t i = 0; i < c.rail_count; ++i) { - x += c.rail[i][0]; - z += c.rail[i][1]; - } - x /= static_cast(c.rail_count); - z /= static_cast(c.rail_count); - const float speed = std::hypot(x, z); - if (speed > 0.0f) { - c.rail_x.store(x / speed, std::memory_order_relaxed); - c.rail_z.store(z / speed, std::memory_order_relaxed); - } - c.rail_speed.store(speed, std::memory_order_relaxed); - } - } else { - c.rail_count = c.rail_head = 0; - } - c.last_state = state; - c.step_qpc.store(now, std::memory_order_release); - c.state.store(state, std::memory_order_release); -} - -// ---- input side ---------------------------------------------------------------------------- -// Any thread the game polls its pad from. -void filter_pad(unsigned user, overlay::GamepadValues& pad) noexcept { - auto& c = control(); - if (!c.enabled.load(std::memory_order_acquire)) return; - const auto now = qpc_now(); - const bool grinding = grinding_now(c, now); - const auto window_start = c.grind_start.load(std::memory_order_acquire) - ticks(c.prewindow.load(std::memory_order_relaxed)); - const bool guard = c.guard.load(std::memory_order_acquire); - std::uint8_t* values[2]{&pad.left_trigger, &pad.right_trigger}; - bool any_claimed = false; - AcquireSRWLockExclusive(&c.trigger_lock); - for (std::size_t side = 0; side < 2; ++side) { - auto& t = c.triggers[user & 3][side]; - const auto raw = *values[side]; - if (!t.pressed && raw >= press_level) { - t.pressed = true; - t.pressed_at = now; - if (grinding) t.claimed = true; - } else if (t.pressed && raw <= release_level) { - t.pressed = false; - if (t.claimed) { - t.claimed = false; - t.released = now; - } - } - // Pressed a moment before the grind connected: it counts too. - if (grinding && t.pressed && !t.claimed && t.pressed_at >= window_start) t.claimed = true; - if (t.claimed && guard) *values[side] = 0; - } - for (const auto& slot : c.triggers) - for (const auto& t : slot) any_claimed = any_claimed || t.claimed; - ReleaseSRWLockExclusive(&c.trigger_lock); - if (grinding && any_claimed && c.lean_guard.load(std::memory_order_acquire)) { - if (pad.left_x > 8000 || pad.left_x < -8000) c.lean_masked.store(now, std::memory_order_relaxed); - pad.left_x = 0; - } -} -// +1 LT, -1 RT, 0: none, or both. -int chosen_trigger(Control& c, std::int64_t now, bool& both) noexcept { - bool held[2]{}, recent[2]{}; - AcquireSRWLockShared(&c.trigger_lock); - for (const auto& slot : c.triggers) - for (std::size_t side = 0; side < 2; ++side) { - held[side] = held[side] || slot[side].claimed; - recent[side] = recent[side] || (slot[side].released && seconds(now - slot[side].released) < release_grace_seconds); - } - ReleaseSRWLockShared(&c.trigger_lock); - both = false; - if (held[0] && held[1]) { both = true; return 0; } - if (held[0] || held[1]) return held[0] ? 1 : -1; - if (recent[0] && recent[1]) { both = true; return 0; } - if (recent[0] || recent[1]) return recent[0] ? 1 : -1; - return 0; -} -void record(Control& c, const Decision& d) noexcept { - AcquireSRWLockExclusive(&c.decision_lock); - if (c.decision_count < c.decisions.size()) c.decisions[c.decision_count++] = d; - ReleaseSRWLockExclusive(&c.decision_lock); -} - -// ---- the launch ---------------------------------------------------------------------------- -// Which way across the rail (+1 the tester's "left" = n = (tz, -tx), the skater root's right axis -// when riding forward; -1 the other) the claimed trigger points. -int toward_side(Control& c, int trigger, float nx, float nz, std::int64_t now, Decision& d) noexcept { - d.switch_stance = c.switch_stance.load(std::memory_order_relaxed); - if (static_cast(c.sides.load()) == Sides::travel) { d.basis = Basis::travel; return trigger; } - const auto stance = static_cast(c.stance.load()); - if (stance == Stance::unset) { d.basis = Basis::no_stance; return trigger; } - if (seconds(now - c.axes_qpc.load(std::memory_order_acquire)) > axes_fresh_seconds) { d.basis = Basis::no_facing; return trigger; } - float rx = c.right_x.load(std::memory_order_relaxed), rz = c.right_z.load(std::memory_order_relaxed); - const float length = std::hypot(rx, rz); - if (length < 0.2f) { d.basis = Basis::no_facing; return trigger; } - rx /= length; - rz /= length; - // The toes point to the root's right axis when the right foot leads (goofy riding normal or - // fakie: measured), to the other side when the left foot leads (regular, or goofy switch). - const bool right_foot_leads = (stance == Stance::goofy) != d.switch_stance; - const float toe_x = right_foot_leads ? rx : -rx, toe_z = right_foot_leads ? rz : -rz; - d.toe_across = toe_x * nx + toe_z * nz; - if (std::abs(d.toe_across) < min_toe_across) { d.basis = Basis::slide; return trigger; } - d.basis = Basis::toeheel; - const int toe = d.toe_across > 0 ? 1 : -1; - return trigger > 0 ? toe : -toe; // LT toe, RT heel -} -// Physics thread: every call of the jump-trajectory setter. -void modify_launch(std::uintptr_t caller_rva, std::uintptr_t, float (&v)[3]) noexcept { - auto& c = control(); - if (!c.enabled.load(std::memory_order_acquire) || caller_rva != gx::grind_pop_call) return; - const auto now = qpc_now(); - if (!grinding_now(c, now)) return; // no complies and pops from a manual launch from the same call - c.pops.fetch_add(1, std::memory_order_relaxed); - Decision d; - d.qpc = now; - d.state = c.state.load(std::memory_order_acquire); - d.up = v[1]; - d.lean_blocked = seconds(now - c.lean_masked.load(std::memory_order_relaxed)) < 0.15; - const float tx = c.rail_x.load(std::memory_order_relaxed), tz = c.rail_z.load(std::memory_order_relaxed); - const float speed = c.rail_speed.load(std::memory_order_relaxed); - const float nx = tz, nz = -tx; - d.rail_speed = speed; - d.along = v[0] * tx + v[2] * tz; - d.before_side = d.after_side = v[0] * nx + v[2] * nz; - if (speed < min_rail_speed) { d.reason = Reason::slow; record(c, d); return; } - // The launch must be this skater's: mostly along its rail, at about its speed (measured 0.93x). - const float horizontal = std::hypot(v[0], v[2]); - if (!(d.along > 0.5f * speed && d.along < 1.6f * speed && horizontal < 2.0f * speed + 3.0f)) { - d.reason = Reason::not_ours; - record(c, d); - return; - } - bool both{}; - d.trigger = chosen_trigger(c, now, both); - if (d.trigger == 0) { d.reason = both ? Reason::both_triggers : Reason::no_trigger; record(c, d); return; } - d.toward = toward_side(c, d.trigger, nx, nz, now, d); - const float side = static_cast(d.toward); - // Pop-out strength scales the sideways speed of every trigger-set exit; at 100% (the default) - // the game's own exit toward the chosen side is kept as it is. - const int strength = c.strength.load(std::memory_order_relaxed); - if (strength >= 100 && d.before_side * side >= min_kept_side_speed) { d.reason = Reason::kept; record(c, d); return; } - const float full = std::abs(d.before_side) >= min_kept_side_speed ? std::abs(d.before_side) - : c.side_speed.load(std::memory_order_relaxed); - const float magnitude = full * static_cast(strength) / 100.0f; - const float change = side * magnitude - d.before_side; - v[0] += change * nx; - v[2] += change * nz; - d.after_side = side * magnitude; - d.reason = Reason::applied; - c.applied.fetch_add(1, std::memory_order_relaxed); - record(c, d); -} - -void clear_triggers(Control& c) noexcept { - AcquireSRWLockExclusive(&c.trigger_lock); - c.triggers = {}; - ReleaseSRWLockExclusive(&c.trigger_lock); -} -std::string status_text(Control& c) { - if (!c.started.load(std::memory_order_acquire)) return "Grind exit control is unavailable for this game build."; - const bool on = c.enabled.load(std::memory_order_acquire); - const auto stance = static_cast(c.stance.load()); - const bool toeheel = static_cast(c.sides.load()) == Sides::toeheel; - std::string text = std::format( - "Grind exit control: {}. Sides: {}. Stance: {}. Pop-out strength {}%. Grab guard {}, lean guard {}, pre-press window {:.2f} s, " - "side speed {:.2f} m/s. Pops out of grinds seen: {}, sides set: {}.", - on ? "ON" : "off", toeheel ? "LT toe / RT heel" : "LT left / RT right of travel", - stance == Stance::goofy ? "goofy" : stance == Stance::regular ? "regular" : "NOT SET (type: grindexit stance goofy|regular)", - c.strength.load(), c.guard.load() ? "on" : "off", c.lean_guard.load() ? "on" : "off", c.prewindow.load(), c.side_speed.load(), c.pops.load(), - c.applied.load()); - if (on && toeheel && stance == Stance::unset) text += " Until the stance is set, LT/RT are left/right of travel."; - if (on && !c.hooked.load()) text += " The launch hook is not ready yet (it installs once the game's code is recognised)."; - return text; -} -bool install(Control& c) { - if (c.enabled.load(std::memory_order_acquire)) return true; - if (!add_no_bail_step_listener(&on_step)) return false; - c.state.store(0); - c.step_qpc.store(0); - c.last_state = 0; - clear_triggers(c); - trainer::set_trajectory_modifier(&modify_launch); - DingoSDKOverlaySetGamepadFilter(&filter_pad); - c.enabled.store(true, std::memory_order_release); - logging::write(logging::Level::info, logging::Channel::skater, "Grind exit control on."); - return true; -} -void uninstall(Control& c) { - if (!c.enabled.exchange(false, std::memory_order_acq_rel)) return; - DingoSDKOverlaySetGamepadFilter(nullptr); - trainer::set_trajectory_modifier(nullptr); - remove_no_bail_step_listener(&on_step); - clear_triggers(c); - logging::write(logging::Level::info, logging::Channel::skater, "Grind exit control off."); -} -} // namespace - -bool available() noexcept { return control().started.load(std::memory_order_acquire); } -bool enabled() noexcept { return control().enabled.load(std::memory_order_acquire); } -bool toe_heel() noexcept { return static_cast(control().sides.load()) == Sides::toeheel; } -int stance() noexcept { return control().stance.load(); } -int strength() noexcept { return control().strength.load(); } - -bool start(std::uintptr_t image_base) noexcept { - auto& c = control(); - if (c.started.load(std::memory_order_acquire)) return true; - if (!no_bail_available()) { - logging::write(logging::Level::warning, logging::Channel::skater, - "Grind exit control is unavailable: it follows the skater's physics steps through No Bail's hooks, which did not start."); - return false; - } - try { - c.settings_path = settings_file(); - load_settings(c); - } catch (...) {} - c.base.store(image_base, std::memory_order_release); - c.started.store(true, std::memory_order_release); - if (c.wanted.load() && !install(c)) - logging::write(logging::Level::warning, logging::Channel::skater, "Grind exit control could not start: no free step listener."); - return true; -} - -void on_client_tick(std::uintptr_t client) noexcept { - auto& c = control(); - if (!c.started.load(std::memory_order_acquire) || !c.enabled.load(std::memory_order_acquire)) return; - const auto base = c.base.load(std::memory_order_acquire); - if (!c.hooked.load(std::memory_order_acquire) && trainer::start_trick_heights(base)) c.hooked.store(true, std::memory_order_release); - // The skater's facing (rows: right, up, forward, position), for toe/heel. - const auto tick = GetTickCount64(); - if (tick >= c.next_axes_try) { - try { - overlay::DebugModel skater; - const auto matrix = client_source::detail::debug_skater(base, client, skater); - if (skater.skater_position_valid && std::isfinite(matrix[0]) && std::isfinite(matrix[2])) { - c.right_x.store(matrix[0], std::memory_order_relaxed); - c.right_z.store(matrix[2], std::memory_order_relaxed); - c.axes_qpc.store(qpc_now(), std::memory_order_release); - } - } catch (...) { - c.next_axes_try = tick + 250; // no skater: menus, loading, a respawn - } - } - std::array taken{}; - std::size_t count{}; - AcquireSRWLockExclusive(&c.decision_lock); - count = c.decision_count; - std::copy_n(c.decisions.begin(), count, taken.begin()); - c.decision_count = 0; - ReleaseSRWLockExclusive(&c.decision_lock); - for (std::size_t i = 0; i < count; ++i) { - const auto& d = taken[i]; - try { - const char* trigger = d.trigger > 0 ? "LT" : d.trigger < 0 ? "RT" : "-"; - const char* toward = d.toward > 0 ? "left of travel" : d.toward < 0 ? "right of travel" : "-"; - logging::log(logging::Level::debug, logging::Channel::skater, - "Grind exit: {} ({} -> {}; {}; {}{}). state {}, rail speed {:.2f}, along {:.2f}, up {:.2f}, sideways {:+.2f} -> {:+.2f}, toe across {:+.2f}", - reason_text(d.reason), trigger, toward, d.trigger ? basis_text(d.basis) : "-", - d.switch_stance ? "switch" : "normal/fakie", d.lean_blocked ? ", lean held back" : "", d.state, d.rail_speed, - d.along, d.up, d.before_side, d.after_side, d.toe_across); - } catch (...) {} - } -} - -std::string command(const std::vector& words) { - auto& c = control(); - const auto verb = words.empty() ? std::string("status") : words[0]; - const auto arg = words.size() > 1 ? words[1] : std::string{}; - const auto number = [&](float low, float high, float& out) { - const auto parsed = std::from_chars(arg.data(), arg.data() + arg.size(), out); - return parsed.ec == std::errc{} && out >= low && out <= high; - }; - if (verb == "status") return status_text(c); - if (verb == "exits") { - if (arg == "on" || arg == "off") { - if (!c.started.load()) return status_text(c); - c.wanted.store(arg == "on"); - try { save_settings(c); } catch (...) {} - if (arg == "off") uninstall(c); - else if (!install(c)) return "Grind exit control could not follow the skater (no free step listener)."; - return status_text(c); - } - if (arg.empty() || arg == "status") return status_text(c); - return "Usage: grindexit exits on|off|status"; - } - if (verb == "stance") { - if (arg != "goofy" && arg != "regular") return "Usage: grindexit stance goofy|regular (your skater's stance in the game settings)"; - c.stance.store(static_cast(arg == "goofy" ? Stance::goofy : Stance::regular)); - try { save_settings(c); } catch (...) {} - return status_text(c); - } - if (verb == "sides") { - if (arg != "toeheel" && arg != "travel") return "Usage: grindexit sides toeheel|travel"; - c.sides.store(static_cast(arg == "travel" ? Sides::travel : Sides::toeheel)); - try { save_settings(c); } catch (...) {} - return status_text(c); - } - if (verb == "strength") { - int value{}; - const auto parsed = std::from_chars(arg.data(), arg.data() + arg.size(), value); - if (parsed.ec != std::errc{} || parsed.ptr != arg.data() + arg.size() || value < min_strength || value > 100) - return "Usage: grindexit strength <10 to 100> (percent of the full pop-out to the side; 100 is the game's own)"; - c.strength.store(value); - try { save_settings(c); } catch (...) {} - return status_text(c); - } - if (verb == "guard" || verb == "leanguard") { - if (arg != "on" && arg != "off") return "Usage: grindexit " + verb + " on|off"; - (verb == "guard" ? c.guard : c.lean_guard).store(arg == "on"); - if (verb == "guard" && arg == "off") clear_triggers(c); - return status_text(c); - } - if (verb == "prewindow") { - float value{}; - if (!number(0.0f, 2.0f, value)) return "Usage: grindexit prewindow <0 to 2> (seconds a trigger may be pressed before the grind connects; 1)"; - c.prewindow.store(value); - return status_text(c); - } - if (verb == "sidespeed") { - float value{}; - if (!number(0.5f, 4.0f, value)) return "Usage: grindexit sidespeed <0.5 to 4> (metres per second; the game's own is 1.8)"; - c.side_speed.store(value); - return status_text(c); - } - return "Usage: grindexit exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>, guard on|off, leanguard on|off, prewindow , sidespeed "; -} -} // namespace dingosdk::grind_exit_control diff --git a/Extension/Skater/grind_exit_control.h b/Extension/Skater/grind_exit_control.h deleted file mode 100644 index f5693266..00000000 --- a/Extension/Skater/grind_exit_control.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once -#include -#include -#include - -// Grind exit control: pick the side you pop out of a grind to with LT / RT, independently of the -// trick and of the body's rotation (Skate's own controls tie both to the left stick). -// -// While on: -// - A trigger pressed DURING a grind, or up to `prewindow` seconds (1) before the grind -// connects, is "claimed": it picks the side of the next pop out of the grind. The player -// chooses what LT / RT mean: -// toe/heel LT the toe side, RT the heel side, worked out from which foot leads (the -// game's stance flag, Engine/Game/Build/20260929/grind_exit.h, and the player's -// stance setting) and the skater's facing. On slides, where toe and heel point -// along the rail, and while the stance is not set: left/right of travel. -// left/right LT the left of the direction of travel, RT the right. -// Both claimed: the game's own exit. -// - At the pop, only the sideways part of the game's launch is replaced: the game's own -// sideways speed when it had one, mirrored to the chosen side, else `sidespeed` (1.8 m/s, the -// game's own). Along the rail, the pop's height, the trick, the flip and the body's rotation -// are the game's. -// - Pop-out strength (10-100%, kept): scales the sideways speed of every trigger-set exit, for -// rails next to a wall. 100% is the behaviour above, the game's own full exit. -// - Grab guard: a claimed trigger is hidden from the game until it is let go, so holding it -// through the pop does not start a grab; press it again in the air to grab. -// - Lean guard: while a claimed trigger is held in a grind, the left stick's left/right is -// hidden from the game until the pop, so the game's own lean exit cannot start and pull the -// board the other way; from the pop on the stick is the game's again, for the body's rotation. -// - Multiplayer: on in any session, whatever the host's settings. It changes only how the local -// player's inputs pick the exit side (the speed is the game's own); other players see the -// result through the poses ReSkate already syncs. -// It is limited to the supported game build: the launch hook is the trainer's, which installs -// only when the game's code matches (Extension/Trainer/trainer_jump.cpp), and every address -// comes from Engine/Game/Build/20260929. With debug logging on, every -// pop out of a grind is written to ReSkate.log with -// what was decided and why. On/off, the side convention and the stance are kept in -// %LOCALAPPDATA%\ReSkate\grindexit\control.txt. -namespace dingosdk::grind_exit_control { -// Requires the validated build and No Bail started. Switches itself on when it was left on. -bool start(std::uintptr_t image_base) noexcept; -// Client thread, every tick, with the local client: keeps the launch hook ready, samples the -// skater's facing, writes the physics thread's decisions to the log. -void on_client_tick(std::uintptr_t client) noexcept; -// The console verbs: exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>, -// guard on|off, leanguard on|off, prewindow , sidespeed . -std::string command(const std::vector& words); - -// For the menus, any thread. -bool available() noexcept; -bool enabled() noexcept; -bool toe_heel() noexcept; // else left/right of travel -int stance() noexcept; // 0 not set, 1 regular, 2 goofy -int strength() noexcept; // pop-out strength, percent of the sideways speed (10-100; 100 the full exit) -} diff --git a/Extension/Skater/no_bail.h b/Extension/Skater/no_bail.h index 377a8bb9..cff47cb7 100644 --- a/Extension/Skater/no_bail.h +++ b/Extension/Skater/no_bail.h @@ -29,7 +29,7 @@ PhysicsStateWatch watched_physics_state() noexcept; // Stopping flight must not discard the independent manual preference. void clear_no_bail_flight() noexcept; // The local skater as No Bail follows it (update_no_bail publishes it), for a feature that reads it -// (Hall of Meat): any thread, its ownership chain resolved again now. +// (the plugins' physics steps, grind exits): any thread, its ownership chain resolved again now. struct NoBailSkater { std::uintptr_t base{}, entity{}, core{}, context{}, rig{}; }; @@ -40,8 +40,8 @@ bool no_bail_skater(NoBailSkater& skater) noexcept; // One observer, nullptr for none; it runs on the physics thread. using NoBailStepObserver = void (*)(const NoBailSkater& skater, float seconds, bool wipeout) noexcept; void set_no_bail_step_observer(NoBailStepObserver observer) noexcept; -// Further listeners on the same steps (the grind exit control, grind_exit_control.h), -// independent of the observer above so none displaces Hall of Meat's. Same thread and timing. Adding one already there, or removing one that is not, does nothing; +// Further listeners on the same steps (skater_events.h, grind_exit.h), independent of the observer +// above so none displaces another. Same thread and timing. Adding one already there, or removing one that is not, does nothing; // false when all slots are taken. inline constexpr std::size_t no_bail_step_listener_slots = 4; bool add_no_bail_step_listener(NoBailStepObserver listener) noexcept; diff --git a/Extension/Skater/skater_events.cpp b/Extension/Skater/skater_events.cpp index 9efb47a1..ab40646d 100644 --- a/Extension/Skater/skater_events.cpp +++ b/Extension/Skater/skater_events.cpp @@ -3,11 +3,12 @@ #include "Engine/Core/Platform/memory.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/skater_state.h" -#include "Extension/HallOfMeat/hall_of_meat_skater.h" +#include "Extension/Skater/skater_physics.h" #include "Extension/Skater/no_bail.h" #include #include #include +#include #include #include #include @@ -19,12 +20,15 @@ constexpr std::size_t most_kept = 32; constexpr std::size_t cause_count = 35; constexpr float heard_hit = 3.0f; // m/s: lighter touches are not news (Board Break's body_hit) constexpr float fall_at_least = 0.4f, fall_at_most = 3.0f; +constexpr std::size_t most_steps = 512; +std::atomic steps_wanted{}; struct Shared { std::mutex mutex; std::deque landings; std::deque hits; std::deque falls; + std::deque steps; std::array velocity{}; std::uint64_t velocity_at{}; }; @@ -119,8 +123,14 @@ void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { } w.was_wipeout = wiping; } - if (hall_of_meat::skater_available()) { - const auto step = hall_of_meat::read_step(skater, seconds, wipeout); + if (skater_physics::available()) { + const auto step = skater_physics::read_step(skater, seconds, wipeout); + if (steps_wanted.load(std::memory_order_acquire)) { + const double at = std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + std::lock_guard lock(s.mutex); + if (s.steps.size() >= most_steps) s.steps.pop_front(); + s.steps.push_back({at, step}); + } const bool ragdoll = step.ragdoll.value_or(false); if (ragdoll) { std::optional heard; @@ -170,6 +180,17 @@ void want() noexcept { listening.store(false); } } +void want_steps() noexcept { + want(); + steps_wanted.store(true, std::memory_order_release); +} +std::vector take_steps() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + std::vector out(s.steps.begin(), s.steps.end()); + s.steps.clear(); + return out; +} std::vector take_landings() { auto& s = shared(); std::lock_guard lock(s.mutex); diff --git a/Extension/Skater/skater_events.h b/Extension/Skater/skater_events.h index a5a52ad7..19bc6f09 100644 --- a/Extension/Skater/skater_events.h +++ b/Extension/Skater/skater_events.h @@ -1,5 +1,6 @@ #pragma once #include "Engine/Game/Skater/skater_body.h" +#include "Extension/Skater/skater_physics.h" #include #include #include @@ -33,8 +34,17 @@ struct Slam { std::int32_t cause{-1}; std::uint64_t at{}; }; +// Every physics step as it was read (the physics_step event): when, on the steady clock in +// milliseconds (as reskate.time.now), and what it read. +struct StepNews { + double at{}; + skater_physics::Step step; +}; // Client thread: starts reading the steps (once). void want() noexcept; +// Client thread: from now on every step is kept for take_steps too (at most the last 512). +void want_steps() noexcept; +std::vector take_steps(); std::vector take_landings(); std::vector take_hits(); std::vector take_falls(); diff --git a/Extension/HallOfMeat/hall_of_meat_skater.cpp b/Extension/Skater/skater_physics.cpp similarity index 94% rename from Extension/HallOfMeat/hall_of_meat_skater.cpp rename to Extension/Skater/skater_physics.cpp index cfd62b51..13d24b43 100644 --- a/Extension/HallOfMeat/hall_of_meat_skater.cpp +++ b/Extension/Skater/skater_physics.cpp @@ -1,4 +1,4 @@ -#include "hall_of_meat_skater.h" +#include "skater_physics.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" #include "Engine/Game/Build/addresses.h" @@ -11,7 +11,7 @@ #include #include -namespace dingosdk::hall_of_meat { +namespace dingosdk::skater_physics { namespace { namespace body = addr::skater_body; namespace state = addr::skater_state; @@ -112,26 +112,26 @@ std::uintptr_t step_length_address() noexcept { } } -bool start_skater(std::uintptr_t base) noexcept { +bool start(std::uintptr_t base) noexcept { if (ready().load(std::memory_order_acquire)) return true; for (const auto& contract : body::contracts) { std::array actual{}; if (!memory::peek(base + contract.rva, actual) || actual != contract.bytes) { logging::log(logging::Level::warning, logging::Channel::skater, - "Hall of Meat cannot read the skater's body: the native contract at 0x{:x} did not match.", contract.rva); + "Plugins cannot read the skater's body: the native contract at 0x{:x} did not match.", contract.rva); return false; } } if (!no_bail_available()) { logging::write(logging::Level::warning, logging::Channel::skater, - "Hall of Meat cannot follow the skater: its physics steps come through No Bail's hooks, which did not start."); + "The skater's physics steps are unavailable: they come through No Bail's hooks, which did not start."); return false; } ready().store(true, std::memory_order_release); return true; } -bool skater_available() noexcept { return ready().load(std::memory_order_acquire); } +bool available() noexcept { return ready().load(std::memory_order_acquire); } Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { Step step; diff --git a/Extension/Skater/skater_physics.h b/Extension/Skater/skater_physics.h new file mode 100644 index 00000000..02d53752 --- /dev/null +++ b/Extension/Skater/skater_physics.h @@ -0,0 +1,39 @@ +#pragma once +#include "Engine/Game/Skater/skater_body.h" +#include "Extension/Skater/no_bail.h" +#include +#include + +// The local skater as its physics simulates it, every physics step No Bail hands on (no_bail.h): what +// each body touched (Engine/Game/Build/20260929/skater_body.h) and how the skater moves, on the board +// or off it and whether in a ragdoll (Engine/Game/Build/20260929/skater_state.h). Plugins hear it as +// the physics_step event (skater_events.h). Read-only, but for the physics step length the game speed +// (game_speed.h) gives the skater. +namespace dingosdk::skater_physics { +// One physics step of the local skater. +struct Step { + float seconds{}; // how long the step simulated, in the game's time + bool wipeout{}; // the step asks for a wipeout + bool airborne{}; // the skater is in the air, on the board or off it + // The body is a ragdoll; empty when the skater state could not be read. + std::optional ragdoll; + // How the skater moves (with the board while on it, else as its pelvis), metres per second; + // empty when unknown. + std::optional velocity; + // How fast the pelvis turns, any way round, radians per second; empty when unknown. + std::optional spin; + skater_body::Contacts body; // what each body touched in the step +}; + +// Requires the validated build and No Bail started: verifies the layout once. +bool start(std::uintptr_t image_base) noexcept; +bool available() noexcept; +// Physics thread, in the step. Exact here; read outside the step, the contacts may be half written. +Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept; +// The local skater's physics step length (seconds of game time): the game sets it only when it builds +// the skater's core (game_speed.h). 0 while there is no local skater. +float step_length() noexcept; +// Game update thread: gives the local skater this physics step length. False while there is no local +// skater. +bool set_step_length(float seconds) noexcept; +} diff --git a/Extension/Skater/xray.cpp b/Extension/Skater/xray.cpp new file mode 100644 index 00000000..bda1d027 --- /dev/null +++ b/Extension/Skater/xray.cpp @@ -0,0 +1,77 @@ +#include "xray.h" +#include "xray_render.h" +#include +#include +#include +#include +#include + +namespace dingosdk::xray { +namespace { +// The picture is followed this long after the last frame that drew the x-ray. +constexpr std::uint64_t follow_ms = 1000; +struct State { + std::atomic ready{}; + std::mutex mutex; + std::set> owners; // client thread + bool hooked{}; // client thread + std::atomic drawn_at{}; // render thread -> client tick +}; +State& state() { + static auto* value = new State; // the renderer's hooks may call in until exit + return *value; +} +} + +bool start(std::uintptr_t base) noexcept { + auto& s = state(); + if (s.ready.load(std::memory_order_acquire)) return true; + if (!start_render(base)) return false; // logged + s.ready.store(true, std::memory_order_release); + return true; +} +bool available() noexcept { return state().ready.load(std::memory_order_acquire); } + +void prepare(std::string_view owner, bool on) { + auto& s = state(); + std::lock_guard lock(s.mutex); + if (on) { + s.owners.emplace(owner); + prepare_skeleton(); // once, in the background + } else if (const auto found = s.owners.find(owner); found != s.owners.end()) { + s.owners.erase(found); + } +} + +void tick() noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return; + try { + bool wanted{}; + { + std::lock_guard lock(s.mutex); + wanted = !s.owners.empty(); + } + const bool drawn = GetTickCount64() - s.drawn_at.load(std::memory_order_acquire) < follow_ms; + follow_render(wanted && drawn); + if (wanted != s.hooked && hook_render(wanted)) s.hooked = wanted; + } catch (...) { + } +} + +bool latest(Pose& pose) { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return false; + s.drawn_at.store(GetTickCount64(), std::memory_order_release); + auto mesh = skeleton_mesh(); + Picture picture; + PosedSkeleton posed; + if (!mesh || !latest_picture(picture) || !pose_skeleton(*mesh, picture.skin, posed)) return false; + pose.camera = picture.camera; + pose.vertical_fov = picture.vertical_fov; + pose.positions = std::move(posed.positions); + pose.normals = std::move(posed.normals); + pose.mesh = std::move(mesh); + return true; +} +} diff --git a/Extension/Skater/xray.h b/Extension/Skater/xray.h new file mode 100644 index 00000000..ac731678 --- /dev/null +++ b/Extension/Skater/xray.h @@ -0,0 +1,36 @@ +#pragma once +#include "xray_skeleton.h" +#include +#include +#include +#include +#include + +// The local skater's x-ray, for plugins (reskate.skater.xray, the draw handler's d:skeleton): skate.'s +// own skeleton mesh (xray_skeleton.h) posed by the renderer's skinning matrices as it drew the skater +// (xray_render.h), seen by the camera of that same picture. The overlay draws it, each body in the +// colour a plugin gives it (Extension/UI/Overlay/xray_overlay.cpp). +// +// The renderer's two hooks are on while any plugin keeps the x-ray ready (off, the renderer never +// calls them), and the picture is read only while one draws it. +namespace dingosdk::xray { +// Requires the validated build: prepares the renderer's hooks, which patches nothing. +bool start(std::uintptr_t image_base) noexcept; +bool available() noexcept; +// Client thread: a plugin wants the x-ray ready (true) or no longer (false). The mesh is read in the +// background the first time. +void prepare(std::string_view owner, bool on); +// Client thread, every tick: the hooks on while any plugin keeps it ready; the picture read while it +// was drawn lately. +void tick() noexcept; + +// The skeleton as the renderer last drew the skater, in world space, with that picture's camera. +struct Pose { + std::array camera{}; // world matrix: right, up, back, position rows + float vertical_fov{}; // degrees + std::vector positions, normals; // one per vertex + std::shared_ptr mesh; // its triangles, and each vertex's body +}; +// Render thread, as a plugin draws it: false while there is no picture of the skater. +bool latest(Pose& pose); +} diff --git a/Extension/HallOfMeat/hall_of_meat_render.cpp b/Extension/Skater/xray_render.cpp similarity index 95% rename from Extension/HallOfMeat/hall_of_meat_render.cpp rename to Extension/Skater/xray_render.cpp index eea64a43..9f84daed 100644 --- a/Extension/HallOfMeat/hall_of_meat_render.cpp +++ b/Extension/Skater/xray_render.cpp @@ -1,4 +1,4 @@ -#include "hall_of_meat_render.h" +#include "xray_render.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" @@ -15,7 +15,7 @@ #include #include -namespace dingosdk::hall_of_meat { +namespace dingosdk::xray { namespace { namespace build = addr::skater_render; using DrawPacket = void (*)(std::uintptr_t object, std::uintptr_t packet); @@ -109,7 +109,7 @@ void observe_packet(std::uintptr_t object, std::uintptr_t packet) { } if (!s.found_logged.exchange(true)) logging::log(logging::Level::info, logging::Channel::graphics, - "Hall of Meat: the renderer draws the local skater (render object {}, {} bones).", index, count); + "X-ray: the renderer draws the local skater (render object {}, {} bones).", index, count); } void draw_packet_hook(std::uintptr_t object, std::uintptr_t packet) { auto& s = state(); @@ -179,7 +179,7 @@ bool start_render(std::uintptr_t base) noexcept { std::array actual{}; if (!memory::peek(base + contract.rva, actual) || actual != contract.bytes) { logging::log(logging::Level::warning, logging::Channel::graphics, - "Hall of Meat cannot see where the skater is drawn: the native contract at 0x{:x} did not match.", contract.rva); + "The skater x-ray cannot see where the skater is drawn: the native contract at 0x{:x} did not match.", contract.rva); return false; } } @@ -206,7 +206,7 @@ bool start_render(std::uintptr_t base) noexcept { return true; } logging::log(logging::Level::warning, logging::Channel::graphics, - "Hall of Meat cannot see where the skater is drawn: hooking the renderer failed (status {}).", static_cast(status)); + "The skater x-ray cannot see where the skater is drawn: hooking the renderer failed (status {}).", static_cast(status)); while (prepared) (void)hook_remove(targets[--prepared]); return false; } @@ -226,7 +226,7 @@ bool hook_render(bool on) noexcept { s.hooked = on; return true; } - logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat could not {} its renderer hooks (status {}).", + logging::log(logging::Level::warning, logging::Channel::graphics, "The skater x-ray could not {} its renderer hooks (status {}).", on ? "enable" : "disable", static_cast(status)); // Both or neither. while (changed) { diff --git a/Extension/HallOfMeat/hall_of_meat_render.h b/Extension/Skater/xray_render.h similarity index 69% rename from Extension/HallOfMeat/hall_of_meat_render.h rename to Extension/Skater/xray_render.h index 1f1d382d..5b0390a9 100644 --- a/Extension/HallOfMeat/hall_of_meat_render.h +++ b/Extension/Skater/xray_render.h @@ -5,16 +5,16 @@ #include // The local skater as the renderer draws it (Engine/Game/Build/20260929/skater_render.h): the -// skinning matrices its mesh was drawn with and the camera of the same picture, so Hall of Meat's -// skeleton lies exactly where the game draws the skater. Two hooks that only observe: the skinned +// skinning matrices its mesh was drawn with and the camera of the same picture, so the x-ray's +// skeleton (xray.h) lies exactly where the game draws the skater. Two hooks that only observe: the skinned // draw packets, and the main view's camera. -namespace dingosdk::hall_of_meat { +namespace dingosdk::xray { // Requires the validated build: verifies the layout and prepares both hooks, which patches nothing. bool start_render(std::uintptr_t image_base) noexcept; -// Client tick: both hooks on while Hall of Meat is, off otherwise, so a renderer it is switched off -// for never calls it. False when they could not be changed (logged; both stay as they were). +// Client tick: both hooks on while a plugin keeps the x-ray ready, off otherwise, so the renderer +// never calls them for nothing. False when they could not be changed (logged; both stay as they were). bool hook_render(bool on) noexcept; -// Client tick: while `wanted` (a bail shows), which render objects are the local skater's +// Client tick: while `wanted` (a plugin draws the x-ray), which render objects are the local skater's // (no_bail.h no_bail_skater), riding and walking; otherwise both hooks pass every call straight on. void follow_render(bool wanted) noexcept; diff --git a/Extension/HallOfMeat/hall_of_meat_skeleton.cpp b/Extension/Skater/xray_skeleton.cpp similarity index 96% rename from Extension/HallOfMeat/hall_of_meat_skeleton.cpp rename to Extension/Skater/xray_skeleton.cpp index 4c85c73c..e48325c3 100644 --- a/Extension/HallOfMeat/hall_of_meat_skeleton.cpp +++ b/Extension/Skater/xray_skeleton.cpp @@ -1,4 +1,4 @@ -#include "hall_of_meat_skeleton.h" +#include "xray_skeleton.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/skater_skeleton.h" @@ -13,7 +13,7 @@ #include #include -namespace dingosdk::hall_of_meat { +namespace dingosdk::xray { namespace { namespace build = addr::skater_skeleton; @@ -167,16 +167,16 @@ void prepare_skeleton() noexcept { try { auto read = std::make_shared(read_skeleton_mesh(game_folder())); logging::log(logging::Level::info, logging::Channel::skater, - "Hall of Meat: read the skater's skeleton ({} vertices, {} triangles) in {} ms.", read->vertices.size(), + "X-ray: read the skater's skeleton ({} vertices, {} triangles) in {} ms.", read->vertices.size(), read->triangles.size() / 3, GetTickCount64() - started); loader().mesh.store(std::move(read)); } catch (const std::exception& failure) { - logging::log(logging::Level::warning, logging::Channel::skater, "Hall of Meat shows no skeleton: {}.", failure.what()); + logging::log(logging::Level::warning, logging::Channel::skater, "The skater x-ray shows no skeleton: {}.", failure.what()); } }).detach(); }); } catch (...) { - logging::write(logging::Level::warning, logging::Channel::skater, "Hall of Meat shows no skeleton: its reading could not start."); + logging::write(logging::Level::warning, logging::Channel::skater, "The skater x-ray shows no skeleton: its reading could not start."); } } diff --git a/Extension/HallOfMeat/hall_of_meat_skeleton.h b/Extension/Skater/xray_skeleton.h similarity index 91% rename from Extension/HallOfMeat/hall_of_meat_skeleton.h rename to Extension/Skater/xray_skeleton.h index 416e17f6..98376dd6 100644 --- a/Extension/HallOfMeat/hall_of_meat_skeleton.h +++ b/Extension/Skater/xray_skeleton.h @@ -11,11 +11,11 @@ #include #include -// The skeleton Hall of Meat draws: skate.'s own skeleton mesh, read once from the installed game's +// The skeleton the x-ray draws (xray.h): skate.'s own skeleton mesh, read once from the installed game's // data (Engine/Game/Build/20260929/skater_skeleton.h) and posed by the renderer's own skinning -// matrices (hall_of_meat_render.h), so it lies where the game draws the skater. Each vertex knows the +// matrices (xray_render.h), so it lies where the game draws the skater. Each vertex knows the // ragdoll's body (skater_body.h) it moves with, for whatever that body went through. -namespace dingosdk::hall_of_meat { +namespace dingosdk::xray { using game::LinearTransform; using game::Vec3; @@ -49,7 +49,7 @@ struct PosedSkeleton { // world). False, leaving `posed` as it was, when it lacks some. bool pose_skeleton(const SkeletonMesh& mesh, std::span skin, PosedSkeleton& posed); -// The game's folder (the one with Skate.exe), whose data the skeleton and the overlay's images come from. +// The game's folder (the one with Skate.exe), whose data the skeleton and the game's pictures come from. std::filesystem::path game_folder(); // From the game folder; throws when it cannot be read. [[nodiscard]] SkeletonMesh read_skeleton_mesh(const std::filesystem::path& game_root); diff --git a/Extension/Trainer/trainer_jump.h b/Extension/Trainer/trainer_jump.h index 929f709d..f1803bcf 100644 --- a/Extension/Trainer/trainer_jump.h +++ b/Extension/Trainer/trainer_jump.h @@ -16,7 +16,7 @@ bool start_trick_heights(std::uintptr_t base) noexcept; void set_trick_heights(float no_comply, float boneless) noexcept; // The scripted launches since the last call, oldest first; false when there are none. bool take_trick_launch(TrickLaunch &out) noexcept; -// The grind exit control (Extension/Skater/grind_exit_control.h): may change the launch velocity +// Grind exits (Extension/Skater/grind_exit.h): may change the launch velocity // (x, y, z at parameters + 0x80) of any call of the game's jump-trajectory setter before the game // uses it, given the caller's address relative to the game image (the return address minus 5) and // the trajectory object it fills. Runs before the trainer's own trick multipliers. Physics thread; diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 7d2b1609..a9251022 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -112,53 +112,6 @@ int main() { const auto find = [](const std::vector& rows, std::string_view id) { return std::find_if(rows.begin(), rows.end(), [&](const auto& row) { return row.id == id; }); }; - // Hall of Meat sits with the player's other switches. - const auto meat_row = [&] { - const auto page = render(state, model, cb, player_section); - return *find(page.main, "hall-of-meat"); - }; - check(meat_row().title == "Hall of Meat: Off" && meat_row().command.empty(), "Hall of Meat waits until it started"); - model.hall_of_meat = {true, false}; - activate(state, model, cb, "hall-of-meat", ""); - check(meat_row().command == "hall-of-meat" && capture.command == "hallofmeat 1", "Hall of Meat turns on"); - 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"); - // Grind exit triggers: the switch, what LT / RT mean, and the stance toe/heel needs. - const auto grind_row = [&](std::string_view id) { - const auto page = render(state, model, cb, player_section); - const auto row = find(page.main, id); - return row == page.main.end() ? Row{} : *row; - }; - check(grind_row("grind-exits").title == "Grind exit triggers: Off" && grind_row("grind-exits").command.empty(), - "Grind exit triggers wait until they started"); - model.grind_exit = {true, false, true, 0}; - activate(state, model, cb, "grind-exits", ""); - check(grind_row("grind-exits").command == "grind-exits" && capture.command == "grindexit exits on", "Grind exit triggers turn on"); - check(grind_row("grind-exit-sides").title == "Input Mode: Toe / Heel" && grind_row("grind-exit-stance").title == "Your stance: Not set", - "Toe / heel shows the stance it needs"); - activate(state, model, cb, "grind-exit-stance", ""); - check(capture.command == "grindexit stance goofy", "The stance is set"); - model.grind_exit = {true, true, true, 2}; - activate(state, model, cb, "grind-exit-stance", ""); - check(capture.command == "grindexit stance regular", "and changed"); - activate(state, model, cb, "grind-exit-sides", ""); - check(capture.command == "grindexit sides travel", "LT / RT switch to left / right of travel"); - model.grind_exit = {true, true, false, 2}; - check(grind_row("grind-exit-sides").title == "Input Mode: Left / Right" && grind_row("grind-exit-stance").id.empty(), - "which needs no stance"); - activate(state, model, cb, "grind-exits", ""); - check(capture.command == "grindexit exits off", "and the triggers turn off"); - // Pop-out strength steps down from the full exit and wraps back to it. - check(grind_row("grind-exit-strength").title == "Pop-out strength: 100%", "Pop-out strength shows the full exit"); - activate(state, model, cb, "grind-exit-strength", ""); - check(capture.command == "grindexit strength 75", "and steps down"); - model.grind_exit.strength = 40; - activate(state, model, cb, "grind-exit-strength", ""); - check(capture.command == "grindexit strength 25", "a slider value steps to the next lower step"); - model.grind_exit.strength = 25; - activate(state, model, cb, "grind-exit-strength", ""); - check(capture.command == "grindexit strength 100", "and the lowest wraps to the full exit"); // Keyframe smoothing sits with what you see, under the replay editor. const auto smoothing_row = [&] { const auto page = render(state, model, cb, visuals_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index c2a45384..a39452db 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -200,20 +200,6 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks const overlay::OfflineFeatureRequest request{overlay::OfflineFeatureGroup::board_wear, !wear.effective}; report(wear.available && cb.queue_offline_feature && 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 == "grind-exits") { - console(m.grind_exit.enabled ? "grindexit exits off" : "grindexit exits on", m.grind_exit.available); - } else if (command == "grind-exit-sides") { - console(m.grind_exit.toe_heel ? "grindexit sides travel" : "grindexit sides toeheel", m.grind_exit.available); - } else if (command == "grind-exit-stance") { - // Not set or regular -> goofy -> regular. - console(m.grind_exit.stance == 2 ? "grindexit stance regular" : "grindexit stance goofy", m.grind_exit.available); - } else if (command == "grind-exit-strength") { - // 100 -> 75 -> 50 -> 25 -> 100 (the ReSkate menu's slider sets any value from 10 to 100). - const int strength = m.grind_exit.strength; - const int next = strength > 75 ? 75 : strength > 50 ? 50 : strength > 25 ? 25 : 100; - console("grindexit strength " + std::to_string(next), m.grind_exit.available); } else if (command == "keyframe-smoothing") { console(m.keyframe_smoothing.enabled ? "keysmooth off" : "keysmooth on", m.keyframe_smoothing.available); } else if (command == "keyframe-smoothness") { @@ -316,18 +302,6 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 toggle(p.main, "noclip", "Noclip", m.debug.noclip, (m.debug.noclip_available || m.debug.noclip) && cb.queue_debug); 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); - const auto& grind = m.grind_exit; - const bool grind_console = grind.available && cb.queue_console_command; - toggle(p.main, "grind-exits", "Grind exit triggers", grind.enabled, grind_console); - button(p.main, "grind-exit-sides", std::string("Input Mode: ") + (grind.toe_heel ? "Toe / Heel" : "Left / Right"), - "grind-exit-sides", grind_console); - if (grind.toe_heel) - button(p.main, "grind-exit-stance", - std::string("Your stance: ") + (grind.stance == 2 ? "Goofy" : grind.stance == 1 ? "Regular" : "Not set"), - "grind-exit-stance", grind_console); - button(p.main, "grind-exit-strength", "Pop-out strength: " + std::to_string(grind.strength) + "%", "grind-exit-strength", - grind_console); 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/game_images.cpp b/Extension/UI/Overlay/game_images.cpp new file mode 100644 index 00000000..0c7e7a33 --- /dev/null +++ b/Extension/UI/Overlay/game_images.cpp @@ -0,0 +1,236 @@ +#include "game_images.h" +#include "Extension/Skater/xray_skeleton.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/ui_textures.h" +#include "Engine/Vfs/game_textures.h" +#include "Extension/UI/Overlay/overlay_internal.h" +#include "Extension/UI/skate_theme.h" +#include +#include +#include +#include +#include + +namespace dingosdk::overlay { +namespace { +namespace ui = addr::ui_textures; +namespace theme = dingosdk::skate_theme; + +// The pictures, by the names plugins give them. +enum Picture : std::size_t { picture_count = 13 }; +constexpr std::array names{ + "stopwatch", "wipeout", "wipeout_broken", "spread_eagle", "airtime", "gap_height", "flaming_wheel", "roll", + "thrasher", "brush_bar", "rough_tile", "scratches", "streak"}; +struct Source { + const ui::Texture* texture; + std::uint32_t side; // fitted into a side x side square, its aspect kept + bool brightness{}; // white with the texture's brightness as alpha (light marks on black), else its own alpha +}; +constexpr std::array sources{{ + {&ui::stopwatch, 64}, {&ui::wipeout, 64}, {&ui::wipeout_broken, 64}, {&ui::spread_eagle, 64}, + {&ui::airtime, 64}, {&ui::gap_height, 64}, {&ui::flaming_wheel, 64}, {&ui::roll, 64}, + {&ui::thrasher_wordmark, 512}, {&ui::brush_bar, 512}, {&ui::rough_tile, 256}, {&ui::scratches, 512, true}, + {&ui::streak, 512}, +}}; +using Images = std::array, picture_count>; + +struct Reads { + std::mutex mutex; + bool started{}; + std::future pending; + Images decoded; // kept: every atlas the overlay builds (one per swapchain) needs them +}; +Reads& reads() { + static auto* state = new Reads; + return *state; +} + +// The picture's part of the texture, white: with its own alpha, or its brightness as alpha. +frostbite::Image cut(const frostbite::Image& image, const ui::Region& region, bool brightness) { + const auto at = [](float fraction, std::uint32_t size) { return static_cast(fraction * static_cast(size) + 0.5f); }; + if (!image.width || !image.height || image.rgba.size() != std::size_t{image.width} * image.height * 4) + throw std::runtime_error("the texture is empty"); + const auto x0 = std::min(at(region.left, image.width), image.width - 1), y0 = std::min(at(region.top, image.height), image.height - 1); + const auto x1 = std::clamp(at(region.right, image.width), x0 + 1, image.width); + const auto y1 = std::clamp(at(region.bottom, image.height), y0 + 1, image.height); + frostbite::Image result{x1 - x0, y1 - y0, std::vector(std::size_t{x1 - x0} * (y1 - y0) * 4)}; + for (std::uint32_t y = y0; y < y1; ++y) + std::memcpy(result.rgba.data() + std::size_t{y - y0} * result.width * 4, + image.rgba.data() + (std::size_t{y} * image.width + x0) * 4, std::size_t{result.width} * 4); + for (std::size_t i = 0; i < result.rgba.size(); i += 4) { + auto* p = result.rgba.data() + i; + if (brightness) p[3] = std::max({p[0], p[1], p[2]}); + p[0] = p[1] = p[2] = 255; + } + return result; +} + +Images decode() { + Images result; + try { + vfs::GameTextures textures(xray::game_folder()); + for (std::size_t index = 0; index < picture_count; ++index) { + const auto& source = sources[index]; + try { + result[index] = cut(textures.read(source.texture->toc, source.texture->bundle, source.texture->name, source.side), + source.texture->region, source.brightness); + } catch (const std::exception& error) { + logging::log(logging::Level::warning, logging::Channel::graphics, "The game's picture {} is unavailable: {}.", + source.texture->name, error.what()); + } + } + } catch (const std::exception& error) { + logging::log(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: {}.", error.what()); + } + return result; +} + +// The images in the atlas being built or drawn with: render thread only. +struct Loaded { + ImFontAtlas* atlas{}; + bool filled{}; + std::array rects{}; // each one's custom rect in the atlas; -1 none +}; +Loaded& loaded() { + static Loaded state; + return state; +} +// A picture in the atlas the overlay draws with: where, and its texture coordinates. +struct Found { + const ImFontAtlasCustomRect* rect{}; + ImVec2 uv0, uv1; +}; +bool find(Picture picture, Found& found) { + const auto& l = loaded(); + if (!l.filled || !l.atlas || ImGui::GetIO().Fonts != l.atlas || !l.atlas->TexID || l.rects[picture] < 0) return false; + found.rect = l.atlas->GetCustomRectByIndex(l.rects[picture]); + if (!found.rect || !found.rect->IsPacked() || !found.rect->Width || !found.rect->Height) return false; + l.atlas->CalcCustomRectUV(found.rect, &found.uv0, &found.uv1); + return true; +} + +// Fitted into the box with its aspect kept, centred: an icon, a logo. +bool draw_image(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) { + Found found; + if (!find(picture, found)) return false; + const auto* rect = found.rect; + const float scale = std::min((max.x - min.x) / rect->Width, (max.y - min.y) / rect->Height); + const ImVec2 size(rect->Width * scale, rect->Height * scale); + const ImVec2 at(min.x + (max.x - min.x - size.x) * 0.5f, min.y + (max.y - min.y - size.y) * 0.5f); + draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + size.x, at.y + size.y), found.uv0, found.uv1, tint); + return true; +} +// Its body (ui_textures.h Texture::body) stretched onto the box, the rest of it around: a brush +// stroke's bar on the box, its splatter beyond. +bool draw_shape(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, ImU32 tint) { + Found found; + if (!find(picture, found)) return false; + const auto& body = sources[picture].texture->body; + const float width = (max.x - min.x) / (body.right - body.left), height = (max.y - min.y) / (body.bottom - body.top); + const ImVec2 at(min.x - body.left * width, min.y - body.top * height); + draw->AddImage(loaded().atlas->TexID, at, ImVec2(at.x + width, at.y + height), found.uv0, found.uv1, tint); + return true; +} +// Nine-sliced onto the box: its corners (Texture::slice of the picture) kept `edge` pixels square, +// its edges stretched along them and its middle across the rest. A box smaller than two edges +// shrinks them to half of it. +bool draw_panel(ImDrawList* draw, Picture picture, ImVec2 min, ImVec2 max, float edge, ImU32 tint) { + Found found; + if (!find(picture, found)) return false; + const float slice = sources[picture].texture->slice; + const float edge_x = std::clamp(edge, 0.0f, (max.x - min.x) * 0.5f), edge_y = std::clamp(edge, 0.0f, (max.y - min.y) * 0.5f); + const float uv_x = slice * (found.uv1.x - found.uv0.x), uv_y = slice * (found.uv1.y - found.uv0.y); + const std::array xs{min.x, min.x + edge_x, max.x - edge_x, max.x}, ys{min.y, min.y + edge_y, max.y - edge_y, max.y}; + const std::array us{found.uv0.x, found.uv0.x + uv_x, found.uv1.x - uv_x, found.uv1.x}; + const std::array vs{found.uv0.y, found.uv0.y + uv_y, found.uv1.y - uv_y, found.uv1.y}; + for (std::size_t row = 0; row < 3; ++row) + for (std::size_t column = 0; column < 3; ++column) + draw->AddImage(loaded().atlas->TexID, ImVec2(xs[column], ys[row]), ImVec2(xs[column + 1], ys[row + 1]), + ImVec2(us[column], vs[row]), ImVec2(us[column + 1], vs[row + 1]), tint); + return true; +} +} // namespace + +void prepare_game_images() noexcept { + try { + auto& state = reads(); + std::lock_guard lock(state.mutex); + if (std::exchange(state.started, true)) return; + state.pending = std::async(std::launch::async, &decode); + } catch (...) { + logging::write(logging::Level::warning, logging::Channel::graphics, "The game's pictures are unavailable: their reading could not start."); + } +} + +void reserve_game_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept { + auto& l = loaded(); + l = {}; + l.rects.fill(-1); + try { + auto& state = reads(); + std::lock_guard lock(state.mutex); + // A read still running stays pending for the next atlas. + if (state.pending.valid() && state.pending.wait_for(wait) == std::future_status::ready) state.decoded = state.pending.get(); + for (std::size_t index = 0; index < picture_count; ++index) { + const auto& image = state.decoded[index]; + if (!image) continue; + if (!l.atlas) { + atlas.TexDesiredWidth = std::max(atlas.TexDesiredWidth, 2048); + atlas.Flags |= ImFontAtlasFlags_NoPowerOfTwoHeight; + l.atlas = &atlas; + } + l.rects[index] = atlas.AddCustomRectRegular(static_cast(image->width), static_cast(image->height)); + } + } catch (...) { + l = {}; + } +} + +void fill_game_images(ImFontAtlas& atlas) noexcept { + auto& l = loaded(); + if (l.atlas != &atlas) return; + try { + unsigned char* pixels{}; + int width{}, height{}; + atlas.GetTexDataAsRGBA32(&pixels, &width, &height); + if (!pixels || width <= 0 || height <= 0) { + l = {}; + return; + } + auto& state = reads(); + std::lock_guard lock(state.mutex); + for (std::size_t index = 0; index < picture_count; ++index) { + if (l.rects[index] < 0) continue; + const auto& image = *state.decoded[index]; + const auto* rect = atlas.GetCustomRectByIndex(l.rects[index]); + if (!rect || !rect->IsPacked() || rect->X + image.width > static_cast(width) || + rect->Y + image.height > static_cast(height)) { + l = {}; + return; + } + for (std::uint32_t y = 0; y < image.height; ++y) + std::memcpy(pixels + ((static_cast(rect->Y) + y) * width + rect->X) * 4, + image.rgba.data() + static_cast(y) * image.width * 4, static_cast(image.width) * 4); + } + atlas.TexPixelsUseColors = true; + l.filled = true; + } catch (...) { + l = {}; + } +} + +bool game_image_known(std::string_view name) noexcept { return std::find(names.begin(), names.end(), name) != names.end(); } +bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 max, ImU32 tint, GameImageFit fit, float edge) { + const auto at = std::find(names.begin(), names.end(), name); + if (at == names.end()) return false; + const auto picture = static_cast(at - names.begin()); + switch (fit) { + case GameImageFit::contain: return draw_image(draw, picture, min, max, tint); + case GameImageFit::shape: return draw_shape(draw, picture, min, max, tint); + case GameImageFit::panel: return draw_panel(draw, picture, min, max, edge, tint); + } + return false; +} +float thrasher_arch() noexcept { return ui::thrasher_wordmark_arch; } +} diff --git a/Extension/UI/Overlay/game_images.h b/Extension/UI/Overlay/game_images.h new file mode 100644 index 00000000..7d0509bd --- /dev/null +++ b/Extension/UI/Overlay/game_images.h @@ -0,0 +1,29 @@ +#pragma once +// Pictures of skate.'s own that plugins draw by name (the draw handler's d:game_image): the scoring +// icons, the THRASHER wordmark and the brush strokes and tiles of its menus, read from the installed +// game's data (Engine/Game/Build/20260929/ui_textures.h) and placed in the overlay's font atlas like +// the chat emotes. Nothing of the game's is shipped. +#include +#include +#include + +namespace dingosdk::overlay { +// Startup: starts reading the pictures in the background, once. +void prepare_game_images() noexcept; +// Each atlas the overlay builds: their room reserved first, waiting up to `wait` for a read still +// running, and filled after the atlas is built. +void reserve_game_images(ImFontAtlas& atlas, std::chrono::milliseconds wait) noexcept; +void fill_game_images(ImFontAtlas& atlas) noexcept; +// Whether `name` is one of the pictures: stopwatch, wipeout, wipeout_broken, spread_eagle, airtime, +// gap_height, flaming_wheel, roll, thrasher, brush_bar, rough_tile, scratches, streak. +bool game_image_known(std::string_view name) noexcept; +// How the picture goes onto the box: fitted with its aspect kept and centred; its body stretched onto +// the box with the rest of it around (a brush stroke's splatter beyond its bar); or nine-sliced, its +// corners `edge` pixels square. +enum class GameImageFit { contain, shape, panel }; +// Render thread. False while the picture is not loaded (yet, or at all). +bool draw_game_image(ImDrawList* draw, std::string_view name, ImVec2 min, ImVec2 max, ImU32 tint, GameImageFit fit, float edge); +// The THRASHER wordmark's arch: between a fifth in from either side its letters end above this +// fraction of its height, leaving room beneath. +float thrasher_arch() noexcept; +} diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index 32f01fa9..62235659 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1211,14 +1211,6 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { list.heading("SKATE YOUR WAY"); 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"); - const auto &grind = m.grind_exit; - if (list.setting("Grind exit triggers", on_off(grind.enabled), grind.available && console)) act("grind-exits"); - if (list.setting("Input Mode", grind.toe_heel ? "Toe / Heel" : "Left / Right", grind.available && console)) act("grind-exit-sides"); - if (grind.toe_heel && - list.setting("Your stance", grind.stance == 2 ? "Goofy" : grind.stance == 1 ? "Regular" : "Not set", grind.available && console)) - act("grind-exit-stance"); - if (list.setting("Pop-out strength", std::to_string(grind.strength) + "%", grind.available && console)) act("grind-exit-strength"); 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 978f7312..2bb24e61 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -96,7 +96,7 @@ struct FlightInput { // May change what the GAME reads from an XInput pad (never what ReSkate itself reads). `user` is // the XInput slot. Called on whatever thread the game polls from, once per poll that returns a -// pad. The grind exit control's grab and lean guards (grind_exit_control.h). +// pad. Plugins' hidden pad controls (Extension/Skater/grind_exit.h, reskate.input.hide). struct GamepadValues { std::uint16_t buttons = 0; std::uint8_t left_trigger = 0, right_trigger = 0; @@ -216,20 +216,6 @@ struct MissionRow { int completed = -1; }; -// Hall of Meat's switch (Extension/HallOfMeat/hall_of_meat.h): started, and switched on. -struct HallOfMeatModel { - bool available = false, enabled = false; -}; - -// Grind exit control's switches (Extension/Skater/grind_exit_control.h): started, switched on, -// LT/RT toe/heel (else left/right of travel), and the player's stance (0 not set, 1 regular, -// 2 goofy). -struct GrindExitModel { - bool available = false, enabled = false, toe_heel = true; - int stance = 0; - int strength = 100; // pop-out strength, percent (10-100) -}; - // Keyframe smoothing (Extension/ReplayKeyframes/replay_keyframes.h): started, switched on, and how // smooth (0..1) when on. struct KeyframeSmoothingModel { @@ -257,8 +243,6 @@ struct Model { WorldControlsModel world_controls; ProgressionModel progression; PlayerCardModel player_card; - HallOfMeatModel hall_of_meat; - GrindExitModel grind_exit; KeyframeSmoothingModel keyframe_smoothing; ObjectPersistenceModel object_persistence; ParkEditorModel editor; diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index d871fb64..78fdb766 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -3,7 +3,7 @@ #include "Engine/Core/Profiling/profiler.h" #include "overlay_internal.h" #include "Extension/Trainer/trainer_page.h" -#include "Extension/HallOfMeat/hall_of_meat_overlay.h" +#include "Extension/UI/Overlay/game_images.h" #include "plugin_overlay.h" #include "park_previews.h" #include "chat_emotes.h" @@ -383,10 +383,10 @@ bool setup_graphics() { std::chrono::duration_cast(waited_until - std::chrono::steady_clock::now())); }; const auto emote_count = reserve_chat_emotes(*ImGui::GetIO().Fonts, left()); - reserve_hall_of_meat_images(*ImGui::GetIO().Fonts, left()); + reserve_game_images(*ImGui::GetIO().Fonts, left()); const auto preview_count = load_park_previews(*ImGui::GetIO().Fonts, left()); fill_chat_emotes(*ImGui::GetIO().Fonts); - fill_hall_of_meat_images(*ImGui::GetIO().Fonts); + fill_game_images(*ImGui::GetIO().Fonts); if (emote_count) dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::graphics, "Chat emotes: %zu ready.", emote_count); @@ -759,7 +759,6 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool game_text_frame = game_text_pending(); const bool skate_hud_frame = skate_hud_pending(); const bool nametag_frame = nametags_pending(); - const bool meat_frame = hall_of_meat_pending(); const bool plugins_frame = plugins_pending(); const bool item_browser_frame = item_browser_pending(); const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); @@ -782,7 +781,7 @@ void render(IDXGISwapChain* presented, UINT flags) { } // Hidden, the overlay still draws while a notice or chat line is on screen. if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && - !nametag_frame && !meat_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; + !nametag_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -834,7 +833,6 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_perf_window(); } draw_hub_page(); - draw_hall_of_meat(); draw_plugins(); draw_nametags(); draw_game_text(); diff --git a/Extension/UI/Overlay/plugin_overlay.cpp b/Extension/UI/Overlay/plugin_overlay.cpp index f7327c30..6cf841e5 100644 --- a/Extension/UI/Overlay/plugin_overlay.cpp +++ b/Extension/UI/Overlay/plugin_overlay.cpp @@ -1,7 +1,9 @@ #include "plugin_overlay.h" #include "Extension/Plugins/plugin_camera.h" #include "Extension/Plugins/plugin_ui.h" +#include "Extension/UI/Overlay/game_images.h" #include "Extension/UI/Overlay/overlay_internal.h" +#include "Extension/UI/Overlay/xray_overlay.h" #include #include #include @@ -157,7 +159,9 @@ void draw_plugins() { if (display.x <= 0 || display.y <= 0) return; const float k = display.y / 1080.0f; auto& s = detail::state(); - auto* font = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* fallback = ImGui::GetFont(); + const std::array fonts{s.menu.bold ? s.menu.bold : fallback, s.menu.body ? s.menu.body : fallback, + s.menu.heading ? s.menu.heading : fallback, s.menu.title ? s.menu.title : fallback}; auto* draw = ImGui::GetBackgroundDrawList(); const auto camera = plugin_camera::projector(display.x, display.y); using Kind = plugin_ui::Shape::Kind; @@ -165,6 +169,7 @@ void draw_plugins() { for (const auto& shape : layer.shapes) { const auto& style = shape.style; const auto& p = shape.points; + auto* font = fonts[static_cast(std::clamp(style.font, 0, 3))]; switch (shape.kind) { case Kind::text: text(draw, font, k, at(p[0]), shape); break; case Kind::rect: @@ -212,6 +217,21 @@ void draw_plugins() { ImVec2(style.uv[0], style.uv[1]), ImVec2(style.uv[2], style.uv[3]), style.colour); break; } + case Kind::game_image: + (void)draw_game_image(draw, shape.text, at(p[0]), at(p[1]), style.colour, static_cast(std::clamp(shape.fit, 0, 2)), + shape.edge * k); + break; + case Kind::gradient: + if (shape.colours.size() == 4) + draw->AddRectFilledMultiColor(at(p[0]), at(p[1]), shape.colours[0], shape.colours[1], shape.colours[2], shape.colours[3]); + break; + case Kind::skeleton: { + if (shape.colours.size() != skater_body::count) break; + std::array paint{}; + std::copy(shape.colours.begin(), shape.colours.end(), paint.begin()); + draw_xray(draw, paint, static_cast(style.colour >> IM_COL32_A_SHIFT & 0xff) / 255.0f); + break; + } } } } diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index c7732423..a7c92f1b 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -816,9 +816,8 @@ constexpr std::array page_subtitles{ "Everything, a button away.", "Pick your spot. Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it.", "What ReSkate adds to skate.", "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Under the hood."}; -constexpr std::array addon_names{"ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"}; -constexpr std::array addon_subtitles{ - "What ReSkate adds to skate.", "Every bone counts.", "Pick the side you pop out.", "Your sound, in the game.", "What your plugins add."}; +constexpr std::array addon_names{"ADDONS", "MUSIC", "PLUGINS"}; +constexpr std::array addon_subtitles{"What ReSkate adds to skate.", "Your sound, in the game.", "What your plugins add."}; // The side rail's groups: the heading above the page that starts each. const char* group_of(int page) { switch (page) { diff --git a/Extension/UI/Overlay/skate_menu_addons.cpp b/Extension/UI/Overlay/skate_menu_addons.cpp index 96caa1f3..b8706c5e 100644 --- a/Extension/UI/Overlay/skate_menu_addons.cpp +++ b/Extension/UI/Overlay/skate_menu_addons.cpp @@ -8,7 +8,7 @@ #include #include -// The ADDONS page (what ReSkate adds to skate.: Hall of Meat, grind exits, music, the plugins' panels) +// The ADDONS page (what ReSkate adds to skate.: music, and the plugins' panels) // and the full screen menu's HOME page. namespace dingosdk::overlay::menu { using namespace theme; @@ -52,22 +52,12 @@ void addon_tile(SkateMenu& menu, const char* id, const char* title, const char* end_card(); } -void all_addons(SkateMenu& menu, const Model& model) { - const auto on_off = [](bool available, bool on) -> const char* { return !available ? "NOT AVAILABLE" : on ? "ON" : "OFF"; }; - const auto colour = [](bool available, bool on) { return !available ? skate_theme::tile_light : on ? skate_theme::good : skate_theme::tile_light; }; +void all_addons(SkateMenu& menu) { // Two a row full screen. int placed = 1; const auto next = [&] { if (console_style() && placed++ % 2) ImGui::SameLine(0, px(12)); }; - const auto& meat = model.hall_of_meat; - addon_tile(menu, "addon-hall-of-meat", "HALL OF MEAT", "Your bails show the bones they hurt and score a Meat card.", - on_off(meat.available, meat.enabled), colour(meat.available, meat.enabled), addon_hall_of_meat); - const auto& grind = model.grind_exit; - next(); - addon_tile(menu, "addon-grind-exits", "GRIND EXITS", "Press LT or RT while grinding to pick the side you pop out to.", - on_off(grind.available, grind.enabled), colour(grind.available, grind.enabled), addon_grind_exits); - next(); addon_tile(menu, "addon-music", "MUSIC", "Spotify, YouTube Music, your own files and online radio through the game's music player.", nullptr, 0, addon_music); next(); @@ -82,7 +72,7 @@ void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbac // A window lists the addons in its side rail; full screen has no rail, so they are tabs here // (LT / RT on a controller). if (menu.fullscreen_drawn) { - static constexpr std::array names{"ALL ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"}; + static constexpr std::array names{"ALL ADDONS", "MUSIC", "PLUGINS"}; std::vector labels(names.begin(), names.end()); category_tabs(menu, menu.addon, labels, "addon-tabs"); menu.addon = std::clamp(menu.addon, 0, static_cast(addon_count) - 1); @@ -92,10 +82,8 @@ void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbac case addon_music: music_page(menu, model, callbacks); break; default: ImGui::BeginChild("addon", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); - if (menu.addon == addon_hall_of_meat) hall_of_meat_addon(menu, model, callbacks); - else if (menu.addon == addon_grind_exits) grind_exits_addon(menu, model, callbacks); - else if (menu.addon == addon_plugins) plugins_addon(menu); - else all_addons(menu, model); + if (menu.addon == addon_plugins) plugins_addon(menu); + else all_addons(menu); ImGui::EndChild(); break; } @@ -223,11 +211,6 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks if (home_tile(menu, "##freecam", "FREECAM", on_off(debug.free_camera), size, debug.camera_available && callbacks.queue_debug, debug.free_camera, false, "Leave your skater and fly the camera.") == 1) request(DebugAction::set_free_camera, !debug.free_camera); - place(width, 1); - const auto& meat = model.hall_of_meat; - if (home_tile(menu, "##hall-of-meat", "HALL OF MEAT", on_off(meat.enabled), size, meat.available && callbacks.queue_console_command, - meat.enabled, false, "Your bails show the bones they hurt and score a Meat card.") == 1) - quietly(callbacks, meat.enabled ? "hallofmeat 0" : "hallofmeat 1"); } ImGui::Dummy(ImVec2(0, px(18))); @@ -249,7 +232,7 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks if (home_tile(menu, "##go-map", "MAP", "Travel, parks, time and weather", size, true, false, false, "Pick a map and load it.", false) == 1) open_page(menu, page_map); place(width, 3); - if (home_tile(menu, "##go-addons", "ADDONS", "Hall of Meat, grind exits, music, plugins", size, true, false, false, + if (home_tile(menu, "##go-addons", "ADDONS", "Music, and your plugins' panels", size, true, false, false, "What ReSkate adds to skate.", false) == 1) open_page(menu, page_addons, addon_all); place(width, 4); diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index f3075c13..8b781e62 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -120,8 +120,6 @@ bool trainer_page_wanted(); // Skater > CHEATS: noclip, No Bail and the boosts (skate_menu_skater.cpp). void cheats_tab(SkateMenu&, const Model&, const CallbacksV3&); // The addons' own pages (skate_menu_skater.cpp), and the ADDONS page itself (skate_menu_addons.cpp). -void hall_of_meat_addon(SkateMenu&, const Model&, const CallbacksV3&); -void grind_exits_addon(SkateMenu&, const Model&, const CallbacksV3&); void addons_page(SkateMenu&, const Model&, const CallbacksV3&); // The time of day the menu shows: the one last asked for while the game is still changing to it // (skate_menu_world.cpp). Setting it from a menu control goes through set_time_of_day, `quiet` @@ -143,6 +141,6 @@ enum Page { page_home, page_map, page_build, page_skater, page_sandbox, page_addons, page_online, page_progress, page_mods, page_settings, page_developer, page_count }; -enum Addon { addon_all, addon_hall_of_meat, addon_grind_exits, addon_music, addon_plugins, addon_count, addon_first = addon_hall_of_meat }; +enum Addon { addon_all, addon_music, addon_plugins, addon_count, addon_first = addon_music }; void open_page(SkateMenu&, int page, int addon = -1); } diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index eb772840..654e3af7 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -206,68 +206,7 @@ void cheats_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callback end_card(); } -// Addons > HALL OF MEAT: bails show the bones they hurt and score a Meat card. -void hall_of_meat_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - const auto& debug = model.debug; - begin_card(menu, "hall-of-meat", "HALL OF MEAT"); - bool hall_of_meat = model.hall_of_meat.enabled; - // No Bail (and noclip, which includes it) stops the bails Hall of Meat scores. - const bool bails_off = debug.no_bail || debug.no_bail_active; - const char* meat_help = hall_of_meat && bails_off - ? "On, but No Bail is stopping your bails, so nothing shows. Turn No Bail (and noclip) off in Skater > Cheats." - : bails_off - ? "Your bails show the bones they hurt and score a Meat card. Needs No Bail (and noclip) off." - : "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"); - end_card(); -} - -// Addons > GRIND EXITS: grind exit control (Extension/Skater/grind_exit_control.h). -void grind_exits_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - const auto& grind = model.grind_exit; - const bool grind_console = grind.available && callbacks.queue_console_command; - begin_card(menu, "grind-exits", "GRIND EXITS", "Choose the side you pop out of a grind using LT/RT"); - bool grind_on = grind.enabled; - if (toggle_row(menu, "Grind exit triggers", "Press LT or RT while grinding to pick the side you pop out to.", - grind_on, grind_console)) - send_console(menu, callbacks, grind_on ? "grindexit exits on" : "grindexit exits off"); - field(menu, "Input Mode"); - int grind_sides = grind.toe_heel ? 0 : 1; - if (choice(menu, "grind-sides", grind_sides, {"Toe / Heel", "Left / Right"}, grind_console, 0.0f, - {"Direction is based on your skater's toe/heel side", "Direction is based on left or right of your direction of travel"})) - send_console(menu, callbacks, grind_sides == 0 ? "grindexit sides toeheel" : "grindexit sides travel"); - if (grind.toe_heel) { - field(menu, "Your stance"); - int grind_stance = grind.stance == 2 ? 1 : grind.stance == 1 ? 0 : -1; - if (choice(menu, "grind-stance", grind_stance, {"Regular", "Goofy"}, grind_console)) - send_console(menu, callbacks, grind_stance == 1 ? "grindexit stance goofy" : "grindexit stance regular"); - if (grind.stance == 0) warn("Pick your stance. Until then LT / RT are left / right of travel."); - } - { - // Sent once the slider is let go (not every frame of a drag); the dragged value shows - // until the setting comes back, in steps of 5. - static int edited = -1; - static double edited_at = 0; - int strength = edited >= 0 ? edited : grind.strength; - field(menu, "Pop-out strength", "How far to the side a trigger exit pops. 100% is the full exit; lower it for rails next to a wall."); - ImGui::BeginDisabled(!grind_console); - if (slider_int("##grind-strength", &strength, 10, 100, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { - edited = std::clamp((strength + 2) / 5 * 5, 10, 100); - edited_at = ImGui::GetTime(); - } - if (ImGui::IsItemDeactivatedAfterEdit() && edited >= 0) { - send_console(menu, callbacks, "grindexit strength " + std::to_string(edited)); - edited_at = ImGui::GetTime(); - } - if (edited >= 0 && !ImGui::IsItemActive() && (grind.strength == edited || ImGui::GetTime() - edited_at > 2.0)) edited = -1; - ImGui::EndDisabled(); - } - end_card(); -} - void skater_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - // (Grind exits are an addon: Addons > Grind Exits.) category_tabs(menu, menu.skater_tab, {"FREECAM", "FIRST PERSON", "CHEATS"}, "skater-tabs"); ImGui::PushID(menu.skater_tab); ImGui::BeginChild("skater-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); diff --git a/Extension/UI/Overlay/xray_overlay.cpp b/Extension/UI/Overlay/xray_overlay.cpp new file mode 100644 index 00000000..de7b0ee3 --- /dev/null +++ b/Extension/UI/Overlay/xray_overlay.cpp @@ -0,0 +1,79 @@ +#include "xray_overlay.h" +#include "Extension/Skater/xray.h" +#include +#include +#include +#include + +namespace dingosdk::overlay { +namespace { +// `colour` darkened to `light` (0 black, 1 itself), at `alpha`. +ImU32 lit(ImU32 colour, float light, float alpha) { + const auto channel = [&](int shift) { + return static_cast(static_cast((colour >> shift) & 0xff) * std::clamp(light, 0.0f, 1.0f)) << shift; + }; + const auto a = static_cast(255.0f * std::clamp(alpha, 0.0f, 1.0f)); + return channel(IM_COL32_R_SHIFT) | channel(IM_COL32_G_SHIFT) | channel(IM_COL32_B_SHIFT) | a << IM_COL32_A_SHIFT; +} +} + +// The bodies a plugin painted, as an x-ray over the world projected like the nametags; the rest are +// not drawn. Every triangle of a painted body is drawn back to front, each vertex lit by how it faces +// the camera: an outline near solid and a middle fainter, so the bones read through each other and +// against the world. A triangle reaching into an unpainted body fades out. +void draw_xray(ImDrawList* draw, const std::array& paint, float alpha) { + static xray::Pose skeleton; // render thread only: reused frame to frame + if (alpha <= 0 || !xray::latest(skeleton)) return; + using Vec3 = std::array; + const auto dot = [](const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }; + const auto& positions = skeleton.positions; + const auto count = positions.size(); + const auto* mesh = skeleton.mesh.get(); + if (!count || count > 0xffff || !mesh || mesh->parts.size() != count || skeleton.normals.size() != count || + !(skeleton.vertical_fov > 1 && skeleton.vertical_fov < 175)) + return; + constexpr float solid = 0.6f; + const auto display = ImGui::GetIO().DisplaySize; + const auto& m = skeleton.camera; + const Vec3 right{m[0], m[1], m[2]}, up{m[4], m[5], m[6]}, back{m[8], m[9], m[10]}, origin{m[12], m[13], m[14]}; + const float focal = display.y / (2.0f * std::tan(skeleton.vertical_fov * 3.14159265f / 360.0f)); + const ImVec2 centre(display.x * 0.5f, display.y * 0.5f); + // Render thread only: reused frame to frame. + static std::vector at; + static std::vector depth; + static std::vector colour; // 0 for an unpainted body's vertex + static std::vector> order; // a triangle's depth, its first index + at.resize(count); + depth.resize(count); + colour.resize(count); + for (std::size_t i = 0; i < count; ++i) { + const auto& p = positions[i]; + const Vec3 delta{p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]}; + depth[i] = -dot(delta, back); + const float z = std::max(depth[i], 0.1f); + at[i] = ImVec2(centre.x + dot(delta, right) * focal / z, centre.y - dot(delta, up) * focal / z); + const float distance = std::sqrt(dot(delta, delta)); + const float facing = distance > 1e-4f ? std::abs(dot(skeleton.normals[i], delta)) / distance : 1.0f; + const auto painted = paint[std::min(mesh->parts[i], skater_body::count - 1)]; + const float faint = 0.25f + 0.6f * (1.0f - facing); + colour[i] = painted ? lit(painted, 0.55f + 0.45f * facing, alpha * (solid + (1.0f - solid) * faint)) : 0; + } + const auto& triangles = mesh->triangles; + order.clear(); + for (std::uint32_t t = 0; t + 2 < triangles.size(); t += 3) { + const auto a = triangles[t], b = triangles[t + 1], c = triangles[t + 2]; + if (a >= count || b >= count || c >= count || depth[a] <= 0.1f || depth[b] <= 0.1f || depth[c] <= 0.1f || + (!colour[a] && !colour[b] && !colour[c])) + continue; + order.emplace_back(depth[a] + depth[b] + depth[c], t); + } + if (order.empty()) return; + std::sort(order.begin(), order.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); // far first + const auto uv = ImGui::GetFontTexUvWhitePixel(); + draw->PrimReserve(static_cast(order.size() * 3), static_cast(count)); + const auto first = draw->_VtxCurrentIdx; + for (std::size_t i = 0; i < count; ++i) draw->PrimWriteVtx(at[i], uv, colour[i]); + for (const auto& [unused, t] : order) + for (std::uint32_t corner = 0; corner < 3; ++corner) draw->PrimWriteIdx(static_cast(first + triangles[t + corner])); +} +} diff --git a/Extension/UI/Overlay/xray_overlay.h b/Extension/UI/Overlay/xray_overlay.h new file mode 100644 index 00000000..d588c9ef --- /dev/null +++ b/Extension/UI/Overlay/xray_overlay.h @@ -0,0 +1,11 @@ +#pragma once +// The local skater's x-ray (Extension/Skater/xray.h) as a plugin's draw handler paints it +// (d:skeleton): each body in its colour, the rest left out. +#include "Engine/Game/Skater/skater_body.h" +#include +#include + +namespace dingosdk::overlay { +// Render thread. `paint` by body (skater_body.h's order; 0 leaves a body out), `alpha` the whole. +void draw_xray(ImDrawList* draw, const std::array& paint, float alpha); +} diff --git a/Extension/HallOfMeat/hall_of_meat_hud.cpp b/Extension/UI/hud_corner.cpp similarity index 90% rename from Extension/HallOfMeat/hall_of_meat_hud.cpp rename to Extension/UI/hud_corner.cpp index 46e285b2..814208d8 100644 --- a/Extension/HallOfMeat/hall_of_meat_hud.cpp +++ b/Extension/UI/hud_corner.cpp @@ -1,4 +1,4 @@ -#include "hall_of_meat_hud.h" +#include "hud_corner.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" @@ -13,10 +13,11 @@ #include #include #include +#include #include #include -namespace dingosdk::hall_of_meat { +namespace dingosdk::hud_corner { namespace { namespace corner = addr::hud_corner; namespace menu_data = multiplayer::menu_data; @@ -94,7 +95,7 @@ bool start_model_write_hook(Address base) noexcept { } if (status != HookOk) { logging::log(logging::Level::warning, logging::Channel::ui, - "Hall of Meat leaves skate.'s score HUD as it is: hooking the UI model's write failed ({}).", hook_status_string(status)); + "Plugins leave skate.'s score HUD as it is: hooking the UI model's write failed ({}).", hook_status_string(status)); return false; } h.ready = true; @@ -256,7 +257,7 @@ void hold_score(Corner& c, const menu_data::Context& context, bool held) { const bool taken = field.taken.load(std::memory_order_acquire); if (taken && now != sw.down && !std::exchange(c.slip_logged, true)) logging::write(logging::Level::warning, logging::Channel::ui, - "Hall of Meat: the game wrote the score HUD past the model write hook."); + "HUD corner: the game wrote the score HUD past the model write hook."); if (!taken) field.meant.store(now, std::memory_order_release); // what the game means until it writes otherwise field.taken.store(true, std::memory_order_release); if (now != sw.down) context.publish(context.field(c.score_model, sw.field), &sw.down); @@ -279,7 +280,7 @@ bool hook_hud(bool on) noexcept { if (h.hooked.load(std::memory_order_acquire) == on) return true; const auto status = on ? hook_enable(h.target) : hook_disable(h.target); if (status != HookOk) { - logging::log(logging::Level::warning, logging::Channel::ui, "Hall of Meat could not {} its UI model write hook ({}).", + logging::log(logging::Level::warning, logging::Channel::ui, "The HUD corner could not {} its UI model write hook ({}).", on ? "enable" : "disable", hook_status_string(status)); return false; } @@ -303,7 +304,7 @@ bool hide_hud(bool hidden) noexcept { c.stack = {}; c.score_model = {}; if (!std::exchange(c.warned, true)) - logging::log(logging::Level::warning, logging::Channel::ui, "Hall of Meat cannot hide skate.'s HUD corner: {}.", error.what()); + logging::log(logging::Level::warning, logging::Channel::ui, "Plugins cannot hide skate.'s HUD corner: {}.", error.what()); } catch (...) { c.stack = {}; c.score_model = {}; @@ -311,3 +312,34 @@ bool hide_hud(bool hidden) noexcept { return !c.cover.handle && !score_held(); } } + +namespace dingosdk::hud_corner { +namespace { +struct Asks { + std::map> owners; // hidden, by plugin + bool hooked{}; +}; +Asks& asks() { + static auto* value = new Asks; + return *value; +} +} +void ask(std::string_view owner, bool hidden) { asks().owners.insert_or_assign(std::string(owner), hidden); } +void release(std::string_view owner) { + auto& a = asks(); + if (const auto found = a.owners.find(owner); found != a.owners.end()) a.owners.erase(found); +} +void tick() noexcept { + auto& a = asks(); + try { + bool hidden{}; + for (const auto& [owner, on] : a.owners) hidden = hidden || on; + if (!a.owners.empty() && !a.hooked) a.hooked = hook_hud(true); + if (!a.hooked) return; + const bool back = hide_hud(hidden); + // No plugin left: the hook goes once the corner is the game's again. + if (a.owners.empty() && back && hook_hud(false)) a.hooked = false; + } catch (...) { + } +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_hud.h b/Extension/UI/hud_corner.h similarity index 61% rename from Extension/HallOfMeat/hall_of_meat_hud.h rename to Extension/UI/hud_corner.h index a0c6acfd..f73aa2aa 100644 --- a/Extension/HallOfMeat/hall_of_meat_hud.h +++ b/Extension/UI/hud_corner.h @@ -1,8 +1,9 @@ #pragma once #include +#include // skate.'s HUD in the bottom left corner (the d-pad menu, and the score HUD there while a line goes -// on), hidden while Hall of Meat's card shows in its place (Engine/Game/Build/20260929/hud_corner.h): +// on), hidden while a plugin shows something of its own there (reskate.hud.corner) (Engine/Game/Build/20260929/hud_corner.h): // - the d-pad hides itself while it is not alone in its stack: an empty item of ours lies in the // stack beside it (the game's items are never touched); // - the score HUD shows by its view model alone: a hook on the UI model's write of one value @@ -11,14 +12,21 @@ // ever sees the HUD down, until it is given back. // The game's models are read and written on the client thread only, under the model lock its own // writers take too. -namespace dingosdk::hall_of_meat { +namespace dingosdk::hud_corner { // Startup, with the game's image base: prepares the model write hook, which patches nothing; without // it the score HUD stays as it is. void start_hud(std::uintptr_t base) noexcept; -// Client thread: the model write hook on while Hall of Meat is, off otherwise, so the game's UI writes -// never pass through it while it is switched off. False when it could not be changed (logged). +// Client thread: the model write hook on while a plugin may hide the corner, off otherwise, so the +// game's UI writes never pass through it for nothing. False when it could not be changed (logged). bool hook_hud(bool on) noexcept; // Client thread, every tick: hides the corner while `hidden`, and gives the game's own back after. // True once nothing of ours is left in it. bool hide_hud(bool hidden) noexcept; + +// Plugins: `hidden` hides the corner while any plugin asks; false keeps the plugin ready to (the +// hook on, so hiding it later is immediate); release drops the plugin. Client thread. +void ask(std::string_view owner, bool hidden); +void release(std::string_view owner); +// Client thread, every tick. +void tick() noexcept; } diff --git a/README.md b/README.md index 40feb472..170152b3 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,8 @@ the launcher, the runtime that loads into the game, and the dedicated server. - The **Park Editor**: place, move and save objects with freecam, snapping and undo. Save a selection as a set and place it again anywhere, on any map. - Skater options: first person, movement, boosts, noclip. - - **Hall of Meat** (off by default): bail and see the bones you hurt, with a skate. 3 style Meat card. - - **Grind exit triggers** (off by default): press LT or RT while grinding to pick the side you pop - out to (toe / heel, or left / right), whatever trick and spin you do - ([how it works](Extension/Skater/GRIND_EXITS.md)). + - Hall of Meat and grind exit triggers are plugin mods now + ([contrib/plugins/hall-of-meat](contrib/plugins/hall-of-meat), [contrib/plugins/grind-exits](contrib/plugins/grind-exits)). - Progression, controls, graphics and multiplayer settings. - **Mods.** - Drop a mod in `Mods/` and it is merged into the game at launch. Mods can add custom maps, loading diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index a334d441..ae83212c 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -15,11 +15,9 @@ #include "Extension/Settings/job_spin.h" #include "Extension/Settings/replay_drafts.h" #include "Engine/Game/World/client_state.h" -#include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_game_runtime.h" #include "Extension/ReplayKeyframes/replay_keyframes.h" -#include "Extension/Skater/grind_exit_control.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -1092,9 +1090,7 @@ void update_model(std::uintptr_t client, TickState& frame) { DINGO_PROFILE_ZONE("tick/item browser"); dingosdk::update_local_item_browser(); } - dingosdk::hall_of_meat::on_client_tick(); dingosdk::breakable_deck::tick(); - dingosdk::grind_exit_control::on_client_tick(client); { DINGO_PROFILE_ZONE("tick/plugins"); dingosdk::plugins::on_client_tick(); @@ -1106,7 +1102,6 @@ void update_model(std::uintptr_t client, TickState& frame) { r.multiplayer_map = description.level.empty() ? std::string{} : description.level + "|" + description.lm_level; const auto& current_level = description.lm_level.empty() ? description.level : description.lm_level; dingosdk::live_mods::set_current_level(current_level); - dingosdk::hall_of_meat::set_level(current_level); dingosdk::plugins::set_level(current_level); dingosdk::replay_drafts::on_client_tick(r.base, state); // A live mod apply hands over the destinations its mods declare. A map @@ -1237,10 +1232,6 @@ void update_model(std::uintptr_t client, TickState& frame) { const auto missions_model = dingosdk::local_profile_missions(); 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.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(), - dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(), - dingosdk::grind_exit_control::strength()}; model.keyframe_smoothing = {dingosdk::replay_keyframes::available(), dingosdk::replay_keyframes::on(), dingosdk::replay_keyframes::smoothness()}; model.bindings = dingosdk::local_profile_controller_bindings(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 2a8f8389..68807217 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -29,9 +29,12 @@ #include "Engine/Vfs/mod_catalog.h" #include "Extension/Assets/map_download.h" #include "Extension/Boot/offline_boot.h" -#include "Extension/HallOfMeat/hall_of_meat.h" +#include "Extension/Skater/skater_physics.h" +#include "Extension/Skater/xray.h" +#include "Extension/UI/hud_corner.h" +#include "Extension/UI/Overlay/game_images.h" #include "Extension/Plugins/plugin_runtime.h" -#include "Extension/Skater/grind_exit_control.h" +#include "Extension/Skater/grind_exit.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -445,10 +448,16 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { (void)dingosdk::start_no_bail(r.base); const bool plugins = dingosdk::plugins::start(r.base); record("{\"event\":\"plugins_initialized\",\"active\":" + std::string(plugins ? "true" : "false") + "}"); - const bool meat = dingosdk::hall_of_meat::start(r.base); - record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); - const bool grind_control = dingosdk::grind_exit_control::start(r.base); - record("{\"event\":\"grind_exit_control_initialized\",\"active\":" + std::string(grind_control ? "true" : "false") + "}"); + // What plugins reach of the skater's physics, its x-ray and skate.'s HUD corner; the game's + // pictures are read now, as the overlay builds its atlas once. + const bool physics = dingosdk::skater_physics::start(r.base); + const bool xray = dingosdk::xray::start(r.base); + dingosdk::hud_corner::start_hud(r.base); + dingosdk::overlay::prepare_game_images(); + record("{\"event\":\"skater_physics_initialized\",\"active\":" + std::string(physics ? "true" : "false") + ",\"xray\":" + + std::string(xray ? "true" : "false") + "}"); + const bool grind_exits = dingosdk::grind_exit::start(r.base); + record("{\"event\":\"grind_exit_initialized\",\"active\":" + std::string(grind_exits ? "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 67bd929a..d57d10f7 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -128,6 +128,13 @@ add_library(dingosdk_runtime SHARED Extension/Skater/skater_events.cpp Extension/Skater/outfit_overlay.cpp Extension/Skater/board_pose.cpp + Extension/Skater/skater_physics.cpp + Extension/Skater/xray.cpp + Extension/Skater/xray_render.cpp + Extension/Skater/xray_skeleton.cpp + Extension/Skater/game_speed.cpp + Extension/Skater/grind_exit.cpp + Extension/UI/hud_corner.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp Extension/World/native_entity_pages.cpp @@ -157,20 +164,9 @@ add_library(dingosdk_runtime SHARED Extension/Skater/client_debug.cpp Extension/Skater/ai_skaters.cpp Extension/Skater/no_bail.cpp - Extension/Skater/grind_exit_commands.cpp - Extension/Skater/grind_exit_control.cpp Extension/Skater/physics_tuning.cpp Extension/Multiplayer/Remote/remote_collision.cpp Extension/Skater/physics_tuning_model.cpp - Extension/HallOfMeat/hall_of_meat.cpp - Extension/HallOfMeat/hall_of_meat_card.cpp - Extension/HallOfMeat/hall_of_meat_commands.cpp - Extension/HallOfMeat/hall_of_meat_hud.cpp - Extension/HallOfMeat/hall_of_meat_model.cpp - Extension/HallOfMeat/hall_of_meat_render.cpp - Extension/HallOfMeat/hall_of_meat_skater.cpp - Extension/HallOfMeat/hall_of_meat_skeleton.cpp - Extension/HallOfMeat/hall_of_meat_slow_motion.cpp Extension/Skater/breakable_deck.cpp Extension/Skater/breakable_deck_commands.cpp Extension/Trainer/trainer.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index c1efd3e8..3f4ca8e4 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -26,6 +26,8 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/skate_menu_addons.cpp Extension/UI/Overlay/plugins_page.cpp Extension/UI/Overlay/plugin_overlay.cpp + Extension/UI/Overlay/game_images.cpp + Extension/UI/Overlay/xray_overlay.cpp Extension/UI/Overlay/plugin_images.cpp Extension/UI/Overlay/menu_map_pictures.cpp Extension/UI/Overlay/skate_menu_skater.cpp @@ -47,7 +49,6 @@ add_library(dingosdk_overlay STATIC Extension/Trainer/trainer_page.cpp Extension/Music/music_page.cpp Extension/Trainer/trainer_view.cpp - Extension/HallOfMeat/hall_of_meat_overlay.cpp ) target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE dingosdk_playstation_input shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) diff --git a/contrib/plugins/README.md b/contrib/plugins/README.md index d589fb12..fa1e5fb5 100644 --- a/contrib/plugins/README.md +++ b/contrib/plugins/README.md @@ -16,9 +16,18 @@ Both also run without the game, in `dingosdk_plugin_tests` (`DINGOSDK_BUILD_PLUG ## Ports -- **road-rash** is Road Rash (`Extension/RoadRash`) as a mod: bails leave marks that build up, - worn over the outfit with `reskate.outfit`. It needs the Road Rash items mod, and the built-in - Road Rash switched off (`roadrash 0`), since ReSkate refuses outfit changes while that one is on. - While the built-in one exists it owns the `roadrash` command, so this one answers to `road rash` - (`road rash heal`, `road rash bails 20`, `road rash blood 0`, `road rash tattoos 1`, - `road rash test` for its own checks). Its switches are on its panel in the PLUGINS tab too. +Features that were built into ReSkate, as mods of their own. Each has its own checks (` +test`) that `dingosdk_plugin_tests` runs too, and its switches on its panel in the PLUGINS tab. + +- **road-rash**: bails leave marks that build up, worn over the outfit with `reskate.outfit`. It + needs the Road Rash items mod. `roadrash`, `roadrash heal`, `roadrash bails 20`. +- **board-break**: a hard slam can snap the board, drawn with `reskate.board`. It ships inside the + Board Break deck pack. `boardbreak`, `boardbreak snap mid`. Which deck every board has stays in + ReSkate (`boarddeck`). +- **fpv-drone**: the FPV drone, flown from the free camera with the keyboard, a pad or a radio. + `fpv fly`, `fpv follow`. +- **hall-of-meat**: skate. 3's Hall of Meat: bails show the bones they hurt on an x-ray of the + skater (`reskate.skater.xray`, `d:skeleton`) and score a Meat card drawn with skate.'s own + pictures (`d:game_image`). `hallofmeat 0|1`. +- **grind-exits**: LT / RT in a grind pick the side of the pop (`reskate.skater.grind_exit`, + `reskate.input.hide`). `grindexit status`. diff --git a/contrib/plugins/grind-exits/README.md b/contrib/plugins/grind-exits/README.md new file mode 100644 index 00000000..97e17138 --- /dev/null +++ b/contrib/plugins/grind-exits/README.md @@ -0,0 +1,63 @@ +# Grind Exits (plugin) + +Pick the side you pop out of a grind to with **LT / RT**, independently of the trick and of the body's +rotation. In skate.'s own controls both come from the left stick at the same moment, so a frontside +180 always pops out toward one side and a backside 180 toward the other. With this, any trick and any +rotation can go out either side: a frontside 180 out to your toe side, a backside 180 out to your heel +side, a kickflip out to whichever side you pick. + +## Using it + +On once installed. Its settings are on the ReSkate menu's ADDONS page, PLUGINS tab, and on the console +(`~`): `grindexit exits on|off|status`. + +- **While grinding, press LT or RT** (or up to 1 s before the grind connects, for example as you ollie + up to the rail), then do any trick out of the grind with the right stick as usual. Keep the trigger + held until you land, then let go. +- **Input mode** (`grindexit sides toeheel|travel`): + - **Toe / Heel**: LT pops out to your toe side, RT to your heel side, riding normal, fakie or switch. + Needs **your stance** (`grindexit stance goofy|regular`). On slides (boardslide, lipslide), where + toes and heels point along the rail, LT / RT are left / right of travel. + - **Left / Right**: LT pops out to the left of your direction of travel, RT to the right. +- **No trigger, or both**: the game's own exit. +- **Grabs**: a trigger used to pick the side is kept from the game until you let go of it, so holding + it through the pop does not grab. Let go and press it again in the air to grab (`grindexit guard on|off`). +- **Spins**: push the left stick for your spin as you always do. While the trigger is held in the grind, + the stick's left / right reaches the game at the pop instead of before it, so the game's own lean + exit cannot drag the board toward the spin and into the rail (`grindexit leanguard on|off`). +- **Pop-out strength** (`grindexit strength 10-100`): how far to the side a trigger exit pops, as a + share of the full exit (100%, the default, is the game's own sideways speed). Lower it for rails next + to a wall. +- **Multiplayer**: works in any session or server. It adds no speed or height; it only changes which + input picks the exit side. + +`grindexit test` runs the plugin's own checks of the trigger rules. + +## How it works + +- `plugin/triggers.lua`: each tick, which trigger picks the side and what the game must not see of + the pad. No game access, so `plugin/tests.lua` checks it. +- `plugin/main.lua`: the wiring to `reskate`: the pad (`reskate.input.gamepad`, which sees the + triggers even while they are kept from the game), the hiding (`reskate.input.hide`), the side asked + of the next pop (`reskate.skater.grind_exit`) and each pop as it went (the `grind_exit` event), the + console command and the menu panel. + +ReSkate does what has to happen in the game's own threads (`Extension/Skater/grind_exit.h`): at the +pop the game calls its jump-trajectory setter with the launch velocity, which the trainer hooks; for a +pop out of a grind only the sideways part of that velocity is replaced, across the rail: the game's +own sideways speed when it had one, mirrored to the chosen side, else the game's usual 1.8 m/s. The +speed along the rail, the pop's height, the trick, the flip and the rotation are the game's. The pad +filter hides the triggers and the left stick's left / right in the game's input thread. + +**Toe / heel.** The game keeps which foot leads in a flag (context + 0x13c4, bit 5), found by recording +the skater's state while riding regular and switch: it changed exactly at each change of stance and +never during grinds, pops or 180s. With the player's stance it gives the lead foot; the toes face the +skater root's right axis when the right foot leads (measured), the other way when the left foot does. + +**Measured** (when it was built into ReSkate, on build 25414733, with a goofy skater over four +sessions): 48 pops out of grinds with the game's own controls to learn its exits, then 144 pops whose +side the triggers set. Forward, fakie and switch 50-50s, ollies, kick- and heelflips, shove-its and +frontside / backside 180s went out to the chosen side with the trick and the rotation unchanged. +Frontside 180 out to the toe side clipped the rail until the lean guard was added; with it, all 4 tries +landed. As a plugin the triggers are read once a client tick rather than at every pad poll, so the game +may see a trigger for a frame before it is hidden. diff --git a/contrib/plugins/grind-exits/manifest.json b/contrib/plugins/grind-exits/manifest.json new file mode 100644 index 00000000..59260c6a --- /dev/null +++ b/contrib/plugins/grind-exits/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Grind_Exits", + "version_number": "0.1.0", + "website_url": "https://github.com/Dingo-Shenanigans/ReSkate", + "description": "Press LT or RT while grinding to pick the side you pop out to, whatever trick and spin you do.", + "dependencies": [] +} diff --git a/contrib/plugins/grind-exits/plugin/main.lua b/contrib/plugins/grind-exits/plugin/main.lua new file mode 100644 index 00000000..d6f4fb38 --- /dev/null +++ b/contrib/plugins/grind-exits/plugin/main.lua @@ -0,0 +1,183 @@ +-- Grind exit triggers: pick the side you pop out of a grind to with LT / RT, independently of the +-- trick and of the body's rotation (skate.'s own controls tie both to the left stick). +-- +-- triggers.lua decides, each tick, which trigger picks the side and what the game must not see of +-- the pad (reskate.input.hide). At the pop ReSkate replaces only the sideways part of the game's +-- launch with the side asked for (reskate.skater.grind_exit): the game's own sideways speed when it +-- had one, mirrored to that side, else the game's usual 1.8 m/s, times the pop-out strength. Along +-- the rail, the pop's height, the trick, the flip and the rotation stay the game's. +local triggers = require("triggers") + +local function setting(key, default, kind) + local value = reskate.storage.get(key) + if type(value) == kind then return value end + return default +end +local settings = { + on = setting("on", true, "boolean"), + toe_heel = setting("toe_heel", true, "boolean"), -- else left/right of travel + stance = setting("stance", "", "string"), -- "", "regular" or "goofy" + strength = setting("strength", 100, "number"), -- percent of the full sideways speed, 10 to 100 + guard = setting("guard", true, "boolean"), + lean_guard = setting("lean_guard", true, "boolean"), + prewindow = setting("prewindow", 1, "number"), -- seconds a trigger may be pressed before the grind connects +} +local state = triggers.new() +local grinding = false +local wished = nil -- the side last asked of ReSkate ("toe", "heel", "left", "right"), or nil +local hidden_key = "" +local pops, applied = 0, 0 +local last_pop = "none yet" +local why_not = nil -- ReSkate's refusal, when it refused + +local function save(key, value) + settings[key] = value + reskate.storage.set(key, value) +end +local function wish(side) + if side == wished then return end + wished = side + local ok, why + if side then + local stance = settings.stance ~= "" and settings.stance or nil + ok, why = reskate.skater.grind_exit({side = side, stance = stance, strength = settings.strength / 100}) + else + ok, why = reskate.skater.grind_exit(nil) + end + why_not = not ok and tostring(why) or nil +end +local function hide(h) + local key = tostring(h.left_trigger) .. tostring(h.right_trigger) .. tostring(h.left_x) + if key == hidden_key then return end + hidden_key = key + reskate.input.hide(h) +end +local function reset() + state = triggers.new() + wish(nil) + hide({}) +end + +reskate.on("skater_state", function(now) grinding = now == "grinding" end) + +reskate.on("tick", function() + if not settings.on then return end + local now = reskate.time.now() + local pad = reskate.input.gamepad() + triggers.step(state, now, pad and pad.left_trigger, pad and pad.right_trigger, grinding, settings.prewindow * 1000) + hide(triggers.hidden(state, settings.guard, settings.lean_guard)) + local trigger = triggers.chosen(state, now) + wish(trigger and triggers.side(trigger, settings.toe_heel) or nil) +end) + +-- Each pop out of a grind, as it went. +reskate.on("grind_exit", function(pop) + pops += 1 + if pop.applied then applied += 1 end + last_pop = pop.applied and string.format("%s of travel (%s), sideways %+.2f -> %+.2f m/s", pop.side, pop.basis or "-", pop.before, pop.after) + or pop.reason == "kept" and "the game already chose that side" + or pop.reason == "none" and "no trigger: the game's own exit" + or pop.reason == "slow" and "too slow to tell the rail's direction" + or "the launch did not match the skater's grind" +end) + +-- ---- the switches ---------------------------------------------------------------------------- + +local function set_on(on) + if on == settings.on then return end + save("on", on) + if not on then reset() end +end +local function status() + local stance = settings.stance ~= "" and settings.stance or "NOT SET (grindexit stance goofy|regular)" + local text = string.format("%s. Sides: %s. Stance: %s. Pop-out strength %d%%. Grab guard %s, lean guard %s, pre-press window %.2f s. " .. + "Pops out of grinds seen: %d, sides set: %d. Last: %s.", + settings.on and "ON" or "off", settings.toe_heel and "LT toe / RT heel" or "LT left / RT right of travel", stance, settings.strength, + settings.guard and "on" or "off", settings.lean_guard and "on" or "off", settings.prewindow, pops, applied, last_pop) + if settings.on and settings.toe_heel and settings.stance == "" then text ..= " Until the stance is set, LT/RT are left/right of travel." end + if why_not then text ..= " ReSkate refused: " .. why_not end + return text +end + +local function answer(args) + local function say(text) reskate.console.print("grindexit: " .. text) end + local verb, arg = args[1] or "status", args[2] + if verb == "status" then return say(status()) end + if verb == "exits" then + if arg == "on" or arg == "off" then set_on(arg == "on") end + return say(status()) + end + if verb == "stance" then + if arg ~= "goofy" and arg ~= "regular" then return say("grindexit stance goofy|regular (your skater's stance in the game settings)") end + save("stance", arg) + wished = nil -- asked again with the stance + return say(status()) + end + if verb == "sides" then + if arg ~= "toeheel" and arg ~= "travel" then return say("grindexit sides toeheel|travel") end + save("toe_heel", arg == "toeheel") + return say(status()) + end + if verb == "strength" then + local value = tonumber(arg) + if not value or value ~= math.floor(value) or value < 10 or value > 100 then + return say("grindexit strength <10 to 100> (percent of the full pop-out to the side; 100 is the game's own)") + end + save("strength", value) + wished = nil + return say(status()) + end + if verb == "guard" or verb == "leanguard" then + if arg ~= "on" and arg ~= "off" then return say("grindexit " .. verb .. " on|off") end + save(verb == "guard" and "guard" or "lean_guard", arg == "on") + return say(status()) + end + if verb == "prewindow" then + local value = tonumber(arg) + if not value or value < 0 or value > 2 then return say("grindexit prewindow <0 to 2> (seconds a trigger may be pressed before the grind connects; 1)") end + save("prewindow", value) + return say(status()) + end + if verb == "test" then + local passed, failures = require("tests").run() + for _, failure in failures do say("FAIL " .. failure) end + return say(string.format("%d passed, %d failed", passed, #failures)) + end + say("grindexit exits on|off|status, stance goofy|regular, sides toeheel|travel, strength <10-100>, guard on|off, leanguard on|off, prewindow , test") +end +reskate.console.command("grindexit", "Pick the side you pop out of a grind with LT / RT: exits on|off, stance, sides, strength, guard, leanguard", + answer) + +reskate.menu.panel("Grind Exits", function(ui) + set_on(ui:toggle("on", "Grind exit triggers", settings.on)) + ui:label("Press LT or RT while grinding (or up to a second before the grind connects) to pick the side you pop out to, then do any trick out of it. Keep the trigger held until you land.") + local mode = ui:dropdown("mode", "Input mode", {"Toe / Heel", "Left / Right"}, settings.toe_heel and 1 or 2) + if (mode == 1) ~= settings.toe_heel then save("toe_heel", mode == 1) end + if settings.toe_heel then + local stance = ui:dropdown("stance", "Your stance", {"Not set", "Regular", "Goofy"}, + settings.stance == "regular" and 2 or settings.stance == "goofy" and 3 or 1) + local wanted = stance == 2 and "regular" or stance == 3 and "goofy" or "" + if wanted ~= settings.stance then + save("stance", wanted) + wished = nil + end + end + local strength = math.floor(ui:slider("strength", "Pop-out strength (%)", settings.strength, 10, 100) + 0.5) + if strength ~= settings.strength then + save("strength", strength) + wished = nil + end + ui:label("Lower it for rails next to a wall. 100% is the game's own full exit.") + if ui:toggle("guard", "Grab guard: the trigger that picked the side does not grab", settings.guard) ~= settings.guard then + save("guard", not settings.guard) + end + if ui:toggle("lean", "Lean guard: the left stick's left/right waits for the pop", settings.lean_guard) ~= settings.lean_guard then + save("lean_guard", not settings.lean_guard) + end + ui:label(string.format("Pops out of grinds seen: %d, sides set: %d. Last: %s.", pops, applied, last_pop)) +end) + +-- Its pops are heard from the start, before any trigger picks a side. +reskate.on("load", function() reskate.skater.grind_exit(nil) end) +reskate.on("unload", reset) +reskate.log("grind exits " .. (settings.on and "on" or "off") .. ": grindexit status") diff --git a/contrib/plugins/grind-exits/plugin/tests.lua b/contrib/plugins/grind-exits/plugin/tests.lua new file mode 100644 index 00000000..50f2eb6f --- /dev/null +++ b/contrib/plugins/grind-exits/plugin/tests.lua @@ -0,0 +1,67 @@ +-- Checks of the trigger rules, run by `grindexit test` (and by dingosdk_plugin_tests, without the game). +local triggers = require("triggers") + +local checks = {} +local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end + +check("a trigger pressed in a grind picks the side and is kept from the game", function() + local s = triggers.new() + triggers.step(s, 0, 0, 0, true) + triggers.step(s, 16, 1, 0, true) + assert(triggers.chosen(s, 16) == "lt") + local h = triggers.hidden(s, true, true) + assert(h.left_trigger and not h.right_trigger and h.left_x == "grinding") +end) +check("a trigger pressed before the grind counts within the window", function() + local s = triggers.new() + triggers.step(s, 0, 0, 1, false) -- pressed in the air + assert(triggers.chosen(s, 0) == nil, "not before the grind connects") + triggers.step(s, 600, 0, 1, true, 1000) + assert(triggers.chosen(s, 600) == "rt", "it counts once it does") + local late = triggers.new() + triggers.step(late, 0, 0, 1, false) + triggers.step(late, 1500, 0, 1, true, 1000) + assert(triggers.chosen(late, 1500) == nil, "pressed too long before: the game's own") +end) +check("both triggers are the game's own exit", function() + local s = triggers.new() + triggers.step(s, 0, 1, 1, true) + assert(triggers.chosen(s, 0) == nil) +end) +check("a trigger let go just before the pop still counts, then not", function() + local s = triggers.new() + triggers.step(s, 0, 1, 0, true) + triggers.step(s, 50, 0, 0, true) + assert(triggers.chosen(s, 100) == "lt", "within the grace") + assert(triggers.chosen(s, 200) == nil, "past it") + assert(not triggers.hidden(s, true, true).left_trigger, "let go, it is the game's again") +end) +check("a press needs 40 %, a release 20 %", function() + local s = triggers.new() + triggers.step(s, 0, 0.3, 0, true) + assert(triggers.chosen(s, 0) == nil) + triggers.step(s, 16, 0.5, 0, true) + triggers.step(s, 32, 0.3, 0, true) + assert(triggers.chosen(s, 32) == "lt", "still held at 30 %") +end) +check("the guards can be off", function() + local s = triggers.new() + triggers.step(s, 0, 1, 0, true) + local h = triggers.hidden(s, false, false) + assert(not h.left_trigger and h.left_x == nil) +end) +check("toe and heel, or left and right of travel", function() + assert(triggers.side("lt", true) == "toe" and triggers.side("rt", true) == "heel") + assert(triggers.side("lt", false) == "left" and triggers.side("rt", false) == "right") +end) + +local tests = {} +function tests.run() + local passed, failures = 0, {} + for _, c in checks do + local ok, why = pcall(c.fn) + if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end + end + return passed, failures +end +return tests diff --git a/contrib/plugins/grind-exits/plugin/triggers.lua b/contrib/plugins/grind-exits/plugin/triggers.lua new file mode 100644 index 00000000..c3ee9291 --- /dev/null +++ b/contrib/plugins/grind-exits/plugin/triggers.lua @@ -0,0 +1,74 @@ +-- The triggers, as grind exits reads them each tick: which one picks the side of the next pop out of +-- a grind, and which the game must not see. No game access. +-- +-- - A trigger pressed DURING a grind, or up to `prewindow` seconds before the grind connects, is +-- "claimed": it picks the side of the next pop. Both claimed: the game's own exit. +-- - A claimed trigger let go shortly before the pop still counts (release_grace_ms). +-- - Grab guard: a claimed trigger is kept from the game until it is let go, so holding it through +-- the pop does not start a grab; pressed again in the air, it grabs. +-- - Lean guard: while a claimed trigger is held, the left stick's left/right is kept from the game +-- while the skater grinds (ReSkate gives it back at the pop), so the game's own lean exit cannot +-- start and pull the board the other way. +local triggers = {} + +triggers.press_level, triggers.release_level = 0.4, 0.2 -- of a full pull: about 40 % in, 20 % out +triggers.release_grace_ms = 100 + +function triggers.new() + return { + sides = {lt = {pressed = false, claimed = false, pressed_at = -math.huge, released = -math.huge}, + rt = {pressed = false, claimed = false, pressed_at = -math.huge, released = -math.huge}}, + grinding = false, + grind_start = -math.huge, + } +end + +-- One tick at `now` (ms): the pad's triggers (0 to 1, nil without a pad) and whether the skater grinds. +function triggers.step(state, now, lt, rt, grinding, prewindow_ms) + if grinding and not state.grinding then state.grind_start = now end + state.grinding = grinding + local window_start = state.grind_start - (prewindow_ms or 1000) + for side, value in {lt = lt or 0, rt = rt or 0} do + local t = state.sides[side] + if not t.pressed and value >= triggers.press_level then + t.pressed = true + t.pressed_at = now + if grinding then t.claimed = true end + elseif t.pressed and value <= triggers.release_level then + t.pressed = false + if t.claimed then + t.claimed = false + t.released = now + end + end + -- Pressed a moment before the grind connected: it counts too. + if grinding and t.pressed and not t.claimed and t.pressed_at >= window_start then t.claimed = true end + end +end + +-- The trigger that picks the side now: "lt", "rt", or nil (none, or both). +function triggers.chosen(state, now) + local lt, rt = state.sides.lt, state.sides.rt + if lt.claimed and rt.claimed then return nil end + if lt.claimed or rt.claimed then return lt.claimed and "lt" or "rt" end + local recent_lt = now - lt.released < triggers.release_grace_ms + local recent_rt = now - rt.released < triggers.release_grace_ms + if recent_lt and recent_rt then return nil end + if recent_lt or recent_rt then return recent_lt and "lt" or "rt" end + return nil +end + +-- What the game is kept from: each claimed trigger (with the grab guard), and the left stick's +-- left/right while grinding (with the lean guard) while any is claimed. +function triggers.hidden(state, guard, lean_guard) + local lt, rt = state.sides.lt.claimed, state.sides.rt.claimed + return {left_trigger = guard and lt, right_trigger = guard and rt, left_x = lean_guard and (lt or rt) and "grinding" or nil} +end + +-- The side a trigger asks for: toe/heel (LT the toe side, RT the heel side) or left/right of travel. +function triggers.side(trigger, toe_heel) + if toe_heel then return trigger == "lt" and "toe" or "heel" end + return trigger == "lt" and "left" or "right" +end + +return triggers diff --git a/contrib/plugins/grind-exits/reskate-plugin.json b/contrib/plugins/grind-exits/reskate-plugin.json new file mode 100644 index 00000000..db4a5e42 --- /dev/null +++ b/contrib/plugins/grind-exits/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["events.skater", "skater.control", "input", "storage", "menu", "console"] +} diff --git a/contrib/plugins/hall-of-meat/README.md b/contrib/plugins/hall-of-meat/README.md new file mode 100644 index 00000000..bbe82ee0 --- /dev/null +++ b/contrib/plugins/hall-of-meat/README.md @@ -0,0 +1,43 @@ +# Hall of Meat (plugin) + +skate. 3's Hall of Meat for skate., as a sandboxed Luau plugin: when you bail, the bones you hurt show +through your skater, yellow where a hit bruised one and red where one broke it, with the bail's Meat +card in the bottom left corner. It ships no game data: the skeleton (the Dem Bones costume's mesh), +the card's icons, the THRASHER wordmark and the brush strokes are read from the installed game by +ReSkate. + +## What it does + +- **The skeleton.** From the first hurt bone until you are back on your feet, then it fades out. A fall + that hurts nothing shows nothing. A fresh hit flashes the bone it hit. +- **The card.** It takes the place of skate.'s own HUD in that corner (the d-pad and the score HUD come + back after). Time, hits (head hits count double, hits from vehicles half again), broken bones, road + rash, airtime, fall, top speed and rotations, each with its points, then the Meat. Small stats join + the card once they are worth showing. Each map keeps its best Meat (**NEW BEST** / **BEST 12,345**). +- **A break.** The screen's edges pulse red and, in single player only, the game slows down for a + moment. ReSkate never slows the game in a multiplayer session. + +On once installed: switch it on or off on the ReSkate menu's ADDONS page, PLUGINS tab, or with the +console (`~`): `hallofmeat 0|1`; `hallofmeat` on its own says this map's best and the last bail. +`hallofmeat test` runs the plugin's own checks. With No Bail on, a wipeout No Bail stops is not a bail, +so nothing shows. + +## How it works + +- `plugin/model.lua`: the bail's phases (riding, bailing, down, getting up) and its Meat, fed every + physics step of the local skater (the `physics_step` event: what each body hit and how hard, how it + moves, whether it is a ragdoll), on the game's clock. +- `plugin/card.lua`: the card's rows. +- `plugin/draw.lua`: the x-ray (`reskate.skater.xray`, `d:skeleton`), the red edge (`d:gradient`) and + the card (`d:game_image`, skate.'s own pictures). +- `plugin/main.lua`: the wiring to `reskate`, skate.'s HUD corner hidden while the card shows + (`reskate.hud.corner`), the slow motion (`reskate.time.slow`), each map's best (`reskate.storage`), + the console command and the menu panel. + +ReSkate keeps what has to run in the game itself: the skater's physics steps +(`Extension/Skater/skater_physics.h`), the x-ray's renderer hooks and skeleton mesh +(`Extension/Skater/xray.h`), the HUD corner (`Extension/UI/hud_corner.h`) and the slow motion +(`Extension/Skater/game_speed.h`). + +The skater's render hooks, its skeleton and its body maps were first found by +[gBGYo's ReSkate fork](https://github.com/gBGYo/ReSkate). diff --git a/contrib/plugins/hall-of-meat/manifest.json b/contrib/plugins/hall-of-meat/manifest.json new file mode 100644 index 00000000..14449f7c --- /dev/null +++ b/contrib/plugins/hall-of-meat/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Hall_of_Meat", + "version_number": "0.1.0", + "website_url": "https://github.com/Dingo-Shenanigans/ReSkate", + "description": "skate. 3's Hall of Meat: your bails show the bones they hurt and score a Meat card.", + "dependencies": [] +} diff --git a/contrib/plugins/hall-of-meat/plugin/card.lua b/contrib/plugins/hall-of-meat/plugin/card.lua new file mode 100644 index 00000000..dca27239 --- /dev/null +++ b/contrib/plugins/hall-of-meat/plugin/card.lua @@ -0,0 +1,59 @@ +-- Hall of Meat's score card, as in skate. 3: a row per stat, with skate.'s own icon for it, then the +-- Meat. No game access. +local model = require("model") +local card = {} + +-- When a stat comes onto the card: once the bail has done something worth showing, so a small bail's +-- card stays short and a big one grows as it goes. The time shows from the start; the Meat counts +-- every stat whether shown or not. +card.hits_shown, card.broken_shown = 5, 5 +card.road_rash_shown_feet, card.airtime_shown_seconds, card.fall_shown_feet = 15, 3, 100 +card.speed_shown_mph, card.rotations_shown = 20, 2 +-- skate. shows lengths in feet and speeds in miles per hour. +card.feet_per_metre = 3.28084 +card.mph_per_metre_per_second = 2.23694 +-- Each stat's row, by the game's picture it shows, in the card's order. +card.icons = { + time = "stopwatch", hits = "wipeout", broken = "wipeout_broken", road_rash = "spread_eagle", + airtime = "airtime", fall = "gap_height", speed = "flaming_wheel", rotations = "roll", +} + +-- 12,345 and -1,234, as skate. shows its numbers. +function card.grouped(value) + value = math.floor(value + 0.5) + local negative = value < 0 + local digits = tostring(math.abs(value)) + local out = digits:reverse():gsub("(%d%d%d)", "%1,"):reverse() + if out:sub(1, 1) == "," then out = out:sub(2) end + return (negative and "-" or "") .. out +end + +-- The card for what is drawn now, against the map's best before the bail (nil: unknown). No card +-- while riding. +function card.make(view, against) + if view.phase == "riding" then return {opacity = 0, rows = {}, total = 0, badge = ""} end + local t = view.tally + local result = {opacity = view.alpha, rows = {}, total = t.score, badge = "", highlight = false} + local function row(shown, stat, value, points) + if shown then table.insert(result.rows, {stat = stat, value = value, points = points}) end + end + local road_rash, fallen = t.scraped * card.feet_per_metre, t.fallen * card.feet_per_metre + local speed = t.top_speed * card.mph_per_metre_per_second + row(true, "time", string.format("%.1f s", t.seconds), t.time_points) + row(t.impacts >= card.hits_shown, "hits", card.grouped(t.impacts) .. (t.impacts == 1 and " hit" or " hits"), + t.hit_points + t.head_bonus + t.vehicle_bonus) + row(t.broken >= card.broken_shown, "broken", card.grouped(t.broken) .. " broken", t.broken * model.points_per_break) + row(road_rash >= card.road_rash_shown_feet, "road_rash", string.format("%.1f ft", road_rash), t.scrape_points) + row(t.airtime >= card.airtime_shown_seconds, "airtime", string.format("%.1f s", t.airtime), t.airtime_points) + row(fallen >= card.fall_shown_feet, "fall", string.format("%.1f ft", fallen), t.fall_points) + row(speed >= card.speed_shown_mph, "speed", string.format("%.1f MPH", speed), t.speed_points) + row(t.rotations >= card.rotations_shown, "rotations", string.format("%.1f rotations", t.rotations), t.rotation_points) + if against and against.new_best then + result.badge, result.highlight = "NEW BEST", true + elseif against and against.best > 0 then + result.badge = "BEST " .. card.grouped(against.best) + end + return result +end + +return card diff --git a/contrib/plugins/hall-of-meat/plugin/draw.lua b/contrib/plugins/hall-of-meat/plugin/draw.lua new file mode 100644 index 00000000..a588696f --- /dev/null +++ b/contrib/plugins/hall-of-meat/plugin/draw.lua @@ -0,0 +1,130 @@ +-- What Hall of Meat draws: from the local skater's bail until they get up, the bones it hurt as an +-- x-ray over the world, the bail's score card in the bottom left corner (where skate.'s own HUD shows, +-- hidden meanwhile with reskate.hud.corner), and the screen's red edge when a bone breaks. Drawn with +-- skate.'s own icons, logo and brush strokes (d:game_image), read from the installed game. +local card = require("card") +local draw = {} + +-- skate.'s colours, as the overlay's theme has them: dark tiles, its blue, white numbers. +local tile, blue, white, good, grey = {26, 26, 26, 240}, {1, 131, 255, 255}, {245, 245, 245, 255}, {46, 184, 92, 255}, {150, 152, 158, 255} +local bruised, broken, dark_red, hot = {255, 196, 36, 255}, {232, 32, 32, 255}, {150, 12, 12, 255}, {255, 255, 255, 255} + +-- "#RRGGBBAA" for a colour at `opacity` of its own alpha. +local function hex(c, opacity) + local a = math.clamp(math.floor(c[4] * math.clamp(opacity or 1, 0, 1) + 0.5), 0, 255) + return string.format("#%02X%02X%02X%02X", c[1], c[2], c[3], a) +end +local function mix(from, to, amount) + amount = math.clamp(amount, 0, 1) + local out = {} + for i = 1, 4 do out[i] = math.floor(from[i] + (to[i] - from[i]) * amount + 0.5) end + return out +end +local function v(x, y) return vector.create(x, y, 0) end + +-- The hurt bodies, painted: bruised ones yellow, broken ones a slowly pulsing red, a fresh hit +-- flashing white-hot; the rest are left out. +function draw.skeleton(d, view, seconds) + local paint, any = {}, false + for name, injury in view.injuries do + if injury == "none" then continue end + local colour = injury == "broken" and mix(dark_red, broken, 0.5 + 0.5 * math.sin(seconds * 6)) or bruised + paint[name] = hex(mix(colour, hot, (view.flashes[name] or 0) * 0.8)) + any = true + end + if any then d:skeleton(paint, math.clamp(view.alpha, 0, 1)) end +end + +-- The screen at `strength` (0 to 1), as a hard hit shows it: darkened a little, the broken bones' red +-- washing in from all four edges, top and bottom deeper than the sides. +function draw.break_pulse(d, strength) + strength = math.clamp(strength, 0, 1) + if strength <= 0 then return end + local size = d:size() + local w, h = size.x, size.y + d:rect(v(0, 0), v(w, h), {colour = hex({0, 0, 0, 255}, 0.3 * strength), fill = true}) + local edge, clear = hex(broken, 0.7 * strength), hex(broken, 0) + local vertical, horizontal = h * 0.3 * strength, w * 0.18 * strength + -- Each band from its edge (the colour) inwards (clear); corners: top left, top right, bottom right, bottom left. + d:gradient(v(0, 0), v(w, vertical), {edge, edge, clear, clear}) + d:gradient(v(0, h - vertical), v(w, h), {clear, clear, edge, edge}) + d:gradient(v(0, 0), v(horizontal, h), {edge, clear, clear, edge}) + d:gradient(v(w - horizontal, 0), v(w, h), {clear, edge, edge, clear}) +end + +-- The card from frame to frame: the total counts up from what it last showed, and a row fades in from +-- when it came. +local motion = {total = 0, at = 0, since = {}} + +-- The score card as skate. 3's Hall of Meat shows it, in the bottom left corner, growing upwards as rows +-- come: a brush bar per stat with its icon, value and points, then a scratched panel with the THRASHER +-- logo, the title in its arch, the total on a blue stroke and the badge. +function draw.card(d, shown, now) + local size = d:size() + if shown.opacity <= 0 or size.x <= 0 or size.y <= 0 then + motion = {total = 0, at = 0, since = {}} + return + end + -- The total catches up with its value in about a quarter of a second. + local step = math.clamp((now - motion.at) / 1000, 0, 0.1) + motion.at = now + motion.total += (shown.total - motion.total) * (1 - math.exp(-step * 12)) + if math.abs(shown.total - motion.total) < 1 then motion.total = shown.total end + + -- From the screen's bottom left corner, in 1080p pixels; as wide as the logo it carries. + local k, o = size.y / 1080, shown.opacity + local left = 64 * k + local right, pad = left + 250 * k, 14 * k + local logo_height, title_size, total_size, badge_size = 72 * k, 22, 56, 18 -- text sizes are at 1080 lines + local logo_step = logo_height * 0.70 + 4 * k -- the wordmark's arch leaves room beneath it + local height = pad * 2 + logo_step + (title_size + total_size) * k + (shown.badge == "" and 0 or (badge_size + 4) * k) + local row_height, row_gap, icon, text = 30 * k, 10 * k, 22 * k, 18 + + -- A bar per stat. A new one opens and fades in, and the card grows upwards by it. + local lines, rows_height = {}, 0 + for _, row in shown.rows do + motion.since[row.stat] = motion.since[row.stat] or now + local opened = math.clamp((now - motion.since[row.stat]) / 300, 0, 1) + table.insert(lines, {row = row, top = rows_height, shown = opened}) + rows_height += (row_height + row_gap) * opened * (2 - opened) -- eases out + end + local y = size.y - 96 * k - height - rows_height + for _, line in lines do line.top += y end + y += rows_height + -- Every bar before any row's content, so no bar's splatter covers another's numbers. + for _, line in lines do + d:game_image("brush_bar", v(left, line.top), v(right, line.top + row_height), {colour = hex(tile, o * line.shown * 0.92), fit = "shape"}) + end + for _, line in lines do + local row, middle = line.row, line.top + row_height / 2 + local x = left + pad + d:game_image(card.icons[row.stat], v(x, middle - icon / 2), v(x + icon, middle + icon / 2), {colour = hex(blue, o * line.shown)}) + x += icon + 10 * k + local style = {colour = hex(white, o * line.shown), size = text, font = "bold"} + d:text(v(x, middle - text * k / 2), row.value, style) + d:text(v(right - pad, middle - text * k / 2), card.grouped(row.points), {colour = style.colour, size = text, font = "bold", align = "right"}) + end + + local edge = 16 * k + d:game_image("rough_tile", v(left, y), v(right, y + height), {colour = hex(tile, o * 0.92), fit = "panel", edge = 16}) + d:game_image("scratches", v(left + edge, y + edge), v(right - edge, y + height - edge), {colour = hex(white, o * 0.5), fit = "shape"}) + local centre = (left + right) / 2 + y += pad + d:game_image("thrasher", v(left + pad, y), v(right - pad, y + logo_height), {colour = hex(blue, o)}) + y += logo_step + d:text(v(centre, y), "Hall of Meat", {colour = hex(white, o), size = title_size, font = "heading", align = "centre"}) + y += title_size * k + local total = card.grouped(motion.total) + -- The blue stroke under the total, about as wide as its digits. + local stroke = #total * total_size * k * 0.3 + 20 * k + d:game_image("streak", v(centre - stroke, y + total_size * k * 0.55), v(centre + stroke, y + total_size * k * 0.95), + {colour = hex(blue, o), fit = "shape"}) + d:text(v(centre, y), total, {colour = hex(white, o), size = total_size, font = "title", align = "centre"}) + y += total_size * k + if shown.badge ~= "" then + y += 4 * k + d:text(v(centre, y), shown.badge, {colour = hex(shown.highlight and good or grey, o), size = badge_size, font = "bold", align = "centre"}) + end +end + +return draw diff --git a/contrib/plugins/hall-of-meat/plugin/main.lua b/contrib/plugins/hall-of-meat/plugin/main.lua new file mode 100644 index 00000000..6c9d91e6 --- /dev/null +++ b/contrib/plugins/hall-of-meat/plugin/main.lua @@ -0,0 +1,151 @@ +-- Hall of Meat, as in skate. 3: when the local skater bails, the bones it hurt show through the +-- skater, yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the +-- bottom left corner; both stay a second after the skater gets up, then fade out. A bone breaking +-- flashes the screen's edges red and, in single player, slows the game down a moment. +-- +-- Each physics step of the local skater (physics_step) feeds the bail (model.lua). The skeleton is +-- skate.'s own skeleton mesh as the renderer draws the skater (reskate.skater.xray, d:skeleton); the +-- card (card.lua, draw.lua) takes the place of skate.'s bottom left HUD while it shows +-- (reskate.hud.corner). Each map's best Meat is kept (reskate.storage). +local model = require("model") +local card = require("card") +local draw = require("draw") + +local on = reskate.storage.get("on") +if type(on) ~= "boolean" then on = true end +local tracker = model.tracker() +local level = "" +local best = nil -- the current map's best Meat; nil while no map is known +local standing = nil -- where the last finished bail that showed stands against the map's best +local last = nil -- the last finished bail's summary +local speed_asked = 1 -- the game speed last asked for +local corner_hidden = false + +local function best_key(name) return "best." .. string.lower(name) end +local function set_level(name) + level = name or "" + best = level ~= "" and (tonumber(reskate.storage.get(best_key(level))) or 0) or nil + standing = nil +end + +-- A bail is over: it stands against the map's best, which a new best replaces. +local function finished(summary) + if not summary then return end + last = summary + standing = summary.shown and best and model.standing(best, summary.tally.score) or nil + if standing and standing.new_best then + best = standing.best + reskate.storage.set(best_key(level), best) + end +end + +local function set_on(value) + if value == on then return end + on = value + reskate.storage.set("on", on) + reskate.skater.xray(on) + reskate.hud.corner(false) + if not on then + tracker:reset() + speed_asked = 1 + reskate.time.slow(1) + end +end + +reskate.on("load", function() + reskate.skater.xray(on) + reskate.hud.corner(false) -- ready to hide it the moment a card shows + local current = reskate.level.current() + if current then set_level(current.name) end +end) +reskate.on("level_loaded", function(name) + finished(tracker:lose(reskate.time.now())) + set_level(name) +end) +reskate.on("level_unloading", function() finished(tracker:lose(reskate.time.now())) end) +-- The skater gone (a respawn, a teleport): a bail ends. +reskate.on("skater_state", function(state) + if state == "none" then finished(tracker:lose(reskate.time.now())) end +end) + +reskate.on("physics_step", function(step) + if not on then return end + finished(tracker:step(step.at, step)) +end) + +reskate.on("tick", function() + if not on then return end + local now = reskate.time.now() + local showing = tracker:view(now).phase ~= "riding" + -- The card takes the place of skate.'s bottom left HUD while it shows: the bail is what counts. + if showing ~= corner_hidden then + corner_hidden = showing + reskate.hud.corner(showing) + end + -- A break slows the game down (never in a multiplayer session: ReSkate refuses it there). + local speed = tracker:game_speed(now) + if math.abs(speed - speed_asked) > 0.005 or (speed >= 1 and speed_asked < 1) then + speed_asked = speed + reskate.time.slow(speed >= 0.999 and 1 or speed) + end +end) + +reskate.on("draw", function(d) + if not on then return end + local now = reskate.time.now() + local view = tracker:view(now) + draw.break_pulse(d, view.break_pulse) + if view.phase == "riding" then + draw.card(d, {opacity = 0, rows = {}, total = 0, badge = ""}, now) + return + end + draw.skeleton(d, view, now / 1000) + -- The card's bail against the map's best before it: while it goes on, as it would stand; once + -- over, as it stood (the best already counts it). + local against + if view.phase == "getting_up" then + against = standing + elseif best then + against = model.standing(best, view.tally.score) + end + draw.card(d, card.make(view, against), now) +end) + +-- ---- the console and the menu ---------------------------------------------------------------- + +local function describe(summary) + if not summary then return "no bail yet" end + local t = summary.tally + return string.format("%s Meat (%d hits, %d broken, %.1f s, %.1f s airtime, fell %.1f m, %.1f m/s top, %.1f rotations, road rash %.1f m)%s", + card.grouped(t.score), t.impacts, t.broken, t.seconds, t.airtime, t.fallen, t.top_speed, t.rotations, t.scraped, + summary.shown and "" or ", hurt nothing so it did not show") +end +local function answer(name) + return function(args) + local function say(text) reskate.console.print(name .. ": " .. text) end + local what = args[1] + if what == "1" or what == "on" then + set_on(true) + elseif what == "0" or what == "off" then + set_on(false) + elseif what == "test" then + local passed, failures = require("tests").run() + for _, failure in failures do say("FAIL " .. failure) end + return say(string.format("%d passed, %d failed", passed, #failures)) + elseif what ~= nil then + return say("say " .. name .. " 0|1, or test") + end + say(string.format("%s; this map's best %s; last bail: %s", on and "on" or "off", best and card.grouped(best) or "unknown", + describe(last))) + end +end +reskate.console.command("hallofmeat", "Your bails show the bones they hurt and score a Meat card: 0|1, test", answer("hallofmeat")) + +reskate.menu.panel("Hall of Meat", function(ui) + set_on(ui:toggle("on", "Hall of Meat", on)) + ui:label("Your bails show the bones they hurt and score a Meat card. A break slows the game in single player. No Bail stops the bails it scores.") + ui:label("This map's best: " .. (best and card.grouped(best) or "no map yet")) + ui:label("Last bail: " .. describe(last)) +end) + +reskate.log("Hall of Meat " .. (on and "on" or "off") .. ": hallofmeat 0|1") diff --git a/contrib/plugins/hall-of-meat/plugin/model.lua b/contrib/plugins/hall-of-meat/plugin/model.lua new file mode 100644 index 00000000..6b11c171 --- /dev/null +++ b/contrib/plugins/hall-of-meat/plugin/model.lua @@ -0,0 +1,391 @@ +-- Hall of Meat, the model: what a bail did to the skater, and what it scores. Fed one physics step at +-- a time (the physics_step event): what each body touched, how the skater moves and whether the game +-- simulates the body as a ragdoll. No game access. +-- +-- A bail follows the skater, not a clock: +-- riding -> bailing the game's wipeout, and nothing else: a fall from height ragdolls in the +-- air before the wipeout of its impact (its flight and its hits of just +-- before carry into the bail), and skate.'s flumps (a dive, a roll along the +-- ground, from any height) are ragdolls the game never wipes out +-- bailing -> down the body has come to rest: the bail's time and points are final +-- bailing, down -> getting_up the ragdoll ends (the skater stands up), or the skater is gone +-- (a respawn, a teleport); the bail is over, stays and fades out +-- Measured in play: the ragdoll begins with the wipeout or in the flight before it, and ends exactly +-- once, when the skater stands up on foot. The bail shows only once it has hurt a bone: a fall that +-- leaves the skater unbruised shows nothing. +-- +-- Two clocks. The bail runs on the game's: its steps' own lengths added up, so in slow motion (a +-- break) the moment the ragdoll has to begin, the rest, the impacts and every stat still measure what +-- the body did. What the player sees runs on the real one (the steps' `at`, reskate.time.now): a +-- hit's flash, a break's effect, the linger and the fade after the bail. +local model = {} + +-- Impact speeds along the contact normal, as measured in play: a fall on flat ground hits the bones +-- it lands on at 3 to 9 m/s and grazes the rest at 2 to 3, a hard landing hits the legs at 8 to 10, +-- a drop from height everything at 13 to 35. So a hard fall on flat ground bruises, and only a drop +-- from height breaks bones. +model.hit_speed = 7.0 +model.broken_speed = 12.0 +-- A wipeout whose ragdoll does not begin within this long was a stumble: its bail ends. +model.ragdoll_wait_ms = 500 +-- The body has come to rest once it moves slower than still_speed for rest_ms: from then on nothing +-- counts. A rest is final: getting up starts inside the ragdoll, moving the body fast again. +model.still_speed = 0.5 +model.rest_ms = 750 +-- Once the skater gets up, the skeleton and the card stay linger_ms, then fade out over fade_ms. +model.linger_ms = 1000 +model.fade_ms = 800 +model.flash_ms = 350 -- a fresh hit flashes this long +-- A bone breaking hits the whole screen: its edges pulse red and the game slows down to +-- break_game_speed (single player only), together, by one effect: easing in over break_rise_ms, held +-- break_hold_ms, easing out over break_fall_ms; in real time, from the step that saw the break. Each +-- break has its own and the strongest counts. +model.break_rise_ms = 150 +model.break_hold_ms = 1000 +model.break_fall_ms = 1000 +model.break_effect_ms = model.break_rise_ms + model.break_hold_ms + model.break_fall_ms +model.break_game_speed = 0.3 +-- A bone's contact lasts several physics steps: hits of one bone closer together than this are one +-- impact, as hard as its hardest step. +model.impact_gap_ms = 200 +model.max_impacts = 64 +-- The game reports the wipeout of an impact a few physics steps after it: a bail takes the flight +-- and the hits of this long before it starts. +model.wipeout_after_impact_ms = 250 + +-- The Meat a bail scores: every hit by how hard it was, and each bone broken a bonus once. The +-- lightest hit (hit_speed) scores 100, a 30 m/s slam about 900. A hit to the head counts double, one +-- from a vehicle half again. +model.points_per_reference_hit = 100.0 +model.hit_points_exponent = 1.5 +model.points_per_break = 1000 +model.head_multiplier = 2.0 +model.vehicle_multiplier = 1.5 +-- Road rash: a body sliding along the ground or a wall, not the board, at least this fast scrapes. +-- The skater's road rash is how far it slid: in each step, how fast its scraping bodies slid on +-- average. A metre scores what four of the lightest hits do. +model.scrape_speed = 1.5 +model.points_per_scraped_metre = 400.0 +model.bruising_scrape = 1.0 -- metres one bone slid: it shows as bruised +-- The rest of the bail scores as skate. 3's Hall of Meat did, each stat its own points (a fall half of it). +model.points_per_second = 600.0 +model.points_per_air_second = 2000.0 +model.points_per_metre_fallen = 125.0 +model.points_per_speed = 130.0 -- per metre per second +model.points_per_rotation = 600.0 +model.radians_per_rotation = 2 * math.pi + +-- The bodies as the physics_step event names them; the board is never a body hit. The ragdoll has no +-- head body of its own: the upper neck, which the head rides on, is shown as the head. +model.bodies = { + "Head", "Neck", "Left hand", "Left forearm", "Left upper arm", "Left collarbone", + "Right hand", "Right forearm", "Right upper arm", "Right collarbone", "Upper chest", "Chest", + "Lower back", "Spine", "Left toes", "Left foot", "Left shin", "Left thigh", + "Right toes", "Right foot", "Right shin", "Right thigh", "Pelvis", +} +local known = {} +for _, name in model.bodies do known[name] = true end +-- The feet touch the ground and the board all the time. +local feet = {["Left toes"] = true, ["Left foot"] = true, ["Right toes"] = true, ["Right foot"] = true} + +local function finite(v) return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge end +local function speed_of(v) return finite(v) and v > 0 and v or 0 end +local function length_ms(seconds) return finite(seconds) and seconds > 0 and seconds * 1000 or 0 end +local function elapsed(now, since) return now > since and now - since or 0 end +local function points(amount, per) return math.floor(amount * per + 0.5) end +local function length(v) return math.sqrt(v[1] * v[1] + v[2] * v[2] + v[3] * v[3]) end + +function model.injury(peak) + if peak >= model.broken_speed then return "broken" end + if peak >= model.hit_speed then return "hit" end + return "none" +end +-- A hit's points by its speed alone. +function model.hit_points(speed) + if not (speed >= model.hit_speed) then return 0 end + return math.floor(model.points_per_reference_hit * (speed / model.hit_speed) ^ model.hit_points_exponent + 0.5) +end +local function head_bonus(impact) + return impact.body == "Head" and math.floor(model.hit_points(impact.speed) * (model.head_multiplier - 1) + 0.5) or 0 +end +local function vehicle_bonus(impact) + return impact.vehicle and math.floor(model.hit_points(impact.speed) * (model.vehicle_multiplier - 1) + 0.5) or 0 +end +function model.impact_points(impact) return model.hit_points(impact.speed) + head_bonus(impact) + vehicle_bonus(impact) end +-- A bail's score against the best on the same map before it. +function model.standing(best, score) + if score > best then return {best = score, new_best = true} end + return {best = best, new_best = false} +end + +local Tracker = {} +Tracker.__index = Tracker + +function model.tracker() + local self = setmetatable({}, Tracker) + self:reset() + return self +end +function Tracker:reset() + self.phase = "riding" -- riding, bailing or down; getting_up is the linger and the fade after a bail's end + self.ragdolled = false + self.hurt = false + self.breaks, self.seen_breaks = 0, 0 + self.breaks_seen_at = {} -- real times of the steps that saw breaks + self.played = 0 -- the game's clock: every step's length added up + self.started = 0 -- the game's clock at the bail's wipeout + self.ended = nil -- real time: when the bail ended + self.still_since = nil + self.stopped_at = nil + self.flight_ms, self.flight_fallen = 0, 0 -- while riding: the last flight, which a bail takes over + self.landed = nil + self.airtime_ms, self.fallen, self.top_speed, self.turned, self.scraped = 0, 0, 0, 0, 0 + self.bones = {} + self.impacts = {} +end +function Tracker:bone(name) + local bone = self.bones[name] + if not bone then + bone = {peak = 0, hit_at = nil, flashed_at = nil, impact = 0, scraped = 0, lead = {}} + self.bones[name] = bone + end + return bone +end + +-- One physics step, at `now` in real time (ms). Returns the bail's summary when this step ended one. +function Tracker:step(now, step) + local step_ms = length_ms(step.seconds) + self.played += step_ms + local flown = step.airborne and step_ms or 0 + if step.airborne then + self.landed = nil + elseif self.landed == nil then + self.landed = self.played + end + local ragdoll = step.ragdoll == true + local v = step.velocity + local descent = v and math.max(0, -(v[2] or 0)) * step_ms / 1000 or 0 + local began = false + if self.phase == "riding" then + if step.airborne then + self.flight_ms += flown + self.flight_fallen += descent + elseif self.played - self.landed > model.wipeout_after_impact_ms then -- landed, and stayed up + self.flight_ms, self.flight_fallen = 0, 0 + end + -- Each body's hardest recent hit, for a bail that starts a few steps after it. + for name, contact in step.bodies or {} do + if not known[name] then continue end + local speed = speed_of(contact.impact) + local lead = self:bone(name).lead + if speed <= 0 or (lead.at and self.played - lead.at <= model.wipeout_after_impact_ms and speed <= lead.speed) then continue end + self:bone(name).lead = {speed = speed, vehicle = contact.vehicle == true, at = self.played} + end + if not step.wipeout then return nil end + self:begin(now) -- the flight it came from holds this step's airtime and fall + began = true + end + self.ragdolled = self.ragdolled or ragdoll + local speed = v and length(v) or nil + self:rest(speed) + if self.phase == "bailing" then + if not began then + self.airtime_ms += flown + self.fallen += descent + end + if speed then self.top_speed = math.max(self.top_speed, speed) end + if step.spin then self.turned += speed_of(step.spin) * step_ms / 1000 end + self:count(now, step, step_ms) + end + -- The skater stood up, or never went down. + local stood_up + if self.ragdolled then stood_up = step.ragdoll == false else stood_up = self.played - self.started >= model.ragdoll_wait_ms end + if self.breaks ~= self.seen_breaks then -- a break this step: its effect runs from now + self.seen_breaks = self.breaks + if #self.breaks_seen_at < #model.bodies then table.insert(self.breaks_seen_at, now) end + end + if stood_up then return self:finish(now) end + return nil +end + +-- Whether the body has come to rest, by how fast it moves: then the bail's time and points are final. +function Tracker:rest(speed) + if self.phase ~= "bailing" or not speed then return end + if speed >= model.still_speed then + self.still_since = nil + elseif not self.still_since then + self.still_since = self.played + elseif self.played - self.still_since >= model.rest_ms then + self.phase = "down" + self.stopped_at = self.still_since -- it has rested since it went still + end +end + +-- What each body hit and how far it slid in one step of the bail. +function Tracker:count(now, step, step_ms) + local seconds = step_ms / 1000 + local sliding, scraping = 0, 0 + for name, contact in step.bodies or {} do + if not known[name] then continue end + local speed = speed_of(contact.impact) + if speed > 0 then self:hit(name, speed, contact.vehicle == true, self.played, now) end + -- Sliding along anything but the board scrapes: the feet ride on it all the time. + local slide = contact.slide + if feet[name] or not (contact.world or contact.vehicle) or not finite(slide) or slide < model.scrape_speed then continue end + local bone = self:bone(name) + bone.scraped += slide * seconds + if bone.scraped >= model.bruising_scrape then self.hurt = true end + sliding += slide + scraping += 1 + end + if scraping > 0 then self.scraped += sliding / scraping * seconds end +end + +-- The skater is gone (a respawn, a teleport, a map change): a bail ends now. Its summary when one did. +function Tracker:lose(now) + -- Nothing from before the skater went carries over to the one that comes back. + self.flight_ms, self.flight_fallen = 0, 0 + self.landed = nil + self.breaks, self.seen_breaks = 0, 0 + self.breaks_seen_at = {} + for _, bone in self.bones do bone.lead = {} end + if self.phase ~= "riding" then return self:finish(now) end + return nil +end + +-- A bail from this step, with the flight it came from and the hits just before it. +function Tracker:begin(now) + local played, flight, fallen, before = self.played, self.flight_ms, self.flight_fallen, self.bones + self:reset() + self.played = played + self.phase = "bailing" + self.started = played + self.airtime_ms = flight -- this step's share is in it + self.fallen = fallen + for name, bone in before do + local lead = bone.lead + if lead.at and played - lead.at <= model.wipeout_after_impact_ms then self:hit(name, lead.speed, lead.vehicle, lead.at, now) end + end +end + +-- A hit of one bone at `at` on the game's clock, shown from `now`: a new impact, or the harder step of +-- the contact it belongs to. +function Tracker:hit(name, speed, vehicle, at, now) + local bone = self:bone(name) + if bone.peak < model.broken_speed and speed >= model.broken_speed then self.breaks += 1 end + bone.peak = math.max(bone.peak, speed) + if speed < model.hit_speed then return end + self.hurt = true + if bone.hit_at and at - bone.hit_at <= model.impact_gap_ms then + local impact = self.impacts[bone.impact] + impact.speed = math.max(impact.speed, speed) + impact.vehicle = impact.vehicle or vehicle + elseif #self.impacts < model.max_impacts then + table.insert(self.impacts, {body = name, speed = speed, vehicle = vehicle}) + bone.impact = #self.impacts + end + bone.hit_at = at + bone.flashed_at = now +end + +function Tracker:finish(now) + self.phase = "riding" + self.ended = now + if not self.stopped_at then self.stopped_at = self.played end + local summary = {shown = self.hurt, peaks = {}, scraped = {}, tally = self:tally()} + for name, bone in self.bones do + summary.peaks[name] = bone.peak + summary.scraped[name] = bone.scraped + end + return summary +end + +-- The bail's time, on the game's clock: until the body came to rest, or the bail ended, or the latest step. +function Tracker:bail_ms() return math.max(0, (self.stopped_at or self.played) - self.started) end + +-- Where the bail is at `now`, whether it hurt a bone (and so shows) or not. +function Tracker:phase_at(now) + if self.phase ~= "riding" then return self.phase end + if self.ended and elapsed(now, self.ended) < model.linger_ms + model.fade_ms then return "getting_up" end + return "riding" +end + +local function injury_of(bone) + local by_hits = model.injury(bone.peak) + if by_hits == "none" and bone.scraped >= model.bruising_scrape then return "hit" end + return by_hits +end + +-- A bail's numbers, so far or in the end. +function Tracker:tally() + local t = {impacts = #self.impacts, hit_points = 0, head_bonus = 0, vehicle_bonus = 0, broken = 0} + for _, impact in self.impacts do + t.hit_points += model.hit_points(impact.speed) + t.head_bonus += head_bonus(impact) + t.vehicle_bonus += vehicle_bonus(impact) + end + for _, bone in self.bones do + if model.injury(bone.peak) == "broken" then t.broken += 1 end + end + t.scraped = self.scraped + t.scrape_points = points(t.scraped, model.points_per_scraped_metre) + t.damage = t.hit_points + t.head_bonus + t.vehicle_bonus + t.scrape_points + t.seconds = self:bail_ms() / 1000 + t.time_points = points(t.seconds, model.points_per_second) + t.airtime = self.airtime_ms / 1000 + t.airtime_points = points(t.airtime, model.points_per_air_second) + t.fallen = self.fallen + t.fall_points = points(t.fallen, model.points_per_metre_fallen) + t.top_speed = self.top_speed + t.speed_points = points(t.top_speed, model.points_per_speed) + t.rotations = self.turned / model.radians_per_rotation + t.rotation_points = points(t.rotations, model.points_per_rotation) + t.score = t.damage + t.broken * model.points_per_break + t.time_points + t.airtime_points + t.fall_points + t.speed_points + + t.rotation_points + return t +end + +-- What is drawn at `now`: riding (nothing) until the bail hurts a bone. +function Tracker:view(now) + local phase = self:phase_at(now) + if phase == "riding" or not self.hurt then return {phase = "riding", alpha = 0, injuries = {}, flashes = {}, break_pulse = 0} end + local since_end = phase == "getting_up" and elapsed(now, self.ended) or 0 + local view = {phase = phase, injuries = {}, flashes = {}, tally = self:tally(), break_pulse = self:break_effect(now)} + view.alpha = since_end > model.linger_ms and 1 - (since_end - model.linger_ms) / model.fade_ms or 1 + for name, bone in self.bones do + view.injuries[name] = injury_of(bone) + if bone.flashed_at then + local since = elapsed(now, bone.flashed_at) + if since < model.flash_ms then view.flashes[name] = 1 - since / model.flash_ms end + end + end + return view +end + +function Tracker:break_effect(now) + local function smooth(part, whole) + local t = part / whole + return t * t * (3 - 2 * t) + end + local strongest = 0 + for _, at in self.breaks_seen_at do + local since = elapsed(now, at) + if since < model.break_effect_ms then + local effect + if since < model.break_rise_ms then + effect = smooth(since, model.break_rise_ms) + elseif since < model.break_rise_ms + model.break_hold_ms then + effect = 1 + else + effect = 1 - smooth(since - model.break_rise_ms - model.break_hold_ms, model.break_fall_ms) + end + strongest = math.max(strongest, effect) + end + end + return strongest +end + +-- How fast the game is to run now: down to break_game_speed as a break hits, else 1. +function Tracker:game_speed(now) return 1 - (1 - model.break_game_speed) * self:break_effect(now) end + +return model diff --git a/contrib/plugins/hall-of-meat/plugin/tests.lua b/contrib/plugins/hall-of-meat/plugin/tests.lua new file mode 100644 index 00000000..7da9482c --- /dev/null +++ b/contrib/plugins/hall-of-meat/plugin/tests.lua @@ -0,0 +1,292 @@ +-- Checks of Hall of Meat's model and card with made-up physics steps, run by `hallofmeat test` (and by +-- dingosdk_plugin_tests, without the game). +local model = require("model") +local card = require("card") + +local checks = {} +local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end +local function near(a, b, within) return math.abs(a - b) < (within or 1e-4) end +local t0 = 100000 + +-- The game stepping its physics at `speed` (1 full speed): each step as long in the game's time as +-- the real time since the one before, times the speed. +local function game(speed) + local g = {tracker = model.tracker(), speed = speed or 1, last = nil} + function g:step(now, step) + local s = table.clone(step) + s.seconds = self.last and now > self.last and (now - self.last) / 1000 * self.speed or 0 + self.last = now + return self.tracker:step(now, s) + end + function g:view(now) return self.tracker:view(now) end + function g:phase(now) return self.tracker:phase_at(now) end + -- Steps `step` from `now` on, every 16 ms, for `ms`; returns the time after the last. + function g:keep(now, ms, step) + local stop = now + ms + while now < stop do + self:step(now, step) + now += 16 + end + return now + end + return g +end +local function lying() return {ragdoll = true, bodies = {}} end +local function standing_up() return {ragdoll = false, bodies = {}} end +local function hit(body, speed, wipeout) + local s = lying() + s.wipeout = wipeout == true + s.bodies[body] = {impact = speed, slide = 0, world = true} + return s +end +local function riding_hit(body, speed) + local s = hit(body, speed) + s.ragdoll = false + return s +end +local function wipeout() return {ragdoll = true, wipeout = true, bodies = {}} end +local function flying(ragdoll) return {airborne = true, ragdoll = ragdoll ~= false, bodies = {}} end +local function slide(body, speed, wipeout) + local s = lying() + s.wipeout = wipeout == true + s.bodies[body] = {impact = 0, slide = speed, world = true} + return s +end +local function moving(speed) + local s = lying() + s.velocity = {speed, 0, 0} + return s +end + +check("impacts count only during a bail", function() + local g = game() + assert(g:step(t0, riding_hit("Head", 9)) == nil and g:view(t0).phase == "riding") + local later = t0 + model.wipeout_after_impact_ms + 16 + g:step(later, wipeout()) + assert(g:phase(later) == "bailing", "a wipeout starts a bail") + assert((g:view(later).injuries["Head"] or "none") == "none", "an impact long before the bail is not counted") +end) +check("the hit that causes the wipeout counts, at its hardest", function() + local g = game() + g:step(t0, riding_hit("Right thigh", 35)) + g:step(t0 + 16, riding_hit("Right thigh", 12)) + g:step(t0 + 32, wipeout()) + local view = g:view(t0 + 32) + assert(view.phase == "bailing" and view.injuries["Right thigh"] == "broken") + assert(view.tally.impacts == 1 and view.tally.hit_points == model.hit_points(35)) +end) +check("nothing shows until a bone is hurt", function() + local g = game() + g:step(t0, wipeout()) + g:step(t0 + 16, hit("Spine", model.hit_speed - 0.5)) + assert(g:phase(t0 + 16) == "bailing" and g:view(t0 + 16).phase == "riding") + g:step(t0 + 32, hit("Spine", model.hit_speed)) + local view = g:view(t0 + 32) + assert(view.phase == "bailing" and near(view.alpha, 1) and view.tally.impacts == 1) + local unhurt = game() + unhurt:step(t0, hit("Spine", model.hit_speed - 0.5, true)) + local summary = unhurt:step(t0 + 16, standing_up()) + assert(summary and not summary.shown and unhurt:view(t0 + 32).phase == "riding") +end) +check("hard hits break, light ones bruise, a fresh hit flashes", function() + local g = game() + g:step(t0, hit("Left forearm", model.hit_speed + 0.1, true)) + g:step(t0 + 16, hit("Head", model.broken_speed + 0.1)) + g:step(t0 + 32, hit("Spine", model.hit_speed - 0.1)) + g:step(t0 + 48, hit("Left forearm", 1)) + local view = g:view(t0 + 48) + assert(view.injuries["Left forearm"] == "hit" and view.injuries["Head"] == "broken" and view.injuries["Spine"] == "none") + assert(view.flashes["Head"] > 0.9 and (g:view(t0 + 16 + model.flash_ms).flashes["Head"] or 0) == 0) +end) +check("a flump starts no bail", function() + local g = game() + local now = g:keep(t0, 1500, flying()) + for i = 1, 60 do + local s = hit(i % 2 == 0 and "Left upper arm" or "Spine", model.broken_speed + 5) + s.velocity = {8, -6, 0} + g:step(now, s) + now += 16 + end + assert(g:view(now).phase == "riding" and g:step(now, standing_up()) == nil) +end) +check("a break pulses the screen edge and slows the game as one", function() + local g = game() + g:step(t0, hit("Left hand", model.hit_speed + 1, true)) + assert(g:view(t0 + 16).break_pulse == 0 and g.tracker:game_speed(t0 + 16) == 1) + g:step(t0 + 16, hit("Left forearm", model.broken_speed + 1)) + local broke = t0 + 16 + assert(near(g:view(broke + model.break_rise_ms / 2).break_pulse, 0.5), "pulses up") + assert(near(g:view(broke + model.break_rise_ms).break_pulse, 1), "to its full strength") + assert(near(g.tracker:game_speed(broke + model.break_rise_ms), model.break_game_speed), "slow") + assert(near(g:view(broke + model.break_rise_ms + model.break_hold_ms + model.break_fall_ms / 2).break_pulse, 0.5), "and fades") + assert(g:view(broke + model.break_effect_ms).break_pulse == 0 and g.tracker:game_speed(broke + model.break_effect_ms) == 1) + g:step(broke + model.break_effect_ms, hit("Left forearm", model.broken_speed + 5)) + assert(g:view(broke + model.break_effect_ms + model.break_rise_ms).break_pulse == 0, "a broken bone hit again does not pulse") +end) +check("the board and bad values are ignored", function() + local g = game() + local s = wipeout() + s.bodies["Board"] = {impact = 20} + s.bodies["Pelvis"] = {impact = 0 / 0} + s.bodies["Lower back"] = {impact = -8} + g:step(t0, s) + for _, injury in g:view(t0).injuries do assert(injury == "none") end +end) +check("a bail lasts until the skater gets up, then lingers and fades", function() + local g = game() + g:step(t0, hit("Pelvis", model.hit_speed + 1, true)) + local now = g:keep(t0 + 16, 3000, hit("Chest", 2)) + now = g:keep(now, 6000, lying()) + g:step(now, hit("Head", model.hit_speed + 1)) + now = g:keep(now + 16, 1000, lying()) + assert(g:view(now).phase == "bailing" and g:view(now).tally.impacts == 2) + local summary = g:step(now, standing_up()) + assert(summary and near(summary.tally.seconds, (now - t0) / 1000, 0.02) and summary.shown) + assert(g:view(now + model.linger_ms - 16).phase == "getting_up" and near(g:view(now + model.linger_ms - 16).alpha, 1)) + assert(near(g:view(now + model.linger_ms + model.fade_ms / 2).alpha, 0.5)) + assert(g:view(now + model.linger_ms + model.fade_ms).phase == "riding") +end) +check("a rest ends the counting", function() + local g = game() + g:step(t0, hit("Pelvis", model.hit_speed + 1, true)) + local now = g:keep(t0 + 16, 1000, moving(4)) + local went_still = now + now = g:keep(now, model.rest_ms - 16, moving(0.2)) + assert(g:phase(now - 16) == "bailing") + now = g:keep(now, 32, moving(0.1)) + assert(g:phase(now) == "down" and near(g:view(now).tally.seconds, (went_still - t0) / 1000, 0.02)) + local rested = g:view(now).tally.score + local getting_up = hit("Left foot", model.hit_speed + 1) + getting_up.velocity = {0, 3, 2} + now = g:keep(now + 3000, 600, getting_up) + assert(g:view(now).tally.score == rested, "getting up adds nothing") +end) +check("a fall from height bails at its wipeout, with the whole flight's airtime", function() + local g = game() + local now = g:keep(t0, 500, flying(false)) + now = g:keep(now, 2000, flying()) + assert(g:phase(now) == "riding") + g:step(now, hit("Head", 20, true)) + assert(g:view(now).phase == "bailing" and g:view(now).tally.impacts == 1) + assert(math.abs(g:view(now).tally.airtime - 2.5) < 0.02) +end) +check("a stumble without a ragdoll ends", function() + local g = game() + local stumble = wipeout() + stumble.ragdoll = false + g:step(t0, stumble) + assert(g:phase(g:keep(t0 + 16, model.ragdoll_wait_ms - 32, standing_up())) == "bailing") + assert(g:step(t0 + model.ragdoll_wait_ms, standing_up()) ~= nil) +end) +check("a lost skater ends the bail", function() + local g = game() + assert(g.tracker:lose(t0) == nil) + g:step(t0, hit("Pelvis", model.hit_speed + 1, true)) + local summary = g.tracker:lose(t0 + 500) + assert(summary and summary.shown and g:view(t0 + 500).phase == "getting_up" and g.tracker:lose(t0 + 516) == nil) +end) +check("one contact is one impact; points grow faster than speed", function() + local g = game() + local light, harder = model.hit_speed + 0.5, model.broken_speed - 1 + g:step(t0, hit("Spine", light, true)) + g:step(t0 + 16, hit("Spine", harder)) + g:step(t0 + 32, hit("Spine", 2)) + local tally = g:view(t0 + 32).tally + assert(tally.impacts == 1 and tally.damage == model.hit_points(harder)) + g:step(t0 + 32 + model.impact_gap_ms + 16, hit("Spine", light)) + tally = g:view(t0 + 32 + model.impact_gap_ms + 16).tally + assert(tally.impacts == 2 and tally.damage == model.hit_points(harder) + model.hit_points(light)) + assert(model.hit_points(model.hit_speed - 0.1) == 0 and model.hit_points(model.hit_speed) == 100 and model.hit_points(30) == 887) +end) +check("the Meat adds up: a hit to the head counts double, a vehicle half again", function() + local g = game() + local breaks, bruises = model.broken_speed + 1, model.hit_speed + 1 + g:step(t0, hit("Head", breaks, true)) + g:step(t0 + 16, hit("Left hand", bruises)) + local tally = g:view(t0 + 16).tally + assert(tally.broken == 1 and tally.impacts == 2 and tally.head_bonus == model.hit_points(breaks)) + assert(tally.score == 2 * model.hit_points(breaks) + model.hit_points(bruises) + model.points_per_break + tally.time_points) + local car = game() + local s = hit("Spine", 10, true) + s.bodies["Spine"].vehicle = true + car:step(t0, s) + car:step(t0 + 16, hit("Spine", 12)) + assert(car:view(t0 + 16).tally.vehicle_bonus == math.floor(model.hit_points(12) * 0.5 + 0.5)) +end) +check("sliding along the ground is road rash, the board and the feet are not", function() + local g = game() + g:step(t0, slide("Pelvis", 5, true)) + local now = t0 + for _ = 1, 50 do + now += 20 + g:step(now, slide("Pelvis", 5)) + end + local view = g:view(now) + assert(math.abs(view.tally.scraped - 5) < 1e-3 and view.injuries["Pelvis"] == "hit" and view.tally.impacts == 0) + local board = game() + local on_board = slide("Spine", 5, true) + on_board.bodies["Spine"] = {impact = 0, slide = 5, board = true} + board:step(t0, on_board) + board:step(t0 + 1000, on_board) + board:step(t0 + 1020, slide("Left foot", 5)) + board:step(t0 + 1040, slide("Spine", model.scrape_speed - 0.1)) + assert(board.tracker:tally().scraped == 0) +end) +check("the fall, the top speed and the turning score", function() + local g = game() + local falling = flying(false) + falling.velocity = {3, -10, 0} + local now = g:keep(t0, 2000, falling) + local impact = hit("Pelvis", model.hit_speed + 2, true) + impact.velocity = {12, -16, 0} + g:step(now, impact) + local rolling = lying() + rolling.velocity = {0, 2, 4} + rolling.spin = 2 * model.radians_per_rotation + now = g:keep(now + 16, 500, rolling) + local t = g:view(now).tally + assert(math.abs(t.fallen - 19.84) < 0.05 and near(t.top_speed, 20, 0.01)) + assert(t.score == t.damage + t.broken * model.points_per_break + t.time_points + t.airtime_points + t.fall_points + t.speed_points + + t.rotation_points) +end) +check("a bail runs on the game's clock", function() + local slow = game(model.break_game_speed) + local thrown = hit("Left shin", model.hit_speed + 1, true) + thrown.ragdoll = false + slow:step(t0, thrown) + local falling = flying(false) + falling.velocity = {0, -10, 0} + local now = slow:keep(t0 + 16, 1000, falling) + assert(slow:phase(now) == "bailing", "the ragdoll still has the game's moment to begin") +end) +check("a best is set only by beating it", function() + assert(model.standing(0, 300).new_best and model.standing(0, 300).best == 300) + assert(not model.standing(500, 300).new_best and not model.standing(500, 500).new_best) +end) +check("the card shows the bail", function() + assert(card.grouped(0) == "0" and card.grouped(999) == "999" and card.grouped(1000) == "1,000" and card.grouped(1234567) == "1,234,567") + assert(card.grouped(-1234) == "-1,234") + local g = game() + assert(card.make(g:view(t0), nil).opacity == 0) + g:step(t0, hit("Head", model.broken_speed + 1, true)) + g:step(t0 + 16, hit("Left hand", model.hit_speed + 1)) + g:step(g:keep(t0 + 32, 1456, lying()) + 12, lying()) + local view = g:view(t0 + 1500) + local shown = card.make(view, model.standing(0, view.tally.score)) + assert(near(shown.opacity, 1) and shown.total == view.tally.score) + assert(#shown.rows == 1 and shown.rows[1].stat == "time" and shown.rows[1].value == "1.5 s") + assert(shown.badge == "NEW BEST" and shown.highlight) + shown = card.make(view, model.standing(99999, view.tally.score)) + assert(shown.badge == "BEST 99,999" and not shown.highlight and card.make(view, nil).badge == "") +end) + +local tests = {} +function tests.run() + local passed, failures = 0, {} + for _, c in checks do + local ok, why = pcall(c.fn) + if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end + end + return passed, failures +end +return tests diff --git a/contrib/plugins/hall-of-meat/reskate-plugin.json b/contrib/plugins/hall-of-meat/reskate-plugin.json new file mode 100644 index 00000000..afbe00e3 --- /dev/null +++ b/contrib/plugins/hall-of-meat/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["events.skater", "events.physics", "overlay", "hud", "game.speed", "storage", "menu", "console"] +}