From c7d2ca3601c66cc5413d5bcd4f71f0e451a0cdb4 Mon Sep 17 00:00:00 2001 From: Krischan-Klug <58807678+Krischan-Klug@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:29:38 +0200 Subject: [PATCH] Add Hall of Meat: the bones a bail hurt and its Meat score As in skate. 3: when the local skater bails, the bones it hurt show over the world through the body, yellow where a hit bruised one and red where one broke it, with the bail's Meat card in the bottom left corner in the place of skate.'s own HUD there: its time, hits (head hits double, vehicles half again), broken bones, road rash, airtime, fall, top speed and rotations, each scoring, and the map's best. A bone breaking pulses the screen's edges red and, in single player, slows the game down a moment. Off until switched on, as in skate.: in skate.'s menu (Custom Stuff > Player) or with `hallofmeat 0|1`, saved with the profile. Everything it draws is skate.'s own, read from the installed game: its skeleton mesh (the Dem Bones costume) posed with the renderer's own skinning matrices, its scoring icons, the THRASHER wordmark and its UI brush strokes. Nothing is shipped. How it works: No Bail already hooks the skeleton response, which runs once a physics step right after the body contacts; it now hands that step on, with the local skater it follows, but only while Hall of Meat is on. Each step reads what every ragdoll body hit and how hard, and whether the skater is a ragdoll; the bail runs on the game's own clock so slow motion measures what the body did. The feature's own hooks, on the renderer's skinned draw packets and main view and on the UI model's write, are prepared at startup and switched on only with the feature, and off again once the HUD corner and the game's speed are given back: switched off, the game never calls them and the physics step pays nothing. The skeleton mesh is read the first time it is on; only the images are read at startup, since the overlay builds its atlas once. The renderer's picture of the skater is read only while a bail shows. The menu's switch reads the overlay model like the others. Every address is in the build table with a fingerprint checked before use. Extension/HallOfMeat/README.md describes the parts; its tests build with DINGOSDK_BUILD_HALL_OF_MEAT_TESTS. The slow motion changes SimulationTime's rate, frame cap and time scale as a session override and steps the local skater along, since skate. sets a skater's step only when it builds its core. It runs the same at every break, but never faster than the game ran: with the trainer's game speed below 1 it only takes the part that is slower still. Afterwards it writes back what it found, each setting only while it still holds what the slow motion wrote, so the trainer's speed comes back as it was; a setting changed from elsewhere meanwhile is left as it is and ends that slow motion. named_setting_value reads a setting's value for it. Tuned in play: a bruise from 7 m/s and a break from 12 m/s along the contact normal (raised twice: a hard fall on flat ground broke bones), a break worth ten of the lightest hits (a heavy bail's hits and breaks outweighed the rest of the card) and a fall half of skate. 3's rate. Fix: the overlay now draws in the back buffer's pixels, not the window's, so it lines up on displays Windows scales (1920x1080 borderless on 4K). The skater's render hooks, its skeleton and its body maps were first found by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate). Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 32 + .../Game/Abi/Test/linear_transform_tests.cpp | 45 ++ Engine/Game/Abi/linear_transform.h | 39 ++ Engine/Game/Build/20260929/hud_corner.h | 41 ++ Engine/Game/Build/20260929/skater_body.h | 91 +++ Engine/Game/Build/20260929/skater_render.h | 79 +++ Engine/Game/Build/20260929/skater_skeleton.h | 29 + Engine/Game/Build/20260929/skater_state.h | 47 ++ Engine/Game/Build/20260929/ui_model.h | 15 + Engine/Game/Build/20260929/ui_textures.h | 71 ++ .../Game/Rendering/Test/draw_packet_tests.cpp | 47 ++ Engine/Game/Rendering/draw_packet.h | 31 + .../Settings/Test/simulation_time_tests.cpp | 26 + Engine/Game/Settings/simulation_time.h | 19 + Engine/Game/Skater/skater_body.h | 56 ++ Engine/Resource/Test/skinned_mesh_tests.cpp | 134 ++++ Engine/Resource/mesh_set.cpp | 145 ++++ Engine/Resource/mesh_set.h | 33 + Engine/Resource/skeleton_asset.cpp | 36 + Engine/Resource/skeleton_asset.h | 14 + Engine/Vfs/Test/game_textures_tests.cpp | 86 +++ Engine/Vfs/game_textures.cpp | 57 ++ Engine/Vfs/game_textures.h | 33 + Extension/Console/commands.cpp | 1 + Extension/Console/commands.h | 1 + Extension/HallOfMeat/README.md | 64 ++ .../Test/hall_of_meat_skeleton_tests.cpp | 109 +++ .../HallOfMeat/Test/hall_of_meat_tests.cpp | 663 ++++++++++++++++++ Extension/HallOfMeat/hall_of_meat.cpp | 231 ++++++ Extension/HallOfMeat/hall_of_meat.h | 61 ++ Extension/HallOfMeat/hall_of_meat_card.cpp | 44 ++ Extension/HallOfMeat/hall_of_meat_card.h | 39 ++ .../HallOfMeat/hall_of_meat_commands.cpp | 15 + Extension/HallOfMeat/hall_of_meat_hud.cpp | 313 +++++++++ Extension/HallOfMeat/hall_of_meat_hud.h | 24 + Extension/HallOfMeat/hall_of_meat_model.cpp | 253 +++++++ Extension/HallOfMeat/hall_of_meat_model.h | 268 +++++++ Extension/HallOfMeat/hall_of_meat_overlay.cpp | 477 +++++++++++++ Extension/HallOfMeat/hall_of_meat_overlay.h | 23 + Extension/HallOfMeat/hall_of_meat_render.cpp | 266 +++++++ Extension/HallOfMeat/hall_of_meat_render.h | 28 + Extension/HallOfMeat/hall_of_meat_skater.cpp | 177 +++++ Extension/HallOfMeat/hall_of_meat_skater.h | 23 + .../HallOfMeat/hall_of_meat_skeleton.cpp | 184 +++++ Extension/HallOfMeat/hall_of_meat_skeleton.h | 60 ++ .../HallOfMeat/hall_of_meat_slow_motion.cpp | 100 +++ .../HallOfMeat/hall_of_meat_slow_motion.h | 74 ++ Extension/Settings/named_settings.cpp | 13 + Extension/Settings/named_settings.h | 3 + Extension/Skater/no_bail.cpp | 23 + Extension/Skater/no_bail.h | 12 + .../Test/native_tools_view_tests.cpp | 12 + Extension/UI/NativeMenu/native_tools_view.h | 3 + Extension/UI/Overlay/overlay.h | 6 + Extension/UI/Overlay/overlay_internal.h | 1 + Extension/UI/Overlay/overlay_render.cpp | 35 +- README.md | 1 + Runtime/client_tick.cpp | 4 + Runtime/runtime_init.cpp | 3 + cmake/Core.cmake | 6 +- cmake/Runtime.cmake | 9 + cmake/UI.cmake | 1 + 62 files changed, 4832 insertions(+), 4 deletions(-) create mode 100644 Engine/Game/Abi/Test/linear_transform_tests.cpp create mode 100644 Engine/Game/Abi/linear_transform.h create mode 100644 Engine/Game/Build/20260929/hud_corner.h create mode 100644 Engine/Game/Build/20260929/skater_body.h create mode 100644 Engine/Game/Build/20260929/skater_render.h create mode 100644 Engine/Game/Build/20260929/skater_skeleton.h create mode 100644 Engine/Game/Build/20260929/skater_state.h create mode 100644 Engine/Game/Build/20260929/ui_model.h create mode 100644 Engine/Game/Build/20260929/ui_textures.h create mode 100644 Engine/Game/Rendering/Test/draw_packet_tests.cpp create mode 100644 Engine/Game/Rendering/draw_packet.h create mode 100644 Engine/Game/Settings/Test/simulation_time_tests.cpp create mode 100644 Engine/Game/Settings/simulation_time.h create mode 100644 Engine/Game/Skater/skater_body.h create mode 100644 Engine/Resource/Test/skinned_mesh_tests.cpp create mode 100644 Engine/Resource/mesh_set.cpp create mode 100644 Engine/Resource/mesh_set.h create mode 100644 Engine/Resource/skeleton_asset.cpp create mode 100644 Engine/Resource/skeleton_asset.h create mode 100644 Engine/Vfs/Test/game_textures_tests.cpp create mode 100644 Engine/Vfs/game_textures.cpp create mode 100644 Engine/Vfs/game_textures.h create mode 100644 Extension/HallOfMeat/README.md create mode 100644 Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp create mode 100644 Extension/HallOfMeat/Test/hall_of_meat_tests.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat.h create mode 100644 Extension/HallOfMeat/hall_of_meat_card.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_card.h create mode 100644 Extension/HallOfMeat/hall_of_meat_commands.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_hud.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_hud.h create mode 100644 Extension/HallOfMeat/hall_of_meat_model.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_model.h create mode 100644 Extension/HallOfMeat/hall_of_meat_overlay.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_overlay.h create mode 100644 Extension/HallOfMeat/hall_of_meat_render.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_render.h create mode 100644 Extension/HallOfMeat/hall_of_meat_skater.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_skater.h create mode 100644 Extension/HallOfMeat/hall_of_meat_skeleton.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_skeleton.h create mode 100644 Extension/HallOfMeat/hall_of_meat_slow_motion.cpp create mode 100644 Extension/HallOfMeat/hall_of_meat_slow_motion.h diff --git a/CMakeLists.txt b/CMakeLists.txt index cabe51e..d7d0791 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,20 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_test(NAME physics_tuning COMMAND dingosdk_physics_tuning_tests "${DINGOSDK_TEST_GAME_ROOT}") add_executable(dingosdk_camera_observer_tests Extension/Skater/Test/camera_observer_tests.cpp) add_test(NAME camera_observer_recovery COMMAND dingosdk_camera_observer_tests) + add_executable(dingosdk_linear_transform_tests Engine/Game/Abi/Test/linear_transform_tests.cpp) + target_include_directories(dingosdk_linear_transform_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME linear_transform COMMAND dingosdk_linear_transform_tests) + add_executable(dingosdk_draw_packet_tests Engine/Game/Rendering/Test/draw_packet_tests.cpp) + target_include_directories(dingosdk_draw_packet_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME draw_packet COMMAND dingosdk_draw_packet_tests) + add_executable(dingosdk_skinned_mesh_tests Engine/Resource/Test/skinned_mesh_tests.cpp) + target_include_directories(dingosdk_skinned_mesh_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_skinned_mesh_tests PRIVATE dingosdk_frostbite) + add_test(NAME skinned_mesh COMMAND dingosdk_skinned_mesh_tests) + add_executable(dingosdk_game_textures_tests Engine/Vfs/Test/game_textures_tests.cpp) + target_include_directories(dingosdk_game_textures_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_game_textures_tests PRIVATE dingosdk_game_archives) + add_test(NAME game_textures COMMAND dingosdk_game_textures_tests "${DINGOSDK_TEST_GAME_ROOT}") add_executable(dingosdk_native_menu_view_tests Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp) add_executable(dingosdk_loading_screen_tests Extension/World/Test/loading_screen_tests.cpp) target_include_directories(dingosdk_loading_screen_tests PRIVATE "${PROJECT_SOURCE_DIR}") @@ -180,6 +194,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_executable(dingosdk_settings_lock_tests Engine/Game/Settings/Test/multiplayer_settings_lock_tests.cpp) target_include_directories(dingosdk_settings_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests) + add_executable(dingosdk_simulation_time_tests Engine/Game/Settings/Test/simulation_time_tests.cpp) + target_include_directories(dingosdk_simulation_time_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME simulation_time COMMAND dingosdk_simulation_time_tests) add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp) add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests) endif() @@ -221,6 +238,21 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives) endif() +option(DINGOSDK_BUILD_HALL_OF_MEAT_TESTS "Build Hall of Meat regression tests" OFF) +if(DINGOSDK_BUILD_HALL_OF_MEAT_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}") +endif() get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS) foreach(target IN LISTS project_targets) if(NOT target MATCHES "^(dingosdk_imgui|dingosdk_detours|dingosdk_sqlite|dingosdk_lz4|dingosdk_zstd|dingosdk_miniz)$") diff --git a/Engine/Game/Abi/Test/linear_transform_tests.cpp b/Engine/Game/Abi/Test/linear_transform_tests.cpp new file mode 100644 index 0000000..267235e --- /dev/null +++ b/Engine/Game/Abi/Test/linear_transform_tests.cpp @@ -0,0 +1,45 @@ +// LinearTransform math: points placed, directions turned, transforms composed. +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +using namespace dingosdk::game; +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; } + +void a_point_is_placed_in_its_transform() { + // Turned 90 degrees about +Z (x -> y), standing at (1, 2, 3). + const LinearTransform transform{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {1, 2, 3, 1}}}; + const auto point = place({2, 0, 1}, transform); + check(near(point[0], 1) && near(point[1], 4) && near(point[2], 4), "offsets turn with the transform and move with it"); + const auto direction = turn({2, 0, 1}, transform); + check(near(direction[0], 0) && near(direction[1], 2) && near(direction[2], 1), "a direction only turns"); +} + +void transforms_compose_inner_first() { + const LinearTransform outer{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {1, 2, 3, 1}}}; + const LinearTransform inner{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 5, 1}}}; // 5 along z + const auto both = compose(inner, outer); + const auto through = place(place({2, 0, 1}, inner), outer), at_once = place({2, 0, 1}, both); + for (std::size_t i = 0; i < 3; ++i) check(near(through[i], at_once[i]), "one transform does what both do"); +} + +void lengths() { + check(near(length({3, 4, 0}), 5.0f) && length({}) == 0.0f && near(length({0.1f, 0, 0}), 0.1f), "lengths"); +} +} + +int main() { + try { + a_point_is_placed_in_its_transform(); + transforms_compose_inner_first(); + lengths(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Linear transform tests passed.\n"; + return 0; +} diff --git a/Engine/Game/Abi/linear_transform.h b/Engine/Game/Abi/linear_transform.h new file mode 100644 index 0000000..97c9ec6 --- /dev/null +++ b/Engine/Game/Abi/linear_transform.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include + +// Frostbite's LinearTransform as the game keeps it in memory: the right, up and forward rows, +// then the translation, four floats each (the fourth lanes are not the matrix's and are never +// read). Points are row vectors: a point given in a transform lands at point x transform. +namespace dingosdk::game { +using Vec3 = std::array; +using LinearTransform = std::array, 4>; + +inline float length(const Vec3& v) noexcept { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } +// Where a point given in a transform lands. +constexpr Vec3 place(const Vec3& point, const LinearTransform& transform) noexcept { + Vec3 result{}; + for (std::size_t i = 0; i < 3; ++i) + result[i] = point[0] * transform[0][i] + point[1] * transform[1][i] + point[2] * transform[2][i] + transform[3][i]; + return result; +} +// Where a direction given in a transform points: turned, not moved. +constexpr Vec3 turn(const Vec3& direction, const LinearTransform& transform) noexcept { + Vec3 result{}; + for (std::size_t i = 0; i < 3; ++i) + result[i] = direction[0] * transform[0][i] + direction[1] * transform[1][i] + direction[2] * transform[2][i]; + return result; +} +// `inner` given in `outer`: the transform a point of `inner` lands in through both. +constexpr LinearTransform compose(const LinearTransform& inner, const LinearTransform& outer) noexcept { + LinearTransform result{}; + for (std::size_t row = 0; row < 3; ++row) { + const auto axis = turn({inner[row][0], inner[row][1], inner[row][2]}, outer); + result[row] = {axis[0], axis[1], axis[2], 0.0f}; + } + const auto position = place({inner[3][0], inner[3][1], inner[3][2]}, outer); + result[3] = {position[0], position[1], position[2], 1.0f}; + return result; +} +} diff --git a/Engine/Game/Build/20260929/hud_corner.h b/Engine/Game/Build/20260929/hud_corner.h new file mode 100644 index 0000000..1e82c49 --- /dev/null +++ b/Engine/Game/Build/20260929/hud_corner.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include + +namespace dingosdk::game::build::v20260929::hud_corner { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// skate.'s HUD in the bottom left corner is HudViewModel's DpadScoringStack (UI/Features/HUD/ +// HudViewModel; PersistentHUDCore's DpadScoring_Stack shows it), a stack view model +// (UI/Foundations/Components/Stack/StackViewModel) of stack item view models. In free roam it holds +// the d-pad menu (HUDDpad_Widget) at priority 1; a scoring line pushes the score HUD +// (ScoringHUD_Widget) at priority 3. HudViewModel has two roots; the d-pad lives in one of them. +// +// What shows, read from the widgets' own logic and measured in play on 2026-10-07: +// - the d-pad hides itself while it is not alone: HUDDpad_Widget's Root_Container shows only while +// the stack's items it counts number exactly 1 (CompareInt A=B, B 1). An item at priority 2 +// counts; one at priority 4 does not (it stays in the stack, active, and changes nothing). +// - the score HUD never hides for the stack: it shows by ScoringHUDViewModel alone (below). +inline constexpr std::uint32_t hud_view_schema = 0xe4c44873; // HudViewModel +inline constexpr std::uint32_t dpad_scoring_stack = 0xeae7b84f; // its DpadScoringStack +inline constexpr std::string_view dpad_widget = "UI/Features/HUD/Dpad/Widgets/HUDDpad_Widget"; +inline constexpr std::int32_t dpad_priority = 1, score_priority = 3; +// StackViewModel +inline constexpr std::uint32_t stack_items = 0x61742cb4; // Items +inline constexpr std::uint32_t default_transition = 0x54c9abee; // DefaultTransitionStyle, which a push gives each item +// StackItemViewModel +inline constexpr std::uint32_t item_key = 0x1e95752c; // uint32 Key: the stack tells its items apart by it +inline constexpr std::uint32_t item_priority = 0x644a8875; // int32 Priority +inline constexpr std::uint32_t item_transition = 0xd78d240b; // Transition +inline constexpr std::uint32_t item_content = 0x716496c8; // Content: a presenter (its blueprint and data) +inline constexpr std::uint32_t content_blueprint = 0x8d1441c7; + +// The score HUD (ui/features/hud/scoring/ScoringHUD_Widget) shows the game's UI model +// ScoringHUDViewModel, one root of the UI model registry, which its own widget logic writes on its +// own thread (expression functions on score events: no native code names the fields). +// HudWidgetActive shows the HUD (its multiplier ring and trick list); the line label shows only +// while ExtraInfoStyle is LineScore (0; 1 ScoreInfo shows the extra info instead, 2 None neither). +inline constexpr std::uint32_t score_view_schema = 0x9b74fc7c; // ScoringHUDViewModel +inline constexpr std::uint32_t hud_widget_active = 0x3aa2f739; // bool HudWidgetActive +inline constexpr std::uint32_t extra_info_style = 0x0426783f; // int32 ScoringWidgetExtraInfoStyle +inline constexpr std::int32_t extra_info_none = 2; +} diff --git a/Engine/Game/Build/20260929/skater_body.h b/Engine/Game/Build/20260929/skater_body.h new file mode 100644 index 0000000..c7da2f5 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_body.h @@ -0,0 +1,91 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" + +namespace dingosdk::game::build::v20260929::skater_body { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// The skater's body (Engine/Game/Skater/skater_body.h), per physics step: how hard each +// body hit something, and where its joint is. Measured in Ghidra on 2026-10-04; the ragdoll's skeleton publisher and its +// body maps were first found by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate). +// +// The physics bone ids are the order of the game's physics bone name map (0xfd6600), one +// config field per name. +// +// The physics step of a skater's core (0x47da350) first processes the rig's body +// contacts (0x4777b60 -> process_body_contacts), then hands the same rig to the +// skeleton response (no_bail.h bail_skeleton_contract). So at the skeleton +// response the contact struct holds this step's values. +inline constexpr Fingerprint contacts_before_skeleton_contract{0x47da3b9, { + 0x48,0x8b,0x8b,0xc0,0x03,0x00,0x00,0x45,0x33,0xc0,0xc5,0xfa,0x10,0x89,0xec,0x17, + 0x00,0x00,0x48,0x8b,0xc8,0xe8,0x8d,0xd7,0xf9,0xff,0x48,0x8b,0x8b,0xb0,0x03,0x00}}; +// rig+0x2f10 -> holder, holder+0x1040 -> the contact struct process_body_contacts +// takes in RCX. +inline constexpr Fingerprint contact_holder_contract{0x4777c75, { + 0x48,0x8b,0xb3,0x10,0x2f,0x00,0x00,0x44,0x8b,0x8a,0x18,0x14,0x00,0x00,0xc5,0xf8, + 0x11,0x45,0xe7,0xc5,0xf8,0x10,0x80,0x40,0x07,0x00,0x00,0x0f,0xb6,0x86,0x95,0x1c}}; +inline constexpr Fingerprint contact_struct_contract{0x4777d64, { + 0x48,0x8b,0x8e,0x40,0x10,0x00,0x00,0x48,0x8d,0x55,0xc7,0xc5,0xf8,0x77,0xe8,0xf9, + 0xaf,0xff,0xff,0x80,0xbe,0x94,0x1c,0x00,0x00,0x00,0x74,0x51,0x4c,0x89,0xb4,0x24}}; +// process_body_contacts (0x4772d70) clears one 0x70-byte record per body bone at +// contact+0x5e0 at the start of every step: 0x18 bones, ids 0..23. +inline constexpr Fingerprint bone_record_reset_contract{0x4772f76, { + 0x48,0x83,0xf9,0x18,0x0f,0x85,0x60,0xff,0xff,0xff,0x4c,0x89,0x9f,0x70,0x03,0x00, + 0x00,0x4c,0x89,0x9f,0x90,0x03,0x00,0x00,0x4c,0x89,0x9f,0xb0,0x03,0x00,0x00,0x4c}}; +// For each contact of a bone id above 0 it keeps the step's largest impact speed +// in that bone's record: |dot(contact normal, other body velocity - bone velocity)|, +// metres per second along the normal (times a mass-ratio factor for a few contact +// kinds). +0x50 holds ordinary contacts; +0x54 the ones near one of two tracked points +// (their bones at contact+0x1060/0x1064: not only feet, the head was one in a bail). +inline constexpr Fingerprint bone_peak_contract{0x47738bf, { + 0x48,0x6b,0xcb,0x70,0xba,0x50,0x00,0x00,0x00,0xb8,0x54,0x00,0x00,0x00,0x48,0x81, + 0xc1,0xe0,0x05,0x00,0x00,0x48,0x03,0xcf,0x41,0x83,0xfc,0x02,0x0f,0x44,0xc2,0x48}}; +// The bone record in full (0x477396f for ordinary contacts, 0x4773936 for those near a tracked +// point), written for a contact of a bone above 0 when it beats the record's peak: the contact +// normal (+0x00, tracked +0x10), the normal crossed with the relative velocity (+0x20, tracked +// +0x30: its length is how fast the bone moves along the surface, which the bails of 2026-10-06 +// confirm), the contact point (+0x40) and the peak (+0x50, tracked +0x54). +0x58/+0x5c keep the +// contact's float +0x44, which stayed 0 in every contact of those bails. +inline constexpr Fingerprint bone_record_fill_contract{0x477396f, { + 0xc5,0xfa,0x11,0x61,0x50,0x8b,0x44,0x24,0x40,0x89,0x01,0xc5,0xfa,0x10,0x44,0x24, + 0x44,0xc5,0xfa,0x11,0x41,0x04,0x48,0x8b,0x44,0x24,0x48,0x48,0x89,0x41,0x08,0xc5}}; +// Then it marks the kind of what the bone hit (the other collidable's object +0x23b0, else the +// contact's own +0x7c): +0x64 kind 4, the board (the feet-on-board test asks for it); +0x60 +// kind 8, vehicles (a bail into a car, 2026-10-06); +0x63 any other kind, the static world +// (ground, walls and a rail in those bails); +0x62 kind 5 and +0x61 kind 11, not met yet. +inline constexpr Fingerprint bone_hit_kinds_contract{0x47739d4, { + 0x08,0x41,0x63,0x44,0x08,0x41,0x60,0x44,0x08,0x49,0x61,0x44,0x08,0x51,0x62,0x41, + 0x83,0xff,0x04,0x0f,0x94,0xc0,0x08,0x41,0x64,0x45,0x84,0xdb,0x0f,0x84,0x9d,0x00}}; +// Per bone besides the records: a byte at contact+0x340 is set while the bone touches something in +// this step, cleared at the start of each step and set by a contact (beside the bone's contact timer +// at contact+0x280, which a contact zeroes). +inline constexpr Fingerprint bone_contact_timer_contract{0x4772ee0, { + 0xc5,0xfa,0x10,0x84,0x8f,0x80,0x02,0x00,0x00,0xc5,0xfa,0x5c,0x4a,0x60,0x48,0x6b, + 0xc1,0x70,0xc5,0xfa,0x11,0x8c,0x8f,0x80,0x02,0x00,0x00,0x44,0x88,0x9c,0x39,0x40}}; +inline constexpr Fingerprint bone_contact_contract{0x4773529, { + 0xc7,0x84,0x9f,0x80,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0xc6,0x84,0x3b,0x40,0x03, + 0x00,0x00,0x01,0xc5,0xfa,0x10,0x84,0x9f,0xe0,0x02,0x00,0x00,0xc4,0xc1,0x78,0x2f}}; +// Everything above, verified once before any of it is read. +inline constexpr std::array contracts{contacts_before_skeleton_contract, contact_holder_contract, + contact_struct_contract, bone_record_reset_contract, bone_peak_contract, bone_record_fill_contract, + bone_hit_kinds_contract, bone_contact_timer_contract, bone_contact_contract}; +// The core's step input (published at core+0x3c8 by core_set_pose_input): +0x20 is the length of the +// skater's physics step in seconds, copied to the context's +0x17ec every update (core_apply_step_input) +// and handed to the contacts and the skeleton. Taken from the simulation rate when the skater spawns +// or is teleported, never after (measured 2026-10-08). +inline constexpr std::uintptr_t core_step_input_offset = 0x3c8; // core +inline constexpr std::uintptr_t step_input_length_offset = 0x20; // step input, float seconds +inline constexpr std::uintptr_t contact_holder_offset = 0x2f10; // rig +inline constexpr std::uintptr_t contact_struct_offset = 0x1040; // holder +inline constexpr std::uintptr_t bone_records_offset = 0x5e0; // contact struct +inline constexpr std::uintptr_t bone_record_size = 0x70; +inline constexpr std::size_t body_bone_count = 0x18; +inline constexpr std::uintptr_t bone_peak_offset = 0x50; // float, ordinary contacts +inline constexpr std::uintptr_t bone_tracked_peak_offset = 0x54; // float, near a tracked point +inline constexpr std::uintptr_t bone_slide_offset = 0x20; // float[4], and its tracked one at +0x30 +inline constexpr std::uintptr_t bone_tracked_offset = 0x10; // from an ordinary field to its tracked one +inline constexpr std::uintptr_t bone_hit_vehicle_offset = 0x60; // bytes +inline constexpr std::uintptr_t bone_hit_kind_11_offset = 0x61; +inline constexpr std::uintptr_t bone_hit_kind_5_offset = 0x62; +inline constexpr std::uintptr_t bone_hit_world_offset = 0x63; +inline constexpr std::uintptr_t bone_hit_board_offset = 0x64; +inline constexpr std::uintptr_t bone_touching_offset = 0x340; // contact struct, byte per bone +} diff --git a/Engine/Game/Build/20260929/skater_render.h b/Engine/Game/Build/20260929/skater_render.h new file mode 100644 index 0000000..349ade1 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_render.h @@ -0,0 +1,79 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +#include +#include + +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 render view and the handle chain were first used by gBGYo's ReSkate fork +// (github.com/gBGYo/ReSkate). +// +// A skinned render object hands its draw packet out through 0x48db8e0(object, packet), packing +// the bones first when they changed (0x1448d2a30): each bone's skinning matrix (vertex in the +// mesh's model space to the world, the skeleton's inverse bind already in it) placed by the +// object's world matrix, three rows of four floats, transposed. The placement leaves the world +// position's whole metres out: the packet's root (float[4]) adds them back, and a relative packet +// is in its actor's frame (the packet's first matrix) besides. Bone 0 keeps its own position. +inline constexpr Fingerprint draw_packet_contract{0x48db8e0, { + 0x4c,0x8b,0xdc,0x55,0x56,0x57,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x48,0x8b,0x41, + 0x28,0x48,0x8b,0xf2,0x48,0x8b,0xf9,0x48,0x8b,0x68,0x40,0x48,0x85,0xed,0x0f,0x84}}; +inline constexpr std::uintptr_t render_object_vtable = 0x65f2910; +inline constexpr std::uintptr_t render_index_offset = 0x108; // render object, uint32 +inline constexpr std::uintptr_t render_object_lock_offset = 0x104; // render object, uint32: set while packing +inline constexpr std::uintptr_t packet_actor_offset = 0x00; // float[16], copied from object+0x50 +inline constexpr std::uintptr_t packet_bones_offset = 0x80; // pointer to float[12] per bone (object+0xe0) +inline constexpr std::uintptr_t packet_bone_count_offset = 0x88; // uint32 (object+0xf8) +inline constexpr std::uintptr_t packet_root_offset = 0x90; // pointer to float[4] (object+0xd8) +inline constexpr std::uintptr_t packet_relative_offset = 0x98; // byte (object+0xfe) +inline constexpr std::uintptr_t packet_visible_offset = 0x99; // byte (object+0xff) + +// Which render object is the local skater's: the skater entity keeps a render handle for riding +// and one for walking (entity+0x4d0, 8 bytes apart; each mode its own render object). A handle +// names a record in a manager's paged pool (handle - 2 is its index; index + 16 has its highest +// bit at page + 4, the pages from pool+8 on), checked in each manager's own lookup: the first +// record keeps the second manager's handle at +0x40, the second the render index + 2 at +0x0c. +inline constexpr std::uintptr_t entity_render_handles_offset = 0x4d0; // skater entity, uint32 x 2 +inline constexpr std::size_t render_modes = 2; +inline constexpr std::uintptr_t entity_render_handle_stride = 8; +inline constexpr std::uint32_t first_handle = 2; +inline constexpr std::uintptr_t manager_pool_offset = 0xc8; +struct RenderManager { + std::uintptr_t global; // the manager's pointer + std::uintptr_t vtable; + std::size_t record_size; + std::uintptr_t handle_offset; // in its record: the next handle +}; +inline constexpr std::array handle_chain{{ + {0x71b78f8, 0x6671138, 0x98, 0x40}, + {0x77ef158, 0x666d4c0, 0x68, 0x0c}, +}}; +// The managers' own lookups: the first's (0x144f0dcf0, its vtable slot 65, passes this+0x10) +// finds its 0x98-byte records in the pool at +0x10+0xb8; the second's (0x144ee23c0, reached from a +// secondary vtable) its 0x68-byte ones. That the second's pool is at +0xc8 as well, gBGYo measured. +inline constexpr Fingerprint first_lookup_contract{0x4f0dd80, { + 0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x48,0x8d,0xb1,0xb8,0x00,0x00,0x00, + 0x48,0x8b,0xd9,0x83,0xc2,0xfe,0xb9,0x3f,0x00,0x00,0x00,0x48,0x83,0xc2,0x10,0x48}}; +inline constexpr Fingerprint second_lookup_contract{0x4ee23c0, { + 0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x4c,0x8d,0x89,0xb8,0xfb,0xff, + 0xff,0x48,0xbb,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0x7f,0x44,0x8d,0x42,0xfe,0xb9}}; + +// The camera of each rendered view: 0x3f8b2b0(blackboard, current view, previous view, jitter) +// copies the 320-byte view (0x143f7e700) for the shaders. In it the camera's world matrix at +// +0x40 (rows right, up, back, position; their fourth lanes not the matrix's) and the vertical +// field of view in radians at +0x104. The main picture's view has kind 0 (uint32), 0x38 bytes +// before it. +inline constexpr Fingerprint render_view_contract{0x3f8b2b0, { + 0x48,0x8b,0xc4,0x53,0x56,0x57,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x81,0xec,0x88, + 0x0c,0x00,0x00,0xc5,0xf8,0x29,0x70,0xb8,0xc5,0xf8,0x29,0x78,0xa8,0xc5,0x78,0x29}}; +inline constexpr std::size_t view_size = 0x140; +inline constexpr std::uintptr_t view_camera_offset = 0x40; // float[16] +inline constexpr std::uintptr_t view_fov_offset = 0x104; // float, radians +inline constexpr std::uintptr_t view_kind_back = 0x38; // uint32, before the view +inline constexpr std::uint32_t main_view_kind = 0; + +inline constexpr std::array contracts{draw_packet_contract, first_lookup_contract, + second_lookup_contract, render_view_contract}; +} diff --git a/Engine/Game/Build/20260929/skater_skeleton.h b/Engine/Game/Build/20260929/skater_skeleton.h new file mode 100644 index 0000000..8410497 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_skeleton.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +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 +// 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. +// +// The render skeleton: 386 bones (Hips 7, Neck1 102, Head 103, the hands with their fingers, the +// face, the board) and how they hang together. +inline constexpr std::string_view skeleton_toc = "Win32/levels/game/bam_levelroot/bam_levelroot.toc"; +inline constexpr std::string_view skeleton_bundle = "win32/levels/game/bam_levelroot/bam_levelroot"; +inline constexpr std::string_view skeleton_asset = "characters/maincharacters/common/animbase/animbase_default_renderskeleton"; +// The costume's skinned mesh, in six levels of detail: 46,083 triangles at the finest, then +// 31,310, 22,031 (twice), 13,817 and 4,605. +inline constexpr std::string_view mesh_toc = "Win32/items.toc"; +inline constexpr std::string_view mesh_bundle = + "win32/characters/maincharacters/generic/cas/clothing/unlicensed/generic/apparel/fullbody/costume/dembones/2026/" + "gen_costume_dembones_complex_dmpreset_cas_main_bundlereftable"; +inline constexpr std::string_view mesh_asset = + "characters/maincharacters/generic/cas/clothing/unlicensed/generic/apparel/fullbody/costume/dembones/2026/" + "gen_costume_dembones_base_mesh"; +// 13,817 triangles on 12,222 vertices: every bone's shape, few enough to pose and draw on the +// CPU every frame. +inline constexpr std::size_t mesh_lod = 4; +} diff --git a/Engine/Game/Build/20260929/skater_state.h b/Engine/Game/Build/20260929/skater_state.h new file mode 100644 index 0000000..3ff12d2 --- /dev/null +++ b/Engine/Game/Build/20260929/skater_state.h @@ -0,0 +1,47 @@ +#pragma once +#include "client_source_spawn.h" +#include +#include + +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 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). +inline constexpr std::uintptr_t physics_state_offset = 0x1414; // uint32, context +inline constexpr std::array board_air_states{200, 201}; // PHYSICS_AIR, KNOWN_AIR + +// The core's trick state (its vtable slot +0xb0 returns core+0x3b8) holds the offboard state +// at +0x78: the skater on foot and in a ragdoll (the script native GetOffboardScoring copies +// it out). Each offboard substate class raises exactly one substate flag of it from its vtable +// (0x1465e8c28 on, each beside its name and id); the three ragdoll ones, FollowRagdoll (3) +// 0x14482d970, FollowAnimatedRagdoll (4) 0x14482d3b0 and FollowSimulatedRagdoll (5) 0x14482db00, +// the same one. In the air is the substate's own +0x150, copied over. Flags are one byte, 0 or 1. +inline constexpr std::uintptr_t trick_state_offset = 0x3b8; // core +inline constexpr std::uintptr_t offboard_state_offset = 0x78; // trick state +inline constexpr std::uintptr_t ragdoll_offset = 0x18c; // offboard state +inline constexpr std::uintptr_t in_the_air_offset = 0x180; // offboard state + +// How fast the skater moves: the linear velocity of its physics bodies, read as the SDK's noclip +// reads and writes them (Extension/Skater/client_noclip.cpp). The board's physics: core+0x430 -> +// +0x18 (vtable board_physics_vtable); the skeleton's: the rig (core+0x438) -> +0x2f10 (vtable +// rig_physics_vtable). Each keeps its bodies at +0x20: their count (uint32) at the start, body n at +// n * 0x130, each pointing back to its owner at +0x10 with its velocity (float[3], m/s) at +0x70 and +// its spin (angular velocity, float[3], radians per second) at +0x90. 0x140fc06b0 syncs a body with +// the physics world: by its pending flags (+0x60) it sets the velocity (8, 0x1425da600) from +0x70 +// and the spin (0x20, 0x1425d8530) from +0x90. +// The board has 9, body 0 its root; the skeleton 26, body n the ragdoll's body n (skater_body.h). +inline constexpr std::uintptr_t board_holder_offset = 0x430; // core +inline constexpr std::uintptr_t board_physics_offset = 0x18; // board holder +inline constexpr std::uintptr_t rig_physics_offset = 0x2f10; // rig +inline constexpr std::uintptr_t physics_bodies_offset = 0x20; // board or rig physics +inline constexpr std::uintptr_t physics_body_size = 0x130; +inline constexpr std::uintptr_t body_owner_offset = 0x10; +inline constexpr std::uintptr_t body_velocity_offset = 0x70; +inline constexpr std::uintptr_t body_spin_offset = 0x90; +inline constexpr std::uint32_t board_body_count = 9, rig_body_count = 26; +inline constexpr std::size_t board_root_body = 0; +inline constexpr std::uintptr_t board_physics_vtable = client_source_spawn::board_physics_vtable; +inline constexpr std::uintptr_t rig_physics_vtable = client_source_spawn::rig_physics_vtable; +} diff --git a/Engine/Game/Build/20260929/ui_model.h b/Engine/Game/Build/20260929/ui_model.h new file mode 100644 index 0000000..5759df4 --- /dev/null +++ b/Engine/Game/Build/20260929/ui_model.h @@ -0,0 +1,15 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include + +namespace dingosdk::game::build::v20260929::ui_model { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// The UI model's write of one value (manager, handle, type, value bytes, flag; true when written), +// found on 2026-10-07. The game's widget logic is data (expression functions: no native code names +// the fields), so its writes land here too. The publish the native menu reader uses (0x1924850, +// which takes the model lock at manager + 0xf20 around it) and the model's other writers call it: +// the array element writer (0x1921e80), the path setter (0x1924570) and the data model setter +// (0x1923480), each under the same lock. +inline constexpr Fingerprint model_write{0x19248b0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x55,0x57, + 0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x8b,0xec,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x4d,0x8b,0xe1,0x49}}; +} diff --git a/Engine/Game/Build/20260929/ui_textures.h b/Engine/Game/Build/20260929/ui_textures.h new file mode 100644 index 0000000..8c09d54 --- /dev/null +++ b/Engine/Game/Build/20260929/ui_textures.h @@ -0,0 +1,71 @@ +#pragma once +#include + +namespace dingosdk::game::build::v20260929::ui_textures { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// Icons, logos and UI shapes skate. ships, read from the installed game's data for the overlay +// (Engine/Vfs/game_textures.h): where each texture is, by superbundle TOC, bundle and name. +// Found with a scan of every texture the game ships (78,566 in 6,056 bundles) on 2026-10-06. + +// A part of a picture, as fractions of its width and height. +struct Region { + float left{}, top{}, right{1}, bottom{1}; +}; +struct Texture { + std::string_view toc, bundle, name; + Region region{}; // the part of the texture that is the picture + // A shape's: the part of the picture a box is laid on (a brush stroke's bar, without its + // splatter), and how much of each edge a panel keeps unstretched at any size (nine-slice), as a + // fraction of the picture's width and height. + Region body{}; + float slice{}; +}; + +// skate.'s scoring icons: white on clear, 64 pixels (the wipeout's broken one 68 x 64). +inline constexpr std::string_view scoring_toc = "Win32/globals.toc"; +inline constexpr std::string_view scoring_bundle = "win32/configurations/gameconfigurations/delmargameconfiguration"; +inline constexpr Texture airtime{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_airtime_64_64_64"}; +inline constexpr Texture wipeout{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_wipeout_64_64_64"}; +inline constexpr Texture wipeout_broken{scoring_toc, scoring_bundle, + "ui/textures/icons/scoring/img_icon_scoring_wipeout_broken_64_68_64"}; +inline constexpr Texture spread_eagle{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_spreadeagle_64_64_64"}; +inline constexpr Texture gap_height{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_gapheight_64_64_64"}; +inline constexpr Texture roll{scoring_toc, scoring_bundle, "ui/textures/icons/scoring/img_icon_scoring_roll_64_64_64"}; + +// The game's level roots, whose bundles hold most of its UI textures. +inline constexpr std::string_view root_toc = "Win32/levels/game/dingolevel_root/dingolevel_root.toc"; +inline constexpr std::string_view root_bundle = "win32/levels/game/dingolevel_root/dingolevel_root"; +inline constexpr std::string_view bam_toc = "Win32/levels/game/bam_levelroot/bam_levelroot.toc"; +inline constexpr std::string_view bam_bundle = "win32/levels/game/bam_levelroot/bam_levelroot"; + +// The stopwatch of the game's timers, white on clear. +inline constexpr Texture stopwatch{root_toc, root_bundle, "ui/textures/icons/generic/img_generic_stopwatch_64_64_64"}; +// The speed line challenge's flaming wheel: white and black on clear. +inline constexpr Texture flaming_wheel{bam_toc, bam_bundle, + "ui/textures/icons/activities/challenges/icon/img_icon_speedlinechallenge_256_128_128"}; + +// The THRASHER wordmark: the THRASHER / SKATEBOARD MAGAZINE logo of the Thrasher tee (512 x 256, grey on +// clear), its wordmark alone (pixels 13 to 498 across, 39 to 192 down; the subtitle is below). Drawn in +// true proportion, unlike the logos squeezed for a garment. Streamed: its bundle keeps only the small mips. +inline constexpr Texture thrasher_wordmark{"Win32/items.toc", + "win32/characters/maincharacters/generic/cas/clothing/licensed/thrasher/apparel/top/shirt/tshirtrelaxed/2023/colorways/" + "thrasher_shirt_tshirtrelaxed_00004_ap_cas_main_bundlereftable", + "characters/materials/logo/licensed/thrasher/logo_thrasher_2x1_011_co", {13.0f / 512, 39.0f / 256, 499.0f / 512, 193.0f / 256}}; +// The wordmark arches: between a fifth in from either side its letters end above this, as a fraction +// of its height (at 55% in the middle, 69% a fifth in), leaving room beneath the arch. +inline constexpr float thrasher_wordmark_arch = 0.70f; + +// skate.'s UI shapes, the stuff its menus are built from: white on clear, tinted as they are drawn. +// A tile with rough, hand-cut edges (256 x 256): about 16 pixels of each edge are rough. +inline constexpr Texture rough_tile{root_toc, root_bundle, "ui/textures/common/tiles/img_tilebackground_roughfull_256_256_256", + {}, {}, 32.0f / 256}; +// A film's scratches and dust, light on black (2048 x 2048, opaque): drawn by their brightness over a panel. +inline constexpr Texture scratches{root_toc, root_bundle, + "ui/textures/common/backgrounds/img_background_slicedgrainytexture01_2048_2048_2048"}; +// A short brush stroke with splatter (692 x 260): the bar is pixels 68 to 610 across, 79 to 184 down. +inline constexpr Texture brush_bar{bam_toc, bam_bundle, "ui/textures/common/banner/img_bannerbrush_small_692_692_260", + {}, {68.0f / 692, 79.0f / 260, 611.0f / 692, 185.0f / 260}}; +// A long brush streak, thick on the left and tapering right (1024 x 152): pixels 8 to 1013 across, 30 to 115 down. +inline constexpr Texture streak{root_toc, root_bundle, "ui/textures/common/brushs/img_brush_splashstreak_1024_1024_152", + {}, {8.0f / 1024, 30.0f / 152, 1014.0f / 1024, 116.0f / 152}}; +} diff --git a/Engine/Game/Rendering/Test/draw_packet_tests.cpp b/Engine/Game/Rendering/Test/draw_packet_tests.cpp new file mode 100644 index 0000000..aff18a7 --- /dev/null +++ b/Engine/Game/Rendering/Test/draw_packet_tests.cpp @@ -0,0 +1,47 @@ +// A draw packet's bones: unpacked and placed in the world. +#include "Engine/Game/Rendering/draw_packet.h" +#include +#include +#include +#include + +using namespace dingosdk; +using namespace dingosdk::draw_packet; +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; } + +void a_packed_bone_unpacks() { + // Turned 90 degrees about +Z (x -> y), at (1, 2, 3): the columns of that matrix, as packed. + const PackedBone packed{0, -1, 0, 1, 1, 0, 0, 2, 0, 0, 1, 3}; + const auto transform = unpack(packed); + const auto point = game::place({1, 0, 0}, transform); + check(near(point[0], 1) && near(point[1], 3) && near(point[2], 3), "the rows come back from the columns"); +} + +void bones_are_placed_by_root_and_actor() { + const PackedBone origin{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0}; + const LinearTransform actor{{{0, 1, 0, 0}, {-1, 0, 0, 0}, {0, 0, 1, 0}, {10, 0, 0, 1}}}; + LinearTransform out{}; + check(skin(origin, placement({100, 200, 300, 0}, false, actor), out) && near(out[3][0], 100) && near(out[3][2], 300), + "the root adds the whole metres back"); + check(skin(origin, placement({1, 0, 0, 0}, true, actor), out), "relative"); + const auto point = game::place({0, 0, 0}, out); + check(near(point[0], 10) && near(point[1], 1), "a relative packet then goes into its actor's transform"); + PackedBone bad = origin; + bad[5] = std::numeric_limits::quiet_NaN(); + check(!skin(bad, placement({}, false, actor), out), "a bone that is not finite is refused"); +} +} + +int main() { + try { + a_packed_bone_unpacks(); + bones_are_placed_by_root_and_actor(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Draw packet tests passed.\n"; + return 0; +} diff --git a/Engine/Game/Rendering/draw_packet.h b/Engine/Game/Rendering/draw_packet.h new file mode 100644 index 0000000..dd063b2 --- /dev/null +++ b/Engine/Game/Rendering/draw_packet.h @@ -0,0 +1,31 @@ +#pragma once +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +// 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. +namespace dingosdk::draw_packet { +using game::LinearTransform; + +// A packed bone: the skinning matrix's three columns of four floats, transposed for the shader. +using PackedBone = std::array; +constexpr LinearTransform unpack(const PackedBone& p) noexcept { + return {{{p[0], p[4], p[8], 0}, {p[1], p[5], p[9], 0}, {p[2], p[6], p[10], 0}, {p[3], p[7], p[11], 1}}}; +} +// Where the packed bones go: the packet's root (the world position's whole metres the packing left +// out) and, for a relative packet, its actor's transform after it. +constexpr LinearTransform placement(const std::array& root, bool relative, const LinearTransform& actor) noexcept { + const LinearTransform at_root{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {root[0], root[1], root[2], 1}}}; + return relative ? game::compose(at_root, actor) : at_root; +} +// The bone's skinning matrix in the world. False for one that is not finite. +inline bool skin(const PackedBone& packed, const LinearTransform& placed, LinearTransform& out) noexcept { + for (const float value : packed) + if (!std::isfinite(value) || std::abs(value) > 1e7f) return false; + out = game::compose(unpack(packed), placed); + return true; +} +} diff --git a/Engine/Game/Settings/Test/simulation_time_tests.cpp b/Engine/Game/Settings/Test/simulation_time_tests.cpp new file mode 100644 index 0000000..cd48380 --- /dev/null +++ b/Engine/Game/Settings/Test/simulation_time_tests.cpp @@ -0,0 +1,26 @@ +// The step length the game keeps for a simulation rate, and the rate back from it. +#include "Engine/Game/Settings/simulation_time.h" +#include +#include +#include +#include + +namespace { +int failures = 0; +void check(bool condition, const char* what) { + if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; } +} +} // namespace + +int main() { + using namespace dingosdk::simulation_time; + check(std::bit_cast(step_seconds(60)) == 0x3c888888, "60 a second: the game's 1/60, from whole nanoseconds"); + check(step_seconds(200) == 0.005f, "200 a second: 5 ms"); + check(step_seconds(0) == 0.0f, "no rate, no step"); + check(rate_of(step_seconds(60)) == 60 && rate_of(step_seconds(200)) == 200 && rate_of(step_seconds(1200)) == 1200, + "the rate back from its step"); + check(rate_of(0.0f) == 0 && rate_of(std::numeric_limits::quiet_NaN()) == 0, "no step, no rate"); + if (failures) return 1; + std::cout << "Simulation time tests passed.\n"; + return 0; +} diff --git a/Engine/Game/Settings/simulation_time.h b/Engine/Game/Settings/simulation_time.h new file mode 100644 index 0000000..4dcc4f7 --- /dev/null +++ b/Engine/Game/Settings/simulation_time.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +// skate.'s simulation clock as plain numbers. It steps its simulation at a fixed rate +// (SimulationTime.ForceSimRate, 60 a second) on a clock its time scale slows (SimulationTime.TimeScale), +// each step as long as the rate gives: whole nanoseconds (1e9 / rate), then seconds (Ghidra +// sim_time_clock_apply_rate; measured 2026-10-09: 60 gives 0x3c888888, 200 gives 0.005). A skater +// takes its physics step length from it when its core is built (Ghidra build_pose_object). +namespace dingosdk::simulation_time { +// The length of one simulation step at `rate`, seconds, exactly as the game keeps it. 0 for no rate. +inline float step_seconds(std::uint32_t rate) noexcept { + return rate ? static_cast(static_cast(1000000000u / rate) * 1e-9) : 0.0f; +} +// The rate a step length was made at; 0 for none. +inline std::uint32_t rate_of(float step_seconds) noexcept { + return std::isfinite(step_seconds) && step_seconds > 0 ? static_cast(std::lround(1.0 / step_seconds)) : 0; +} +} diff --git a/Engine/Game/Skater/skater_body.h b/Engine/Game/Skater/skater_body.h new file mode 100644 index 0000000..e0e363e --- /dev/null +++ b/Engine/Game/Skater/skater_body.h @@ -0,0 +1,56 @@ +#pragma once +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#include + +// 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. +// +// 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 +// game confirms it twice: the contact code's feet-on-board test picks exactly 15, 16, 19 and +// 20, and the skeleton publisher maps each body to the animation joint of its name +// (rig+0x1ae0). Bone 0 is the board's root, never a body hit. The ragdoll has no head body of +// its own: NECK1, the upper neck, is the one the head rides on, so it is shown as the head. +namespace dingosdk::skater_body { +enum class Bone : std::uint8_t { + board_root, neck1, neck, left_hand, left_forearm, left_arm, left_shoulder, + right_hand, right_forearm, right_arm, right_shoulder, spine3, spine2, spine1, spine, + left_toe, left_foot, left_leg, left_upleg, right_toe, right_foot, right_leg, right_upleg, hips, +}; +inline constexpr std::size_t count = 24; +inline constexpr std::array 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"}; +constexpr std::size_t index(Bone bone) noexcept { return static_cast(bone); } +// The feet touch the ground and the board all the time; their contacts do not tell +// whether a body is still tumbling. +constexpr bool foot(Bone bone) noexcept { + return bone == Bone::left_toe || bone == Bone::left_foot || bone == Bone::right_toe || bone == Bone::right_foot; +} + +using game::Vec3; + +// What a body's hardest contact in one physics step hit, by the kind the game gives the other +// side. Kinds 5 and 11 are the game's, still to be named. +struct HitKinds { + bool board{}, vehicle{}, world{}; // world: the ground, walls, rails and the rest of the static world + bool kind_5{}, kind_11{}; +}; +// One body in one physics step: whether it touches something, and its hardest contact. Bodies +// only have contacts while the ragdoll simulates them (a bail): not while riding or walking. +struct BodyContact { + bool touching{}; // a contact in this step + float impact{}; // the hardest contact's speed along its normal, metres per second + Vec3 slide{}; // its normal x relative velocity: the length is the speed along the surface + HitKinds hit; +}; +// The whole body in one physics step. +struct Contacts { + std::array bodies{}; +}; +} diff --git a/Engine/Resource/Test/skinned_mesh_tests.cpp b/Engine/Resource/Test/skinned_mesh_tests.cpp new file mode 100644 index 0000000..a74fc00 --- /dev/null +++ b/Engine/Resource/Test/skinned_mesh_tests.cpp @@ -0,0 +1,134 @@ +// The skinned MeshSet and SkeletonAsset readers, on made-up resources. +#include "Engine/Resource/mesh_set.h" +#include "Engine/Resource/skeleton_asset.h" +#include +#include +#include +#include + +using namespace dingosdk::frostbite; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +template void put(std::vector& data, std::size_t offset, T value) { + if (data.size() < offset + sizeof(T)) data.resize(offset + sizeof(T)); + std::memcpy(data.data() + offset, &value, sizeof(T)); +} +void pointer(std::vector& data, std::size_t offset, std::size_t target) { + put(data, offset, target - 16); +} + +// One level of detail, one section of three vertices on palette {5, 7}, one triangle. +struct Made { + std::vector resource, geometry; +}; +Made made_mesh(std::uint8_t second_weight = 55) { + Made m; + auto& r = m.resource; + r.resize(1100); + const std::array header{208, 188, 384}; + for (std::size_t i = 0; i < header.size(); ++i) put(r, i * 4, header[i]); + put(r, 124, 1); + put(r, 180, 1); + pointer(r, 48, 256); + put(r, 256, 1); // kind + put(r, 256 + 8, 1); // one section + pointer(r, 256 + 12, 512); + put(r, 256 + 84, 33); // 16-bit indices + put(r, 256 + 88, 6); // index bytes + put(r, 256 + 92, 108); // vertex bytes: 3 x 36 + put(r, 256 + 116, 0x42); // the chunk's guid + pointer(r, 512 + 16, 1024); // palette + put(r, 512 + 24, 2); + put(r, 512 + 31, 3); // triangle list + put(r, 512 + 32, 1); + put(r, 512 + 44, 3); + const std::size_t d = 512 + 112; + const std::array, 5> elements{{{1, 3, 0, 0}, {2, 23, 12, 0}, {3, 23, 20, 0}, {4, 13, 28, 0}, {5, 13, 32, 0}}}; + for (std::size_t i = 0; i < elements.size(); ++i) + for (std::size_t j = 0; j < 4; ++j) put(r, d + i * 4 + j, elements[i][j]); + put(r, d + 64, 36); + put(r, d + 96, 5); + put(r, d + 97, 1); + put(r, 1024, 5); + put(r, 1026, 7); + auto& g = m.geometry; + for (std::size_t v = 0; v < 3; ++v) { + const auto at = v * 36; + put>(g, at, {static_cast(v), 0.0f, 0.0f}); + put>(g, at + 12, {0, 1, 0, 0}); + put>(g, at + 20, {0, 0, 0, 0}); + put>(g, at + 28, {200, second_weight, 0, 0}); + put>(g, at + 32, {0, 0, 0, 0}); + } + put>(g, 108, {0, 1, 2}); + return m; +} + +void a_skinned_lod_reads() { + const auto m = made_mesh(); + const auto lod = read_mesh_lod(m.resource, 0); + check(lod.vertex_bytes == 108 && lod.index_bytes == 6 && lod.chunk != Guid{}, "the level's buffers and chunk"); + const auto mesh = read_skinned_mesh(m.resource, 0, m.geometry); + check(mesh.vertices.size() == 3 && mesh.triangles == std::vector{0, 1, 2}, "three vertices, one triangle"); + const auto& v = mesh.vertices[2]; + check(v.position[0] == 2.0f && v.bones[0] == 5 && v.bones[1] == 7 && v.weights[0] == 200 && v.weights[1] == 55, + "positions, and bones through the palette"); +} + +void four_influences_are_enough() { + auto m = made_mesh(); + m.resource[512 + 112 + 96] = std::byte{3}; // only position, the first bones and weights: + const std::array, 3> elements{{{1, 3, 0, 0}, {2, 23, 12, 0}, {4, 13, 28, 0}}}; + for (std::size_t i = 0; i < elements.size(); ++i) + for (std::size_t j = 0; j < 4; ++j) put(m.resource, 512 + 112 + i * 4 + j, elements[i][j]); + const auto mesh = read_skinned_mesh(m.resource, 0, m.geometry); + check(mesh.vertices[1].bones[1] == 7 && mesh.vertices[1].weights[1] == 55 && mesh.vertices[1].weights[4] == 0, + "a coarse level of detail weighs four bones"); +} + +void a_bad_mesh_is_refused() { + const auto broken = made_mesh(54); + bool refused{}; + try { (void)read_skinned_mesh(broken.resource, 0, broken.geometry); } catch (const std::runtime_error&) { refused = true; } + check(refused, "weights that do not add up"); + refused = false; + try { (void)read_mesh_lod(broken.resource, 1); } catch (const std::runtime_error&) { refused = true; } + check(refused, "a level of detail it does not have"); +} + +ebx::Value object(std::vector> fields) { + auto made = std::make_shared(); + for (auto& [name, value] : fields) made->fields.push_back({0, std::move(name), std::move(value)}); + return {made}; +} + +void a_skeleton_reads() { + const auto asset = object({ + {"BoneNames", {ebx::Value::Array{{std::string("Root")}, {std::string("Hand")}}}}, + {"Hierarchy", {ebx::Value::Array{{std::int64_t{-1}}, {std::int64_t{0}}}}}, + }); + const auto skeleton = read_skeleton(*std::get>(asset.data)); + check(skeleton.names == std::vector{"Root", "Hand"} && skeleton.parents == std::vector{-1, 0}, "names and parents"); + const auto backwards = object({ + {"BoneNames", {ebx::Value::Array{{std::string("A")}, {std::string("B")}}}}, + {"Hierarchy", {ebx::Value::Array{{std::int64_t{1}}, {std::int64_t{-1}}}}}, + }); + bool refused{}; + try { (void)read_skeleton(*std::get>(backwards.data)); } catch (const std::runtime_error&) { refused = true; } + check(refused, "a parent after its child"); +} +} + +int main() { + try { + a_skinned_lod_reads(); + four_influences_are_enough(); + a_bad_mesh_is_refused(); + a_skeleton_reads(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Skinned mesh tests passed.\n"; + return 0; +} diff --git a/Engine/Resource/mesh_set.cpp b/Engine/Resource/mesh_set.cpp new file mode 100644 index 0000000..2669e7d --- /dev/null +++ b/Engine/Resource/mesh_set.cpp @@ -0,0 +1,145 @@ +#include "mesh_set.h" +#include +#include +#include +#include + +namespace dingosdk::frostbite { +namespace { +// The resource: three layout words this reader knows, the skinned flag, the levels of detail +// (a table of pointers) and their count. A pointer counts from byte 16. +constexpr std::array known_header{208, 188, 384, 0}; +constexpr std::size_t skinned_flag = 124, lod_count = 180, lod_table = 48, pointer_base = 16; +// A level of detail: its kind (1), its sections, the index format (33: 16-bit indices), the +// buffer sizes and the geometry chunk. +constexpr std::size_t lod_kind = 0, section_count = 8, section_table = 12, index_format = 84, index_bytes = 88, + vertex_bytes = 92, geometry_chunk = 116; +constexpr std::uint32_t known_lod_kind = 1, sixteen_bit_indices = 33; +// A section, 384 bytes: its bone palette, primitive type (3: a triangle list), triangles, first +// index, where its vertices start in the vertex buffer and how many, and its vertex declaration. +constexpr std::size_t section_size = 384, palette = 16, palette_count = 24, primitive = 31, triangle_count = 32, + first_index = 36, vertex_start = 40, vertex_count = 44, declaration = 112; +constexpr std::uint8_t triangle_list = 3; +// The declaration: 16 elements of {usage, format, offset, stream}, then {stride, classification} +// per stream, then the element and stream counts. Four bone influences a vertex, or eight with +// the second pair (the coarser levels of detail leave it out). +constexpr std::size_t stream_table = 64, element_total = 96, stream_total = 97; +enum Usage : std::uint8_t { position = 1, bones_low = 2, bones_high = 3, weights_low = 4, weights_high = 5 }; +constexpr std::array usage_format{0, 3, 23, 23, 13, 13}; // float3, ushort4, ushort4, ubyte4n, ubyte4n +constexpr std::array usage_size{0, 12, 8, 8, 4, 4}; + +void require(bool valid, const char* message) { + if (!valid) throw std::runtime_error(std::string("MeshSet: ") + message); +} +struct Reader { + std::span data; + template T at(std::size_t offset) const { + require(offset <= data.size() && sizeof(T) <= data.size() - offset, "it is truncated"); + T value{}; + std::memcpy(&value, data.data() + offset, sizeof(T)); + return value; + } + std::size_t pointer(std::size_t offset) const { + const auto value = at(offset); + require(value && value <= data.size() - pointer_base, "a pointer leads outside it"); + return static_cast(value) + pointer_base; + } +}; +struct Attribute { + std::size_t base{}, stride{}, offset{}; + bool present{}; +}; +} + +MeshLod read_mesh_lod(std::span resource, std::size_t lod) { + const Reader r{resource}; + for (std::size_t i = 0; i < known_header.size(); ++i) + require(r.at(i * 4) == known_header[i], "its layout is not the one this reader knows"); + require(r.at(skinned_flag) == 1, "it is not skinned"); + require(lod < r.at(lod_count), "it has no such level of detail"); + const auto at = r.pointer(lod_table + lod * 8); + require(r.at(at + lod_kind) == known_lod_kind, "the level of detail is of another kind"); + require(r.at(at + index_format) == sixteen_bit_indices, "its indices are not 16-bit"); + MeshLod result; + result.chunk = r.at(at + geometry_chunk); + result.vertex_bytes = r.at(at + vertex_bytes); + result.index_bytes = r.at(at + index_bytes); + require(result.chunk != Guid{} && result.vertex_bytes > 0 && result.index_bytes > 0, "the level of detail is empty"); + return result; +} + +SkinnedMesh read_skinned_mesh(std::span resource, std::size_t lod, std::span geometry) { + const auto layout = read_mesh_lod(resource, lod); + const Reader r{resource}, g{geometry}; + require(geometry.size() >= std::size_t{layout.vertex_bytes} + layout.index_bytes, "the geometry chunk is too short"); + const auto at = r.pointer(lod_table + lod * 8); + const auto sections = r.at(at + section_count); + const auto table = r.pointer(at + section_table); + SkinnedMesh result; + for (std::uint32_t section = 0; section < sections; ++section) { + const auto s = table + section * section_size; + require(r.at(s + primitive) == triangle_list, "a section is not a triangle list"); + const auto count = r.at(s + vertex_count), triangles = r.at(s + triangle_count); + const auto bones = r.pointer(s + palette); + const auto bone_count = r.at(s + palette_count); + const auto d = s + declaration; + const auto elements = r.at(d + element_total), streams = r.at(d + stream_total); + require(elements <= 16 && streams > 0 && streams <= 16, "a vertex declaration is malformed"); + std::array bases{}, strides{}; + std::size_t end = r.at(s + vertex_start); + for (std::size_t stream = 0; stream < streams; ++stream) { + strides[stream] = r.at(d + stream_table + stream * 2); + require(strides[stream] > 0 && r.at(d + stream_table + stream * 2 + 1) == 0, + "a vertex stream is not per vertex"); + bases[stream] = end; + end += strides[stream] * count; + } + require(end <= layout.vertex_bytes, "a section's vertices run past the vertex buffer"); + std::array attributes{}; + for (std::size_t element = 0; element < elements; ++element) { + const auto usage = r.at(d + element * 4); + if (usage < position || usage > weights_high) continue; + const auto stream = r.at(d + element * 4 + 3); + const std::size_t offset = r.at(d + element * 4 + 2); + require(r.at(d + element * 4 + 1) == usage_format[usage] && stream < streams && + offset + usage_size[usage] <= strides[stream], + "a skinning attribute has another format"); + attributes[usage] = {bases[stream], strides[stream], offset, true}; + } + for (const auto usage : {position, bones_low, weights_low}) require(attributes[usage].present, "a section is not skinned"); + require(attributes[bones_high].present == attributes[weights_high].present, "a section's second influences are half there"); + const std::size_t influences = attributes[bones_high].present ? 8 : 4; + const auto address = [&](std::uint8_t usage, std::size_t vertex) { + const auto& a = attributes[usage]; + return a.base + vertex * a.stride + a.offset; + }; + const auto first = static_cast(result.vertices.size()); + for (std::size_t vertex = 0; vertex < count; ++vertex) { + SkinnedVertex out; + out.position = g.at>(address(position, vertex)); + for (const float coordinate : out.position) require(std::isfinite(coordinate), "a vertex is not finite"); + unsigned total{}; + for (std::size_t influence = 0; influence < influences; ++influence) { + const auto high = influence >= 4; + const auto weight = g.at(address(high ? weights_high : weights_low, vertex) + influence % 4); + if (!weight) continue; // an unused slot's bone need not be set + const auto slot = g.at(address(high ? bones_high : bones_low, vertex) + influence % 4 * 2); + require(slot < bone_count, "a vertex names a bone outside its palette"); + out.bones[influence] = r.at(bones + slot * 2u); + out.weights[influence] = weight; + total += weight; + } + require(total == 255, "a vertex's weights do not add up"); + result.vertices.push_back(out); + } + const std::size_t start = std::size_t{r.at(s + first_index)} * 2; + require(start + std::size_t{triangles} * 6 <= layout.index_bytes, "a section's indices run past the index buffer"); + for (std::size_t i = 0; i < std::size_t{triangles} * 3; ++i) { + const auto index = g.at(layout.vertex_bytes + start + i * 2); + require(index < count, "a triangle names a vertex outside its section"); + result.triangles.push_back(first + index); + } + } + return result; +} +} diff --git a/Engine/Resource/mesh_set.h b/Engine/Resource/mesh_set.h new file mode 100644 index 0000000..f3ec2d8 --- /dev/null +++ b/Engine/Resource/mesh_set.h @@ -0,0 +1,33 @@ +#pragma once +// Frostbite skinned MeshSet resources: one level of detail's triangles, and how each vertex is +// weighted to the skeleton's bones. Only what a skinned mesh needs; the layout was worked out +// on skate.'s costume meshes, first by gBGYo's ReSkate fork (github.com/gBGYo/ReSkate). +#include "binary_io.h" +#include +#include +#include +#include +#include + +namespace dingosdk::frostbite { +struct MeshLod { + Guid chunk; // the geometry chunk: the vertex buffer, then the index buffer + std::uint32_t vertex_bytes{}, index_bytes{}; +}; +// The `lod`-th level of detail (0 the finest) of a MeshSet resource. Throws for a layout this +// reader does not know. +[[nodiscard]] MeshLod read_mesh_lod(std::span resource, std::size_t lod); + +struct SkinnedVertex { + std::array position{}; // model space + std::array bones{}; // skeleton bones + std::array weights{}; // summing to 255; 0 for an unused influence +}; +struct SkinnedMesh { + std::vector vertices; + std::vector triangles; // vertex indices, three a triangle +}; +// That level's sections as one mesh, from its geometry chunk. Throws when they do not fit. +[[nodiscard]] SkinnedMesh read_skinned_mesh(std::span resource, std::size_t lod, + std::span geometry); +} diff --git a/Engine/Resource/skeleton_asset.cpp b/Engine/Resource/skeleton_asset.cpp new file mode 100644 index 0000000..fd7a528 --- /dev/null +++ b/Engine/Resource/skeleton_asset.cpp @@ -0,0 +1,36 @@ +#include "skeleton_asset.h" +#include +#include + +namespace dingosdk::frostbite { +namespace { +void require(bool valid, const char* message) { + if (!valid) throw std::runtime_error(std::string("SkeletonAsset: ") + message); +} +template const T& as(const ebx::Value& value, const char* what) { + const auto* result = std::get_if(&value.data); + require(result != nullptr, what); + return *result; +} +const ebx::Value& field(const ebx::Object& object, std::string_view name) { + const auto* found = object.find(name); + require(found != nullptr, "a field is missing"); + return found->value; +} +} + +Skeleton read_skeleton(const ebx::Object& asset) { + const auto& names = as(field(asset, "BoneNames"), "BoneNames is not a list"); + const auto& hierarchy = as(field(asset, "Hierarchy"), "Hierarchy is not a list"); + const auto count = names.size(); + require(count > 0 && hierarchy.size() == count, "its lists disagree on the bone count"); + Skeleton result; + for (std::size_t bone = 0; bone < count; ++bone) { + result.names.push_back(as(names[bone], "a bone name is not text")); + const auto parent = as(hierarchy[bone], "a parent is not a number"); + require(parent >= -1 && parent < static_cast(bone), "a bone's parent does not come before it"); + result.parents.push_back(static_cast(parent)); + } + return result; +} +} diff --git a/Engine/Resource/skeleton_asset.h b/Engine/Resource/skeleton_asset.h new file mode 100644 index 0000000..8c36640 --- /dev/null +++ b/Engine/Resource/skeleton_asset.h @@ -0,0 +1,14 @@ +#pragma once +// Frostbite SkeletonAssets: a skeleton's bones and how they hang together. +#include "ebx_document.h" +#include +#include + +namespace dingosdk::frostbite { +struct Skeleton { + std::vector names; + std::vector parents; // each bone's, before it; -1 for the root +}; +// `asset` is the SkeletonAsset (an EBX document's root object). Throws when it is not one. +[[nodiscard]] Skeleton read_skeleton(const ebx::Object& asset); +} diff --git a/Engine/Vfs/Test/game_textures_tests.cpp b/Engine/Vfs/Test/game_textures_tests.cpp new file mode 100644 index 0000000..21432e4 --- /dev/null +++ b/Engine/Vfs/Test/game_textures_tests.cpp @@ -0,0 +1,86 @@ +// Game textures read from an installed game: dingosdk_game_textures_tests . +// Without a folder there is nothing to read, and it passes. +#include "Engine/Vfs/game_textures.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/ui_textures.h" +#include +#include +#include + +using namespace dingosdk; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } + +// How much of a region of the image is solid, 0 to 1. +float solid(const frostbite::Image& image, const addr::ui_textures::Region& region) { + const auto left = static_cast(region.left * image.width), right = static_cast(region.right * image.width); + const auto top = static_cast(region.top * image.height), bottom = static_cast(region.bottom * image.height); + std::size_t opaque{}, all{}; + for (auto y = top; y < bottom; ++y) + for (auto x = left; x < right; ++x, ++all) opaque += image.rgba[(std::size_t{y} * image.width + x) * 4 + 3] >= 128; + return all ? static_cast(opaque) / static_cast(all) : 0.0f; +} + +void the_overlays_textures_read(const char* game_root) { + namespace ui = addr::ui_textures; + vfs::GameTextures textures(game_root); + for (const auto& texture : {ui::airtime, ui::wipeout, ui::spread_eagle, ui::gap_height, ui::stopwatch}) { + const auto image = textures.read(texture.toc, texture.bundle, texture.name, 64); + check(image.width == 64 && image.height == 64 && image.rgba.size() == 64 * 64 * 4, "a 64 pixel icon"); + } + const auto broken = textures.read(ui::wipeout_broken.toc, ui::wipeout_broken.bundle, ui::wipeout_broken.name, 64); + check(broken.width == 64 && broken.height == 60, "a wider icon fits the square, its aspect kept"); + const auto wheel = textures.read(ui::flaming_wheel.toc, ui::flaming_wheel.bundle, ui::flaming_wheel.name, 64); + check(wheel.width == 64 && wheel.height == 64, "a larger icon is taken from a smaller mip"); + // Streamed: the full size comes from the TOC's own chunk, not the bundle's small mips. + const auto logo = textures.read(ui::thrasher_wordmark.toc, ui::thrasher_wordmark.bundle, ui::thrasher_wordmark.name, 512); + check(logo.width == 512 && logo.height == 256, "the streamed logo at full size"); + bool opaque{}, clear{}; + for (std::size_t i = 3; i < logo.rgba.size(); i += 4) (logo.rgba[i] ? opaque : clear) = true; + check(opaque && clear, "the logo on a clear background"); + // Beneath the wordmark's arch, between a fifth in from either side, as fractions of the whole logo. + const auto& mark = ui::thrasher_wordmark.region; + const float fifth = (mark.right - mark.left) / 5; + check(solid(logo, {mark.left + fifth, mark.top + ui::thrasher_wordmark_arch * (mark.bottom - mark.top), mark.right - fifth, mark.bottom}) == 0, + "the wordmark's arch is clear beneath"); + bool refused{}; + try { (void)textures.read(ui::airtime.toc, ui::airtime.bundle, "ui/textures/icons/no_such_icon", 64); } + catch (const std::runtime_error&) { refused = true; } + check(refused, "a missing texture is refused"); +} + +void the_ui_shapes_read(const char* game_root) { + namespace ui = addr::ui_textures; + vfs::GameTextures textures(game_root); + const auto read = [&](const ui::Texture& texture, std::uint32_t side) { + return textures.read(texture.toc, texture.bundle, texture.name, side); + }; + const auto bar = read(ui::brush_bar, 512), streak = read(ui::streak, 512); + check(bar.width == 512 && bar.height == 192 && streak.width == 512 && streak.height == 76, "the strokes, their aspect kept"); + check(solid(bar, ui::brush_bar.body) > 0.95f && solid(bar, {}) < 0.6f, "a stroke's body is its solid bar, not its splatter"); + check(solid(streak, ui::streak.body) > 0.15f && solid(streak, {}) < solid(streak, ui::streak.body), + "the streak tapers within its body"); + const auto tile = read(ui::rough_tile, 256); + check(tile.width == 256 && solid(tile, {ui::rough_tile.slice, ui::rough_tile.slice, 1 - ui::rough_tile.slice, + 1 - ui::rough_tile.slice}) > 0.99f, + "the tile is solid inside its rough edges"); + const auto scratches = read(ui::scratches, 512); + std::uint8_t brightest{}; + for (std::size_t i = 0; i < scratches.rgba.size(); i += 4) brightest = std::max(brightest, scratches.rgba[i]); + check(scratches.width == 512 && solid(scratches, {}) > 0.99f && brightest > 64, "light scratches on opaque black"); +} +} + +int main(int argc, char** argv) { + try { + if (argc > 1 && *argv[1]) { + the_overlays_textures_read(argv[1]); + the_ui_shapes_read(argv[1]); + } + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Game texture tests passed.\n"; + return 0; +} diff --git a/Engine/Vfs/game_textures.cpp b/Engine/Vfs/game_textures.cpp new file mode 100644 index 0000000..7bf4285 --- /dev/null +++ b/Engine/Vfs/game_textures.cpp @@ -0,0 +1,57 @@ +#include "game_textures.h" +#include +#include + +namespace dingosdk::vfs { +namespace fb = frostbite; + +GameTextures::GameTextures(std::filesystem::path gameRoot) : data_(std::move(gameRoot)) {} + +const fb::TocDocument& GameTextures::toc(std::string_view name) { + auto found = tocs_.find(name); + if (found == tocs_.end()) found = tocs_.emplace(std::string(name), data_.read_toc(name)).first; + return found->second; +} + +const GameBundle& GameTextures::bundle(std::string_view toc_name, std::string_view name) { + const auto key = std::make_pair(std::string(toc_name), std::string(name)); + auto found = bundles_.find(key); + if (found == bundles_.end()) { + auto read = data_.read_bundle(toc(toc_name), name); + if (!read) throw std::runtime_error("the bundle " + std::string(name) + " is missing"); + found = bundles_.emplace(key, std::move(*read)).first; + } + return found->second; +} + +fb::Image GameTextures::read(std::string_view toc_name, std::string_view bundle_name, std::string_view name, + std::uint32_t side) { + if (!side) throw std::runtime_error("a texture needs a size to fit"); + const auto& in = bundle(toc_name, bundle_name); + std::size_t index{}; + if (!in.find(fb::AssetKind::resource, name, &index) || !in.payload(fb::AssetKind::resource, index)) + throw std::runtime_error("the texture " + std::string(name) + " is missing"); + const auto& resource = in.manifest.resources[index]; + if (resource.resourceType != fb::texture_resource_type) + throw std::runtime_error(std::string(name) + " is not a texture"); + const auto header = fb::read_texture_header(data_.read(*in.payload(fb::AssetKind::resource, index)), resource.resourceMeta); + // A streamed texture's bundle holds only its small mips; the TOC's own chunk holds them all. + std::vector pixels; + const auto& chunks = toc(toc_name).chunks; + const auto whole = std::find_if(chunks.begin(), chunks.end(), + [&](const fb::TocChunk& chunk) { return chunk.guid == header.chunk && !chunk.removed; }); + std::size_t chunk{}; + if (whole != chunks.end()) pixels = data_.read({whole->location, whole->offset, whole->size}); + else if (in.find_chunk(header.chunk, &chunk) && in.payload(fb::AssetKind::chunk, chunk)) + pixels = data_.read(*in.payload(fb::AssetKind::chunk, chunk)); + else throw std::runtime_error("the pixels of " + std::string(name) + " are missing"); + // The smallest mip whose longer side still covers `side`. + const auto longer = std::max(header.width, header.height), shorter = std::min(header.width, header.height); + const auto minimum = longer <= side ? shorter : std::max(1, (side * shorter + longer - 1) / longer); + auto image = fb::decode_texture(header, pixels, minimum); + const auto fitted = std::max(image.width, image.height); + if (fitted <= side) return image; + return fb::resize(image, std::max(1, image.width * side / fitted), + std::max(1, image.height * side / fitted)); +} +} diff --git a/Engine/Vfs/game_textures.h b/Engine/Vfs/game_textures.h new file mode 100644 index 0000000..f4d1d5f --- /dev/null +++ b/Engine/Vfs/game_textures.h @@ -0,0 +1,33 @@ +#pragma once +// Textures out of the installed game's data, decoded for display: icons, logos, anything the +// game ships as a 2D texture. Each superbundle TOC and bundle is read once per reader. +#include "game_bundles.h" +#include "Engine/Resource/texture.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk::vfs { +class GameTextures { +public: + // `gameRoot` is the folder with Skate.exe. + explicit GameTextures(std::filesystem::path gameRoot); + + // The texture `name` in `bundle` of the superbundle `toc`, fitted into a `side` x `side` + // square with its aspect kept: decoded from its smallest mip that still covers it, then + // scaled down. Throws when it is missing or cannot be decoded. + [[nodiscard]] frostbite::Image read(std::string_view toc, std::string_view bundle, std::string_view name, + std::uint32_t side); + +private: + const frostbite::TocDocument& toc(std::string_view name); + const GameBundle& bundle(std::string_view toc, std::string_view name); + + GameData data_; + std::map> tocs_; + std::map, GameBundle> bundles_; +}; +} diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 3a599b6..7103eb5 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -224,6 +224,7 @@ const Commands &game_commands() { register_multiplayer_commands(*result); register_perf_commands(*result); register_trainer_commands(*result); + register_hall_of_meat_commands(*result); return result; }(); return *registry; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index dde960b..1afee71 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -23,6 +23,7 @@ void register_park_editor_commands(Commands &); void register_multiplayer_commands(Commands &); void register_perf_commands(Commands &); void register_trainer_commands(Commands &); +void register_hall_of_meat_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. void request_debug(overlay::DebugAction, bool enabled = false, float value = 0); void request_feature(overlay::OfflineFeatureGroup, bool enabled); diff --git a/Extension/HallOfMeat/README.md b/Extension/HallOfMeat/README.md new file mode 100644 index 0000000..7c1b9ed --- /dev/null +++ b/Extension/HallOfMeat/README.md @@ -0,0 +1,64 @@ +# 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, **Custom Stuff > 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_skeleton_tests.cpp b/Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp new file mode 100644 index 0000000..f48c695 --- /dev/null +++ b/Extension/HallOfMeat/Test/hall_of_meat_skeleton_tests.cpp @@ -0,0 +1,109 @@ +// 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" +#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; } +LinearTransform at(float x, float y, float z) { return {{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {x, y, z, 1}}}; } + +// A root, every body's joint under it, and two bones without a body of their own: the head on the +// upper neck and a finger on the left hand. +frostbite::Skeleton made_skeleton() { + frostbite::Skeleton skeleton; + const auto add = [&](std::string name, int parent) { + skeleton.names.push_back(std::move(name)); + skeleton.parents.push_back(parent); + }; + add("Reference", -1); + for (std::size_t body = 1; body < skater_body::count; ++body) add(std::string(skeleton_joints[body]), 0); + add("Head", 1); // under Neck1 + add("LeftHandIndex1", 3); // under LeftHand + return skeleton; +} +std::uint16_t bone(const frostbite::Skeleton& skeleton, std::string_view name) { + return static_cast(std::find(skeleton.names.begin(), skeleton.names.end(), name) - skeleton.names.begin()); +} +frostbite::SkinnedVertex vertex_on(std::uint16_t first, std::uint8_t weight, std::uint16_t second = 0) { + frostbite::SkinnedVertex vertex; + vertex.position = {0, 1, 0}; + vertex.bones = {first, second}; + vertex.weights = {weight, static_cast(255 - weight)}; + return vertex; +} + +void the_renderers_matrices_pose_it() { + const auto skeleton = made_skeleton(); + const auto head = bone(skeleton, "Head"), finger = bone(skeleton, "LeftHandIndex1"), hand = bone(skeleton, "LeftHand"); + frostbite::SkinnedMesh source; + source.vertices = {vertex_on(head, 255), vertex_on(finger, 153, hand), vertex_on(head, 255)}; + source.triangles = {0, 1, 2}; + const auto mesh = bind_skeleton(skeleton, source); + const auto neck1 = skater_body::index(Bone::neck1), left_hand = skater_body::index(Bone::left_hand); + check(mesh.bone_count == skeleton.names.size(), "a skinning matrix for each bone"); + check(mesh.parts == std::vector{static_cast(neck1), static_cast(left_hand), + static_cast(neck1)}, + "a vertex shows its bone's body: the head the upper neck's, a finger the hand's"); + check(near(mesh.vertices[1].weights[0], 0.6f) && near(mesh.vertices[1].weights[1], 0.4f), "the weights scaled to 1"); + + std::vector skin(mesh.bone_count, at(0, 0, 0)); + skin[head] = at(0, 2, 0); + skin[finger] = at(1, 0, 0); + PosedSkeleton posed; + check(pose_skeleton(mesh, skin, posed), "posed"); + check(near(posed.positions[0][1], 3.0f), "the head where its matrix puts it"); + check(near(posed.positions[1][0], 0.6f) && near(posed.positions[1][1], 1.0f), "a vertex on two bones goes by their weights"); + skin.pop_back(); + check(!pose_skeleton(mesh, skin, posed), "too few matrices are refused"); +} + +void a_vertex_off_the_body_is_refused() { + const auto skeleton = made_skeleton(); + frostbite::SkinnedMesh source; + source.vertices = {vertex_on(bone(skeleton, "Reference"), 255)}; + bool refused{}; + try { (void)bind_skeleton(skeleton, source); } catch (const std::runtime_error&) { refused = true; } + check(refused, "the root is no body's"); + auto renamed = skeleton; + renamed.names[bone(renamed, "Hips")] = "Pelvis"; + refused = false; + try { (void)bind_skeleton(renamed, {}); } catch (const std::runtime_error&) { refused = true; } + check(refused, "every body needs its joint"); +} + +void the_games_skeleton_reads_and_poses(const char* game_root) { + const auto mesh = read_skeleton_mesh(game_root); + check(mesh.vertices.size() == 12222 && mesh.triangles.size() == 13817 * 3, "the level of detail's vertices and triangles"); + for (const auto part : mesh.parts) check(part >= 1 && part < skater_body::count, "every vertex on a body of the skater"); + check(mesh.bone_count == 386, "the render skeleton's bones"); + // Skinning matrices that leave every bone where it is leave the mesh as it was made. + const std::vector unmoved(mesh.bone_count, LinearTransform{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1}}}); + PosedSkeleton posed; + check(pose_skeleton(mesh, unmoved, posed), "posed"); + for (std::size_t i = 0; i < mesh.vertices.size(); ++i) + for (std::size_t axis = 0; axis < 3; ++axis) + check(std::abs(posed.positions[i][axis] - mesh.vertices[i].position[axis]) < 1e-3f, "unmoved bones keep the mesh"); + std::cout << "Read " << mesh.vertices.size() << " vertices, " << mesh.triangles.size() / 3 << " triangles.\n"; +} +} + +int main(int argc, char** argv) { + try { + the_renderers_matrices_pose_it(); + a_vertex_off_the_body_is_refused(); + if (argc > 1 && *argv[1]) the_games_skeleton_reads_and_poses(argv[1]); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "Hall of Meat skeleton tests passed.\n"; + return 0; +} diff --git a/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp b/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp new file mode 100644 index 0000000..444b2b8 --- /dev/null +++ b/Extension/HallOfMeat/Test/hall_of_meat_tests.cpp @@ -0,0 +1,663 @@ +// 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 new file mode 100644 index 0000000..e47e7b2 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat.cpp @@ -0,0 +1,231 @@ +#include "hall_of_meat.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 + (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 new file mode 100644 index 0000000..71a4c64 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat.h @@ -0,0 +1,61 @@ +#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 new file mode 100644 index 0000000..c53a190 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_card.cpp @@ -0,0 +1,44 @@ +#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 new file mode 100644 index 0000000..2f6dc5c --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_card.h @@ -0,0 +1,39 @@ +#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 new file mode 100644 index 0000000..c7d666d --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_commands.cpp @@ -0,0 +1,15 @@ +#include "hall_of_meat.h" +#include "Extension/Console/commands.h" + +// The `hallofmeat` console switch; the menu's (Custom Stuff > 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_hud.cpp b/Extension/HallOfMeat/hall_of_meat_hud.cpp new file mode 100644 index 0000000..46e285b --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_hud.cpp @@ -0,0 +1,313 @@ +#include "hall_of_meat_hud.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/hud_corner.h" +#include "Engine/Game/Build/20260929/ui_model.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::hall_of_meat { +namespace { +namespace corner = addr::hud_corner; +namespace menu_data = multiplayer::menu_data; +using Address = std::uintptr_t; +using Clock = std::chrono::steady_clock; +using Value = std::uint64_t; // a field's value bytes, as the model's write takes them +using ModelWrite = std::uint64_t (*)(Address manager, std::uint64_t handle, Address type, const void* value, std::uint8_t flag); + +// Our stack item: counted beside the d-pad (above its priority, within what the stack counts). +constexpr std::uint32_t cover_key = 0x52534843; // "RSHC": apart from the game's own keys (0x8000....) +constexpr std::uint64_t cover_id = 0x5253484300000000ULL; +constexpr std::int32_t cover_priority = corner::dpad_priority + 1; +static_assert(cover_priority < corner::score_priority); + +// The score HUD's fields that hold it down, their sizes and the values that do. +struct Switch { + std::uint32_t field; + std::size_t size; + Value down; +}; +constexpr std::array score_switches{{ + {corner::hud_widget_active, 1, 0}, + {corner::extra_info_style, 4, static_cast(corner::extra_info_none)}, +}}; + +// A score HUD field as the model write hook sees it, on whichever thread the game writes from. +struct Taken { + std::atomic handle{}; // the field's in the current model; 0 none + std::atomic taken{}; + std::atomic meant{}; // what the game last wrote, to give back +}; +struct Hook { + std::atomic original{}; + void* target{}; + bool ready{}; // prepared + std::atomic hooked{}; // and enabled (hook_hud) + std::array fields; +}; +Hook& hook() { + static auto* value = new Hook; + return *value; +} + +// The game's write of a taken field writes ours instead and keeps the game's. +std::uint64_t model_write(Address manager, std::uint64_t handle, Address type, const void* value, std::uint8_t flag) { + auto& h = hook(); + if (handle && value) { + for (std::size_t index = 0; index < score_switches.size(); ++index) { + auto& field = h.fields[index]; + if (!field.taken.load(std::memory_order_acquire) || handle != field.handle.load(std::memory_order_acquire)) continue; + Value meant{}; + std::memcpy(&meant, value, score_switches[index].size); + field.meant.store(meant, std::memory_order_release); + value = &score_switches[index].down; + break; + } + } + return h.original.load(std::memory_order_acquire)(manager, handle, type, value, flag); +} + +bool start_model_write_hook(Address base) noexcept { + auto& h = hook(); + const auto& contract = addr::ui_model::model_write; + std::array code{}; + auto status = HookNotFound; + if (memory::peek(base + contract.rva, code) && code == contract.bytes) { + auto* target = reinterpret_cast(base + contract.rva); + void* relay{}; + status = hook_prepare(target, reinterpret_cast(&model_write), &relay); + if (status == HookOk && !relay) status = HookUnsupportedFunction; + if (status == HookOk) { + h.original.store(reinterpret_cast(relay), std::memory_order_release); // before the hook is live + h.target = target; + } else (void)hook_remove(target); + } + 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)); + return false; + } + h.ready = true; + return true; +} + +// Client thread only. +struct Corner { + Address base{}; + Address manager{}; + menu_data::Value stack; // the d-pad's stack, once found + menu_data::Value cover; // our stack item, while the corner is hidden + menu_data::Value score_model; // ScoringHUDViewModel, once found + std::array score_offsets{}; + Clock::time_point next_stack_scan, next_score_scan; + bool warned{}, slip_logged{}; +}; +Corner& corner_state() { + static auto* value = new Corner; + return *value; +} + +// The field's handle in the current model (0: none); a new one gives up what was taken, as a new +// model holds the game's own state. +void bind(std::size_t index, std::uint64_t handle) noexcept { + auto& field = hook().fields[index]; + if (field.handle.load(std::memory_order_acquire) == handle) return; + field.taken.store(false, std::memory_order_release); + field.handle.store(handle, std::memory_order_release); +} +bool score_held() noexcept { + const auto& fields = hook().fields; + return std::any_of(fields.begin(), fields.end(), [](const Taken& field) { return field.taken.load(std::memory_order_acquire); }); +} + +// Runs `use(context)` on the game's UI model registry under its model lock, which the game's +// writers take too. False while there is no registry. +template bool with_ui_models(Address base, Use&& use) { + if (!base) return false; + const auto ui = menu_data::read
(base + addr::engine::ui_manager); + const auto manager = ui ? menu_data::read
(ui + 0x140) : 0; + if (!manager) return false; + const menu_data::Context context(base, manager); + game::ModelWriteLock lock(manager); + use(context); + return true; +} + +std::string asset_name(Address asset) { return asset ? menu_data::string(menu_data::read
(asset + 0x18), 256) : std::string{}; } +std::string widget_of(const menu_data::Context& context, const menu_data::Value& item) { + return asset_name(menu_data::read
(context.address(context.path(item, {corner::item_content, corner::content_blueprint})))); +} +menu_data::Value items_of(const menu_data::Context& context, const menu_data::Value& stack) { + return context.field(stack, corner::stack_items); +} +// Whether `next` says it is time to look again (at most once a second while something is not found). +bool time_to_look(Clock::time_point& next) { + const auto now = Clock::now(); + if (now < next) return false; + next = now + std::chrono::seconds(1); + return true; +} + +// A new registry holds the game's own state: our item and what was taken went with the old one. +void follow_registry(Corner& c, const menu_data::Context& context) { + if (c.manager == context.manager) return; + c.manager = context.manager; + c.stack = {}; + c.cover = {}; + c.score_model = {}; + for (std::size_t index = 0; index < score_switches.size(); ++index) bind(index, 0); +} + +// The stack the d-pad is in. +bool find_stack(Corner& c, const menu_data::Context& context) { + if (c.stack.handle && context.type_of(c.stack.handle) == c.stack.type) return true; + c.stack = {}; + if (!time_to_look(c.next_stack_scan)) return false; + for (const auto& root : context.roots({corner::hud_view_schema})) { + const auto stack = context.field(root.model, corner::dpad_scoring_stack); + const auto items = items_of(context, stack); + unsigned count{}, stride{}; + (void)context.array(items, 16, count, stride); + for (unsigned index = 0; index < count; ++index) + if (widget_of(context, context.element(items, index)) == corner::dpad_widget) { + c.stack = stack; + return true; + } + } + return false; +} + +// Puts our item into the d-pad's stack, or takes it out; the game's items stay as they are. Our +// item: empty content, our key, the stack's transition as a push gives it. +void hide_dpad(Corner& c, const menu_data::Context& context, bool hidden) { + const auto field = items_of(context, c.stack); + unsigned count{}, stride{}; + auto items = context.array(field, 16, count, stride); + menu_data::require(!count || stride == menu_data::stack_item.size, "the stack's items differ"); + const auto key_at = context.member(context.type(menu_data::stack_item), corner::item_key).offset; + unsigned ours = count; + for (unsigned index = 0; index < count; ++index) { + std::uint32_t key{}; + std::memcpy(&key, items.data() + std::size_t{index} * stride + key_at, sizeof key); + if (key == cover_key) ours = index; + } + items.resize(std::size_t{count} * menu_data::stack_item.size); + if (hidden && ours == count) { + if (!c.cover.handle) { + c.cover = context.create(menu_data::stack_item, cover_id); + context.set(context.field(c.cover, corner::item_key), cover_key); + context.set(context.field(c.cover, corner::item_priority), cover_priority); + context.copy(context.field(c.cover, corner::item_transition), + context.address(context.field(c.stack, corner::default_transition))); + } + std::vector bytes(menu_data::stack_item.size); + menu_data::require(memory::read_bytes(context.address(c.cover), bytes.data(), bytes.size()), "our stack item is unavailable"); + items.insert(items.end(), bytes.begin(), bytes.end()); + context.array(field, items, count + 1); + } else if (!hidden && ours < count) { + const auto begin = items.begin() + static_cast(std::size_t{ours} * stride); + items.erase(begin, begin + stride); + context.array(field, items, count - 1); + } + if (!hidden && c.cover.handle) { + context.destroy(c.cover); + c.cover = {}; + } +} + +// ScoringHUDViewModel, its switches bound to the model's fields. +bool find_score(Corner& c, const menu_data::Context& context) { + if (c.score_model.handle && context.type_of(c.score_model.handle) == c.score_model.type) return true; + c.score_model = {}; + for (std::size_t index = 0; index < score_switches.size(); ++index) bind(index, 0); + if (!time_to_look(c.next_score_scan)) return false; + const auto roots = context.roots({corner::score_view_schema}); + if (roots.empty()) return false; + const auto model = roots.front().model; + for (std::size_t index = 0; index < score_switches.size(); ++index) { + const auto member = context.member(model.type, score_switches[index].field); + menu_data::require(menu_data::size(member.type) == score_switches[index].size, "a score HUD field's size differs"); + c.score_offsets[index] = member.offset; + bind(index, context.field(model, score_switches[index].field).handle); + } + c.score_model = model; + return true; +} + +// Holds the score HUD down (each switch taken at its value that does), or gives it back. +void hold_score(Corner& c, const menu_data::Context& context, bool held) { + for (std::size_t index = 0; index < score_switches.size(); ++index) { + const auto& sw = score_switches[index]; + auto& field = hook().fields[index]; + Value now{}; + menu_data::require(memory::peek_bytes(context.address(c.score_model) + c.score_offsets[index], &now, sw.size), + "a score HUD field is unavailable"); + if (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."); + 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); + } else if (field.taken.exchange(false, std::memory_order_acq_rel)) { + const auto meant = field.meant.load(std::memory_order_acquire); // what the game wrote while it was held + if (now != meant) context.publish(context.field(c.score_model, sw.field), &meant); + } + } +} +} // namespace + +void start_hud(std::uintptr_t base) noexcept { + corner_state().base = base; + (void)start_model_write_hook(base); // without it the d-pad still hides +} + +bool hook_hud(bool on) noexcept { + auto& h = hook(); + if (!h.ready) return !on; // nothing to hook + 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 ({}).", + on ? "enable" : "disable", hook_status_string(status)); + return false; + } + h.hooked.store(on, std::memory_order_release); + return true; +} + +bool hide_hud(bool hidden) noexcept { + auto& c = corner_state(); + // Without the hook the score HUD is never taken, but what was taken is always given back. + const bool hold = hidden && hook().hooked.load(std::memory_order_acquire); + try { + if (!hidden && !c.cover.handle && !score_held()) return true; // the game's own corner, untouched + with_ui_models(c.base, [&](const menu_data::Context& context) { + follow_registry(c, context); + if (find_stack(c, context)) hide_dpad(c, context, hidden); + if ((hold || score_held()) && find_score(c, context)) hold_score(c, context, hold); + c.warned = false; + }); + } catch (const std::exception& error) { + 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()); + } catch (...) { + c.stack = {}; + c.score_model = {}; + } + return !c.cover.handle && !score_held(); +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_hud.h b/Extension/HallOfMeat/hall_of_meat_hud.h new file mode 100644 index 0000000..a0c6acf --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_hud.h @@ -0,0 +1,24 @@ +#pragma once +#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): +// - 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 +// (Engine/Game/Build/20260929/ui_model.h) writes ours over its HudWidgetActive and +// ExtraInfoStyle and keeps what the game meant, so the game keeps writing them but the widget only +// 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 { +// 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). +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; +} diff --git a/Extension/HallOfMeat/hall_of_meat_model.cpp b/Extension/HallOfMeat/hall_of_meat_model.cpp new file mode 100644 index 0000000..0f1d8a1 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_model.cpp @@ -0,0 +1,253 @@ +#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 new file mode 100644 index 0000000..12423ca --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_model.h @@ -0,0 +1,268 @@ +#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 new file mode 100644 index 0000000..3564fb3 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_overlay.cpp @@ -0,0 +1,477 @@ +#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 new file mode 100644 index 0000000..a3923d0 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_overlay.h @@ -0,0 +1,23 @@ +#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_render.cpp b/Extension/HallOfMeat/hall_of_meat_render.cpp new file mode 100644 index 0000000..eea64a4 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_render.cpp @@ -0,0 +1,266 @@ +#include "hall_of_meat_render.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/skater_render.h" +#include "Engine/Game/Rendering/draw_packet.h" +#include "Engine/Game/UI/game_view.h" +#include "Extension/Skater/no_bail.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::hall_of_meat { +namespace { +namespace build = addr::skater_render; +using DrawPacket = void (*)(std::uintptr_t object, std::uintptr_t packet); +using RenderView = void (*)(std::uintptr_t blackboard, std::uintptr_t current, std::uintptr_t previous, std::uint8_t jitter); +constexpr std::uint32_t no_index = UINT32_MAX; +constexpr std::uint32_t most_bones = 1024; +constexpr std::uint64_t lease_ms = 500; // the client tick's indices, while ticks come and a bail shows +constexpr std::uint64_t fresh_ms = 250; // a picture's parts, while the renderer draws +constexpr float camera_reach = 10.0f; // metres from the client's camera: the main view's, not a mirror's + +struct State { + std::atomic ready{}; // the hooks prepared + bool hooked{}; // and enabled (hook_render): client thread only + std::uintptr_t base{}; + DrawPacket draw_original{}; + RenderView view_original{}; + std::array, build::render_modes> indices{no_index, no_index}; + std::atomic indices_until{}; + std::atomic found_logged{}; + std::mutex lock; // guards the picture's parts + std::vector skin; + std::uint64_t skin_at{}; + std::array camera{}; + float vertical_fov{}; + std::uint64_t camera_at{}; +}; +State& state() { static auto* value = new State; return *value; } + +// A hook must not leave the game a different last error. +struct LastError { + DWORD value = GetLastError(); + ~LastError() { SetLastError(value); } +}; +std::uintptr_t pointer_at(std::uintptr_t address) noexcept { + std::uintptr_t value{}; + return memory::peek(address, value) ? value : 0; +} + +// The record a handle names in a manager's paged pool. +std::uintptr_t record_of(std::uintptr_t manager, const build::RenderManager& kind, std::uint32_t handle) noexcept { + if (handle < build::first_handle || handle > 1'000'000) return 0; + const std::uint64_t slot = std::uint64_t{handle - build::first_handle} + 16; + const auto high = std::bit_width(slot) - 1; // 4 and up + const auto page = pointer_at(manager + build::manager_pool_offset + 8 + 8 * (high - 4)); + return page ? page + static_cast(slot - (std::uint64_t{1} << high)) * kind.record_size : 0; +} +// The render index of the entity's render object for one mode, through the managers' handles. +std::uint32_t render_index(std::uintptr_t base, std::uintptr_t entity, std::size_t mode) noexcept { + std::uint32_t handle{}; + if (!memory::peek(entity + build::entity_render_handles_offset + mode * build::entity_render_handle_stride, handle)) + return no_index; + for (const auto& kind : build::handle_chain) { + const auto manager = pointer_at(base + kind.global); + if (!manager || pointer_at(manager) != base + kind.vtable) return no_index; + const auto record = record_of(manager, kind, handle); + if (!record || !memory::peek(record + kind.handle_offset, handle)) return no_index; + } + return handle >= build::first_handle ? handle - build::first_handle : no_index; +} + +// Render threads, after each skinned draw packet: the local skater's visible one, as skinning +// matrices in the world. +void observe_packet(std::uintptr_t object, std::uintptr_t packet) { + auto& s = state(); + std::uint32_t index{}, count{}, locked{}; + std::uint8_t visible{}, relative{}; + if (GetTickCount64() > s.indices_until.load(std::memory_order_acquire) || + pointer_at(object) != s.base + build::render_object_vtable || !memory::peek(object + build::render_index_offset, index) || + (index != s.indices[0].load(std::memory_order_acquire) && index != s.indices[1].load(std::memory_order_acquire))) + return; + if (!memory::peek(packet + build::packet_visible_offset, visible) || !visible || + !memory::peek(packet + build::packet_bone_count_offset, count) || !count || count > most_bones || + !memory::peek(packet + build::packet_relative_offset, relative) || + !memory::peek(object + build::render_object_lock_offset, locked) || locked) + return; + const auto bones = pointer_at(packet + build::packet_bones_offset), root_at = pointer_at(packet + build::packet_root_offset); + std::vector packed(count); + std::array root{}; + game::LinearTransform actor{}; + if (!bones || !memory::peek_bytes(bones, packed.data(), packed.size() * sizeof(draw_packet::PackedBone)) || + (root_at && !memory::peek(root_at, root)) || !memory::peek(packet + build::packet_actor_offset, actor)) + return; + const auto placed = draw_packet::placement(root, relative != 0, actor); + std::vector skin(count); + for (std::size_t bone = 0; bone < count; ++bone) + if (!draw_packet::skin(packed[bone], placed, skin[bone])) return; + { + std::lock_guard guard(s.lock); + s.skin = std::move(skin); + s.skin_at = GetTickCount64(); + } + 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); +} +void draw_packet_hook(std::uintptr_t object, std::uintptr_t packet) { + auto& s = state(); + s.draw_original(object, packet); + LastError error; + try { + observe_packet(object, packet); + } catch (...) { /* A lost picture is never worth the renderer. */ } +} + +// The camera of the main picture: a sound world matrix, close to the client's own camera. +void observe_view(std::uintptr_t view) { + auto& s = state(); + if (GetTickCount64() > s.indices_until.load(std::memory_order_acquire)) return; + std::uint32_t kind{}; + std::array data{}; + if (!memory::peek(view - build::view_kind_back, kind) || kind != build::main_view_kind || + !memory::peek_bytes(view, data.data(), data.size())) + return; + std::array camera{}; + float radians{}; + std::memcpy(camera.data(), data.data() + build::view_camera_offset, sizeof(camera)); + std::memcpy(&radians, data.data() + build::view_fov_offset, sizeof(radians)); + for (std::size_t row = 0; row < 4; ++row) { + for (std::size_t axis = 0; axis < 3; ++axis) + if (!std::isfinite(camera[row * 4 + axis]) || std::abs(camera[row * 4 + axis]) > 1e7f) return; + camera[row * 4 + 3] = row == 3 ? 1.0f : 0.0f; + } + for (std::size_t row = 0; row < 3; ++row) + for (std::size_t other = row; other < 3; ++other) { + float dot{}; + for (std::size_t axis = 0; axis < 3; ++axis) dot += camera[row * 4 + axis] * camera[other * 4 + axis]; + if (std::abs(dot - (row == other ? 1.0f : 0.0f)) > 0.05f) return; + } + const float fov = radians * 180.0f / std::numbers::pi_v; + const auto client = latest_game_view(); + if (!std::isfinite(fov) || fov <= 1 || fov >= 175 || !client) return; + float distance{}; + for (std::size_t axis = 0; axis < 3; ++axis) { + const float delta = camera[12 + axis] - client->world[12 + axis]; + distance += delta * delta; + } + if (!(distance <= camera_reach * camera_reach)) return; + std::lock_guard guard(s.lock); + s.camera = camera; + s.vertical_fov = fov; + s.camera_at = GetTickCount64(); +} +void render_view_hook(std::uintptr_t blackboard, std::uintptr_t current, std::uintptr_t previous, std::uint8_t jitter) { + auto& s = state(); + s.view_original(blackboard, current, previous, jitter); + LastError error; + try { + observe_view(current); + } catch (...) { /* A lost camera is never worth the renderer. */ } +} +std::array render_targets(std::uintptr_t base) noexcept { + return {reinterpret_cast(base + build::draw_packet_contract.rva), + reinterpret_cast(base + build::render_view_contract.rva)}; +} +} + +bool start_render(std::uintptr_t base) noexcept { + auto& s = state(); + if (s.ready.load(std::memory_order_acquire)) return s.base == base; + for (const auto& contract : build::contracts) { + 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); + return false; + } + } + s.base = base; + const auto targets = render_targets(base); + const std::array replacements{reinterpret_cast(&draw_packet_hook), reinterpret_cast(&render_view_hook)}; + std::array originals{}; + auto status = HookOk; + std::size_t prepared{}; + for (; prepared < targets.size(); ++prepared) { + status = hook_prepare(targets[prepared], replacements[prepared], &originals[prepared]); + if (status != HookOk) break; + if (!originals[prepared]) { + ++prepared; + status = HookUnsupportedFunction; + break; + } + } + if (status == HookOk) { + // Both relays are published before either target is enabled (hook_render). + s.draw_original = reinterpret_cast(originals[0]); + s.view_original = reinterpret_cast(originals[1]); + s.ready.store(true, std::memory_order_release); + 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)); + while (prepared) (void)hook_remove(targets[--prepared]); + return false; +} + +bool hook_render(bool on) noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return !on; // nothing to hook + if (s.hooked == on) return true; + const auto targets = render_targets(s.base); + auto status = HookOk; + std::size_t changed{}; + for (; changed < targets.size(); ++changed) { + status = on ? hook_enable(targets[changed]) : hook_disable(targets[changed]); + if (status != HookOk) break; + } + if (status == HookOk) { + s.hooked = on; + return true; + } + logging::log(logging::Level::warning, logging::Channel::graphics, "Hall of Meat could not {} its renderer hooks (status {}).", + on ? "enable" : "disable", static_cast(status)); + // Both or neither. + while (changed) { + const auto target = targets[--changed]; + (void)(on ? hook_disable(target) : hook_enable(target)); + } + return false; +} + +void follow_render(bool wanted) noexcept { + auto& s = state(); + if (!s.ready.load(std::memory_order_acquire)) return; + NoBailSkater skater; + if (!wanted || !no_bail_skater(skater)) { + s.indices_until.store(0, std::memory_order_release); + return; + } + for (std::size_t mode = 0; mode < build::render_modes; ++mode) + s.indices[mode].store(render_index(s.base, skater.entity, mode), std::memory_order_release); + s.indices_until.store(GetTickCount64() + lease_ms, std::memory_order_release); +} + +bool latest_picture(Picture& picture) noexcept { + auto& s = state(); + const auto now = GetTickCount64(); + std::lock_guard guard(s.lock); + if (!s.skin_at || !s.camera_at || now - s.skin_at > fresh_ms || now - s.camera_at > fresh_ms) return false; + try { + picture.skin = s.skin; + } catch (...) { + return false; + } + picture.camera = s.camera; + picture.vertical_fov = s.vertical_fov; + return true; +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_render.h b/Extension/HallOfMeat/hall_of_meat_render.h new file mode 100644 index 0000000..1f1d382 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_render.h @@ -0,0 +1,28 @@ +#pragma once +#include "Engine/Game/Abi/linear_transform.h" +#include +#include +#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 +// draw packets, and the main view's camera. +namespace dingosdk::hall_of_meat { +// 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). +bool hook_render(bool on) noexcept; +// Client tick: while `wanted` (a bail shows), 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; + +struct Picture { + std::vector skin; // each render bone's skinning matrix: from model space into the world + std::array camera{}; // the camera's world matrix: right, up, back, position rows + float vertical_fov{}; // degrees +}; +// Render thread: the latest picture's, while the renderer draws the local skater. +bool latest_picture(Picture& picture) noexcept; +} diff --git a/Extension/HallOfMeat/hall_of_meat_skater.cpp b/Extension/HallOfMeat/hall_of_meat_skater.cpp new file mode 100644 index 0000000..cfd62b5 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_skater.cpp @@ -0,0 +1,177 @@ +#include "hall_of_meat_skater.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/no_bail.h" +#include "Engine/Game/Build/20260929/skater_body.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include +#include +#include +#include +#include + +namespace dingosdk::hall_of_meat { +namespace { +namespace body = addr::skater_body; +namespace state = addr::skater_state; +using game::Vec3; +static_assert(body::body_bone_count == skater_body::count); + +std::atomic& ready() { + static std::atomic value{}; + return value; +} + +// What each body touched in the step: the contact struct the rig's contact processing fills. +using Records = std::array; +template T field(const Records& records, std::size_t index, std::uintptr_t offset) noexcept { + T value{}; + std::memcpy(&value, records.data() + index * body::bone_record_size + offset, sizeof(T)); + return value; +} +Vec3 vec3(const std::array& value) noexcept { + for (std::size_t i = 0; i < 3; ++i) + if (!std::isfinite(value[i])) return {}; + return {value[0], value[1], value[2]}; +} +float speed(float value) noexcept { return std::isfinite(value) && value > 0 && value < 1000 ? value : 0; } +bool read_contacts(const NoBailSkater& skater, skater_body::Contacts& result) noexcept { + std::uintptr_t holder{}, contacts{}; + Records records{}; + std::array touching{}; + if (!memory::peek(skater.rig + body::contact_holder_offset, holder) || !memory::peek(holder + body::contact_struct_offset, contacts) || + !memory::peek(contacts + body::bone_records_offset, records) || !memory::peek(contacts + body::bone_touching_offset, touching)) + return false; + for (std::size_t index = 0; index < skater_body::count; ++index) { + auto& b = result.bodies[index]; + b.touching = touching[index] != 0; + const float ordinary = speed(field(records, index, body::bone_peak_offset)); + const float tracked = speed(field(records, index, body::bone_tracked_peak_offset)); + b.impact = std::max(ordinary, tracked); + // The slide of the contact that set the peak: one near a tracked point keeps its own. + const std::uintptr_t side = tracked > ordinary ? body::bone_tracked_offset : 0; + b.slide = vec3(field>(records, index, body::bone_slide_offset + side)); + const auto hit = [&](std::uintptr_t offset) { return field(records, index, offset) != 0; }; + b.hit = {hit(body::bone_hit_board_offset), hit(body::bone_hit_vehicle_offset), hit(body::bone_hit_world_offset), + hit(body::bone_hit_kind_5_offset), hit(body::bone_hit_kind_11_offset)}; + } + return true; +} + +// The offboard state's flags: the body in a ragdoll, and in the air. +struct Offboard { + bool ragdoll{}, in_the_air{}; +}; +bool read_offboard(const NoBailSkater& skater, Offboard& flags) noexcept { + std::uintptr_t trick_state{}, offboard{}; + std::uint8_t ragdoll{}, in_the_air{}; + if (!memory::peek(skater.core + state::trick_state_offset, trick_state) || + !memory::peek(trick_state + state::offboard_state_offset, offboard) || + !memory::peek(offboard + state::ragdoll_offset, ragdoll) || !memory::peek(offboard + state::in_the_air_offset, in_the_air)) + return false; + flags = {ragdoll != 0, in_the_air != 0}; + return true; +} + +bool finite(const Vec3& v) noexcept { + return std::all_of(v.begin(), v.end(), [](float value) { return std::isfinite(value) && std::abs(value) < 1000; }); +} +// Body `index` of the board's or the skeleton's physics, when the layout holds: its velocity, and +// its spin when asked for. +bool body_motion(std::uintptr_t physics, std::uintptr_t vtable, std::uint32_t count, std::size_t index, Vec3& velocity, + Vec3* spin = nullptr) noexcept { + std::uintptr_t type{}, bodies{}, owner{}; + std::uint32_t found{}; + const auto at = [&] { return bodies + index * state::physics_body_size; }; + if (!memory::peek(physics, type) || type != vtable || !memory::peek(physics + state::physics_bodies_offset, bodies) || + !memory::peek(bodies, found) || found != count || index >= count || + !memory::peek(at() + state::body_owner_offset, owner) || owner != physics || + !memory::peek(at() + state::body_velocity_offset, velocity) || (spin && !memory::peek(at() + state::body_spin_offset, *spin))) + return false; + return finite(velocity) && (!spin || finite(*spin)); +} +// The board's root, and the pelvis with its spin. +bool read_motion(const NoBailSkater& skater, Vec3& board, Vec3& pelvis, Vec3& spin) noexcept { + std::uintptr_t holder{}, board_physics{}, rig_physics{}; + return memory::peek(skater.core + state::board_holder_offset, holder) && + memory::peek(holder + state::board_physics_offset, board_physics) && + memory::peek(skater.rig + state::rig_physics_offset, rig_physics) && + body_motion(board_physics, skater.base + state::board_physics_vtable, state::board_body_count, state::board_root_body, board) && + body_motion(rig_physics, skater.base + state::rig_physics_vtable, state::rig_body_count, + skater_body::index(skater_body::Bone::hips), pelvis, &spin); +} + +// Where the local skater's physics step length is kept now (a float, seconds of game time), 0 while +// there is no local skater. +std::uintptr_t step_length_address() noexcept { + NoBailSkater skater; + std::uintptr_t input{}; + if (!no_bail_skater(skater) || !memory::peek(skater.core + body::core_step_input_offset, input) || !input) return 0; + return input + body::step_input_length_offset; +} +} + +bool start_skater(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); + 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."); + return false; + } + ready().store(true, std::memory_order_release); + return true; +} + +bool skater_available() noexcept { return ready().load(std::memory_order_acquire); } + +Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { + Step step; + step.seconds = seconds; + step.wipeout = wipeout; + std::uint32_t physics_state{}; + if (memory::peek(skater.context + state::physics_state_offset, physics_state)) { + // On the board by the physics state; off it (on foot, and through a whole bail) by the offboard state. + const bool offboard = physics_state == addr::no_bail::offboard_physics_state; + Offboard flags; + const bool known = offboard && read_offboard(skater, flags); + step.airborne = std::find(state::board_air_states.begin(), state::board_air_states.end(), physics_state) != + state::board_air_states.end() || (known && flags.in_the_air); + if (!offboard || known) step.ragdoll = known && flags.ragdoll; + Vec3 board{}, pelvis{}, spin{}; + if (read_motion(skater, board, pelvis, spin)) { + step.velocity = offboard ? pelvis : board; + step.spin = game::length(spin); + } + } + (void)read_contacts(skater, step.body); + return step; +} + +float step_length() noexcept { + const auto address = step_length_address(); + float seconds{}; + return address && memory::peek(address, seconds) ? seconds : 0.0f; +} + +bool set_step_length(float seconds) noexcept { + const auto address = step_length_address(); + float current{}; + if (!address || !memory::peek(address, current)) return false; + if (current == seconds) return true; + __try { + *reinterpret_cast(address) = seconds; // read by the physics thread each step + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_skater.h b/Extension/HallOfMeat/hall_of_meat_skater.h new file mode 100644 index 0000000..61a04b4 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_skater.h @@ -0,0 +1,23 @@ +#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/HallOfMeat/hall_of_meat_skeleton.cpp b/Extension/HallOfMeat/hall_of_meat_skeleton.cpp new file mode 100644 index 0000000..4c85c73 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_skeleton.cpp @@ -0,0 +1,184 @@ +#include "hall_of_meat_skeleton.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/skater_skeleton.h" +#include "Engine/Resource/ebx_document.h" +#include "Engine/Vfs/game_bundles.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::hall_of_meat { +namespace { +namespace build = addr::skater_skeleton; + +void require(bool valid, const std::string& message) { + if (!valid) throw std::runtime_error(message); +} +Vec3 normalised(const Vec3& v) { + const float length = game::length(v); + return length > 1e-8f ? Vec3{v[0] / length, v[1] / length, v[2] / length} : Vec3{0, 1, 0}; +} +constexpr int no_body = -1; + +// Each bone's body: its own joint's, else its nearest ancestor's; none above the hips. +std::vector bodies_of(const frostbite::Skeleton& skeleton) { + std::vector body(skeleton.names.size(), no_body); + for (std::size_t b = 1; b < skater_body::count; ++b) { + const auto found = std::find(skeleton.names.begin(), skeleton.names.end(), skeleton_joints[b]); + require(found != skeleton.names.end(), "the skeleton has no " + std::string(skeleton_joints[b])); + body[static_cast(found - skeleton.names.begin())] = static_cast(b); + } + for (std::size_t bone = 0; bone < body.size(); ++bone) { // parents come first + const auto parent = skeleton.parents[bone]; + if (body[bone] == no_body && parent >= 0) body[bone] = body[static_cast(parent)]; + } + return body; +} + +vfs::GameBundle bundle(const vfs::GameData& data, const frostbite::TocDocument& toc, std::string_view name) { + auto found = data.read_bundle(toc, name); + require(found.has_value(), "the bundle " + std::string(name) + " is missing"); + return std::move(*found); +} +std::vector asset(const vfs::GameData& data, const vfs::GameBundle& bundle, frostbite::AssetKind kind, + std::string_view name) { + std::size_t index{}; + require(bundle.find(kind, name, &index) && bundle.payload(kind, index), "the asset " + std::string(name) + " is missing"); + return data.read(*bundle.payload(kind, index)); +} +// A geometry chunk: in the bundle when it has it, else in its superbundle's TOC. +std::vector chunk(const vfs::GameData& data, const vfs::GameBundle& bundle, const frostbite::TocDocument& toc, + const frostbite::Guid& id) { + std::size_t index{}; + if (bundle.find_chunk(id, &index) && bundle.payload(frostbite::AssetKind::chunk, index)) + return data.read(*bundle.payload(frostbite::AssetKind::chunk, index)); + const auto found = std::find_if(toc.chunks.begin(), toc.chunks.end(), + [&](const frostbite::TocChunk& candidate) { return candidate.guid == id && !candidate.removed; }); + require(found != toc.chunks.end(), "the mesh's geometry chunk is missing"); + return data.read({found->location, found->offset, found->size}); +} + +struct Loader { + std::once_flag started; + std::atomic> mesh; +}; +Loader& loader() { static auto* value = new Loader; return *value; } +} + +SkeletonMesh bind_skeleton(const frostbite::Skeleton& skeleton, const frostbite::SkinnedMesh& mesh) { + const auto body_of = bodies_of(skeleton); + SkeletonMesh result; + result.bone_count = skeleton.names.size(); + for (const auto& source : mesh.vertices) { + // The heaviest few bones, scaled back to 1. + std::array order{0, 1, 2, 3, 4, 5, 6, 7}; + std::sort(order.begin(), order.end(), [&](auto a, auto b) { return source.weights[a] > source.weights[b]; }); + SkeletonVertex vertex; + vertex.position = source.position; + float kept{}; + for (std::size_t slot = 0; slot < max_influences && source.weights[order[slot]]; ++slot) { + const auto bone = source.bones[order[slot]]; + require(bone < body_of.size(), "a vertex names a bone the skeleton lacks"); + require(body_of[bone] != no_body, "a vertex rides on a bone no body moves"); + vertex.bones[slot] = bone; + vertex.weights[slot] = source.weights[order[slot]] / 255.0f; + kept += vertex.weights[slot]; + } + require(kept > 0, "a vertex has no weight"); + for (auto& weight : vertex.weights) weight /= kept; + result.parts.push_back(static_cast(body_of[vertex.bones[0]])); + result.vertices.push_back(vertex); + } + require(mesh.triangles.size() % 3 == 0, "a triangle is incomplete"); + for (const auto index : mesh.triangles) require(index < result.vertices.size(), "a triangle names a missing vertex"); + result.triangles = mesh.triangles; + // Area-weighted smooth normals: the costume's own are packed in a form not worth decoding + // for an untextured x-ray. + for (std::size_t i = 0; i < result.triangles.size(); i += 3) { + auto& a = result.vertices[result.triangles[i]]; + auto& b = result.vertices[result.triangles[i + 1]]; + auto& c = result.vertices[result.triangles[i + 2]]; + const Vec3 u{b.position[0] - a.position[0], b.position[1] - a.position[1], b.position[2] - a.position[2]}; + const Vec3 v{c.position[0] - a.position[0], c.position[1] - a.position[1], c.position[2] - a.position[2]}; + const Vec3 n{u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]}; + for (auto* vertex : {&a, &b, &c}) + for (std::size_t axis = 0; axis < 3; ++axis) vertex->normal[axis] += n[axis]; + } + for (auto& vertex : result.vertices) vertex.normal = normalised(vertex.normal); + return result; +} + +bool pose_skeleton(const SkeletonMesh& mesh, std::span skin, PosedSkeleton& posed) { + if (skin.size() < mesh.bone_count) return false; + posed.positions.resize(mesh.vertices.size()); + posed.normals.resize(mesh.vertices.size()); + for (std::size_t i = 0; i < mesh.vertices.size(); ++i) { + const auto& vertex = mesh.vertices[i]; + Vec3 position{}, normal{}; + for (std::size_t slot = 0; slot < max_influences && vertex.weights[slot] > 0; ++slot) { + const auto& transform = skin[vertex.bones[slot]]; + const auto p = game::place(vertex.position, transform), n = game::turn(vertex.normal, transform); + for (std::size_t axis = 0; axis < 3; ++axis) { + position[axis] += p[axis] * vertex.weights[slot]; + normal[axis] += n[axis] * vertex.weights[slot]; + } + } + posed.positions[i] = position; + posed.normals[i] = normalised(normal); + } + return true; +} + +std::filesystem::path game_folder() { + std::vector path(32768); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + require(length && length < path.size(), "the game's folder is unknown"); + return std::filesystem::path(std::wstring(path.data(), length)).parent_path(); +} + +SkeletonMesh read_skeleton_mesh(const std::filesystem::path& game_root) { + const vfs::GameData data(game_root); + const auto skeleton_toc = data.read_toc(build::skeleton_toc); + const auto skeleton_document = + frostbite::ebx::read_document(asset(data, bundle(data, skeleton_toc, build::skeleton_bundle), frostbite::AssetKind::ebx, + build::skeleton_asset)); + const auto* root = skeleton_document.root(); + require(root && root->object, "the render skeleton is empty"); + const auto skeleton = frostbite::read_skeleton(*root->object); + const auto mesh_toc = data.read_toc(build::mesh_toc); + const auto mesh_bundle = bundle(data, mesh_toc, build::mesh_bundle); + const auto resource = asset(data, mesh_bundle, frostbite::AssetKind::resource, build::mesh_asset); + const auto lod = frostbite::read_mesh_lod(resource, build::mesh_lod); + const auto geometry = chunk(data, mesh_bundle, mesh_toc, lod.chunk); + return bind_skeleton(skeleton, frostbite::read_skinned_mesh(resource, build::mesh_lod, geometry)); +} + +void prepare_skeleton() noexcept { + try { + std::call_once(loader().started, [] { + std::thread([] { + const auto started = GetTickCount64(); + 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(), + 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()); + } + }).detach(); + }); + } catch (...) { + logging::write(logging::Level::warning, logging::Channel::skater, "Hall of Meat shows no skeleton: its reading could not start."); + } +} + +std::shared_ptr skeleton_mesh() noexcept { return loader().mesh.load(); } +} diff --git a/Extension/HallOfMeat/hall_of_meat_skeleton.h b/Extension/HallOfMeat/hall_of_meat_skeleton.h new file mode 100644 index 0000000..416e17f --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_skeleton.h @@ -0,0 +1,60 @@ +#pragma once +#include "Engine/Game/Skater/skater_body.h" +#include "Engine/Resource/mesh_set.h" +#include "Engine/Resource/skeleton_asset.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// The skeleton Hall of Meat draws: 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 +// ragdoll's body (skater_body.h) it moves with, for whatever that body went through. +namespace dingosdk::hall_of_meat { +using game::LinearTransform; +using game::Vec3; + +// The render bone each body poses, by name; the game's skeleton publisher maps them the same way +// (rig+0x1ae0: 102 Neck1, 101 Neck, 278 LeftHand, ... 7 Hips). The board's root has none. +inline constexpr std::array skeleton_joints{ + "", "Neck1", "Neck", "LeftHand", "LeftForeArm", "LeftArm", "LeftShoulder", + "RightHand", "RightForeArm", "RightArm", "RightShoulder", "Spine3", "Spine2", "Spine1", "Spine", + "LeftToeBase", "LeftFoot", "LeftLeg", "LeftUpLeg", "RightToeBase", "RightFoot", "RightLeg", "RightUpLeg", "Hips"}; +inline constexpr std::size_t max_influences = 4; // bones per vertex, the heaviest + +struct SkeletonVertex { + Vec3 position{}, normal{}; // model space + std::array bones{}; + std::array weights{}; // summing to 1, the unused ones 0 +}; +struct SkeletonMesh { + std::size_t bone_count{}; // the render skeleton's: a pose has a skinning matrix for each + std::vector vertices; + std::vector parts; // each vertex's body: its heaviest bone's own or nearest ancestor's joint + std::vector triangles; // vertex indices, three a triangle +}; +// Binds a mesh skinned on `skeleton`. Throws when the skeleton lacks a body's joint, or a vertex +// rides on a bone no body moves (the root, the board) or does not fit it. +[[nodiscard]] SkeletonMesh bind_skeleton(const frostbite::Skeleton& skeleton, const frostbite::SkinnedMesh& mesh); + +struct PosedSkeleton { + std::vector positions, normals; // world space, one per vertex +}; +// The mesh skinned by `skin`, each render bone's skinning matrix (a vertex from model space into the +// 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. +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); +// Starts reading it in the background, once. +void prepare_skeleton() noexcept; +// Any thread: the mesh once read; nullptr until then, and for good when it could not be (logged). +std::shared_ptr skeleton_mesh() noexcept; +} diff --git a/Extension/HallOfMeat/hall_of_meat_slow_motion.cpp b/Extension/HallOfMeat/hall_of_meat_slow_motion.cpp new file mode 100644 index 0000000..ae30079 --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_slow_motion.cpp @@ -0,0 +1,100 @@ +#include "hall_of_meat_slow_motion.h" +#include "hall_of_meat_skater.h" +#include "Engine/Game/Multiplayer/session_tools.h" +#include "Engine/Game/Settings/simulation_time.h" +#include "Extension/Settings/named_settings.h" +#include +#include + +namespace dingosdk::hall_of_meat { +namespace { +constexpr const char* time_scale = "SimulationTime.TimeScale"; +constexpr const char* sim_rate = "SimulationTime.ForceSimRate"; +constexpr const char* max_sim_fps = "SimulationTime.MaxSimFps"; +// The steps first, then the clock: no step of the change runs at the old rate on the new clock. +constexpr std::array held_settings{sim_rate, max_sim_fps, time_scale}; +constexpr std::size_t time_scale_index = 2; + +bool failed(const std::string& result) { return result.starts_with("error: "); } +// A float as the settings read it back (named_setting_value), so what was written is recognised. +std::string text(float value) { + std::array buffer{}; + const auto result = std::to_chars(buffer.data(), buffer.data() + buffer.size(), value); + return result.ec == std::errc{} ? std::string(buffer.data(), result.ptr) : std::string{}; +} +std::optional number(const std::string& value) { + float result{}; + const auto parsed = std::from_chars(value.data(), value.data() + value.size(), result); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || !std::isfinite(result)) return {}; + return result; +} +} + +bool SlowMotion::still_ours() const { + for (std::size_t index = 0; index < held_settings.size(); ++index) + if (!held_->ours[index].empty() && named_setting_value(held_settings[index]) != held_->ours[index]) return false; + return true; +} + +bool SlowMotion::set(float speed) { + if (!(speed < 1.0f)) yielded_ = false; // this slow motion is over; the next break may slow the game again + if (!(speed < 1.0f) || yielded_ || multiplayer_session_active()) { + release(); + return false; + } + // The game's own rate, as the skater's step still has it (it is the held rate's only while held). + if (!base_rate_) { + const auto own = simulation_time::rate_of(step_length()); + if (!own) return false; + base_rate_ = own; + } + if (!held_) { + Held found; + for (std::size_t index = 0; index < held_settings.size(); ++index) { + auto value = named_setting_value(held_settings[index]); + if (!value) return false; + found.found[index] = std::move(*value); + } + held_ = std::move(found); + } else if (!still_ours()) { + yielded_ = true; // someone else's now: what still holds ours goes back + release(); + return false; + } + const auto found = number(held_->found[time_scale_index]); + const auto scale = found ? slow_motion_time_scale(*found, speed) : std::nullopt; + if (!scale) { + release(); // the game already runs as slowly: the found speed stays, or comes back + return false; + } + const auto rate = slow_motion_rate(*base_rate_, *scale); + const std::array values{std::to_string(rate), std::to_string(rate), text(*scale)}; + for (std::size_t index = 0; index < held_settings.size(); ++index) { + if (values[index] == held_->ours[index]) continue; + if (values[index].empty() || failed(change_named_setting(held_settings[index], values[index], false))) { + release(); // all three or none + return false; + } + held_->ours[index] = values[index]; + } + // The skater steps as the simulation now does (a skater rebuilt meanwhile already does). + if (set_step_length(simulation_time::step_seconds(rate))) skater_changed_ = true; + return true; +} + +void SlowMotion::release() { + if (held_) { + // Back to what was found; a setting that no longer holds ours was changed from elsewhere and stays. + // Written back like any change, so an override the slow motion found (the trainer's) stays theirs, + // and one it made itself ends where the value is the game's own again. + for (std::size_t index = held_settings.size(); index-- > 0;) + if (!held_->ours[index].empty() && named_setting_value(held_settings[index]) == held_->ours[index]) + (void)change_named_setting(held_settings[index], held_->found[index], false); + held_.reset(); + } + // The skater's own step back; while no skater can be reached (a teleport under way), the next call + // tries again, and the game's own rate is kept for it until then. + if (skater_changed_ && base_rate_ && set_step_length(simulation_time::step_seconds(*base_rate_))) skater_changed_ = false; + if (!skater_changed_) base_rate_.reset(); +} +} diff --git a/Extension/HallOfMeat/hall_of_meat_slow_motion.h b/Extension/HallOfMeat/hall_of_meat_slow_motion.h new file mode 100644 index 0000000..7c61d0f --- /dev/null +++ b/Extension/HallOfMeat/hall_of_meat_slow_motion.h @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// The slow motion after a break: 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 +// the time scale goes down, keeping as many steps each real second as before, each the shorter +// (measured in play: at 0.3 with a rate of 200 the slow motion runs smooth). The three +// native settings are changed as a session override (Extension/Settings/named_settings.h). +// +// Every break's slow motion runs the same speeds, whatever the game ran at, but never faster than +// that: with the trainer's game speed (PRACTICE) below 1 it only takes the part of the curve that is +// slower still. What it found is written back at the end, each setting only while it still holds +// what the slow motion wrote; one changed from elsewhere meanwhile (the trainer, the console) is +// theirs, and ends this slow motion. +// +// The local skater goes along: skate. gives a skater its physics step length when its core is built +// (at a spawn and at every teleport) and never touches it again (measured in play; Ghidra +// build_pose_object, world_get_physics_step_length). A rate changed live would leave the skater +// stepping out of line, and one teleported meanwhile would keep the changed step. So while the rate is +// held the skater's step is the held rate's, and once the rate is given back it gets the game's own back. +// +// 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 { +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 + +// The simulation rate (steps a second of game time) that keeps `base_rate` steps each real second at `speed`. +inline std::uint32_t slow_motion_rate(std::uint32_t base_rate, float speed) noexcept { + const float clamped = std::isfinite(speed) ? std::clamp(speed, slowest_speed, 1.0f) : 1.0f; + const float rate = std::round(static_cast(base_rate) / clamped); + return static_cast(std::clamp(rate, 1.0f, static_cast(highest_simulation_rate))); +} +// The time scale the slow motion runs at `speed` when the game ran at `found`; nothing when the game +// already runs as slowly (or the speed is broken). +inline std::optional slow_motion_time_scale(float found, float speed) noexcept { + if (!std::isfinite(found) || !std::isfinite(speed)) return {}; + const float scale = std::clamp(speed, slowest_speed, 1.0f); + if (!(scale < found)) return {}; + return scale; +} + +class SlowMotion { +public: + // Runs the game at `speed` while that is slower than the speed it found (1 or more ends the slow + // motion). False when it does not now (no slower than the game already runs, a session, no local + // skater, the settings not ready, or changed from elsewhere during this slow motion): then the + // game runs as it was. + bool set(float speed); + // Gives back what was found, and the local skater its own step once it can be reached. + void release(); + // Nothing of the game's is held: its settings and the local skater's step are as they were. + bool idle() const noexcept { return !held_ && !skater_changed_; } + +private: + // The three settings as found, and what the slow motion last wrote over each (empty: nothing yet). + struct Held { + std::array found, ours; + }; + bool still_ours() const; + + std::optional held_; + bool yielded_{}; // a setting changed from elsewhere: none until the next slow motion + 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 +}; +} diff --git a/Extension/Settings/named_settings.cpp b/Extension/Settings/named_settings.cpp index 52477c8..7d08c6d 100644 --- a/Extension/Settings/named_settings.cpp +++ b/Extension/Settings/named_settings.cpp @@ -702,6 +702,19 @@ std::string change_named_setting(std::string_view name, std::string_view value, return change(i, value, restore); return "error: Unknown native setting; only catalog entries can execute."; } +std::optional named_setting_value(std::string_view name) { + auto &r = runtime(); + if (!r.thread || r.thread != GetCurrentThreadId()) + return {}; + for (std::size_t i = 0; i < r.models.size(); ++i) + if (console::equal(r.models[i].name, name)) { + const auto current = observe(i); + if (current) + return text(current->value); + return {}; + } + return {}; +} std::string player_change_named_setting(std::string_view name, std::string_view value, bool restore) { auto &r = runtime(); if (!r.thread || r.thread != GetCurrentThreadId()) diff --git a/Extension/Settings/named_settings.h b/Extension/Settings/named_settings.h index 97853b4..6ac26c3 100644 --- a/Extension/Settings/named_settings.h +++ b/Extension/Settings/named_settings.h @@ -1,6 +1,7 @@ #pragma once #include "Engine/Game/Settings/named_settings.h" #include +#include #include namespace dingosdk { // Initialize before installing the client update hook. All other functions @@ -13,6 +14,8 @@ const std::vector &named_settings_model(); // Increases whenever any row of named_settings_model() changes. std::uint64_t named_settings_revision(); std::string change_named_setting(std::string_view name, std::string_view value, bool restore); +// The setting's value now, as the console shows it; nothing when it cannot be read. +std::optional named_setting_value(std::string_view name); // The same for a player's own change (the console): one that changes how the game plays is // refused during a multiplayer session (Engine/Game/Settings/multiplayer_settings_lock.h), and // any such change made before a session is put back when it starts. diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 2efddc5..0863af8 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -59,6 +59,7 @@ struct Protection { std::atomic ready{}; SRWLOCK lock = SRWLOCK_INIT; Lease lease; + std::atomic step_observer{}; }; Protection& protection() { static auto* value = new Protection; return *value; } struct StateWatch { @@ -119,6 +120,19 @@ bool protected_owner(std::uintptr_t object, std::uintptr_t Owner::* member, Owne if (owner) *owner = current; return true; } +// The published owner, its chain resolved again now; when `rig` is given, only if it is that owner's. +bool published_skater(std::uintptr_t rig, NoBailSkater& skater) noexcept { + auto& p = protection(); + if (!p.ready.load(std::memory_order_acquire)) return false; + AcquireSRWLockShared(&p.lock); + const auto published = p.lease.owner; + ReleaseSRWLockShared(&p.lock); + Owner current; + if (!published.entity || (rig && rig != published.rig) || !resolve(published.client, published.entity, current) || + current != published) return false; + skater = {p.base, current.entity, current.core, current.context, current.rig}; + return true; +} bool cancel_request(std::uintptr_t context, std::uintptr_t offset, LONG mask) noexcept { const auto address = context + offset; if (context < 0x10000 || context > highest - offset - sizeof(LONG) || @@ -214,6 +228,11 @@ void skeleton_response(std::uintptr_t rig, float seconds, bool wipeout) { // The state post-update can raise another request after the selector ran. // Filter at this consumer, then let native constraints and recovery run. if (filter_requests(rig, &Owner::rig)) wipeout = false; + if (const auto observer = protection().step_observer.load(std::memory_order_acquire)) { + LastError error; + NoBailSkater skater; + if (published_skater(rig, skater)) observer(skater, seconds, wipeout); + } protection().skeleton_original(rig, seconds, wipeout); } bool clear_contact_output(std::uintptr_t contacts) noexcept { @@ -391,6 +410,10 @@ void clear_no_bail() noexcept { p.lease = {}; ReleaseSRWLockExclusive(&p.lock); } +bool no_bail_skater(NoBailSkater& skater) noexcept { return published_skater(0, skater); } +void set_no_bail_step_observer(NoBailStepObserver observer) noexcept { + protection().step_observer.store(observer, std::memory_order_release); +} void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept { auto& w = state_watch(); Owner owner; diff --git a/Extension/Skater/no_bail.h b/Extension/Skater/no_bail.h index 910da1f..7dd6042 100644 --- a/Extension/Skater/no_bail.h +++ b/Extension/Skater/no_bail.h @@ -27,4 +27,16 @@ void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept; 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. +struct NoBailSkater { + std::uintptr_t base{}, entity{}, core{}, context{}, rig{}; +}; +bool no_bail_skater(NoBailSkater& skater) noexcept; +// Every physics step of that skater, whether or not No Bail protects it: No Bail's hook on the +// skeleton response runs once per step, right after the step's body contacts, and hands it on after +// its own filtering, so a wipeout it filtered never happened. `seconds` is the step's simulated time. +// 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; } diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 3b64308..06d2e8d 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -112,6 +112,18 @@ 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"); model.multiplayer.local_name = "steam_name"; model.player_card = {true, "Card Name", {}}; const auto card_page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index 49d93e7..4b98b12 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -189,6 +189,8 @@ 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 == "board-wear-reset") { console("boardwear reset", m.offline.board_wear.available && m.offline.board_wear.effective); } else if (command == "challenges") { @@ -279,6 +281,7 @@ 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); 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/overlay.h b/Extension/UI/Overlay/overlay.h index 16a9d52..aa57c04 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -191,6 +191,11 @@ 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; +}; + struct Model { std::string state = "Waiting for native state"; std::string detail; @@ -209,6 +214,7 @@ struct Model { WorldControlsModel world_controls; ProgressionModel progression; PlayerCardModel player_card; + HallOfMeatModel hall_of_meat; ObjectPersistenceModel object_persistence; ParkEditorModel editor; float menu_scale = default_menu_scale; diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 324adc3..ab054c2 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -135,6 +135,7 @@ struct State { std::atomic started{false}; bool ready = false; bool ui_was_interactive = false; + std::uint32_t mapped_mouse_event = 0; // Render thread: the last queued mouse position mapped into the back buffer. std::atomic failed{false}; bool win32_ready = false; bool dx12_ready = false; diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 894e742..a012365 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -3,13 +3,38 @@ #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 "park_previews.h" #include "chat_emotes.h" #include "input_capture.h" #include "cursor.h" +#include namespace dingosdk::overlay::detail { +// ImGui draws into the swapchain's back buffer, so its display is the buffer, in the buffer's +// pixels. The Win32 backend sizes the display by the window's client area and places the mouse in +// it; the two differ whenever the window is measured in other units than the buffer: a window +// Windows scales for the display (a 1920x1080 client on a 4K display at 200%, borderless) or a +// buffer stretched to the window. Every mouse position queued since the last frame comes in client +// coordinates and is mapped into the buffer once. +void fit_display_to_buffer(State& s, ID3D12Resource* buffer) { + auto& io = ImGui::GetIO(); + const auto description = buffer->GetDesc(); + const ImVec2 client = io.DisplaySize; + const ImVec2 pixels(static_cast(description.Width), static_cast(description.Height)); + if (client.x <= 0 || client.y <= 0 || pixels.x <= 0 || pixels.y <= 0) return; + io.DisplaySize = pixels; + const ImVec2 scale(pixels.x / client.x, pixels.y / client.y); + for (auto& event : ImGui::GetCurrentContext()->InputEventsQueue) { + if (event.Type != ImGuiInputEventType_MousePos || event.EventId <= s.mapped_mouse_event) continue; + s.mapped_mouse_event = event.EventId; + if (event.MousePos.PosX == -FLT_MAX) continue; // the mouse left the window + event.MousePos.PosX *= scale.x; + event.MousePos.PosY *= scale.y; + } +} + bool completed(UINT64 value, DWORD timeout_ms) { auto& s = state(); if (!value) return true; @@ -277,6 +302,7 @@ bool setup_graphics() { if (!s.fence_event) return false; ImGuiContext* previous = ImGui::GetCurrentContext(); s.context = ImGui::CreateContext(); + s.mapped_mouse_event = 0; // a new context numbers its events afresh ImGui::SetCurrentContext(s.context); ImGui::GetIO().IniFilename = nullptr; ImGui::GetIO().LogFilename = nullptr; @@ -285,10 +311,12 @@ bool setup_graphics() { dingosdk::overlay::load_skate_fonts(s.menu); // Thumbnails are read from the game's own data at startup; the read is // normally long finished by the time the first frame gets here. - // Emotes reserve their room before the park previews build the atlas, and fill it after. + // Emotes and Hall of Meat's images reserve their room before the park previews build the atlas, and fill it after. const auto emote_count = reserve_chat_emotes(*ImGui::GetIO().Fonts, std::chrono::seconds(5)); + reserve_hall_of_meat_images(*ImGui::GetIO().Fonts, std::chrono::seconds(5)); const auto preview_count = load_park_previews(*ImGui::GetIO().Fonts, std::chrono::seconds(5)); fill_chat_emotes(*ImGui::GetIO().Fonts); + fill_hall_of_meat_images(*ImGui::GetIO().Fonts); if (emote_count) dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::graphics, "Chat emotes: %zu ready.", emote_count); @@ -453,6 +481,7 @@ 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 perf_frame = perf_hud_pending() || trainer_hud_pending(); if (trainer_open_requested()) s.visible.store(true); const bool menu_frame = interactive_visible(s); @@ -471,7 +500,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 && !perf_frame) return; + !nametag_frame && !meat_frame && !perf_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; s.last_model = {}; // Reopening immediately reads fresh state. @@ -511,12 +540,14 @@ void render(IDXGISwapChain* presented, UINT flags) { ImGui_ImplDX12_NewFrame(); { OverlayInputAccess access; ImGui_ImplWin32_NewFrame(); } update_menu_pointer(); + fit_display_to_buffer(s, frame.buffer.Get()); ImGui::NewFrame(); if (menu_frame) { draw_menu(); draw_console(); draw_perf_window(); } + draw_hall_of_meat(); draw_nametags(); draw_game_text(); draw_skate_hud(); diff --git a/README.md b/README.md index 8d4e46c..2536490 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ the launcher, the runtime that loads into the game, and the dedicated server. - World: time of day, population, district levels, rotating parks. - The **Park Editor**: place, move and save objects with freecam, snapping and undo. - 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. - 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 7d3f17a..d4ae129 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -6,6 +6,7 @@ #include "Engine/Core/Profiling/profiler.h" #include "Extension/Settings/job_spin.h" #include "Engine/Game/World/client_state.h" +#include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -605,6 +606,7 @@ void update_model(std::uintptr_t client, TickState& frame) { dingosdk::tick_network_objects(); } if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. + dingosdk::hall_of_meat::on_client_tick(); if (!has_request && now < r.next_model && state == r.previous_state) return; r.next_model = now + 500; DINGO_PROFILE_ZONE("tick/update_model/world model (500 ms)"); @@ -612,6 +614,7 @@ 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); // A live mod apply hands over the destinations its mods declare. A map // being played that is no longer among them (its mod disabled or deleted) // cannot stay loaded: the player goes to San Van. @@ -740,6 +743,7 @@ 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.bindings = dingosdk::local_profile_controller_bindings(); model.parks = dingosdk::local_profile_parks(); model.world = dingosdk::local_profile_world_layers(); diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 478c1d9..500a5ca 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -10,6 +10,7 @@ #include "Engine/Game/Build/supported_build.h" #include "Engine/Vfs/mod_catalog.h" #include "Extension/Boot/offline_boot.h" +#include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" @@ -368,6 +369,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { const bool camera_hook = dingosdk::start_camera_observer(r.base); record("{\"event\":\"camera_observer_initialized\",\"active\":" + std::string(camera_hook ? "true" : "false") + "}"); (void)dingosdk::start_no_bail(r.base); + const bool meat = dingosdk::hall_of_meat::start(r.base); + record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); const bool 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/Core.cmake b/cmake/Core.cmake index b823613..8453fd7 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -75,7 +75,9 @@ add_library(dingosdk_frostbite STATIC Engine/Resource/bundle_ref_table.cpp Engine/Resource/ebx_writer.cpp Engine/Resource/toc.cpp - Engine/Resource/texture.cpp) + Engine/Resource/texture.cpp + Engine/Resource/mesh_set.cpp + Engine/Resource/skeleton_asset.cpp) target_include_directories(dingosdk_frostbite SYSTEM PRIVATE External/bcdec) target_link_libraries(dingosdk_frostbite PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_miniz) @@ -93,7 +95,7 @@ endif() # Read access to the installed game's cas archives. add_library(dingosdk_game_archives STATIC Engine/Vfs/game_archives.cpp Engine/Vfs/game_bundles.cpp - Engine/Vfs/item_thumbnails.cpp) + Engine/Vfs/item_thumbnails.cpp Engine/Vfs/game_textures.cpp) target_link_libraries(dingosdk_game_archives PUBLIC dingosdk_native_db dingosdk_frostbite) if(WIN32) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index e9f4e7b..5d74cb7 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -110,6 +110,15 @@ add_library(dingosdk_runtime SHARED 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/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 39697e8..b56049f 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -34,6 +34,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/multiplayer_session.cpp Extension/Trainer/trainer_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 hid cfgmgr32 shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32)