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)